Skip to content

feat(server): standalone scheduler, attach listener and dashboard - #584

Merged
pratyush618 merged 10 commits into
masterfrom
feat/executor-attach-s3-server
Jul 31, 2026
Merged

feat(server): standalone scheduler, attach listener and dashboard#584
pratyush618 merged 10 commits into
masterfrom
feat/executor-attach-s3-server

Conversation

@pratyush618

@pratyush618 pratyush618 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Closes #549. Phase S3 of executor attach (#546), on top of S2 (#547) and S1 (#548).

Adds crates/taskito-server — the scheduler, the executor attach listener, and the dashboard in one binary with no language runtime. Task bodies stay in the app's container; executors dial in over the worker frame protocol. Configuration is environment-only.

Decisions worth reviewing

The scheduler starts on the first attach, not at boot. With nothing attached the poller would claim jobs no executor advertises and fail each one retryably once placement_timeout elapsed — a retry storm against an idle deployment. SchedulerSupervisor::ensure_started is idempotent and the listener calls it after every successful handshake.

The dashboard is a full port, not a subset. One SPA build is served by every implementation, so a missing route is a broken page rather than a smaller feature set: ~60 routes, session auth (PBKDF2/CSRF/RBAC), and Google/GitHub/OIDC login. Auth and feature-store keys are byte-identical to the SDK dashboards (auth:users, auth:session:*, webhooks:subscriptions, overrides:task:*, …), so a session minted by one is accepted by another against the same backend — asserted in tests/auth_flow.rs.

Routes that need a language runtime degrade honestly rather than pretending: /api/proxy-stats[], /api/interception-stats{}, /api/resources reconstructed from worker heartbeats, /api/middleware limited to the disable list. /metrics publishes storage-derived gauges instead of proxying an in-process Prometheus registry.

Security defaults fail closed. A non-loopback TASKITO_LISTEN refuses to start — the handshake carries no credential until S4 — and a set TASKITO_ATTACH_TOKEN is an error rather than a silently ignored control. An off-host dashboard with AUTH=off needs TASKITO_ALLOW_INSECURE=1. Webhook URLs are SSRF-checked on write and immediately before each send. Failed logins are throttled: ten per identity per five minutes, 429 with Retry-After, cleared on success.

Open questions from the plan, settled here

  • Executor inventory on the dashboard: yes. GET /api/executors, plus a page that hides itself when the route 404s, so the shared SPA still works on the SDK dashboards.
  • This process owns retention and the elected reaps. TASKITO_MAINTENANCE=off disables retention so an attach-listener replica can scale independently; dead-worker reaping stays on because in-flight recovery depends on it.

Verification

  • cargo fmt --check, clippy --all-targets --all-features, cargo test --workspace; --features postgres and --features redis both build and test.
  • Live on all three backends — SQLite, hosted Postgres, hosted Redis: the Python SDK enqueued a job, the binary booted against that DSN and served the dashboard, an executor attached over TCP, the scheduler started on that attach, the job completed, and the dashboard reported it. SIGTERM drains in 27ms.
  • OIDC login runs against a stub issuer with a fixture signing key: seven forged or stale id_tokens are each refused, and every guard was mutation-tested — removing the audience, issuer, expiry, nonce, or kid check flips its case to a failure. That work found one real weakness, fixed here: a token naming an unknown kid used to fall back to the only published key.
  • GitHub login runs against a stub too, which required making its endpoints overridable — they were hardcoded, so the exchange had no coverage at all. It was also exercised once against real GitHub end to end.
  • Webhook delivery is verified from the receiving end: the signature covers the exact bytes sent, a rejecting or unreachable endpoint is reported rather than swallowed, and a replay appends a new record instead of rewriting the original.
  • sdks/python/tests/dashboard/test_rust_server_parity.py serves one seeded database from both dashboards and diffs every route the SPA calls: 19 byte-identical, 3 after dropping clock-derived fields, 6 shape-only. Skipped unless the server binary is built.

Not in this PR

Attach credentials and TLS (S4), the per-SDK executor subcommands (S5–S7), the image (S8), the deployment docs (S9), and the Helm chart (S10). Until S4 lands the listener is loopback-only.

Summary by CodeRabbit

  • New Features
    • Added a standalone server for scheduling, executor attachment, storage, and dashboard hosting.
    • Added SQLite, Postgres, and Redis configuration options.
    • Added dashboard APIs for jobs, queues, workflows, metrics, settings, webhooks, topics, retention, and dead letters.
    • Added secure session authentication, role-based access, CSRF protection, login throttling, and GitHub/OIDC sign-in.
    • Added an Executors dashboard with capacity, utilization, task, and activity monitoring.
  • Documentation
    • Added server setup, configuration, build, and runtime guidance.
  • Tests
    • Added comprehensive integration, authentication, OAuth, backend, webhook, dashboard, and compatibility coverage.

One binary runs the scheduler, accepts executor attachments, and serves the
dashboard with no language runtime, so one small image fits every SDK.

The dashboard is a full port rather than a subset because one SPA build is
served by every implementation; auth and feature-store keys match the SDKs so
sessions interoperate.
An id_token naming an unknown kid fell back to the only published key, so a
token signed by a retired key still verified. Fall back only when the token
names no kid at all.
A stub provider — discovery, JWKS, and a token endpoint signing with a fixture
key — is what makes the token exchange, the JWKS fetch, and the signature check
testable at all. Seven forged or stale id_tokens must each be refused, and each
case asserts the exchange happened so none can pass by failing earlier.
One SPA build is served by every implementation, so the JSON has to agree.
Nineteen routes must match byte for byte, three after dropping clock-derived
fields, and six only in shape because they read a language runtime the
standalone server does not have. Skipped unless that server is built.
Password verification is deliberately expensive, so an unthrottled login is
both a guessing oracle and a cheap way to occupy every blocking thread: ten
failures per identity per five minutes, cleared on success. A lockout answers
429 with Retry-After, and hides a hit from a miss while it holds.

Expired sessions and abandoned OAuth state were only swept at startup, which
never happens on a server that stays up; they now sweep on a cadence.
Signing, headers, and the recorded outcome are only observable from the
receiving end, so the store tests could not see any of them. A stub endpoint
proves the signature covers the exact bytes sent, that a rejecting or
unreachable endpoint is reported rather than swallowed, and that a replay
appends a new record instead of rewriting the original.
GitHub publishes no discovery document, so its endpoints were hardcoded and
the whole exchange was unreachable from a test. Making them overridable is
what lets a stub stand in, and the flow's real decisions — the account id as
the subject, the primary verified email, org membership — now have coverage.
A failing membership check reports oauth_failed, not oauth_denied: a broken
check is not a verdict about the user.
Only the standalone server serves /api/executors; an SDK dashboard answers
404. The nav entry stays hidden until the server confirms the route, and the
page explains itself rather than erroring if someone navigates to it anyway —
one SPA build is served by every implementation, so it cannot assume the
route exists.
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 35fa6669-359b-49fc-bd2a-b7125844461a

📥 Commits

Reviewing files that changed from the base of the PR and between 56e0403 and de3e5e1.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (2)
  • crates/taskito-server/Cargo.toml
  • crates/taskito-server/src/dashboard/stores/url_safety.rs
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • ByteVeda/taskito (manual)
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/taskito-server/Cargo.toml
  • crates/taskito-server/src/dashboard/stores/url_safety.rs

📝 Walkthrough

Walkthrough

The PR adds a standalone taskito-server binary with environment-based storage configuration, executor attachment, lazy scheduling, dashboard APIs, session and OAuth authentication, webhook delivery, embedded assets, executor monitoring, integration tests, and cross-SDK parity tests.

Changes

Standalone server and dashboard

Layer / File(s) Summary
Packaging, configuration, and runtime
Cargo.toml, crates/taskito-server/Cargo.toml, crates/taskito-server/build.rs, crates/taskito-server/src/config/*, crates/taskito-server/src/runtime/*, crates/taskito-server/src/main.rs
Adds the server crate, backend selection, listener validation, scheduler lifecycle, dashboard startup, graceful shutdown, asset embedding, and runtime cleanup.
Dashboard authentication and OAuth
crates/taskito-server/src/dashboard/auth/*, crates/taskito-server/src/config/dashboard.rs
Adds session authentication, CSRF validation, roles, password hashing, login throttling, GitHub OAuth, OIDC validation, and session management.
Dashboard APIs and stores
crates/taskito-server/src/dashboard/*
Adds dashboard routing, probes, jobs, queues, workflows, settings, overrides, topics, middleware, metrics, webhooks, delivery logs, and static asset handling.
Server integration tests
crates/taskito-server/tests/*
Adds executor, authentication, backend, OAuth, OIDC, HTTP API, webhook, and runtime tests.

Executor dashboard

Layer / File(s) Summary
Executor inventory UI
dashboard/src/features/executors/*, dashboard/src/routes/executors.tsx, dashboard/src/lib/api-types.ts
Adds executor API types, polling hooks, utilization helpers, an executor table, summary cards, and the /executors route.
Conditional navigation
dashboard/src/components/layout/*
Shows executor navigation only when the server supports the executor endpoint.

Cross-SDK parity

Layer / File(s) Summary
Rust server parity checks
sdks/python/tests/dashboard/test_rust_server_parity.py
Starts the Rust and Python dashboards against shared test data and compares route responses, job responses, and error shapes.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant Dashboard
  participant Storage
  participant Executor
  Browser->>Dashboard: request dashboard route
  Dashboard->>Storage: query jobs, queues, users, or settings
  Storage-->>Dashboard: return stored data
  Executor->>Dashboard: attach executor connection
  Dashboard-->>Browser: return dashboard response
Loading

Possibly related PRs

Suggested labels: scheduler, storage

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR implements the standalone binary and backend support, but attach credentials and TLS are rejected rather than supported as required by [#549]. Implement and honor TASKITO_ATTACH_TOKEN and TASKITO_LISTEN_TLS_CERT/_KEY, including authenticated or TLS listener handshakes, instead of rejecting these settings.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the new standalone server, scheduler, attach listener, and dashboard changes.
Out of Scope Changes check ✅ Passed The changes support the linked objective by adding the standalone server, dashboard, authentication, webhooks, executor monitoring, and related integration tests.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@pratyush618 pratyush618 changed the title Standalone taskito-server: scheduler, attach listener, dashboard feat(server): standalone scheduler, attach listener and dashboard Jul 31, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (13)
crates/taskito-server/src/dashboard/webhook_sender.rs-76-84 (1)

76-84: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Subscription headers can shadow Content-Type and the signature header.

RequestBuilder::header appends a value; it does not replace one. A stored header named X-Taskito-Signature therefore ships next to the computed signature, and a receiver that reads the first value verifies operator-supplied bytes instead. A stored Content-Type has the same effect.

Reject reserved header names in routes/webhooks.rs::coerce_headers, or insert the custom headers first through headers_mut so the computed values overwrite them.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/webhook_sender.rs` around lines 76 - 84,
The webhook request construction allows subscription headers to duplicate
reserved computed headers. Update routes/webhooks.rs::coerce_headers to reject
case-insensitive Content-Type and SIGNATURE_HEADER names before storing custom
headers, preserving the existing computed Content-Type and signature behavior in
the sender.
crates/taskito-server/src/dashboard/routes/webhooks.rs-201-208 (1)

201-208: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A negative offset wraps to a huge usize and returns an empty page.

limit is clamped, offset is not. For ?offset=-1, offset as usize becomes usize::MAX, deliveries::list_for skips everything, and the response reports "offset": -1 with an empty items array while total is non-zero. Either reject the value or clamp it.

🐛 Proposed fix
     let limit = params.int("limit", 50)?.clamp(1, MAX_PAGE_SIZE);
-    let offset = params.int("offset", 0)?;
+    let offset = params.int("offset", 0)?.max(0);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/routes/webhooks.rs` around lines 201 -
208, Validate the parsed offset in the webhook route before constructing
DeliveryFilter, preventing negative values from being cast to usize; either
reject negative offsets or clamp them to zero, while preserving valid offset
behavior and ensuring the response uses the normalized value.
crates/taskito-server/src/dashboard/stores/url_safety.rs-116-154 (1)

116-154: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

IPv4-compatible and NAT64 IPv6 forms skip the IPv4 screening.

is_private maps only IPv4-mapped addresses back to IPv4. ::127.0.0.1 (IPv4-compatible) and 64:ff9b::7f00:1 (NAT64) reach is_private_v6, which does not cover either range, so a loopback or link-local IPv4 target passes.

🛡️ Proposed fix
 fn is_private_v6(address: Ipv6Addr) -> bool {
     let segments = address.segments();
+    // NAT64 well-known prefix 64:ff9b::/96 and IPv4-compatible ::a.b.c.d both
+    // embed an IPv4 destination.
+    if segments[0] == 0x0064 && segments[1] == 0xff9b {
+        return true;
+    }
+    if let Some(compatible) = address.to_ipv4() {
+        return is_private_v4(compatible);
+    }
     address.is_loopback()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/stores/url_safety.rs` around lines 116 -
154, Update is_private to recognize IPv4-compatible and NAT64 IPv6 addresses and
convert their embedded IPv4 destinations before screening; route those extracted
addresses through is_private_v4, while retaining is_private_v6 for ordinary IPv6
addresses. Ensure embedded loopback, private, link-local, and other blocked IPv4
targets cannot bypass validation.
crates/taskito-server/tests/support/github_stub.rs-81-85 (1)

81-85: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the doc comment on with_membership_status.

The comment states "Not a member of any allowed org." The method sets an arbitrary status. crates/taskito-server/tests/github_login.rs calls it with 204 for a member (line 169), 404 for a non-member (line 190), and 500 for a broken check (line 213). The current comment describes only one caller and contradicts the 204 case.

📝 Proposed doc fix
-    /// Not a member of any allowed org.
+    /// Answer membership checks with `status`: 204 member, 404 not a member,
+    /// anything else a failed check.
     pub fn with_membership_status(mut self, status: u16) -> Self {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/tests/support/github_stub.rs` around lines 81 - 85,
Update the doc comment on the with_membership_status method to accurately
describe its purpose. The current comment "Not a member of any allowed org."
describes only one specific status value, but the method accepts and sets
arbitrary HTTP status codes (204 for member, 404 for non-member, 500 for error).
Rewrite the comment to reflect that this method configures the membership check
response status.
crates/taskito-server/src/config/backend.rs-34-43 (1)

34-43: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Reject an unrecognised URL scheme instead of treating it as a SQLite path.

open_by_scheme sends every non-Postgres, non-Redis DSN to open_sqlite. open_sqlite strips only sqlite://, so a DSN like mysql://host/db, or a typo like postgre://host/db, becomes a SQLite filename. SQLite creates that file, the server starts against an empty database, and the operator sees an empty dashboard instead of a configuration error.

Bare paths and :memory: contain no ://, so a scheme guard keeps them working.

🛡️ Proposed fix
 fn open_by_scheme(dsn: &str) -> Result<Backend> {
     let lower = dsn.to_ascii_lowercase();
     if lower.starts_with("postgres://") || lower.starts_with("postgresql://") {
         open_postgres(dsn)
     } else if lower.starts_with("redis://") || lower.starts_with("rediss://") {
         open_redis(dsn)
+    } else if let Some((scheme, _)) = lower.split_once("://").filter(|(s, _)| *s != "sqlite") {
+        // Don't silently create a SQLite file named after the URL.
+        bail!(
+            "TASKITO_DSN uses an unsupported scheme '{scheme}://'; \
+             use sqlite, postgres, or redis, or set TASKITO_BACKEND"
+        )
     } else {
         open_sqlite(dsn)
     }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/config/backend.rs` around lines 34 - 43, Update
open_by_scheme to reject DSNs containing an unrecognized “://” scheme instead of
passing them to open_sqlite; preserve SQLite handling for bare paths and
:memory:, while continuing to route recognized PostgreSQL and Redis schemes to
their existing openers.
crates/taskito-server/src/config/dashboard.rs-87-91 (1)

87-91: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fail when only one half of the admin bootstrap is set.

admin_bootstrap returns None if either variable is missing. With TASKITO_DASHBOARD_AUTH=session and only TASKITO_DASHBOARD_ADMIN_USER set, the server starts with no account and gives the operator no reason. A typo in the password variable name produces the same silent outcome.

Report the missing half instead. This changes the expectation in the bootstrap_needs_both_halves test at lines 165-177.

🛡️ Proposed fix
-fn admin_bootstrap(env: &Env) -> Option<(String, String)> {
-    let username = value(env, "TASKITO_DASHBOARD_ADMIN_USER")?;
-    let password = value(env, "TASKITO_DASHBOARD_ADMIN_PASSWORD")?;
-    Some((username, password))
+fn admin_bootstrap(env: &Env) -> Result<Option<(String, String)>> {
+    match (
+        value(env, "TASKITO_DASHBOARD_ADMIN_USER"),
+        value(env, "TASKITO_DASHBOARD_ADMIN_PASSWORD"),
+    ) {
+        (Some(username), Some(password)) => Ok(Some((username, password))),
+        (None, None) => Ok(None),
+        (Some(_), None) => bail!(
+            "TASKITO_DASHBOARD_ADMIN_USER is set without TASKITO_DASHBOARD_ADMIN_PASSWORD; \
+             set both to bootstrap the first admin, or neither"
+        ),
+        (None, Some(_)) => bail!(
+            "TASKITO_DASHBOARD_ADMIN_PASSWORD is set without TASKITO_DASHBOARD_ADMIN_USER; \
+             set both to bootstrap the first admin, or neither"
+        ),
+    }
 }

Then propagate at line 66: admin_bootstrap: admin_bootstrap(env)?,.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/config/dashboard.rs` around lines 87 - 91, The
admin_bootstrap function currently returns None silently when either
TASKITO_DASHBOARD_ADMIN_USER or TASKITO_DASHBOARD_ADMIN_PASSWORD is missing,
making it impossible to distinguish between intentionally disabling admin
bootstrap and accidentally misconfiguring it. Update admin_bootstrap to validate
that either both environment variables are present or both are absent. When only
one variable is set, the function should report an error indicating which
variable is missing rather than returning None. This ensures misconfigurations
like a typo in the password variable name are caught instead of silently
proceeding with no admin account. Update the test bootstrap_needs_both_halves to
reflect this new error-reporting behavior for incomplete configurations.
crates/taskito-server/build.rs-20-29 (1)

20-29: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Register the candidate directories so a later SPA build triggers a rebuild.

The build script prints cargo:rerun-if-env-changed, which disables the default rebuild-on-any-file-change behaviour. In the None branch no cargo:rerun-if-changed is printed at all. If a developer builds the server before dashboard/dist exists and then runs the SPA build, cargo does not re-run this script. The binary keeps the empty table and serves the "assets not bundled" page until the target is cleaned.

Print the candidate paths in both branches.

🐛 Proposed fix
     let out_dir = PathBuf::from(env::var("OUT_DIR").expect("cargo always sets OUT_DIR"));
+    // Track the candidates even when none exists yet, so building the SPA
+    // afterwards re-runs this script instead of keeping an empty table.
+    let workspace_root = workspace_root();
+    for relative in CANDIDATE_DIRS {
+        println!(
+            "cargo:rerun-if-changed={}",
+            workspace_root.join(relative).display()
+        );
+    }
     let generated = match locate_assets() {
         Some(root) => {
-            println!("cargo:rerun-if-changed={}", root.display());
             let mut files = Vec::new();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/build.rs` around lines 20 - 29, The None branch of
locate_assets() fails to register candidate directories with
cargo:rerun-if-changed, preventing rebuild when the asset directory is created
later. Add cargo:rerun-if-changed println statements in the None branch to
register the candidate paths that locate_assets would have searched, ensuring
cargo re-runs this build script when the SPA build creates dashboard/dist and
other asset directories.
crates/taskito-server/src/dashboard/routes/overrides.rs-132-150 (1)

132-150: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Reorder writes in put_queue to avoid a partial-failure inconsistency.

put_queue persists the override first (Line 138), then applies the live pause/resume change (Lines 139-148). If the live storage call fails after the override write succeeds, the override record reports paused as the new value while the queue's actual live state has not changed. Apply the live change first, and only persist the override once the live change succeeds. This way, a failure surfaces before any state is recorded, and a retry using the same idempotent PUT body converges on the correct state.

🐛 Proposed reorder
 pub async fn put_queue(
     State(state): State<SharedState>,
     Path(queue_name): Path<String>,
     Json(body): Json<Value>,
 ) -> ApiResult<Json<Value>> {
     let paused = body.get("paused").and_then(Value::as_bool);
-    let response = put_override(state.clone(), Scope::Queue, queue_name.clone(), body).await?;
     if let Some(paused) = paused {
+        let name = queue_name.clone();
         on_storage(&state, move |storage| {
             if paused {
-                storage.pause_queue(&queue_name)
+                storage.pause_queue(&name)
             } else {
-                storage.resume_queue(&queue_name)
+                storage.resume_queue(&name)
             }
         })
         .await?;
     }
+    let response = put_override(state, Scope::Queue, queue_name, body).await?;
     Ok(response)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/routes/overrides.rs` around lines 132 -
150, Update put_queue so the live pause_queue or resume_queue operation in
on_storage runs before calling put_override. Only persist the override after the
live storage change succeeds, while preserving the existing behavior when paused
is absent and returning the persisted override response.
crates/taskito-server/src/dashboard/auth/oauth/config.rs-202-216 (1)

202-216: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reject OIDC slots whose derived env prefix collides.

Line 209 maps '-' to '_' when it builds the prefix. acme-okta and acme_okta therefore both derive TASKITO_DASHBOARD_OAUTH_OIDC_ACME_OKTA_*. The duplicate check on line 204 compares slot strings, so both slots are accepted and both read the same client id, secret, and discovery URL. The second provider then authenticates against the first provider's issuer, and startup reports nothing.

Compare derived prefixes instead of raw slots.

🐛 Proposed fix
 fn oidc_provider(env: &Env, slot: &str, existing: &[ProviderConfig]) -> Result<ProviderConfig> {
     validate_slot(slot)?;
-    if existing.iter().any(|provider| provider.slot == slot) {
-        bail!("OIDC slot '{slot}' is listed twice");
-    }
-    let prefix = format!(
-        "TASKITO_DASHBOARD_OAUTH_OIDC_{}",
-        slot.to_ascii_uppercase().replace('-', "_")
-    );
+    let prefix = format!(
+        "TASKITO_DASHBOARD_OAUTH_OIDC_{}",
+        slot.to_ascii_uppercase().replace('-', "_")
+    );
+    // '-' and '_' collapse to the same prefix, so two distinct slots can
+    // otherwise read one set of credentials.
+    if existing.iter().any(|provider| {
+        provider.slot == slot
+            || provider.slot.to_ascii_uppercase().replace('-', "_")
+                == slot.to_ascii_uppercase().replace('-', "_")
+    }) {
+        bail!("OIDC slot '{slot}' collides with another slot on the env prefix {prefix}");
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/auth/oauth/config.rs` around lines 202 -
216, Update oidc_provider to reject duplicate derived environment prefixes, not
just identical raw slot names: construct the normalized prefix before the
duplicate check and compare it against each existing provider’s derived prefix
using the same uppercase and hyphen-to-underscore normalization. Preserve the
existing duplicate-slot error behavior while reporting collisions for distinct
slots that map to the same prefix.
crates/taskito-server/src/dashboard/auth/oauth/providers/oidc.rs-171-175 (1)

171-175: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Use the JWK's declared algorithm instead of the token header's algorithm for validation.

The code currently passes header.alg (controlled by the token presenter) to Validation::new(). The jsonwebtoken library does validate that this algorithm matches the key type derived from the JWK, which provides some protection. However, OpenID Connect security best practices recommend using the issuer's declared algorithm (jwk.common.key_algorithm) rather than the presenter's header claim. This makes the security policy explicit in the code and eliminates reliance on implicit library behavior.

If jwk.common.key_algorithm exists, convert it to a jsonwebtoken::Algorithm and use that for validation. Fall back to header.alg only if the JWK does not declare an algorithm:

Proposed fix
-    let mut validation = Validation::new(header.alg);
+    // Use the issuer's declared algorithm from the JWK, not the
+    // presenter's header claim, to follow OpenID Connect best practices.
+    let algorithm = jwk
+        .common
+        .key_algorithm
+        .and_then(|alg| alg.to_string().parse::<jsonwebtoken::Algorithm>().ok())
+        .unwrap_or(header.alg);
+    let mut validation = Validation::new(algorithm);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/auth/oauth/providers/oidc.rs` around
lines 171 - 175, Update the validation setup around Validation::new in the OIDC
provider to derive the algorithm from jwk.common.key_algorithm when present,
converting it to jsonwebtoken::Algorithm, and fall back to header.alg only when
the JWK omits it. Keep the existing audience, issuer, leeway, and expiration
validation settings unchanged.
crates/taskito-server/src/dashboard/static_assets.rs-105-115 (1)

105-115: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

The starts_with check does not detect the symlink case the comment describes.

Path::starts_with compares components lexically. It does not resolve symlinks. A symlink inside the bundle directory therefore still passes this check, and std::fs::read follows it and serves the target. The comment states that this branch catches a path that leaves the root through a symlink, so the code and the comment disagree.

The bundle directory comes from TASKITO_DASHBOARD_ASSETS and is operator-supplied, so this is not directly attacker-controlled. It still matters when the directory is a mounted volume that another workload can write.

Canonicalize both paths before you compare them, or correct the comment to state that only lexical escapes are rejected.

🔒 Proposed fix
             Source::Directory(root) => {
                 let path = root.join(relative);
-                // `sanitize` already rejected traversal, so a path that leaves
-                // the root can only come from a symlink inside the bundle.
-                if !path.starts_with(root) || !path.is_file() {
-                    return None;
-                }
-                std::fs::read(path).ok().map(Cow::Owned)
+                // `sanitize` already rejected traversal; canonicalizing also
+                // resolves a symlink inside the bundle that points outside it.
+                let resolved = path.canonicalize().ok()?;
+                let root = root.canonicalize().ok()?;
+                if !resolved.starts_with(&root) || !resolved.is_file() {
+                    return None;
+                }
+                std::fs::read(resolved).ok().map(Cow::Owned)
             }

canonicalize calls the filesystem on every request. If that cost matters on the asset path, canonicalize the root once in StaticAssets::new and store it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/static_assets.rs` around lines 105 - 115,
Update the Source::Directory handling to canonicalize the requested path and the
configured root before comparing them, so symlinks escaping the bundle are
rejected; preserve the existing None behavior for invalid or non-file paths.
Prefer canonicalizing the root once in StaticAssets::new and reuse it during
requests if that fits the existing structure.
crates/taskito-server/src/dashboard/auth/oauth/state.rs-62-80 (1)

62-80: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Make the single-use check depend on the delete result.

consume reads the row and then deletes it in two separate storage operations. Two concurrent callbacks that carry the same state token can both complete the read before either delete runs. Both then receive a live OAuthState, so the single-use guarantee stated in the doc comment does not hold under concurrency. The authorization server still rejects the second code exchange, so the exposure is limited to duplicate exchange attempts.

delete_setting already returns a boolean, as used on Line 90. Use that result as the claim check.

🔒 Proposed fix to claim the row before returning it
     let key = format!("{STATE_PREFIX}{token}");
     let Some(raw) = storage.get_setting(&key)? else {
         return Ok(None);
     };
-    storage.delete_setting(&key)?;
+    // Only the caller that removes the row owns this flow; a concurrent
+    // replay observes `false` and is refused.
+    if !storage.delete_setting(&key)? {
+        return Ok(None);
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/auth/oauth/state.rs` around lines 62 -
80, Update OAuthState::consume to use the boolean result from delete_setting as
the single-use claim check: return None when deletion reports that the row was
not deleted, and only parse and return the state after a successful delete.
Preserve the existing empty-token, missing-row, malformed-state, and expiration
handling.
crates/taskito-server/src/dashboard/auth/cookies.rs-17-29 (1)

17-29: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Use SameSite=Lax for the session cookie. The OAuth callback sets the cookie before redirecting to row.next_url, but browsers can treat the redirect chain as cross-site and omit the new SameSite=Strict cookie from the landing request. Keep SameSite=Strict for the CSRF cookie.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/auth/cookies.rs` around lines 17 - 29,
Update established so the session cookie built from SESSION_COOKIE uses
SameSite=Lax, while the CSRF_COOKIE remains SameSite=Strict. Preserve the
existing security attributes, expiration, and redirect behavior.
🧹 Nitpick comments (19)
crates/taskito-server/src/dashboard/stores/kv.rs (1)

35-48: 🚀 Performance & Scalability | 🔵 Trivial

scan_prefix reads every setting value to serve a prefix listing.

list_settings() returns all keys and all values. Delivery logs are stored in the same table and hold up to 200 records per webhook, so an overrides or middleware listing transfers and decodes those large values as well. The dashboard polls these routes, so the cost repeats.

If Storage gains a prefix-scoped or keys-only listing, prefer it here. Alternatively cache the listing for a short interval in the dashboard state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/stores/kv.rs` around lines 35 - 48,
Update scan_prefix to avoid loading and decoding every setting value when
serving prefix listings, preferably by using a prefix-scoped or keys-only
Storage listing API if available, then fetch values only for matching keys.
Preserve the existing suffix formatting, sorting, and Result behavior; avoid
adding unrelated caching unless no suitable Storage API exists.
sdks/python/tests/dashboard/test_rust_server_parity.py (1)

299-303: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert the listing status before you read the body.

Line 299 discards the status code. If /api/jobs?limit=50 returns an error body, line 300 raises TypeError or KeyError instead of reporting the failed request. Add the status assertion so the failure names the cause.

♻️ Proposed refactor
-    _, listed = _get(python_url, "/api/jobs?limit=50")
+    listing_status, listed = _get(python_url, "/api/jobs?limit=50")
+    assert listing_status == 200, listed
     ids = [job["id"] for job in listed]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@sdks/python/tests/dashboard/test_rust_server_parity.py` around lines 299 -
303, Capture the status code returned from the _get call to "/api/jobs?limit=50"
instead of discarding it, and add an assertion that the status code indicates
success before attempting to access fields like job["id"] and job["error"] in
the subsequent lines. This ensures that if the endpoint returns an error
response, the failure message clearly identifies the failed request rather than
raising a confusing TypeError or KeyError when trying to read an invalid body.
crates/taskito-server/tests/http_api.rs (1)

141-143: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Strengthen the traversal assertion.

The assertion accepts both NOT_FOUND and OK, so it passes whatever the router does. It cannot detect a regression that serves the repository file. Assert on the response content instead of only the status.

♻️ Proposed change
     // Traversal is refused before it reaches the filesystem.
-    let (status, _, _) = call(&state, get("/../Cargo.toml")).await;
-    assert!(status == StatusCode::NOT_FOUND || status == StatusCode::OK);
+    let (status, headers, _) = call(&state, get("/../Cargo.toml")).await;
+    assert!(
+        status == StatusCode::NOT_FOUND || is_html(&headers),
+        "traversal must 404 or fall back to the SPA shell, never serve a repo file"
+    );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/tests/http_api.rs` around lines 141 - 143, Strengthen
the traversal test around call by inspecting the response body rather than
accepting either status code. Assert that the response does not contain the
contents of Cargo.toml, ensuring a traversal request cannot serve the repository
file while preserving the existing request setup.
crates/taskito-server/tests/backends.rs (1)

30-46: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Rename namespace to task_name.

The returned string is used as the job task_name and as a settings key suffix. new_job sets namespace: None, so no namespace is involved. The current name and the module doc at Lines 11-12 describe a mechanism the test does not use.

♻️ Proposed rename
-/// Open the backend named by `variable`, or `None` when it is not configured.
+/// Open the backend named by `variable`, or `None` when it is not configured.
+/// The second tuple element is a per-run task name used to isolate rows.
 fn backend_state(variable: &str) -> Option<(SharedState, String)> {
     let dsn = std::env::var(variable).ok().filter(|dsn| !dsn.is_empty())?;
     let opened = backend::open(&dsn, None).unwrap_or_else(|error| {
         // The DSN is not interpolated — it carries credentials.
         panic!("{variable} is set but the backend could not be opened: {error}")
     });
     // A per-run task name keeps assertions exact against a shared database.
-    let namespace = format!("server-test-{}-{}", std::process::id(), now_millis());
+    let task_name = format!("server-test-{}-{}", std::process::id(), now_millis());
     let state = dashboard_state_for(
         opened.storage,
         opened.workflows,
         AuthMode::Open,
         StaticAssets::new(None),
     );
-    Some((state, namespace))
+    Some((state, task_name))
 }

Update both call sites at Lines 150 and 160 accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/tests/backends.rs` around lines 30 - 46, Rename the
local `namespace` in `backend_state` to `task_name`, update both returned-value
call sites around `new_job` accordingly, and revise the module documentation to
describe task names rather than namespaces. Preserve the existing generated
value and settings-key behavior.
crates/taskito-server/tests/support/webhook_receiver.rs (1)

27-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Normalise the requested header name in Received::header.

receive stores header names in lowercase (line 114). header compares the caller's name verbatim. A caller that passes "Content-Type" gets None. That makes a negative assertion such as assert!(delivery.header("X-Taskito-Signature").is_none()) pass without testing anything. Current callers use lowercase names, so behaviour is correct today.

♻️ Proposed change
     /// One header's value.
     pub fn header(&self, name: &str) -> Option<&str> {
+        let name = name.to_ascii_lowercase();
         self.headers
             .iter()
-            .find(|(key, _)| key == name)
+            .find(|(key, _)| *key == name)
             .map(|(_, value)| value.as_str())
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/tests/support/webhook_receiver.rs` around lines 27 -
35, Update Received::header to normalize the requested name to lowercase before
comparing it with the lowercase keys stored by receive, while preserving the
existing Option<&str> return behavior.
crates/taskito-server/tests/support/oidc_issuer.rs (1)

178-192: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Return an error response instead of panicking on an unarmed exchange.

Line 187 panics inside the spawned server task. The test task does not observe that panic message. The client sees a dropped connection or a 500, so the flow reports a generic oauth_failed, and the real cause stays hidden. crates/taskito-server/tests/oidc_login.rs line 290 depends on unarmed exchanges never happening, so this path only runs when a test is written incorrectly — which is exactly when a readable failure matters.

Return an explicit body that the test can recognise, and keep the exchange counter accurate.

♻️ Proposed change to fail readably
-async fn token(State(issuer): State<Arc<Issuer>>) -> Json<Value> {
+async fn token(State(issuer): State<Arc<Issuer>>) -> (StatusCode, Json<Value>) {
     issuer
         .exchanges
         .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
-    let armed = issuer
-        .next
-        .lock()
-        .expect("stub lock")
-        .take()
-        .expect("a test armed the stub before triggering the exchange");
+    let Some(armed) = issuer.next.lock().expect("stub lock").take() else {
+        // A test reached the exchange without arming it. Say so on the wire.
+        return (
+            StatusCode::INTERNAL_SERVER_ERROR,
+            Json(json!({ "error": "stub_not_armed" })),
+        );
+    };

Add the import and wrap the success return:

use axum::http::StatusCode;

// ... at the end of `token`:
(
    StatusCode::OK,
    Json(json!({
        "access_token": "stub-access-token",
        "token_type": "Bearer",
        "expires_in": 3_600,
        "id_token": id_token,
    })),
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/tests/support/oidc_issuer.rs` around lines 178 - 192,
Update token so an unarmed issuer.next exchange returns an explicit HTTP error
response with a recognizable JSON body instead of panicking on expect("a test
armed..."). Keep the exchanges counter incremented before this check, and
preserve the existing signed-token success response for armed exchanges; import
and use StatusCode as needed to unify both response paths.
crates/taskito-server/src/config/listen.rs (1)

73-86: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider keeping the loopback guard with the parser.

parse is public and returns a Tcp variant with no address policy applied. Only from_env enforces the loopback rule. A future caller that uses parse directly obtains an unguarded non-loopback listener address. Moving the check into a AttachListen::ensure_local method, or making parse private, keeps the guard attached to the value.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/config/listen.rs` around lines 73 - 86, Ensure the
loopback-only policy is enforced within the public AttachListen parsing flow
rather than only in from_env. Update parse or introduce and invoke an
AttachListen::ensure_local method so every returned Tcp address is validated,
while preserving Unix socket handling and existing error behavior.
crates/taskito-server/src/config/mod.rs (1)

87-96: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reject an unrecognised boolean instead of falling back to the default.

flag accepts any value and silently uses default. TASKITO_MAINTENANCE=of therefore keeps retention enabled on every replica, which is the case the PR objectives want operators to be able to switch off. Every other parser in this module fails loudly on an invalid value.

Return a Result and report the key and the value.

♻️ Proposed refactor
-pub fn flag(env: &Env, key: &str, default: bool) -> bool {
+pub fn flag(env: &Env, key: &str, default: bool) -> Result<bool> {
     match value(env, key) {
-        None => default,
+        None => Ok(default),
         Some(raw) => match raw.to_ascii_lowercase().as_str() {
-            "1" | "true" | "on" | "yes" => true,
-            "0" | "false" | "off" | "no" => false,
-            _ => default,
+            "1" | "true" | "on" | "yes" => Ok(true),
+            "0" | "false" | "off" | "no" => Ok(false),
+            other => bail!("{key} must be on or off, got '{other}'"),
         },
     }
 }

Update the two call sites at lines 54 and 65 of this file and the secure_cookies call site in crates/taskito-server/src/config/dashboard.rs to propagate with ?.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/config/mod.rs` around lines 87 - 96, Change
config::flag to return a Result<bool, ...>, preserving the default only when the
environment key is absent and returning an error containing both key and raw
value for unrecognized inputs. Update its two call sites in config/mod.rs and
the secure_cookies call site in config/dashboard.rs to propagate the parsing
error with ?, allowing invalid boolean configuration to fail loudly.
crates/taskito-server/src/main.rs (1)

10-26: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the remaining dashboard variables in ENV_HELP.

ENV_HELP is the only reference for a binary that takes no flags. crates/taskito-server/src/config/dashboard.rs also reads TASKITO_DASHBOARD_ADMIN_USER, TASKITO_DASHBOARD_ADMIN_PASSWORD, TASKITO_DASHBOARD_INSECURE_COOKIES, and the OAuth variables. An operator who enables TASKITO_DASHBOARD_AUTH=session needs the first two to create the first account.

📝 Proposed addition
   TASKITO_DASHBOARD_AUTH         off | session (default: off)
+  TASKITO_DASHBOARD_ADMIN_USER   first admin username, created on start (session auth)
+  TASKITO_DASHBOARD_ADMIN_PASSWORD  first admin password; read once, then scrubbed
+  TASKITO_DASHBOARD_INSECURE_COOKIES  1 to drop `Secure` from session cookies (plain HTTP)
   TASKITO_DASHBOARD_ASSETS       serve the SPA from this directory
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/main.rs` around lines 10 - 26, The ENV_HELP
constant is missing dashboard authentication and OAuth configuration variables.
Update ENV_HELP to document TASKITO_DASHBOARD_ADMIN_USER,
TASKITO_DASHBOARD_ADMIN_PASSWORD, TASKITO_DASHBOARD_INSECURE_COOKIES, and all
OAuth variables read by the dashboard configuration, including the required
first-account credentials for session authentication.
crates/taskito-server/src/dashboard/routes/queues.rs (1)

91-139: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Combine the three on_storage calls in scaler into one.

scaler issues three separate on_storage calls (Lines 92, 93, 94) where list_queues in overrides.rs shows the established pattern of combining several storage reads into a single closure. Since /api/scaler is polled periodically by the autoscaler, combining these into one on_storage call reduces the number of blocking-pool round trips per poll.

♻️ Proposed combination
-    let overall = on_storage(&state, |storage| storage.stats()).await?;
-    let workers = on_storage(&state, |storage| storage.list_workers()).await?;
-    let per_queue = on_storage(&state, |storage| storage.stats_all_queues()).await?;
+    let (overall, workers, per_queue) = on_storage(&state, |storage| {
+        Ok((
+            storage.stats()?,
+            storage.list_workers()?,
+            storage.stats_all_queues()?,
+        ))
+    })
+    .await?;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/routes/queues.rs` around lines 91 - 139,
Update scaler to combine the stats, worker listing, and per-queue stats reads
into a single on_storage closure, returning all three results together and
preserving the existing error propagation and downstream calculations. Follow
the established multi-read pattern used by list_queues in overrides.rs.
crates/taskito-server/src/dashboard/auth/middleware.rs (1)

79-83: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Cache the setup-complete state instead of counting users on every request.

Line 81 runs store::count_users on the blocking pool for every non-public /api/ request. Together with the session lookup in resolve_context, each dashboard API call makes two storage round trips. The count only matters while it is zero, and it does not return to zero after setup completes.

Latch the result in SharedState after the first non-zero count, then skip the query.

⚡ Proposed latch
-    if is_api && !gate::is_public_path(path) && on_storage(state, store::count_users).await? == 0 {
-        return Err(ApiError::SetupRequired);
+    if is_api && !gate::is_public_path(path) && !setup_complete(state).await? {
+        return Err(ApiError::SetupRequired);
     }

Add the helper, backed by an AtomicBool on SharedState:

/// Once a user exists the count cannot return to zero, so the answer is
/// latched and the per-request query disappears.
async fn setup_complete(state: &SharedState) -> Result<bool, ApiError> {
    if state.setup_complete.load(std::sync::atomic::Ordering::Relaxed) {
        return Ok(true);
    }
    let complete = on_storage(state, store::count_users).await? > 0;
    if complete {
        state
            .setup_complete
            .store(true, std::sync::atomic::Ordering::Relaxed);
    }
    Ok(complete)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/auth/middleware.rs` around lines 79 - 83,
Add an AtomicBool setup-complete latch to SharedState and implement a
setup_complete helper that returns immediately when latched, otherwise checks
store::count_users through on_storage and stores true after the first non-zero
result. Update the middleware condition around gate::is_public_path to call
setup_complete instead of querying store::count_users directly, preserving
setup_required behavior while avoiding repeated storage calls.
crates/taskito-server/src/dashboard/static_assets.rs (2)

83-97: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

available() re-reads index.html on every request in directory mode.

available() calls read("index.html") and discards the bytes. serve_spa in crates/taskito-server/src/dashboard/mod.rs calls available() on every non-API request, then calls resolve, and for a client-routed path it then calls index(). In directory mode that is two or three full synchronous std::fs::read calls per request, and they run on the async runtime thread.

Two changes fix both points:

  • Give available() a metadata check instead of a read, or cache the result in StaticAssets::new.
  • Have serve_spa rely on resolve and index returning None instead of probing with available() first.

The embedded source is unaffected in cost terms, because EMBEDDED_ASSETS is a small in-memory table.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/static_assets.rs` around lines 83 - 97,
Avoid redundant asset reads in the SPA request flow: change
StaticAssets::available to use metadata or a cached result rather than reading
index.html, and update serve_spa to rely on resolve and index returning None
instead of calling available before them. Preserve embedded and directory asset
resolution behavior while eliminating the repeated synchronous reads.

178-194: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the temporary directory unique and clean it up on failure.

The directory name uses only std::process::id(). Every test in this binary shares that pid, so a second test that adopts the same pattern collides, and tests run concurrently as threads. The remove_dir_all on Line 193 also never runs if an earlier assertion panics, so the directory leaks.

Use the tempfile crate, which gives a unique path and removes it on drop. If you keep the manual approach, add a unique suffix such as a counter or a random_token() fragment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/static_assets.rs` around lines 178 - 194,
Update the test a_directory_source_serves_files_and_rejects_escapes to create
its temporary directory through the tempfile crate, ensuring uniqueness across
concurrent tests and automatic cleanup when assertions fail; remove the manual
process-ID path construction and explicit remove_dir_all cleanup.
crates/taskito-server/src/dashboard/security.rs (2)

11-21: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Two optional hardening steps for the header set.

The set is correct as written. x-frame-options is DENY and frame-ancestors 'none' agrees with it.

Two refinements:

  • strict-transport-security is absent. If the deployment terminates TLS at this process, add it when config.secure_cookies is true. If a proxy terminates TLS, the proxy normally sets it, and no change is needed.
  • 'unsafe-inline' in style-src also permits inline <style> elements, not only the inline style attributes named in the comment. style-src 'self'; style-src-attr 'unsafe-inline' grants only what the SPA needs. Verify the SPA renders correctly before you narrow it.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/security.rs` around lines 11 - 21, Update
the SECURITY_HEADERS configuration to conditionally add
Strict-Transport-Security only when config.secure_cookies is true and this
process terminates TLS; otherwise leave proxy-terminated deployments unchanged.
Also verify the SPA before narrowing the CSP style policy from 'unsafe-inline'
in style-src to self with style-src-attr 'unsafe-inline', preserving rendering
behavior.

50-62: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Use subtle::ConstantTimeEq for secret comparisons.

This function is used for metrics-token authentication and password verification. The hand-written fold is not protected against compiler transformations that can introduce data-dependent control flow. Add subtle as a direct dependency and use left.ct_eq(right) after the length check.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/security.rs` around lines 50 - 62, Update
constant_time_eq to use subtle::ConstantTimeEq: add subtle as a direct
dependency, import the trait, and replace the hand-written byte fold with
left.ct_eq(right) after the existing length check, converting the result to bool
while preserving the current mismatch behavior.
crates/taskito-server/src/dashboard/probes.rs (1)

135-155: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Match the Bearer scheme case-insensitively.

RFC 7235 defines the authentication scheme token as case-insensitive. Line 145 compares the whole header value against Bearer {token}, so a scraper that sends bearer <token> or BEARER <token> is rejected. Split the scheme from the credential, compare the scheme with eq_ignore_ascii_case, and keep constant_time_eq for the credential.

The fail-closed interaction is correct: when metrics_token is set, open mode no longer exempts the probe.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/probes.rs` around lines 135 - 155, Update
require_probe_access to parse the authorization header into its scheme and
credential, compare the scheme with eq_ignore_ascii_case against Bearer, and
continue using constant_time_eq for the credential token. Preserve the existing
fail-closed behavior when metrics_token is configured, including the
authenticated-user fallback.
crates/taskito-server/src/dashboard/mod.rs (2)

81-97: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Avoid copying every asset body on each request.

bytes.into_owned() allocates and copies the whole file for every request, including the Cow::Borrowed case that the embedded source always returns. The bundle is about 1.6 MB per the note in crates/taskito-server/src/dashboard/static_assets.rs, so each SPA load copies the served files needlessly.

Convert the borrowed case to axum::body::Bytes::from_static and keep the owned case as is. That makes the embedded path a zero-copy clone of a reference-counted buffer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/mod.rs` around lines 81 - 97, Update the
asset response in the resolve(path) block to convert Cow::Borrowed data with
axum::body::Bytes::from_static and preserve owned data without copying
unnecessarily. Replace the unconditional bytes.into_owned() conversion while
keeping the existing headers and response behavior unchanged.

46-61: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Bound graceful shutdown and add a request timeout.

Shutdown::wait() only starts the Axum drain. The server then waits for open connections without a deadline. Bound the complete axum::serve(...).with_graceful_shutdown(...) future, not only shutdown.wait().

The runtime uses spawn_blocking for storage. Call Runtime::shutdown_timeout during teardown because Tokio cannot abort started blocking tasks. Add tower_http::timeout::TimeoutLayer; its default response is 408 Request Timeout, and it does not cancel an underlying blocking task.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/mod.rs` around lines 46 - 61, Update
serve to wrap the complete axum::serve(...).with_graceful_shutdown(...) future
in a shutdown deadline, rather than timing only shutdown.wait(); during runtime
teardown, invoke Tokio Runtime::shutdown_timeout so started spawn_blocking
storage tasks receive a bounded shutdown. Add tower_http::timeout::TimeoutLayer
to the router or service stack with the intended request deadline, preserving
the default 408 response behavior.
crates/taskito-server/src/dashboard/dto.rs (1)

240-244: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the empty-error branch.

Lines 167-169 return Some(String::new()) for an empty stored error, which is distinct from None. No test pins that behavior. The SPA distinguishes "" from null, so a regression here changes a rendered cell.

💚 Proposed test
     #[test]
     fn no_error_stays_absent() {
         assert_eq!(summarize_error(None), None);
     }
+
+    #[test]
+    fn an_empty_error_stays_an_empty_string() {
+        assert_eq!(summarize_error(Some("")).as_deref(), Some(""));
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/src/dashboard/dto.rs` around lines 240 - 244, Add a
test alongside no_error_stays_absent for summarize_error that passes an empty
stored error and asserts the result is Some(String::new()), preserving the
distinction between an empty string and None.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7a3a56cc-8372-4ea6-bdbf-90388d0ed6b2

📥 Commits

Reviewing files that changed from the base of the PR and between 67c22cc and 6082b13.

⛔ Files ignored due to path filters (2)
  • Cargo.lock is excluded by !**/*.lock
  • crates/taskito-server/tests/fixtures/oidc_test_key.pem is excluded by !**/*.pem
📒 Files selected for processing (92)
  • Cargo.toml
  • crates/taskito-server/Cargo.toml
  • crates/taskito-server/README.md
  • crates/taskito-server/build.rs
  • crates/taskito-server/src/config/backend.rs
  • crates/taskito-server/src/config/dashboard.rs
  • crates/taskito-server/src/config/listen.rs
  • crates/taskito-server/src/config/mod.rs
  • crates/taskito-server/src/dashboard/auth/bootstrap.rs
  • crates/taskito-server/src/dashboard/auth/context.rs
  • crates/taskito-server/src/dashboard/auth/cookies.rs
  • crates/taskito-server/src/dashboard/auth/gate.rs
  • crates/taskito-server/src/dashboard/auth/middleware.rs
  • crates/taskito-server/src/dashboard/auth/mod.rs
  • crates/taskito-server/src/dashboard/auth/model.rs
  • crates/taskito-server/src/dashboard/auth/oauth/config.rs
  • crates/taskito-server/src/dashboard/auth/oauth/mod.rs
  • crates/taskito-server/src/dashboard/auth/oauth/providers/github.rs
  • crates/taskito-server/src/dashboard/auth/oauth/providers/mod.rs
  • crates/taskito-server/src/dashboard/auth/oauth/providers/oidc.rs
  • crates/taskito-server/src/dashboard/auth/oauth/state.rs
  • crates/taskito-server/src/dashboard/auth/password.rs
  • crates/taskito-server/src/dashboard/auth/routes.rs
  • crates/taskito-server/src/dashboard/auth/store.rs
  • crates/taskito-server/src/dashboard/auth/throttle.rs
  • crates/taskito-server/src/dashboard/blocking.rs
  • crates/taskito-server/src/dashboard/dto.rs
  • crates/taskito-server/src/dashboard/error.rs
  • crates/taskito-server/src/dashboard/mod.rs
  • crates/taskito-server/src/dashboard/probes.rs
  • crates/taskito-server/src/dashboard/query.rs
  • crates/taskito-server/src/dashboard/routes/dead_letters.rs
  • crates/taskito-server/src/dashboard/routes/executors.rs
  • crates/taskito-server/src/dashboard/routes/jobs.rs
  • crates/taskito-server/src/dashboard/routes/logs.rs
  • crates/taskito-server/src/dashboard/routes/metrics.rs
  • crates/taskito-server/src/dashboard/routes/middleware.rs
  • crates/taskito-server/src/dashboard/routes/mod.rs
  • crates/taskito-server/src/dashboard/routes/overrides.rs
  • crates/taskito-server/src/dashboard/routes/queues.rs
  • crates/taskito-server/src/dashboard/routes/resources.rs
  • crates/taskito-server/src/dashboard/routes/retention.rs
  • crates/taskito-server/src/dashboard/routes/settings.rs
  • crates/taskito-server/src/dashboard/routes/topics.rs
  • crates/taskito-server/src/dashboard/routes/webhooks.rs
  • crates/taskito-server/src/dashboard/routes/workflows.rs
  • crates/taskito-server/src/dashboard/security.rs
  • crates/taskito-server/src/dashboard/state.rs
  • crates/taskito-server/src/dashboard/static_assets.rs
  • crates/taskito-server/src/dashboard/stores/deliveries.rs
  • crates/taskito-server/src/dashboard/stores/kv.rs
  • crates/taskito-server/src/dashboard/stores/middleware.rs
  • crates/taskito-server/src/dashboard/stores/mod.rs
  • crates/taskito-server/src/dashboard/stores/overrides.rs
  • crates/taskito-server/src/dashboard/stores/url_safety.rs
  • crates/taskito-server/src/dashboard/stores/webhooks.rs
  • crates/taskito-server/src/dashboard/webhook_sender.rs
  • crates/taskito-server/src/lib.rs
  • crates/taskito-server/src/main.rs
  • crates/taskito-server/src/runtime/listener.rs
  • crates/taskito-server/src/runtime/mod.rs
  • crates/taskito-server/src/runtime/scheduler.rs
  • crates/taskito-server/src/runtime/shutdown.rs
  • crates/taskito-server/src/runtime/upkeep.rs
  • crates/taskito-server/tests/attach_e2e.rs
  • crates/taskito-server/tests/auth_flow.rs
  • crates/taskito-server/tests/backends.rs
  • crates/taskito-server/tests/fixtures/oidc_test_jwks.json
  • crates/taskito-server/tests/github_login.rs
  • crates/taskito-server/tests/http_api.rs
  • crates/taskito-server/tests/oauth_flow.rs
  • crates/taskito-server/tests/oidc_login.rs
  • crates/taskito-server/tests/support/github_stub.rs
  • crates/taskito-server/tests/support/mod.rs
  • crates/taskito-server/tests/support/oidc_issuer.rs
  • crates/taskito-server/tests/support/webhook_receiver.rs
  • crates/taskito-server/tests/webhook_delivery.rs
  • dashboard/src/components/layout/command-palette.tsx
  • dashboard/src/components/layout/mobile-menu.tsx
  • dashboard/src/components/layout/nav-config.test.ts
  • dashboard/src/components/layout/nav-config.ts
  • dashboard/src/components/layout/sidebar.tsx
  • dashboard/src/features/executors/api.ts
  • dashboard/src/features/executors/components/executors-table.tsx
  • dashboard/src/features/executors/components/index.ts
  • dashboard/src/features/executors/hooks.ts
  • dashboard/src/features/executors/index.ts
  • dashboard/src/features/executors/utils.test.ts
  • dashboard/src/features/executors/utils.ts
  • dashboard/src/lib/api-types.ts
  • dashboard/src/routes/executors.tsx
  • sdks/python/tests/dashboard/test_rust_server_parity.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • ByteVeda/taskito (manual)

Comment thread crates/taskito-server/src/dashboard/auth/oauth/mod.rs
Comment thread crates/taskito-server/src/dashboard/auth/oauth/providers/mod.rs
Comment thread crates/taskito-server/src/dashboard/auth/oauth/providers/oidc.rs Outdated
Comment thread crates/taskito-server/src/dashboard/auth/routes.rs
Comment thread crates/taskito-server/src/dashboard/auth/store.rs
Comment thread crates/taskito-server/src/dashboard/stores/webhooks.rs
Comment thread crates/taskito-server/src/dashboard/webhook_sender.rs
Comment thread crates/taskito-server/tests/oidc_login.rs
Comment thread crates/taskito-server/tests/webhook_delivery.rs Outdated
Comment thread sdks/python/tests/dashboard/test_rust_server_parity.py
A user row this build cannot parse was dropped from the listing, which shrank
count_users, reported setup_required, and reopened unauthenticated setup. Roles
now degrade to viewer instead of failing to deserialize, and the count reads
stored rows.

An OAuth callback carried nothing tying it to the browser that began the flow,
so an attacker holding a valid code could have a victim's browser finish it and
land in the attacker's account. A short-lived HttpOnly SameSite=Lax marker is
now required, checked before the state row is read so a forged callback cannot
cancel a pending login either.

A cached JWKS never expired, so every login failed after the issuer rotated
keys until the process restarted; a kid miss now refetches once. Readiness
answers 503 when degraded, since orchestrators read the status and not the
body. A password change revokes the user's other sessions. The webhook
response read is bounded rather than buffered whole, and the readiness error
detail is one capped line rather than a driver string that can carry a DSN.
The guard split the authority by hand while reqwest parsed it properly, so the
two could disagree about where the host ends — and a check that inspects one
host while the request dials another is not a check. Both now use url::Url.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
crates/taskito-server/tests/oidc_login.rs (1)

400-415: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the no-op upsert_provider_user call.

store::upsert_provider_user(backend, SLOT, "provider-subject-1", None, None, Role::Admin) has no effect on the role here: the user already exists from the earlier login, so upsert_provider_user ignores role_on_create and only refreshes email/display_name, both None in this call. The actual promotion happens through the following manual user.role = Role::Admin; store::replace_user(...). Drop the upsert_provider_user call, since it implies a promotion path that this test does not exercise.

♻️ Suggested cleanup
-    let username = format!("{SLOT}:provider-subject-1");
-    store::upsert_provider_user(backend, SLOT, "provider-subject-1", None, None, Role::Admin)
-        .expect("storage");
-    let mut users = store::list_users(backend).expect("storage");
+    let username = format!("{SLOT}:provider-subject-1");
+    let mut users = store::list_users(backend).expect("storage");
     let user = users.get_mut(&username).expect("the user exists");
     user.role = Role::Admin;
     store::replace_user(backend, user).expect("persist the promotion");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/taskito-server/tests/oidc_login.rs` around lines 400 - 415, Remove the
no-op store::upsert_provider_user call from the promotion setup in the OIDC
login test. Keep the existing list_users lookup, user.role = Role::Admin
assignment, store::replace_user persistence, and final role assertion unchanged
so the test exercises only manual promotion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/taskito-server/tests/oidc_login.rs`:
- Around line 400-415: Remove the no-op store::upsert_provider_user call from
the promotion setup in the OIDC login test. Keep the existing list_users lookup,
user.role = Role::Admin assignment, store::replace_user persistence, and final
role assertion unchanged so the test exercises only manual promotion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 04496be2-626e-4068-8fe8-0085d3b53754

📥 Commits

Reviewing files that changed from the base of the PR and between 6082b13 and 56e0403.

⛔ Files ignored due to path filters (1)
  • crates/taskito-server/tests/fixtures/oidc_test_key_rotated.pem is excluded by !**/*.pem
📒 Files selected for processing (22)
  • crates/taskito-server/src/config/dashboard.rs
  • crates/taskito-server/src/dashboard/auth/context.rs
  • crates/taskito-server/src/dashboard/auth/cookies.rs
  • crates/taskito-server/src/dashboard/auth/model.rs
  • crates/taskito-server/src/dashboard/auth/oauth/mod.rs
  • crates/taskito-server/src/dashboard/auth/oauth/providers/mod.rs
  • crates/taskito-server/src/dashboard/auth/oauth/providers/oidc.rs
  • crates/taskito-server/src/dashboard/auth/routes.rs
  • crates/taskito-server/src/dashboard/auth/store.rs
  • crates/taskito-server/src/dashboard/probes.rs
  • crates/taskito-server/src/dashboard/routes/webhooks.rs
  • crates/taskito-server/src/dashboard/routes/workflows.rs
  • crates/taskito-server/src/dashboard/stores/url_safety.rs
  • crates/taskito-server/src/dashboard/webhook_sender.rs
  • crates/taskito-server/tests/fixtures/oidc_test_jwks_rotated.json
  • crates/taskito-server/tests/github_login.rs
  • crates/taskito-server/tests/oauth_flow.rs
  • crates/taskito-server/tests/oidc_login.rs
  • crates/taskito-server/tests/support/mod.rs
  • crates/taskito-server/tests/support/oidc_issuer.rs
  • crates/taskito-server/tests/webhook_delivery.rs
  • sdks/python/tests/dashboard/test_rust_server_parity.py
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • ByteVeda/taskito (manual)
🚧 Files skipped from review as they are similar to previous changes (12)
  • crates/taskito-server/tests/webhook_delivery.rs
  • crates/taskito-server/tests/github_login.rs
  • crates/taskito-server/tests/oauth_flow.rs
  • crates/taskito-server/src/dashboard/auth/oauth/providers/mod.rs
  • crates/taskito-server/src/config/dashboard.rs
  • sdks/python/tests/dashboard/test_rust_server_parity.py
  • crates/taskito-server/src/dashboard/auth/oauth/mod.rs
  • crates/taskito-server/src/dashboard/routes/workflows.rs
  • crates/taskito-server/src/dashboard/routes/webhooks.rs
  • crates/taskito-server/src/dashboard/auth/routes.rs
  • crates/taskito-server/src/dashboard/auth/context.rs
  • crates/taskito-server/src/dashboard/auth/model.rs

@pratyush618
pratyush618 merged commit 69d9779 into master Jul 31, 2026
33 checks passed
@pratyush618
pratyush618 deleted the feat/executor-attach-s3-server branch July 31, 2026 16:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

New crates/taskito-server binary: scheduler, attach listener, dashboard

2 participants