diff --git a/.claude/rules/23-cross-boundary-contract-tests.md b/.claude/rules/23-cross-boundary-contract-tests.md index 97f72b6ba2..ef634c644f 100644 --- a/.claude/rules/23-cross-boundary-contract-tests.md +++ b/.claude/rules/23-cross-boundary-contract-tests.md @@ -121,6 +121,30 @@ passes the same controls to child sessions or subagents. A runtime discriminator such as an empty container ID must have a regression test that would fail if an early return were moved back above the generated-config writer. +### Runtime Exit and Diagnostic Correlation Boundaries + +Task lifecycle and diagnostic ingestion are also cross-service contracts. When a +runtime-owned exit can end an agent prompt or session, tests MUST prove the real +runtime path emits exactly one terminal callback and that the callback reaches +the control-plane task transition. Clearing local prompt state alone is not a +terminal outcome. + +Intentional lifecycle writers such as user, parent-agent, or orchestrator stops +MUST use the canonical cancellation status, write the corresponding status +event, synchronize linked trigger executions, and run terminal cleanup. Test the +compare-and-set behavior so an intentional stop cannot overwrite a concurrent +fatal failure and hide the original cause. + +When observability producers cannot supply task or session identifiers, start +the contract test with that least-correlated producer payload. Exercise the real +ingestion and enrichment boundary, then prove that: + +- one authoritative candidate is enriched with its task and session identifiers; +- missing, cross-node, cross-session, stale, and ambiguous candidates remain + uncorrelated; +- retries may add previously missing correlation but cannot rebind an incident + to a different task or session. + ### Why This Rule Exists The R2 file upload feature shipped with two cross-boundary contract mismatches: diff --git a/.claude/rules/48-stale-while-revalidate-ui.md b/.claude/rules/48-stale-while-revalidate-ui.md index 894c6c5262..4a6041d588 100644 --- a/.claude/rules/48-stale-while-revalidate-ui.md +++ b/.claude/rules/48-stale-while-revalidate-ui.md @@ -100,10 +100,25 @@ modifying an existing one, use `useQuery`/`useMutation` instead of hand-rolled - Use `queryClient.invalidateQueries(...)` after mutations instead of `await reload()` chains threaded through context. - Use `refetchInterval` instead of hand-rolled `setInterval` polls. +- Every authenticated query key must include the resolved user identity (or an + equivalent tenant/session namespace). Clear the previous namespace and gate + protected children while that identity changes so cached data from one + account can never render for another account, even for a single frame. Hand-rolled loaders are only acceptable for genuinely non-query state (WebSockets, streaming, imperative one-shots). +### 5. Responsive shells MUST preserve routed subtree identity + +Changing between mobile and desktop chrome must not remount the routed page, +chat, composer, or media subtree. When breakpoint branches use different +sibling structures, give shared stateful slots stable keys (or keep one shared +slot outside the branches) so React can reconcile them across positions. + +Every responsive shell change must include a portrait-to-landscape regression +test that crosses the actual breakpoint and proves local child state and mount +identity survive. A static test at one viewport is insufficient. + ## Interaction-Effect Trace Requirement When adding any state change that a `useEffect` in the same tree observes @@ -123,3 +138,5 @@ Before committing UI data-fetching or context changes: - [ ] Spinners gate only on "no data yet", never on "refetch in flight" - [ ] Mutations invalidate/refresh data without unmounting visible content - [ ] New fetch surfaces use TanStack Query (or document why not) +- [ ] Authenticated query keys are identity-scoped and account transitions are gated +- [ ] Breakpoint changes preserve routed and media subtree identity diff --git a/.claude/skills/env-reference/SKILL.md b/.claude/skills/env-reference/SKILL.md index 740f7aab11..2fd169d442 100644 --- a/.claude/skills/env-reference/SKILL.md +++ b/.claude/skills/env-reference/SKILL.md @@ -114,6 +114,24 @@ See `apps/api/.env.example` for the full list. Key variables: - `DEPLOYMENT_RELEASE_RETENTION_LAST_RUN_KV_KEY` — KV interval marker (default: `cleanup:deployment-releases:last-run`) - `COMPOSE_IMAGE_ARTIFACT_CLEANUP_BATCH_SIZE` — Maximum abandoned compose archives deleted per daily run (default: `250`) +### Guided Agent Credential Setup + +- `MAX_CONCURRENT_SETUP_SESSIONS` — Concurrent Cloudflare Sandbox setup-session cap (default: `2`) +- `SETUP_SESSION_TTL_MS` — Setup-session lifetime before teardown (default: `900000`) +- `SETUP_SESSION_CAPTURE_POLL_MS` — Device-login and credential-capture poll interval (default: `3000`) +- `CODEX_DEVICE_AUTH_REQUEST_TIMEOUT_MS` — Codex app-server JSON-RPC request timeout (default: `30000`) +- `CLAUDE_SETUP_ENTER_DELAY_MS` — Delay before sending Enter as a separate stdin write after Claude's browser-displayed code is pasted into the CLI (default: `1000`) +- `CLAUDE_SETUP_EXCHANGE_TIMEOUT_MS` — Maximum wait for Claude's CLI exchange to finish after code submission (default: `120000`) +- `CLAUDE_SETUP_REJECTION_SETTLE_MS` — Wait for Ink redraws to settle before classifying the Claude CLI OAuth error line (default: `400`) +- `CLAUDE_SETUP_VERIFICATION_POLL_MS` — Poll interval for the browser-code handoff file inside the Claude setup sandbox (default: `500`) +- `CLAUDE_SETUP_TTY_COLUMNS` — PTY width used for `claude setup-token` to reduce opaque-token wrapping (default: `512`) +- `CLAUDE_SETUP_OUTPUT_BUFFER_BYTES` — Maximum in-memory Claude PTY output retained for parsing (default: `32768`) +- `CLAUDE_VERIFICATION_CODE_MAX_LENGTH` — Maximum accepted browser-displayed `code#state` length (default: `1024`) +- `CLAUDE_SETUP_ERROR_DETAIL_MAX_LENGTH` — Maximum sanitized Claude CLI diagnostic surfaced to the user (default: `160`) +- `CLAUDE_OAUTH_TOKEN_MAX_LENGTH` — Maximum captured Claude OAuth token length (default: `8192`) +- `SETUP_SESSION_SWEEP_MAX_CANDIDATES` — Maximum expired setup sessions torn down per sweep (default: `50`) +- `POOL_LEASE_BUFFER_MS` — Grace after the session TTL before a leaked setup-pool lease self-prunes (default: `300000`) + ### Operational Control Loops - `CRON_SWEEPS_ENABLED_KV_KEY` — Fail-open KV brake key for the five-minute operational sweep (default: `control-loops:cron-enabled`) @@ -214,9 +232,12 @@ by the read-only cron-liveness check. - `TASK_LIST_MAX_PAGE_SIZE` — Maximum task/project list page size - `CHAT_SESSION_MESSAGE_LIMIT` — Default page size for chat session message REST responses when no limit is requested — used by the 3s poll and load-more (default: 500) - `CHAT_SESSION_MESSAGE_MAX` — Ceiling any chat session message request is clamped to; the initial full-conversation load requests up to this (default: 50000) +- `MCP_TRIGGER_LIST_LIMIT` — Default result count for the `list_triggers` MCP tool (default: 20) +- `MCP_TRIGGER_LIST_MAX` — Maximum result count accepted by the `list_triggers` MCP tool (default: 100) ### Timeouts +- `ORCHESTRATOR_STOP_CAS_MAX_ATTEMPTS` — Maximum task-status compare-and-set attempts after a parent hard-stops a child runtime (default: 2) - `TASK_CALLBACK_TIMEOUT_MS` — Timeout budget for delegated-task callback processing - `TASK_CALLBACK_RETRY_MAX_ATTEMPTS` — Retry budget for delegated-task callback processing - `TASK_RECONCILIATION_IDLE_MS` — Idle threshold before a visible task reconciliation check-in (default: 300000) diff --git a/.github/workflows/deploy-reusable.yml b/.github/workflows/deploy-reusable.yml index add8f44a5a..2730ba9429 100644 --- a/.github/workflows/deploy-reusable.yml +++ b/.github/workflows/deploy-reusable.yml @@ -616,6 +616,11 @@ jobs: VITE_PUBLIC_WEBSITE_URL: ${{ vars.PUBLIC_WEBSITE_URL || '' }} VITE_FEATURE_MULTI_TERMINAL: 'true' VITE_DEBUG_DIAGNOSIS_EVENT_MAX_PAGES: ${{ vars.VITE_DEBUG_DIAGNOSIS_EVENT_MAX_PAGES || '100' }} + VITE_PROJECT_LIST_LIMIT: ${{ vars.VITE_PROJECT_LIST_LIMIT || '50' }} + VITE_PROJECT_POLL_INTERVAL_MS: ${{ vars.VITE_PROJECT_POLL_INTERVAL_MS || '30000' }} + VITE_SIDEBAR_PROJECT_POLL_INTERVAL_MS: ${{ vars.VITE_SIDEBAR_PROJECT_POLL_INTERVAL_MS || '60000' }} + VITE_PROJECT_PREFETCH_DELAY_MS: ${{ vars.VITE_PROJECT_PREFETCH_DELAY_MS || '120' }} + VITE_BACKGROUND_FETCH_DELAY_MS: ${{ vars.VITE_BACKGROUND_FETCH_DELAY_MS || '150' }} # Bake the deployment-built vm-agent before Wrangler builds the raw container. - name: Setup Go for Container Runtime diff --git a/.github/workflows/scheduler-lifecycle.yml b/.github/workflows/scheduler-lifecycle.yml new file mode 100644 index 0000000000..bbd01ca8ce --- /dev/null +++ b/.github/workflows/scheduler-lifecycle.yml @@ -0,0 +1,48 @@ +name: Scheduler Lifecycle Exploration + +on: + schedule: + - cron: '33 3 * * *' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: scheduler-lifecycle-${{ github.ref }} + cancel-in-progress: false + +jobs: + explore: + name: Explore Scheduler Lifecycles + timeout-minutes: 15 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + + - name: Build API dependencies + run: pnpm exec turbo run build --filter=@simple-agent-manager/api... + + - name: Explore generated scheduler lifecycles + run: | + set -o pipefail + pnpm --filter @simple-agent-manager/api test:scheduler:nightly 2>&1 | tee scheduler-lifecycle.log + + - name: Upload replay diagnostics + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: scheduler-lifecycle-replay-${{ github.run_id }} + path: scheduler-lifecycle.log + if-no-files-found: error + retention-days: 14 diff --git a/apps/api/.env.example b/apps/api/.env.example index 228187c8e4..191de109bc 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -77,6 +77,26 @@ BASE_DOMAIN=workspaces.example.com # WEBHOOK_DELIVERY_MAX_PAGE_SIZE=100 # WEBHOOK_DELIVERY_PROCESSING_LEASE_SECONDS=300 +# Guided agent credential setup via Cloudflare Sandbox +# MAX_CONCURRENT_SETUP_SESSIONS=2 +# SETUP_SESSION_TTL_MS=900000 +# SETUP_SESSION_CAPTURE_POLL_MS=3000 +# CODEX_DEVICE_AUTH_REQUEST_TIMEOUT_MS=30000 +# Claude Code v2.1.220 treats a large pasted code plus inline carriage return as +# text; keep Enter as a separate write after this settle delay. +# CLAUDE_SETUP_ENTER_DELAY_MS=1000 +# CLAUDE_SETUP_EXCHANGE_TIMEOUT_MS=120000 +# Wait for Ink redraw output to settle before classifying a mangled OAuth error. +# CLAUDE_SETUP_REJECTION_SETTLE_MS=400 +# CLAUDE_SETUP_VERIFICATION_POLL_MS=500 +# CLAUDE_SETUP_TTY_COLUMNS=512 +# CLAUDE_SETUP_OUTPUT_BUFFER_BYTES=32768 +# CLAUDE_VERIFICATION_CODE_MAX_LENGTH=1024 +# CLAUDE_SETUP_ERROR_DETAIL_MAX_LENGTH=160 +# CLAUDE_OAUTH_TOKEN_MAX_LENGTH=8192 +# SETUP_SESSION_SWEEP_MAX_CANDIDATES=50 +# POOL_LEASE_BUFFER_MS=300000 + # NOTE: Hetzner tokens are NOT platform secrets. # Users provide their own Hetzner API tokens through the Settings UI. # These are stored encrypted (per-user) in the database. @@ -532,13 +552,16 @@ INFOMANIAK_IP_POLL_INTERVAL_MS=3000 # ORCHESTRATOR_MAX_RETRIES_PER_TASK=3 # Max retry attempts per task via retry_subtask # ORCHESTRATOR_DEPENDENCY_MAX_EDGES=50 # Max dependency edges per project via add_dependency # ORCHESTRATOR_STOP_GRACE_MS=5000 # Grace period before hard stop after warning (ms) +# ORCHESTRATOR_STOP_CAS_MAX_ATTEMPTS=2 # Task-status CAS attempts after hard stop # ORCHESTRATOR_MESSAGE_MAX_LENGTH=32768 # Max length for injected messages to child agents # ORCHESTRATOR_ZERO_TASK_GRACE_MS=600000 # 10 minutes — allow tasks to land before terminalizing an empty mission # ORCHESTRATOR_MAX_MISSION_LIFETIME_MS=86400000 # 24 hours — force terminal completion of a stuck mission -# MCP get_session_messages limits +# MCP list/read limits # MCP_MESSAGE_LIST_LIMIT=50 # Default number of raw tokens fetched per request # MCP_MESSAGE_LIST_MAX=200 # Max raw tokens per request (before grouping into logical messages) +# MCP_TRIGGER_LIST_LIMIT=20 # Default page size for list_triggers +# MCP_TRIGGER_LIST_MAX=100 # Max page size for list_triggers # MCP idea management limits # MCP_IDEA_CONTENT_MAX_LENGTH=65536 # Max length for idea content/description (64 KB) diff --git a/apps/api/package.json b/apps/api/package.json index 79e9210fc8..cec8e6103b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -9,6 +9,8 @@ "build": "tsc", "dev": "wrangler dev --port ${WRANGLER_PORT:-8787}", "test": "vitest run", + "test:scheduler": "vitest run tests/simulation/scheduler-lifecycle-simulation.test.ts", + "test:scheduler:nightly": "SCHEDULER_SIM_PROFILE=nightly vitest run tests/simulation/scheduler-lifecycle-simulation.test.ts", "test:debugging-workers": "vitest run --config vitest.debugging.workers.config.ts", "test:workers": "vitest run --config vitest.workers.config.ts", "test:watch": "vitest", diff --git a/apps/api/scripts/claude-setup-token.mjs b/apps/api/scripts/claude-setup-token.mjs index 642586e645..5045796867 100644 --- a/apps/api/scripts/claude-setup-token.mjs +++ b/apps/api/scripts/claude-setup-token.mjs @@ -1,24 +1,54 @@ #!/usr/bin/env node import { spawn } from 'node:child_process'; -import { rename, writeFile } from 'node:fs/promises'; +import { readFile, rename, unlink, writeFile } from 'node:fs/promises'; import { join, parse, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { createInterface } from 'node:readline'; const MAX_VERIFICATION_URL_LENGTH = 4096; const MAX_USER_CODE_LENGTH = 128; -const MAX_CLAUDE_TOKEN_LENGTH = 8192; const CLAUDE_OAUTH_TOKEN_PREFIX = 'sk-ant-oat'; const CLAUDE_CONFIG_DIR_ENV = 'CLAUDE_CONFIG_DIR'; const DEVICE_AUTH_STATE_FILE = 'device-auth-state.json'; const CLAUDE_OAUTH_TOKEN_FILE = 'claude-oauth-token.txt'; +const VERIFICATION_CODE_FILE = 'verification-code.txt'; const ANSI_ESCAPE_PATTERN = /\u001b\[[0-9;?]*[ -/]*[@-~]/g; const CONTROL_CHARACTER_PATTERN = /[\u0000-\u001f\u007f]/; const CLAUDE_SETUP_COMMAND = 'env DISABLE_AUTOUPDATER=1 NO_COLOR=1 TERM=xterm-256color claude setup-token'; const URL_PATTERN = /https:\/\/[^\s<>'"`]+/gi; -const TOKEN_PATTERN = /\bsk-ant-oat[A-Za-z0-9._-]{16,}\b/g; +const TOKEN_PATTERN = /\bsk-ant-oat[A-Za-z0-9._-]{16,}/g; +// The Ink error screen always renders `OAuth error: ` and then waits for a +// retry keypress without exiting — treat ANY such marker after the code was +// forwarded as terminal. Requiring a specific status-code suffix here made real +// failures (401 "Authentication failed", state mismatch, network errors) hang +// until the session TTL. +const OAUTH_REJECTION_PATTERN = /Oa?u?t?h?\s*er?r?o?r?\s*:/i; +// Ink redraws overwrite characters in place, so the surviving text can drop +// letters and spaces ("Requstfailed withstatus code 400"). Classification +// patterns must tolerate that mangling — match with optional gaps, never on +// exact prose. +const OAUTH_ERROR_LINE_PATTERN = /Oa?u?t?h?\s*er?r?o?r?\s*:\s*([^\n\r]*)/gi; +const OAUTH_RETRY_SUFFIX_PATTERN = /Press\s*Enter\s*to\s*retry.*$/i; +const OAUTH_INCOMPLETE_CODE_PATTERN = /(?:inv\w{0,5}\s*c\w{0,3}de|full\w{0,3}c\w{0,4}cop\w{0,3})/i; +const OAUTH_STATUS_CODE_PATTERN = /status\s*code\s*(\d{3})/i; +const OAUTH_NETWORK_ERROR_PATTERN = + /(ECONN|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|getaddrinfo|socket|network|fetch\s*fail|tunnel|conn\w{0,4}ion|CONNECT\s*response)/i; +const SECRET_LIKE_PATTERN = /sk-ant[A-Za-z0-9._-]*/gi; +const DEFAULT_VERIFICATION_ENTER_DELAY_MS = 1000; +const DEFAULT_EXCHANGE_TIMEOUT_MS = 120_000; +const DEFAULT_REJECTION_SETTLE_MS = 400; +const DEFAULT_VERIFICATION_CODE_POLL_MS = 500; +const DEFAULT_TTY_COLUMNS = 512; +const DEFAULT_OUTPUT_BUFFER_BYTES = 32_768; +const DEFAULT_VERIFICATION_CODE_MAX_LENGTH = 1_024; +const DEFAULT_OAUTH_ERROR_DETAIL_MAX_LENGTH = 160; +const DEFAULT_CLAUDE_OAUTH_TOKEN_MAX_LENGTH = 8_192; + +function positiveIntFromEnv(name, fallback) { + const value = Number(process.env[name]); + return Number.isFinite(value) && value > 0 ? value : fallback; +} const CODE_PATTERNS = [ /(?:verification|one[- ]time|device)?\s*code[^A-Za-z0-9-]{0,40}([A-Z0-9][A-Z0-9-]{3,127})/i, /enter\s+(?:this\s+|the\s+)?(?:code\s+)?([A-Z0-9][A-Z0-9-]{3,127})/i, @@ -59,12 +89,18 @@ export function validateClaudeVerificationUrl(value) { return url.toString(); } -export function validateClaudeOauthToken(value) { +export function validateClaudeOauthToken( + value, + maxLength = positiveIntFromEnv( + 'CLAUDE_OAUTH_TOKEN_MAX_LENGTH', + DEFAULT_CLAUDE_OAUTH_TOKEN_MAX_LENGTH + ) +) { const token = value.trim(); if (!token.startsWith(CLAUDE_OAUTH_TOKEN_PREFIX)) { throw new Error('Claude setup-token returned an invalid OAuth token prefix'); } - if (token.length > MAX_CLAUDE_TOKEN_LENGTH) { + if (token.length > maxLength) { throw new Error('Claude setup-token returned an overlong OAuth token'); } if (!/^[A-Za-z0-9._-]+$/.test(token)) { @@ -73,9 +109,43 @@ export function validateClaudeOauthToken(value) { return token; } +function extractClaudeOauthToken(text, ttyColumns, acceptStreamEnd) { + TOKEN_PATTERN.lastIndex = 0; + const match = TOKEN_PATTERN.exec(text); + if (!match?.[0] || match.index === undefined) return undefined; + + let token = match[0]; + let cursor = match.index + match[0].length; + let lineStart = text.lastIndexOf('\n', match.index - 1) + 1; + + for (;;) { + const newlineLength = text.startsWith('\r\n', cursor) + ? 2 + : text.startsWith('\n', cursor) + ? 1 + : 0; + if (newlineLength === 0) { + // A token-like suffix at the current end of the stream may still be a + // partial PTY chunk. Wait for a delimiter before accepting it. + return cursor === text.length && !acceptStreamEnd ? undefined : token; + } + + const physicalLineLength = cursor - lineStart; + if (physicalLineLength < ttyColumns) return token; + + const continuationStart = cursor + newlineLength; + const continuation = /^[A-Za-z0-9._-]+/.exec(text.slice(continuationStart)); + if (!continuation?.[0]) return token; + token += continuation[0]; + cursor = continuationStart + continuation[0].length; + lineStart = continuationStart; + } +} + export function resolveClaudeSetupPaths({ statePath, credentialPath, + verificationCodePath, configDir = process.env[CLAUDE_CONFIG_DIR_ENV], }) { if (!configDir) { @@ -89,6 +159,7 @@ export function resolveClaudeSetupPaths({ const expectedStatePath = join(baseDir, DEVICE_AUTH_STATE_FILE); const expectedCredentialPath = join(baseDir, CLAUDE_OAUTH_TOKEN_FILE); + const expectedVerificationCodePath = join(baseDir, VERIFICATION_CODE_FILE); if (resolve(statePath) !== expectedStatePath) { throw new Error('Claude setup-token state path must be the expected setup state file'); @@ -97,10 +168,15 @@ export function resolveClaudeSetupPaths({ throw new Error('Claude setup-token credential path must be the expected OAuth token file'); } + if (resolve(verificationCodePath) !== expectedVerificationCodePath) { + throw new Error('Claude setup-token verification code path must be the expected setup file'); + } + return { statePath: expectedStatePath, temporaryStatePath: join(baseDir, `${DEVICE_AUTH_STATE_FILE}.tmp`), credentialPath: expectedCredentialPath, + verificationCodePath: expectedVerificationCodePath, temporaryCredentialPath: join(baseDir, `${CLAUDE_OAUTH_TOKEN_FILE}.tmp`), }; } @@ -117,7 +193,11 @@ function validateUserCode(value) { return code; } -export function extractClaudeSetupOutput(raw) { +export function extractClaudeSetupOutput( + raw, + ttyColumns = DEFAULT_TTY_COLUMNS, + acceptStreamEnd = true +) { const text = stripAnsi(raw); let verificationUrl; for (const match of text.matchAll(URL_PATTERN)) { @@ -142,23 +222,103 @@ export function extractClaudeSetupOutput(raw) { } } - let token; - TOKEN_PATTERN.lastIndex = 0; - const tokenMatch = TOKEN_PATTERN.exec(text); - if (tokenMatch?.[0]) token = validateClaudeOauthToken(tokenMatch[0]); + const tokenCandidate = extractClaudeOauthToken(text, ttyColumns, acceptStreamEnd); + const token = tokenCandidate ? validateClaudeOauthToken(tokenCandidate) : undefined; return { verificationUrl, userCode, token }; } +/** + * Pull the last rendered `OAuth error: ` line out of the (ANSI-stripped) + * CLI output and reduce it to a short, non-secret diagnostic. The wording is the + * only signal distinguishing an incomplete paste, a server 4xx, and a sandbox + * network failure — discarding it turns every failure into "code rejected". + */ +export function extractOauthErrorDetail( + text, + maxLength = positiveIntFromEnv( + 'CLAUDE_SETUP_ERROR_DETAIL_MAX_LENGTH', + DEFAULT_OAUTH_ERROR_DETAIL_MAX_LENGTH + ) +) { + let lastLine; + OAUTH_ERROR_LINE_PATTERN.lastIndex = 0; + for (const match of text.matchAll(OAUTH_ERROR_LINE_PATTERN)) { + if (match[1]) lastLine = match[1]; + } + if (!lastLine) return null; + const detail = lastLine + .replace(OAUTH_RETRY_SUFFIX_PATTERN, '') + .replace(SECRET_LIKE_PATTERN, '[redacted]') + .replace(new RegExp(CONTROL_CHARACTER_PATTERN.source, 'g'), ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLength); + return detail || null; +} + +/** + * Classify an OAuth error line into a machine-readable failure class so the + * control plane can give accurate guidance instead of always claiming the code + * was rejected. + */ +export function classifyOauthError(detail) { + const text = detail ?? ''; + if (OAUTH_INCOMPLETE_CODE_PATTERN.test(text)) { + return { + code: 'code_incomplete', + message: 'Claude reported the pasted verification code was incomplete', + }; + } + if (OAUTH_STATUS_CODE_PATTERN.test(text)) { + return { code: 'code_rejected', message: 'Claude rejected the verification code' }; + } + if (OAUTH_NETWORK_ERROR_PATTERN.test(text)) { + return { + code: 'exchange_network_error', + message: 'Claude sign-in failed with a network error during the code exchange', + }; + } + return { code: 'code_rejected', message: 'Claude rejected the verification code' }; +} + export async function runClaudeSetupToken({ statePath, credentialPath, + verificationCodePath, spawnProcess = spawn, onSpawn, writeState, writeCredential, + readVerificationCode = (path) => readFile(path, 'utf8'), + deleteVerificationCode = unlink, + verificationCodePollMs = positiveIntFromEnv( + 'CLAUDE_SETUP_VERIFICATION_POLL_MS', + DEFAULT_VERIFICATION_CODE_POLL_MS + ), + ttyColumns = positiveIntFromEnv('CLAUDE_SETUP_TTY_COLUMNS', DEFAULT_TTY_COLUMNS), + outputBufferBytes = positiveIntFromEnv( + 'CLAUDE_SETUP_OUTPUT_BUFFER_BYTES', + DEFAULT_OUTPUT_BUFFER_BYTES + ), + verificationCodeMaxLength = positiveIntFromEnv( + 'CLAUDE_VERIFICATION_CODE_MAX_LENGTH', + DEFAULT_VERIFICATION_CODE_MAX_LENGTH + ), + verificationEnterDelayMs = positiveIntFromEnv( + 'CLAUDE_SETUP_ENTER_DELAY_MS', + DEFAULT_VERIFICATION_ENTER_DELAY_MS + ), + exchangeTimeoutMs = positiveIntFromEnv( + 'CLAUDE_SETUP_EXCHANGE_TIMEOUT_MS', + DEFAULT_EXCHANGE_TIMEOUT_MS + ), + rejectionSettleMs = positiveIntFromEnv( + 'CLAUDE_SETUP_REJECTION_SETTLE_MS', + DEFAULT_REJECTION_SETTLE_MS + ), }) { - const setupPaths = resolveClaudeSetupPaths({ statePath, credentialPath }); + const setupPaths = resolveClaudeSetupPaths({ statePath, credentialPath, verificationCodePath }); const writeStateFile = writeState ?? (async (state) => { @@ -177,20 +337,30 @@ export async function runClaudeSetupToken({ // `script` to allocate a pseudo-terminal while still capturing stdout/stderr // for non-secret URL/token parsing. The transcript path is /dev/null so no // token-bearing terminal log is persisted. - const claude = spawnProcess('script', ['-qfec', CLAUDE_SETUP_COMMAND, '/dev/null'], { - env: { - ...process.env, - DISABLE_AUTOUPDATER: '1', - NO_COLOR: '1', - TERM: 'xterm-256color', - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); + const claude = spawnProcess( + 'script', + ['-qfec', `stty cols ${ttyColumns}; ${CLAUDE_SETUP_COMMAND}`, '/dev/null'], + { + env: { + ...process.env, + DISABLE_AUTOUPDATER: '1', + NO_COLOR: '1', + TERM: 'xterm-256color', + }, + stdio: ['pipe', 'pipe', 'pipe'], + } + ); onSpawn?.(claude); let publishedWaiting = false; + let verificationCodeForwarded = false; let tokenCaptured = false; let terminalStatePublished = false; + let verificationCodePoll; + let verificationEnterTimer; + let exchangeDeadlineTimer; + let rejectionSettleTimer; + let outputBuffer = ''; let stateWriteQueue = Promise.resolve(); let settled = false; let resolveReady; @@ -215,8 +385,25 @@ export async function runClaudeSetupToken({ return stateWriteQueue; } - function publishFailure(message) { - void publishState({ status: 'failed', error: message }).finally(() => { + function clearForwardingTimers() { + if (verificationCodePoll) clearInterval(verificationCodePoll); + verificationCodePoll = undefined; + if (verificationEnterTimer) clearTimeout(verificationEnterTimer); + verificationEnterTimer = undefined; + if (exchangeDeadlineTimer) clearTimeout(exchangeDeadlineTimer); + exchangeDeadlineTimer = undefined; + if (rejectionSettleTimer) clearTimeout(rejectionSettleTimer); + rejectionSettleTimer = undefined; + } + + function publishFailure(message, code, detail) { + clearForwardingTimers(); + void publishState({ + status: 'failed', + error: message, + ...(code ? { code } : {}), + ...(detail ? { detail } : {}), + }).finally(() => { settleReady(new Error(message)); }); } @@ -228,12 +415,54 @@ export async function runClaudeSetupToken({ status: 'waiting_for_user', verificationUrl: details.verificationUrl, userCode: details.userCode ?? null, - }).then(() => settleReady()); + }).then(() => { + verificationCodePoll = setInterval(async () => { + try { + const code = await readVerificationCode(setupPaths.verificationCodePath); + await deleteVerificationCode(setupPaths.verificationCodePath); + const normalizedCode = code.replace(/\s+/g, ''); + if (normalizedCode.length === 0 || normalizedCode.length > verificationCodeMaxLength) { + throw new Error('Invalid Claude verification code length'); + } + clearInterval(verificationCodePoll); + verificationCodePoll = undefined; + verificationCodeForwarded = true; + // Claude Code's interactive prompt treats one large stdin chunk as a + // paste and absorbs a trailing carriage return instead of submitting + // (reproduced with ~100-char real codes; short test codes submit). + // Write the code, then send Enter as a SEPARATE write after a settle + // delay so the CLI registers a real submit keypress. + claude.stdin.write(normalizedCode); + verificationEnterTimer = setTimeout(() => { + verificationEnterTimer = undefined; + claude.stdin.write('\r'); + // The exchange is a bounded HTTP round-trip: any outcome the parser + // does not recognize (unknown error wording, hung request) must fail + // visibly instead of stalling until the session TTL. + exchangeDeadlineTimer = setTimeout(() => { + exchangeDeadlineTimer = undefined; + publishFailure( + 'Claude did not finish the verification code exchange in time', + 'exchange_timeout' + ); + claude.kill('SIGTERM'); + }, exchangeTimeoutMs); + }, verificationEnterDelayMs); + } catch (error) { + if (error?.code !== 'ENOENT') { + publishFailure('Claude verification code could not be forwarded'); + claude.kill('SIGTERM'); + } + } + }, verificationCodePollMs); + settleReady(); + }); } function maybeCaptureToken(details) { if (tokenCaptured || !details.token) return; tokenCaptured = true; + clearForwardingTimers(); void writeCredentialFile(details.token) .then(() => publishState({ status: 'completed' })) .then(() => settleReady()) @@ -245,21 +474,40 @@ export async function runClaudeSetupToken({ }); } - function processLine(line) { + function processOutput(chunk) { + outputBuffer = `${outputBuffer}${chunk}`.slice(-outputBufferBytes); let details; try { - details = extractClaudeSetupOutput(line); + details = extractClaudeSetupOutput(outputBuffer, ttyColumns, false); } catch (error) { publishFailure(error instanceof Error ? error.message : String(error)); claude.kill('SIGTERM'); return; } maybePublishWaiting(details); + if ( + verificationCodeForwarded && + !rejectionSettleTimer && + OAUTH_REJECTION_PATTERN.test(stripAnsi(outputBuffer)) + ) { + // The error screen can arrive across several PTY chunks; wait one settle + // window so the captured detail is the complete rendered line, then + // classify it so the control plane can distinguish an incomplete paste, + // a server rejection, and a sandbox network failure. + rejectionSettleTimer = setTimeout(() => { + rejectionSettleTimer = undefined; + const detail = extractOauthErrorDetail(stripAnsi(outputBuffer)); + const { code, message } = classifyOauthError(detail); + publishFailure(message, code, detail); + claude.kill('SIGTERM'); + }, rejectionSettleMs); + return; + } maybeCaptureToken(details); } - createInterface({ input: claude.stdout }).on('line', processLine); - createInterface({ input: claude.stderr }).on('line', processLine); + claude.stdout.on('data', processOutput); + claude.stderr.on('data', processOutput); claude.on('error', (error) => { publishFailure(error.message); @@ -273,21 +521,30 @@ export async function runClaudeSetupToken({ return ready; } -if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { - const statePath = process.argv[2]; - const credentialPath = process.argv[3]; - if (!statePath || !credentialPath) { - process.stderr.write('Usage: claude-setup-token.mjs \n'); +export function runClaudeSetupTokenCli(argv = process.argv, runner = runClaudeSetupToken) { + const statePath = argv[2]; + const credentialPath = argv[3]; + const verificationCodePath = argv[4]; + if (!statePath || !credentialPath || !verificationCodePath) { + process.stderr.write( + 'Usage: claude-setup-token.mjs \n' + ); process.exitCode = 2; - } else { - runClaudeSetupToken({ - statePath, - credentialPath, - onSpawn: (claude) => { - process.once('SIGTERM', () => claude.kill('SIGTERM')); - }, - }).catch(() => { - process.exitCode = 1; - }); + return; } + + return runner({ + statePath, + credentialPath, + verificationCodePath, + onSpawn: (claude) => { + process.once('SIGTERM', () => claude.kill('SIGTERM')); + }, + }); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + Promise.resolve(runClaudeSetupTokenCli()).catch(() => { + process.exitCode = 1; + }); } diff --git a/apps/api/src/db/migrations/0114_credential_setup_exchanging_status.sql b/apps/api/src/db/migrations/0114_credential_setup_exchanging_status.sql new file mode 100644 index 0000000000..8bb0d0446d --- /dev/null +++ b/apps/api/src/db/migrations/0114_credential_setup_exchanging_status.sql @@ -0,0 +1,15 @@ +-- Keep the one-active guided-login invariant while Claude Code is exchanging +-- the browser-displayed verification code inside its sandboxed CLI. +DROP INDEX IF EXISTS idx_acss_one_active; + +CREATE UNIQUE INDEX idx_acss_one_active + ON agent_credential_setup_sessions(user_id, agent_type) + WHERE status IN ( + 'creating', + 'admitting', + 'provisioning', + 'waiting_for_user', + 'exchanging', + 'capturing', + 'saving' + ); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 00c11ea13e..45dd53c4cd 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -243,7 +243,7 @@ export const agentCredentialSetupSessions = sqliteTable( scope: text('scope').notNull().default('user'), // 'user' | 'project' agentType: text('agent_type').notNull(), credentialKind: text('credential_kind').notNull().default('oauth-token'), - /** creating|admitting|provisioning|waiting_for_user|capturing|saving|completed|failed|cancelled|expired */ + /** creating|admitting|provisioning|waiting_for_user|exchanging|capturing|saving|completed|failed|cancelled|expired */ status: text('status').notNull().default('creating'), /** Cloudflare Sandbox id (== setup session id, 1:1, never shared across users). */ sandboxId: text('sandbox_id').notNull(), @@ -265,7 +265,7 @@ export const agentCredentialSetupSessions = sqliteTable( oneActive: uniqueIndex('idx_acss_one_active') .on(table.userId, table.agentType) .where( - sql`status IN ('creating', 'admitting', 'provisioning', 'waiting_for_user', 'capturing', 'saving')` + sql`status IN ('creating', 'admitting', 'provisioning', 'waiting_for_user', 'exchanging', 'capturing', 'saving')` ), sweep: index('idx_acss_sweep').on(table.status, table.expiresAt), userLookup: index('idx_acss_user').on(table.userId, table.createdAt), diff --git a/apps/api/src/durable-objects/credential-setup-session/index.ts b/apps/api/src/durable-objects/credential-setup-session/index.ts index 209751d376..2053e7f829 100644 --- a/apps/api/src/durable-objects/credential-setup-session/index.ts +++ b/apps/api/src/durable-objects/credential-setup-session/index.ts @@ -1,11 +1,13 @@ /** * CredentialSetupSession — per-session Durable Object that drives one guided * agent login inside a short-lived Cloudflare Sandbox. + * FILE SIZE EXCEPTION: This monolithic lifecycle DO is intentionally kept intact + * for the PR refresh; split state-machine concerns in a dedicated follow-up. * * One DO per setup session (keyed by the session id, which is ALSO the sandbox * id — 1:1, never shared across users). The DO owns the lifecycle state machine: * - * creating -> provisioning -> waiting_for_user -> capturing -> saving + * creating -> provisioning -> admitting -> waiting_for_user -> exchanging -> capturing -> saving * -> completed | failed | cancelled | expired * * It provisions a per-session credential home, starts the provider setup driver, @@ -32,6 +34,9 @@ import type { Env } from '../../env'; import { log } from '../../lib/logger'; import { saveAgentCredentialForUser } from '../../services/agent-credential-save'; import { + getClaudeOauthTokenMaxLength, + getClaudeSetupErrorDetailMaxLength, + getClaudeVerificationCodeMaxLength, isTerminalSetupStatus, type SetupSessionStatus, } from '../../services/credential-setup-config'; @@ -99,6 +104,8 @@ type DeviceAuthDetailsRow = { /** Relative paths of captured credential files inside the per-session setup home. */ const CODEX_AUTH_FILE = 'auth.json'; const CLAUDE_OAUTH_TOKEN_FILE = 'claude-oauth-token.txt'; +const CLAUDE_VERIFICATION_CODE_FILE = 'verification-code.txt'; +const CLAUDE_VERIFICATION_CODE_PATTERN = /^[A-Za-z0-9._~#-]+$/; const DEVICE_AUTH_STATE_FILE = 'device-auth-state.json'; function setupDisplayName(agentType: string): string { @@ -118,6 +125,27 @@ interface DeviceAuthState { verificationUrl?: string; userCode?: string | null; error?: string | null; + /** Optional machine-readable failure class from the driver (e.g. `exchange_timeout`). */ + code?: string; + /** Optional short diagnostic extracted from the provider CLI's error screen. */ + detail?: string | null; +} + +/** + * The driver state file is written inside the sandbox, so treat its diagnostic + * text as untrusted: printable ASCII only, secrets redacted, hard length cap. + * The driver's free-form `error` field is never surfaced — only this bounded + * detail — mirroring the sanitized-failure posture of the existing mapping. + */ +function sanitizeDriverDetail(detail: string | null | undefined, maxLength: number): string | null { + if (typeof detail !== 'string') return null; + const cleaned = detail + .replace(/sk-ant[A-Za-z0-9._-]*/gi, '[redacted]') + .replace(/[^\x20-\x7e]+/g, ' ') + .replace(/\s+/g, ' ') + .trim() + .slice(0, maxLength); + return cleaned.length > 0 ? cleaned : null; } /** @@ -131,6 +159,8 @@ const DeviceAuthStateSchema = v.object({ verificationUrl: v.optional(v.string()), userCode: v.optional(v.nullable(v.string())), error: v.optional(v.nullable(v.string())), + code: v.optional(v.string()), + detail: v.optional(v.nullable(v.string())), }); export class CredentialSetupSession extends DurableObject { @@ -215,7 +245,10 @@ export class CredentialSetupSession extends DurableObject { getState(): SetupSessionStateResult | null { const row = this.readRow(); if (!row) return null; - const deviceAuth = row.status === 'waiting_for_user' ? this.readDeviceAuthDetails() : null; + const deviceAuth = + row.status === 'waiting_for_user' || row.status === 'exchanging' + ? this.readDeviceAuthDetails() + : null; return { id: row.id, status: row.status as SetupSessionStatus, @@ -270,6 +303,38 @@ export class CredentialSetupSession extends DurableObject { }; } + /** Forward the browser-displayed short-lived code to the sandboxed Claude CLI. */ + async submitVerificationCode(code: string): Promise { + const row = this.readRow(); + if (!row) throw new Error('Setup session not found'); + if (row.agent_type !== 'claude-code') { + throw new Error('Verification code submission is only supported for Claude Code setup'); + } + if (row.status !== 'waiting_for_user') { + throw new Error('Claude Code setup is not waiting for a verification code'); + } + + const normalizedCode = code.trim().replace(/\s+/g, ''); + if ( + normalizedCode.length === 0 || + normalizedCode.length > getClaudeVerificationCodeMaxLength(this.env) || + !CLAUDE_VERIFICATION_CODE_PATTERN.test(normalizedCode) + ) { + throw new Error('Invalid Claude verification code'); + } + + const sandbox = await getSandboxInstance(this.env, row.id); + await sandbox.writeFile(`${row.codex_home}/${CLAUDE_VERIFICATION_CODE_FILE}`, normalizedCode); + const current = this.readRow(); + if (!current || current.status !== 'waiting_for_user') { + throw new Error('Claude Code setup is no longer waiting for a verification code'); + } + this.setStatus(row.id, 'exchanging'); + await this.updateD1Status(row.id, 'exchanging'); + await this.ctx.storage.setAlarm(Date.now() + row.capture_poll_ms); + return this.getState() ?? this.terminalState(row, 'exchanging'); + } + /** * Alarm loop: provisions on the first tick, then polls for the captured * auth.json, and enforces the TTL. Every branch either reschedules the alarm @@ -302,7 +367,50 @@ export class CredentialSetupSession extends DurableObject { await this.pollDeviceAuth(row); return; } - // waiting_for_user | capturing | saving — poll for the credential file. + if (row.status === 'saving') { + await this.ctx.storage.setAlarm(Date.now() + row.capture_poll_ms); + return; + } + // waiting_for_user | exchanging | capturing — observe driver failure and capture output. + const driverState = await this.readDeviceAuthState(row); + if (driverState?.status === 'failed') { + let errorCode = 'setup_failed'; + let errorMessage = 'Claude Code could not complete sign-in'; + if (row.status === 'exchanging') { + switch (driverState.code) { + case 'exchange_timeout': + errorCode = 'exchange_timeout'; + errorMessage = + 'Claude sign-in did not complete in time. Start again and paste a fresh code.'; + break; + case 'code_incomplete': + errorCode = 'code_incomplete'; + errorMessage = + 'The pasted code was incomplete. Copy the entire code Claude shows — it has a # in the middle — then start again.'; + break; + case 'exchange_network_error': + errorCode = 'exchange_network_error'; + errorMessage = + 'The sign-in sandbox hit a network error talking to Claude. Start again in a moment.'; + break; + default: + errorCode = 'code_rejected'; + errorMessage = + 'Claude rejected the verification code. Start again and use a fresh code.'; + } + } + const detail = sanitizeDriverDetail( + driverState.detail, + getClaudeSetupErrorDetailMaxLength(this.env) + ); + await this.teardown( + row, + 'failed', + errorCode, + detail ? `${errorMessage} [CLI: ${detail}]` : errorMessage + ); + return; + } await this.attemptCapture(row); } catch (err) { // Unexpected transient error — log and reschedule; the TTL guard bounds @@ -367,11 +475,37 @@ export class CredentialSetupSession extends DurableObject { private startSetupDriverCommand(row: SetupSessionRow, statePath: string): string { if (row.agent_type === 'claude-code') { const credentialPath = `${row.codex_home}/${CLAUDE_OAUTH_TOKEN_FILE}`; + const verificationCodePath = `${row.codex_home}/${CLAUDE_VERIFICATION_CODE_FILE}`; + const enterDelayEnv = this.env.CLAUDE_SETUP_ENTER_DELAY_MS + ? ` CLAUDE_SETUP_ENTER_DELAY_MS=${shellQuote(this.env.CLAUDE_SETUP_ENTER_DELAY_MS)}` + : ''; + const exchangeTimeoutEnv = this.env.CLAUDE_SETUP_EXCHANGE_TIMEOUT_MS + ? ` CLAUDE_SETUP_EXCHANGE_TIMEOUT_MS=${shellQuote(this.env.CLAUDE_SETUP_EXCHANGE_TIMEOUT_MS)}` + : ''; + const rejectionSettleEnv = this.env.CLAUDE_SETUP_REJECTION_SETTLE_MS + ? ` CLAUDE_SETUP_REJECTION_SETTLE_MS=${shellQuote(this.env.CLAUDE_SETUP_REJECTION_SETTLE_MS)}` + : ''; + const extraConfigEnv = [ + 'CLAUDE_SETUP_VERIFICATION_POLL_MS', + 'CLAUDE_SETUP_TTY_COLUMNS', + 'CLAUDE_SETUP_OUTPUT_BUFFER_BYTES', + 'CLAUDE_VERIFICATION_CODE_MAX_LENGTH', + 'CLAUDE_SETUP_ERROR_DETAIL_MAX_LENGTH', + 'CLAUDE_OAUTH_TOKEN_MAX_LENGTH', + ] + .flatMap((name) => { + const value = this.env[name as keyof Env]; + return typeof value === 'string' && value.length > 0 + ? [` ${name}=${shellQuote(value)}`] + : []; + }) + .join(''); return ( `nohup env CLAUDE_CONFIG_DIR=${shellQuote(row.codex_home)} ` + - 'DISABLE_AUTOUPDATER=1 NO_COLOR=1 TERM=dumb ' + + 'DISABLE_AUTOUPDATER=1 NO_COLOR=1 TERM=dumb' + + `${enterDelayEnv}${exchangeTimeoutEnv}${rejectionSettleEnv}${extraConfigEnv} ` + `node /usr/local/bin/sam-claude-setup-token.mjs ${shellQuote(statePath)} ` + - `${shellQuote(credentialPath)} >/dev/null 2>&1 &` + `${shellQuote(credentialPath)} ${shellQuote(verificationCodePath)} >/dev/null 2>&1 &` ); } @@ -486,7 +620,8 @@ export class CredentialSetupSession extends DurableObject { const validation = CredentialValidator.validateCredential( content, row.credential_kind as CredentialKind, - row.agent_type as AgentType + row.agent_type as AgentType, + getClaudeOauthTokenMaxLength(this.env) ); if (!validation.valid) { log.info('credential_setup.auth_file_not_ready', { @@ -597,6 +732,23 @@ export class CredentialSetupSession extends DurableObject { // Helpers // --------------------------------------------------------------------------- + private terminalState( + row: SetupSessionRow, + status: SetupSessionStatus, + errorCode: string | null = null, + errorMessage: string | null = null + ): SetupSessionStateResult { + return { + id: row.id, + status, + expiresAt: row.expires_at, + errorCode, + errorMessage, + verificationUrl: null, + userCode: null, + }; + } + private readRow(): SetupSessionRow | undefined { return this.sql.exec('SELECT * FROM setup_session LIMIT 1').toArray()[0]; } diff --git a/apps/api/src/durable-objects/task-runner/workspace-branch.ts b/apps/api/src/durable-objects/task-runner/workspace-branch.ts new file mode 100644 index 0000000000..c956a653d7 --- /dev/null +++ b/apps/api/src/durable-objects/task-runner/workspace-branch.ts @@ -0,0 +1,145 @@ +import { log } from '../../lib/logger'; +import { getExternalInstallationId } from '../../services/github-installation-ids'; +import type { TaskRunnerContext, TaskRunnerState } from './types'; + +/** + * Ensure the checkout branch exists on the remote before cloning. + * + * Best-effort: failures are logged but do not block workspace creation. The + * clone will produce the definitive error if the branch cannot be resolved. + */ +export async function ensureBranchExistsOnRemote( + state: TaskRunnerState, + rc: TaskRunnerContext +): Promise { + const defaultBranch = state.config.defaultBranch || 'main'; + if (state.config.branch === defaultBranch) return; + + const projectRepo = await loadTaskRunnerProjectRepo(state, rc); + if (projectRepo?.repoProvider === 'artifacts') return; + if (projectRepo?.repoProvider === 'gitlab') { + await ensureGitLabBranchExistsOnRemote(state, rc, defaultBranch); + return; + } + + const repoParts = state.config.repository.split('/'); + if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) { + log.warn('task_runner_do.ensure_branch.invalid_repository', { + taskId: state.taskId, + repository: state.config.repository, + }); + return; + } + + const [owner, repo] = repoParts; + try { + const installation = await loadTaskRunnerGitHubInstallation(state, rc); + if (!installation) { + log.warn('task_runner_do.ensure_branch.installation_not_found', { + taskId: state.taskId, + installationId: state.config.installationId, + }); + return; + } + + const externalInstallationId = getExternalInstallationId(installation); + const { ensureBranchExists } = await import('../../services/github-app'); + const created = await ensureBranchExists( + externalInstallationId, + owner, + repo, + state.config.branch, + defaultBranch, + rc.env + ); + + if (created) { + log.info('task_runner_do.ensure_branch.ok', { + taskId: state.taskId, + branch: state.config.branch, + }); + } else { + log.warn('task_runner_do.ensure_branch.failed', { + taskId: state.taskId, + branch: state.config.branch, + defaultBranch, + }); + } + } catch (err) { + log.warn('task_runner_do.ensure_branch.error', { + taskId: state.taskId, + branch: state.config.branch, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +async function loadTaskRunnerProjectRepo( + state: TaskRunnerState, + rc: TaskRunnerContext +): Promise<{ repoProvider: string | null } | null> { + return rc.env.DATABASE.prepare(`SELECT repo_provider AS repoProvider FROM projects WHERE id = ?`) + .bind(state.projectId) + .first<{ repoProvider: string | null }>(); +} + +async function ensureGitLabBranchExistsOnRemote( + state: TaskRunnerState, + rc: TaskRunnerContext, + defaultBranch: string +): Promise { + try { + const { drizzle } = await import('drizzle-orm/d1'); + const schema = await import('../../db/schema'); + const { ensureGitLabBranchExists, getProjectGitLabRepository } = + await import('../../services/gitlab'); + const metadata = await getProjectGitLabRepository( + drizzle(rc.env.DATABASE, { schema }), + state.projectId + ); + if (!metadata) { + log.warn('task_runner_do.ensure_branch.gitlab_metadata_missing', { + taskId: state.taskId, + projectId: state.projectId, + }); + return; + } + const created = await ensureGitLabBranchExists({ + env: rc.env, + userId: state.userId, + projectId: metadata.gitlabProjectId, + branch: state.config.branch, + ref: defaultBranch, + }); + if (created) { + log.info('task_runner_do.ensure_branch.gitlab_ok', { + taskId: state.taskId, + branch: state.config.branch, + }); + } + } catch (err) { + log.warn('task_runner_do.ensure_branch.gitlab_error', { + taskId: state.taskId, + branch: state.config.branch, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +type TaskRunnerGitHubInstallation = { + installationId: string; + externalInstallationId: string | null; +}; + +async function loadTaskRunnerGitHubInstallation( + state: TaskRunnerState, + rc: TaskRunnerContext +): Promise { + return rc.env.DATABASE.prepare( + `SELECT installation_id AS installationId, external_installation_id AS externalInstallationId + FROM github_installations + WHERE id = ? AND user_id = ?` + ) + .bind(state.config.installationId, state.userId) + .first(); +} diff --git a/apps/api/src/durable-objects/task-runner/workspace-steps.ts b/apps/api/src/durable-objects/task-runner/workspace-steps.ts index 4ebb43a4c2..55d9d39a5d 100644 --- a/apps/api/src/durable-objects/task-runner/workspace-steps.ts +++ b/apps/api/src/durable-objects/task-runner/workspace-steps.ts @@ -3,14 +3,21 @@ * * Handles workspace_creation, workspace_dispatch, workspace_ready, and attachment_transfer steps. */ -import { type CredentialSource, DEFAULT_WORKSPACE_PROFILE } from '@simple-agent-manager/shared'; +import { + type CredentialSource, + DEFAULT_MAX_WORKSPACES_PER_NODE, + DEFAULT_WORKSPACE_PROFILE, +} from '@simple-agent-manager/shared'; import { log } from '../../lib/logger'; import type { DevcontainerCacheCredentials } from '../../services/devcontainer-cache'; -import { getExternalInstallationId } from '../../services/github-installation-ids'; -import { computeBackoffMs, isTransientError } from './helpers'; +import { reserveWorkspacePlacement } from '../../services/workspace-placement'; +import { computeBackoffMs, isTransientError, parseEnvInt } from './helpers'; import { ensureSessionLinked } from './state-machine'; import type { TaskRunnerContext, TaskRunnerState } from './types'; +import { ensureBranchExistsOnRemote } from './workspace-branch'; + +export { ensureBranchExistsOnRemote } from './workspace-branch'; // ========================================================================= // Step Handlers @@ -43,7 +50,8 @@ export async function handleWorkspaceCreation( // proceeding with delegation and dispatch. await ensureWorkspaceBookkeeping(state, rc, state.stepResults.workspaceId); } else { - await createAndProvisionWorkspace(state, rc); + const created = await createAndProvisionWorkspace(state, rc); + if (!created) return; } // Transition task: queued → delegated (optimistic locking) @@ -125,7 +133,7 @@ async function isTaskDelegated(state: TaskRunnerState, rc: TaskRunnerContext): P async function createAndProvisionWorkspace( state: TaskRunnerState, rc: TaskRunnerContext -): Promise { +): Promise { const { ulid } = await import('../../lib/ulid'); const { resolveUniqueWorkspaceDisplayName } = await import('../../services/workspace-names'); const { drizzle } = await import('drizzle-orm/d1'); @@ -141,26 +149,51 @@ async function createAndProvisionWorkspace( const uniqueName = await resolveUniqueWorkspaceDisplayName(db, nodeId, workspaceName); const now = new Date().toISOString(); - await db.insert(schema.workspaces).values({ - id: workspaceId, - nodeId, - projectId: state.projectId, - userId: state.userId, - installationId: state.config.installationId, - name: workspaceName, - displayName: uniqueName.displayName, - normalizedDisplayName: uniqueName.normalizedDisplayName, - repository: state.config.repository, - branch: state.config.branch, - status: 'creating', - vmSize: state.config.vmSize, - vmLocation: state.config.vmLocation, - workspaceProfile: state.config.workspaceProfile ?? DEFAULT_WORKSPACE_PROFILE, - devcontainerConfigName: state.config.devcontainerConfigName ?? null, - agentProfileHint: state.config.agentProfileHint ?? null, - createdAt: now, - updatedAt: now, - }); + const maxWorkspaces = + state.config.projectScaling?.maxWorkspacesPerNode ?? + parseEnvInt(rc.env.MAX_WORKSPACES_PER_NODE, DEFAULT_MAX_WORKSPACES_PER_NODE); + const placementReserved = await reserveWorkspacePlacement( + rc.env.DATABASE, + { + id: workspaceId, + nodeId, + projectId: state.projectId, + userId: state.userId, + installationId: state.config.installationId, + name: workspaceName, + displayName: uniqueName.displayName, + normalizedDisplayName: uniqueName.normalizedDisplayName, + repository: state.config.repository, + branch: state.config.branch, + vmSize: state.config.vmSize, + vmLocation: state.config.vmLocation, + workspaceProfile: state.config.workspaceProfile ?? DEFAULT_WORKSPACE_PROFILE, + devcontainerConfigName: state.config.devcontainerConfigName ?? null, + agentProfileHint: state.config.agentProfileHint ?? null, + createdAt: now, + }, + maxWorkspaces + ); + + if (!placementReserved) { + log.warn('task_runner_do.workspace_placement_lost', { + taskId: state.taskId, + nodeId, + maxWorkspaces, + preferredNode: state.config.preferredNodeId === nodeId, + }); + if (state.config.preferredNodeId === nodeId) { + throw Object.assign( + new Error('Specified node lost capacity or became unavailable before workspace creation'), + { permanent: true } + ); + } + state.stepResults.nodeId = null; + state.stepResults.autoProvisioned = false; + state.stepResults.provisionedVmSize = null; + await rc.advanceToStep(state, 'node_selection'); + return false; + } await rc.env.DATABASE.prepare(`UPDATE tasks SET workspace_id = ?, updated_at = ? WHERE id = ?`) .bind(workspaceId, now, state.taskId) @@ -171,6 +204,7 @@ async function createAndProvisionWorkspace( await startComputeTrackingBestEffort(state, rc, db, workspaceId, nodeId); await ensureWorkspaceBookkeeping(state, rc, workspaceId, now); await rc.ctx.storage.put('state', state); + return true; } async function ensureWorkspaceBookkeeping( @@ -230,89 +264,6 @@ async function setOutputBranch( .run(); } -/** - * Ensure the checkout branch exists on the remote before cloning. - * If the branch differs from the project's default branch and doesn't exist, - * create it from the default branch via the GitHub API. - * - * Best-effort: failures are logged but do not block workspace creation. - * The clone will fail with a clear error from the VM agent if the branch - * truly doesn't exist. - */ -export async function ensureBranchExistsOnRemote( - state: TaskRunnerState, - rc: TaskRunnerContext -): Promise { - const defaultBranch = state.config.defaultBranch || 'main'; - - // If cloning the default branch, no need to check — it always exists - if (state.config.branch === defaultBranch) { - return; - } - - const projectRepo = await loadTaskRunnerProjectRepo(state, rc); - if (projectRepo?.repoProvider === 'artifacts') { - return; - } - if (projectRepo?.repoProvider === 'gitlab') { - await ensureGitLabBranchExistsOnRemote(state, rc, defaultBranch); - return; - } - - // Parse owner/repo from repository string (format: "owner/repo") - const repoParts = state.config.repository.split('/'); - if (repoParts.length !== 2 || !repoParts[0] || !repoParts[1]) { - log.warn('task_runner_do.ensure_branch.invalid_repository', { - taskId: state.taskId, - repository: state.config.repository, - }); - return; - } - - const [owner, repo] = repoParts; - - try { - const installation = await loadTaskRunnerGitHubInstallation(state, rc); - if (!installation) { - log.warn('task_runner_do.ensure_branch.installation_not_found', { - taskId: state.taskId, - installationId: state.config.installationId, - }); - return; - } - - const externalInstallationId = getExternalInstallationId(installation); - const { ensureBranchExists } = await import('../../services/github-app'); - const created = await ensureBranchExists( - externalInstallationId, - owner, - repo, - state.config.branch, - defaultBranch, - rc.env - ); - - if (created) { - log.info('task_runner_do.ensure_branch.ok', { - taskId: state.taskId, - branch: state.config.branch, - }); - } else { - log.warn('task_runner_do.ensure_branch.failed', { - taskId: state.taskId, - branch: state.config.branch, - defaultBranch, - }); - } - } catch (err) { - log.warn('task_runner_do.ensure_branch.error', { - taskId: state.taskId, - branch: state.config.branch, - error: err instanceof Error ? err.message : String(err), - }); - } -} - type TaskRunnerProjectRepo = { repoProvider: string | null; }; @@ -326,67 +277,6 @@ async function loadTaskRunnerProjectRepo( .first(); } -async function ensureGitLabBranchExistsOnRemote( - state: TaskRunnerState, - rc: TaskRunnerContext, - defaultBranch: string -): Promise { - try { - const { drizzle } = await import('drizzle-orm/d1'); - const schema = await import('../../db/schema'); - const { ensureGitLabBranchExists, getProjectGitLabRepository } = - await import('../../services/gitlab'); - const metadata = await getProjectGitLabRepository( - drizzle(rc.env.DATABASE, { schema }), - state.projectId - ); - if (!metadata) { - log.warn('task_runner_do.ensure_branch.gitlab_metadata_missing', { - taskId: state.taskId, - projectId: state.projectId, - }); - return; - } - const created = await ensureGitLabBranchExists({ - env: rc.env, - userId: state.userId, - projectId: metadata.gitlabProjectId, - branch: state.config.branch, - ref: defaultBranch, - }); - if (created) { - log.info('task_runner_do.ensure_branch.gitlab_ok', { - taskId: state.taskId, - branch: state.config.branch, - }); - } - } catch (err) { - log.warn('task_runner_do.ensure_branch.gitlab_error', { - taskId: state.taskId, - branch: state.config.branch, - error: err instanceof Error ? err.message : String(err), - }); - } -} - -type TaskRunnerGitHubInstallation = { - installationId: string; - externalInstallationId: string | null; -}; - -async function loadTaskRunnerGitHubInstallation( - state: TaskRunnerState, - rc: TaskRunnerContext -): Promise { - return rc.env.DATABASE.prepare( - `SELECT installation_id AS installationId, external_installation_id AS externalInstallationId - FROM github_installations - WHERE id = ? AND user_id = ?` - ) - .bind(state.config.installationId, state.userId) - .first(); -} - async function createWorkspaceOnVmAgent( state: TaskRunnerState, rc: TaskRunnerContext, diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index fecb069f4f..451db79bce 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -140,6 +140,15 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv { SETUP_SESSION_TTL_MS?: string; // Setup session lifetime in ms before auto-teardown (default: 900000 = 15 min) SETUP_SESSION_CAPTURE_POLL_MS?: string; // credential capture poll interval in ms (default: 3000) CODEX_DEVICE_AUTH_REQUEST_TIMEOUT_MS?: string; // App-server JSON-RPC request timeout in ms (default: 30000) + CLAUDE_SETUP_ENTER_DELAY_MS?: string; // Claude guided-login: delay before the separate Enter keypress after pasting the code into the sandboxed CLI (default: 1000) + CLAUDE_SETUP_EXCHANGE_TIMEOUT_MS?: string; // Claude guided-login: max wait for the CLI code exchange after submission before failing visibly (default: 120000) + CLAUDE_SETUP_REJECTION_SETTLE_MS?: string; // Claude guided-login: wait for Ink to finish redrawing an OAuth failure before classifying it (default: 400) + CLAUDE_SETUP_VERIFICATION_POLL_MS?: string; // Claude driver poll interval for the browser code file (default: 500) + CLAUDE_SETUP_TTY_COLUMNS?: string; // Claude setup-token PTY width used to reduce token wrapping (default: 512) + CLAUDE_SETUP_OUTPUT_BUFFER_BYTES?: string; // Max in-memory Claude PTY output retained for parsing (default: 32768) + CLAUDE_VERIFICATION_CODE_MAX_LENGTH?: string; // Max browser-displayed code#state length accepted (default: 1024) + CLAUDE_SETUP_ERROR_DETAIL_MAX_LENGTH?: string; // Max sanitized Claude CLI diagnostic length surfaced (default: 160) + CLAUDE_OAUTH_TOKEN_MAX_LENGTH?: string; // Max captured Claude OAuth token length (default: 8192) SETUP_SESSION_SWEEP_MAX_CANDIDATES?: string; // Max expired sessions torn down per cron sweep (default: 50) POOL_LEASE_BUFFER_MS?: string; // Grace beyond TTL before a leaked pool lease self-prunes (default: 300000 = 5 min) // Deployment signing keys (Ed25519 — separate from callback JWT) @@ -538,6 +547,7 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv { ORCHESTRATOR_MAX_RETRIES_PER_TASK?: string; // Max retry attempts per task (default: 3) ORCHESTRATOR_DEPENDENCY_MAX_EDGES?: string; // Max dependency edges per project (default: 50) ORCHESTRATOR_STOP_GRACE_MS?: string; // Grace period before hard stop after warning (default: 5000) + ORCHESTRATOR_STOP_CAS_MAX_ATTEMPTS?: string; // Max task-status CAS attempts after a hard stop (default: 2) ORCHESTRATOR_MESSAGE_MAX_LENGTH?: string; // Max length for injected messages to child agents (default: 32768) // Attention markers HUMAN_INPUT_TIMEOUT_MS?: string; // Attention marker expiry for needs_input (default: 7200000 = 2 hours) @@ -578,6 +588,8 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv { MCP_MESSAGE_LIST_LIMIT?: string; // Default raw tokens per request (default: 50) MCP_MESSAGE_LIST_MAX?: string; // Max raw tokens per request (default: 200) MCP_MESSAGE_SEARCH_MAX?: string; // Max search results for search_messages (default: 20) + MCP_TRIGGER_LIST_LIMIT?: string; // Default page size for list_triggers (default: 20) + MCP_TRIGGER_LIST_MAX?: string; // Max page size for list_triggers (default: 100) MCP_DEPLOYMENT_LOG_DEFAULT_LIMIT?: string; // Default deployment log rows for read_deployment_logs (default: 200) MCP_DEPLOYMENT_LOG_MAX_LIMIT?: string; // Max deployment log rows for read_deployment_logs (default: 1000) // Configurable content limits diff --git a/apps/api/src/lib/d1-limits.ts b/apps/api/src/lib/d1-limits.ts new file mode 100644 index 0000000000..40b76cb684 --- /dev/null +++ b/apps/api/src/lib/d1-limits.ts @@ -0,0 +1,2 @@ +/** Cloudflare D1/SQLite protocol ceiling for bound parameters in one statement. */ +export const D1_MAX_BOUND_PARAMETERS = 100; diff --git a/apps/api/src/routes/agent-credential-setup-sessions.ts b/apps/api/src/routes/agent-credential-setup-sessions.ts index 241ba72640..90773549a5 100644 --- a/apps/api/src/routes/agent-credential-setup-sessions.ts +++ b/apps/api/src/routes/agent-credential-setup-sessions.ts @@ -2,9 +2,10 @@ * Guided agent-credential setup sessions (native provider login). * * User-facing flow for connecting subscription/OAuth-backed coding agents - * without manual token/auth-file paste: + * without exposing terminal setup mechanics: * POST / create a setup session (leases a sandbox slot) * GET /:id poll lifecycle status + * POST /:id/verification-code forward Claude's browser code to its CLI * POST /:id/cancel cancel + tear down * * AUTH: all routes use browser session-cookie auth (requireAuth/requireApproved) @@ -15,22 +16,30 @@ */ import { type AgentType, getAgentDefinition, isValidAgentType } from '@simple-agent-manager/shared'; import { Hono } from 'hono'; +import * as v from 'valibot'; import type { Env } from '../env'; import { log } from '../lib/logger'; import { ulid } from '../lib/ulid'; import { getUserId, requireApproved, requireAuth } from '../middleware/auth'; import { errors } from '../middleware/error'; -import { CreateAgentCredentialSetupSessionSchema, parseOptionalBody } from '../schemas'; +import { + CreateAgentCredentialSetupSessionSchema, + jsonValidator, + parseOptionalBody, +} from '../schemas'; import { ACTIVE_SETUP_STATUSES, + getClaudeVerificationCodeMaxLength, getSetupSessionCapturePollMs, getSetupSessionTtlMs, + isTerminalSetupStatus, } from '../services/credential-setup-config'; import { cancelSetupSession, getSetupSessionState, startSetupSession, + submitSetupSessionVerificationCode, } from '../services/credential-setup-session'; import { leaseSetupSlot, releaseSetupSlot } from '../services/setup-session-pool'; @@ -41,6 +50,9 @@ const SUPPORTED_SETUP_AGENT_TYPES = ['openai-codex', 'claude-code'] as const; type SupportedSetupAgentType = (typeof SUPPORTED_SETUP_AGENT_TYPES)[number]; const SETUP_CREDENTIAL_KIND = 'oauth-token'; const ACTIVE_STATUS_PLACEHOLDERS = ACTIVE_SETUP_STATUSES.map(() => '?').join(', '); +const SubmitVerificationCodeSchema = v.object({ + code: v.pipe(v.string(), v.trim(), v.minLength(1)), +}); function isSupportedSetupAgentType(agentType: AgentType): agentType is SupportedSetupAgentType { return SUPPORTED_SETUP_AGENT_TYPES.includes(agentType as SupportedSetupAgentType); @@ -264,6 +276,42 @@ agentCredentialSetupSessionsRoutes.get('/:id', requireAuth(), requireApproved(), }); }); +// ----------------------------------------------------------------------------- +// POST /:id/verification-code — forward Claude's browser code to the CLI +// ----------------------------------------------------------------------------- +agentCredentialSetupSessionsRoutes.post( + '/:id/verification-code', + requireAuth(), + requireApproved(), + jsonValidator(SubmitVerificationCodeSchema), + async (c) => { + const userId = getUserId(c); + const row = await loadOwnedSession(c.env, c.req.param('id'), userId); + if (row.agent_type !== 'claude-code') { + throw errors.badRequest('Verification codes are only available for Claude Code setup'); + } + if (isTerminalSetupStatus(row.status)) { + throw errors.conflict('Setup session is no longer active'); + } + const code = c.req.valid('json').code; + if (code.length > getClaudeVerificationCodeMaxLength(c.env)) { + throw errors.badRequest('Invalid Claude verification code'); + } + + const state = await submitSetupSessionVerificationCode(c.env, row.id, code); + return c.json({ + id: row.id, + status: state.status, + agentType: row.agent_type, + expiresAt: row.expires_at, + verificationUrl: state.verificationUrl, + userCode: state.userCode, + errorCode: state.errorCode, + errorMessage: state.errorMessage, + }); + } +); + // ----------------------------------------------------------------------------- // POST /:id/cancel — cancel + tear down // ----------------------------------------------------------------------------- diff --git a/apps/api/src/routes/mcp/_helpers.ts b/apps/api/src/routes/mcp/_helpers.ts index 2dea5fdafc..2fa754e78f 100644 --- a/apps/api/src/routes/mcp/_helpers.ts +++ b/apps/api/src/routes/mcp/_helpers.ts @@ -87,6 +87,10 @@ const DEFAULT_MCP_SESSION_LIST_MAX = 50; const DEFAULT_MCP_MESSAGE_LIST_LIMIT = 50; const DEFAULT_MCP_MESSAGE_LIST_MAX = 200; const DEFAULT_MCP_MESSAGE_SEARCH_MAX = 20; +/** Default page size for list_triggers. Override via MCP_TRIGGER_LIST_LIMIT env var. */ +const DEFAULT_MCP_TRIGGER_LIST_LIMIT = 20; +/** Max page size for list_triggers. Override via MCP_TRIGGER_LIST_MAX env var. */ +const DEFAULT_MCP_TRIGGER_LIST_MAX = 100; /** Max length for task description in list/search results. Override via MCP_TASK_DESCRIPTION_SNIPPET_LENGTH env var. */ const DEFAULT_MCP_TASK_DESCRIPTION_SNIPPET_LENGTH = 200; /** Max length for idea link context string. Override via MCP_IDEA_CONTEXT_MAX_LENGTH env var. */ @@ -109,6 +113,8 @@ const DEFAULT_ORCHESTRATOR_MAX_RETRIES_PER_TASK = 3; const DEFAULT_ORCHESTRATOR_DEPENDENCY_MAX_EDGES = 50; /** Grace period in ms before hard stop after warning message. Override via ORCHESTRATOR_STOP_GRACE_MS env var. */ const DEFAULT_ORCHESTRATOR_STOP_GRACE_MS = 5000; +/** Max task-status CAS attempts after a hard stop. Override via ORCHESTRATOR_STOP_CAS_MAX_ATTEMPTS env var. */ +const DEFAULT_ORCHESTRATOR_STOP_CAS_MAX_ATTEMPTS = 2; /** Max length for injected messages to child agents. Override via ORCHESTRATOR_MESSAGE_MAX_LENGTH env var. */ const DEFAULT_ORCHESTRATOR_MESSAGE_MAX_LENGTH = 32_768; /** Agent mailbox defaults (durable messaging). Override via MAILBOX_* env vars. */ @@ -149,6 +155,8 @@ export function getMcpLimits(env: Env) { messageListLimit: parsePositiveInt(env.MCP_MESSAGE_LIST_LIMIT, DEFAULT_MCP_MESSAGE_LIST_LIMIT), messageListMax: parsePositiveInt(env.MCP_MESSAGE_LIST_MAX, DEFAULT_MCP_MESSAGE_LIST_MAX), messageSearchMax: parsePositiveInt(env.MCP_MESSAGE_SEARCH_MAX, DEFAULT_MCP_MESSAGE_SEARCH_MAX), + triggerListLimit: parsePositiveInt(env.MCP_TRIGGER_LIST_LIMIT, DEFAULT_MCP_TRIGGER_LIST_LIMIT), + triggerListMax: parsePositiveInt(env.MCP_TRIGGER_LIST_MAX, DEFAULT_MCP_TRIGGER_LIST_MAX), taskDescriptionSnippetLength: parsePositiveInt( env.MCP_TASK_DESCRIPTION_SNIPPET_LENGTH, DEFAULT_MCP_TASK_DESCRIPTION_SNIPPET_LENGTH @@ -209,6 +217,10 @@ export function getMcpLimits(env: Env) { env.ORCHESTRATOR_STOP_GRACE_MS, DEFAULT_ORCHESTRATOR_STOP_GRACE_MS ), + orchestratorStopCasMaxAttempts: parsePositiveInt( + env.ORCHESTRATOR_STOP_CAS_MAX_ATTEMPTS, + DEFAULT_ORCHESTRATOR_STOP_CAS_MAX_ATTEMPTS + ), orchestratorMessageMaxLength: parsePositiveInt( env.ORCHESTRATOR_MESSAGE_MAX_LENGTH, DEFAULT_ORCHESTRATOR_MESSAGE_MAX_LENGTH diff --git a/apps/api/src/routes/mcp/index.ts b/apps/api/src/routes/mcp/index.ts index 1b001f9297..df734072f6 100644 --- a/apps/api/src/routes/mcp/index.ts +++ b/apps/api/src/routes/mcp/index.ts @@ -135,7 +135,12 @@ import { handleSearchTasks, handleUpdateTaskStatus, } from './task-tools'; -import { handleCreateTrigger, handleDeleteTrigger, handleUpdateTrigger } from './trigger-tools'; +import { + handleCreateTrigger, + handleDeleteTrigger, + handleListTriggers, + handleUpdateTrigger, +} from './trigger-tools'; import { handleExposePort, handleGetCredentialStatus, @@ -405,6 +410,8 @@ mcpRoutes.post('/', async (c) => { case 'display_from_library': return c.json(await handleDisplayFromLibrary(requestId, toolArgs, tokenData, c.env)); // ─── Trigger management tools ──────────────────────────────── + case 'list_triggers': + return c.json(await handleListTriggers(requestId, toolArgs, tokenData, c.env)); case 'create_trigger': return c.json(await handleCreateTrigger(requestId, toolArgs, tokenData, c.env)); case 'update_trigger': diff --git a/apps/api/src/routes/mcp/orchestration-comms.ts b/apps/api/src/routes/mcp/orchestration-comms.ts index 734d3cd610..111323a9b4 100644 --- a/apps/api/src/routes/mcp/orchestration-comms.ts +++ b/apps/api/src/routes/mcp/orchestration-comms.ts @@ -15,6 +15,8 @@ import { ulid } from '../../lib/ulid'; import { sendPromptToAgentOnNode, stopAgentSessionOnNode } from '../../services/node-agent'; import { persistOrchestrationPrompt } from '../../services/orchestration-prompts'; import * as projectDataService from '../../services/project-data'; +import { cleanupTerminalTaskResources } from '../../services/task-terminal-cleanup'; +import { syncTriggerExecutionStatus } from '../../services/trigger-execution-sync'; import { ACTIVE_STATUSES, getMcpLimits, @@ -55,9 +57,8 @@ async function resolveChildAgent( requestId: string | number | null, childTaskId: string, tokenData: McpTokenData, - db: DrizzleD1Database, + db: DrizzleD1Database ): Promise { - // 1. Validate caller is a task agent if (!tokenData.taskId) { return jsonRpcError(requestId, INVALID_PARAMS, 'Only task agents can use orchestration tools'); @@ -73,12 +74,7 @@ async function resolveChildAgent( parentTaskId: schema.tasks.parentTaskId, }) .from(schema.tasks) - .where( - and( - eq(schema.tasks.id, childTaskId), - eq(schema.tasks.projectId, tokenData.projectId), - ), - ) + .where(and(eq(schema.tasks.id, childTaskId), eq(schema.tasks.projectId, tokenData.projectId))) .limit(1); if (!childTask) { @@ -96,7 +92,7 @@ async function resolveChildAgent( return jsonRpcError( requestId, INVALID_PARAMS, - 'Only the direct parent task can communicate with a child task', + 'Only the direct parent task can communicate with a child task' ); } @@ -105,7 +101,7 @@ async function resolveChildAgent( return jsonRpcError( requestId, INVALID_PARAMS, - `Child task is in '${childTask.status}' status — only active tasks can receive messages`, + `Child task is in '${childTask.status}' status — only active tasks can receive messages` ); } @@ -115,7 +111,7 @@ async function resolveChildAgent( return jsonRpcError( requestId, INVALID_PARAMS, - 'Child task has no workspace assigned yet (it may still be provisioning)', + 'Child task has no workspace assigned yet (it may still be provisioning)' ); } @@ -147,7 +143,7 @@ async function resolveChildAgent( return jsonRpcError( requestId, INVALID_PARAMS, - `Child workspace node is not running (status: ${workspace.nodeStatus ?? 'unknown'})`, + `Child workspace node is not running (status: ${workspace.nodeStatus ?? 'unknown'})` ); } @@ -158,8 +154,8 @@ async function resolveChildAgent( .where( and( eq(schema.agentSessions.workspaceId, workspace.id), - eq(schema.agentSessions.status, 'running'), - ), + eq(schema.agentSessions.status, 'running') + ) ) .orderBy(desc(schema.agentSessions.createdAt)) .limit(1); @@ -198,7 +194,7 @@ export async function handleSendMessageToSubtask( requestId: string | number | null, params: Record, tokenData: McpTokenData, - env: Env, + env: Env ): Promise { const limits = getMcpLimits(env); @@ -286,7 +282,7 @@ export async function handleSendMessageToSubtask( message, env, tokenData.userId, - messageId, + messageId ); log.info('mcp.send_message_to_subtask.delivered', { @@ -299,10 +295,12 @@ export async function handleSendMessageToSubtask( }); return jsonRpcSuccess(requestId, { - content: [{ - type: 'text', - text: JSON.stringify({ delivered: true }), - }], + content: [ + { + type: 'text', + text: JSON.stringify({ delivered: true }), + }, + ], }); } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); @@ -325,21 +323,32 @@ export async function handleSendMessageToSubtask( const chatSessionId = ws?.chatSessionId; if (chatSessionId) { try { - const msg = await projectDataService.enqueueMailboxMessage(env, resolution.task.projectId, { - targetSessionId: chatSessionId, - sourceTaskId: tokenData.taskId ?? null, - senderType: 'agent', - senderId: tokenData.workspaceId, - messageClass: 'deliver', - content: message, - metadata: null, - }); + const msg = await projectDataService.enqueueMailboxMessage( + env, + resolution.task.projectId, + { + targetSessionId: chatSessionId, + sourceTaskId: tokenData.taskId ?? null, + senderType: 'agent', + senderId: tokenData.workspaceId, + messageClass: 'deliver', + content: message, + metadata: null, + } + ); return jsonRpcSuccess(requestId, { - content: [{ - type: 'text', - text: JSON.stringify({ delivered: false, queued: true, messageId: msg.id, reason: 'agent_busy' }), - }], + content: [ + { + type: 'text', + text: JSON.stringify({ + delivered: false, + queued: true, + messageId: msg.id, + reason: 'agent_busy', + }), + }, + ], }); } catch (queueErr) { log.warn('mcp.send_message_to_subtask.queue_fallback_failed', { @@ -352,10 +361,12 @@ export async function handleSendMessageToSubtask( // Fallback: return the old response shape if queuing fails return jsonRpcSuccess(requestId, { - content: [{ - type: 'text', - text: JSON.stringify({ delivered: false, reason: 'agent_busy' }), - }], + content: [ + { + type: 'text', + text: JSON.stringify({ delivered: false, reason: 'agent_busy' }), + }, + ], }); } @@ -365,7 +376,11 @@ export async function handleSendMessageToSubtask( error: errorMessage, }); - return jsonRpcError(requestId, INTERNAL_ERROR, `Failed to send message to child agent: ${errorMessage}`); + return jsonRpcError( + requestId, + INTERNAL_ERROR, + `Failed to send message to child agent: ${errorMessage}` + ); } } @@ -375,7 +390,7 @@ export async function handleStopSubtask( requestId: string | number | null, params: Record, tokenData: McpTokenData, - env: Env, + env: Env ): Promise { const limits = getMcpLimits(env); @@ -385,9 +400,10 @@ export async function handleStopSubtask( return jsonRpcError(requestId, INVALID_PARAMS, 'taskId is required'); } - const reason = typeof params.reason === 'string' - ? sanitizeUserInput(params.reason.trim()).slice(0, limits.orchestratorMessageMaxLength) - : undefined; + const reason = + typeof params.reason === 'string' + ? sanitizeUserInput(params.reason.trim()).slice(0, limits.orchestratorMessageMaxLength) + : undefined; // Resolve child agent const db = drizzle(env.DATABASE, { schema }); @@ -407,7 +423,7 @@ export async function handleStopSubtask( agentSession.id, `[STOP REQUESTED BY PARENT] ${reason}`, env, - tokenData.userId, + tokenData.userId ); } catch (err) { // Best-effort — don't fail the stop if the message can't be delivered (e.g., 409 busy) @@ -430,7 +446,7 @@ export async function handleStopSubtask( workspace.id, agentSession.id, env, - tokenData.userId, + tokenData.userId ); } catch (err) { log.error('mcp.stop_subtask.stop_failed', { @@ -442,43 +458,136 @@ export async function handleStopSubtask( return jsonRpcError( requestId, INTERNAL_ERROR, - `Failed to stop child agent session: ${err instanceof Error ? err.message : String(err)}`, + `Failed to stop child agent session: ${err instanceof Error ? err.message : String(err)}` ); } - // Update task status to failed with parent-stop reason (atomic batch) + // An intentional parent stop is a cancellation, not a runtime failure. Use a + // compare-and-set transition so a concurrent fatal callback keeps the actual + // failure as the authoritative terminal story. const now = new Date().toISOString(); - const failReason = reason - ? `stopped_by_parent: ${reason}` - : 'stopped_by_parent'; + const stopReason = reason ? `Stopped by parent: ${reason}` : 'Stopped by parent'; + let preservedTerminalStatus: string | null = null; try { - await db.batch([ - db.update(schema.tasks) - .set({ - status: 'failed', - errorMessage: failReason, - updatedAt: now, - }) - .where(eq(schema.tasks.id, taskId)), - db.insert(schema.taskStatusEvents).values({ - id: ulid(), - taskId, - fromStatus: task.status, - toStatus: 'failed', - actorType: 'agent', - actorId: tokenData.workspaceId, - reason: failReason, - createdAt: now, - }), - ]); + const cancelFromStatus = async (fromStatus: string): Promise => { + const [transition] = await env.DATABASE.batch([ + env.DATABASE.prepare( + `UPDATE tasks + SET status = 'cancelled', error_message = ?, completed_at = ?, updated_at = ? + WHERE id = ? AND status = ?` + ).bind(stopReason, now, now, taskId, fromStatus), + env.DATABASE.prepare( + `INSERT INTO task_status_events + (id, task_id, from_status, to_status, actor_type, actor_id, reason, created_at) + SELECT ?, ?, ?, 'cancelled', 'agent', ?, ?, ? + WHERE EXISTS ( + SELECT 1 FROM tasks + WHERE id = ? AND status = 'cancelled' AND completed_at = ? + )` + ).bind(ulid(), taskId, fromStatus, tokenData.workspaceId, stopReason, now, taskId, now), + ]); + if (!transition) { + throw new Error('Task cancellation returned no transition result'); + } + return Boolean(transition.meta.changes); + }; + + let cancelled = false; + let fromStatus = task.status; + for (let attempt = 1; attempt <= limits.orchestratorStopCasMaxAttempts; attempt += 1) { + cancelled = await cancelFromStatus(fromStatus); + if (cancelled) break; + + const current = await env.DATABASE.prepare('SELECT status FROM tasks WHERE id = ?') + .bind(taskId) + .first<{ status: string }>(); + if (!current) throw new Error('Child task disappeared during cancellation'); + if ( + current.status === 'completed' || + current.status === 'failed' || + current.status === 'cancelled' + ) { + log.info('mcp.stop_subtask.terminal_state_preserved', { + parentTaskId: tokenData.taskId, + childTaskId: taskId, + attemptedFromStatus: fromStatus, + currentStatus: current.status, + }); + preservedTerminalStatus = current.status; + break; + } + if (!ACTIVE_STATUSES.includes(current.status)) { + throw new Error( + `Child task entered unexpected status '${current.status}' during cancellation` + ); + } + if (attempt === limits.orchestratorStopCasMaxAttempts) { + throw new Error( + `Child task remained active after ${limits.orchestratorStopCasMaxAttempts} cancellation attempts` + ); + } + log.warn('mcp.stop_subtask.status_cas_retry', { + parentTaskId: tokenData.taskId, + childTaskId: taskId, + attempt, + maxAttempts: limits.orchestratorStopCasMaxAttempts, + attemptedFromStatus: fromStatus, + currentStatus: current.status, + }); + fromStatus = current.status; + } + if (!cancelled && !preservedTerminalStatus) { + throw new Error('Task cancellation did not reach a terminal state'); + } } catch (err) { - // Status update failure is non-fatal — the agent session is already stopped log.error('mcp.stop_subtask.status_update_failed', { parentTaskId: tokenData.taskId, childTaskId: taskId, error: err instanceof Error ? err.message : String(err), }); + return jsonRpcError( + requestId, + INTERNAL_ERROR, + 'Child agent stopped, but task cancellation failed' + ); + } + + if (preservedTerminalStatus) { + return jsonRpcSuccess(requestId, { + content: [ + { + type: 'text', + text: JSON.stringify({ + stopped: true, + taskId, + terminalStatePreserved: true, + status: preservedTerminalStatus, + }), + }, + ], + }); + } + + await syncTriggerExecutionStatus(env.DATABASE, taskId, 'cancelled'); + try { + await cleanupTerminalTaskResources(env, taskId, { + status: 'cancelled', + errorMessage: stopReason, + requiredUserId: tokenData.userId, + logContext: { projectId: task.projectId, source: 'mcp.stop_subtask' }, + }); + } catch (err) { + log.error('mcp.stop_subtask.terminal_cleanup_failed', { + parentTaskId: tokenData.taskId, + childTaskId: taskId, + error: err instanceof Error ? err.message : String(err), + }); + return jsonRpcError( + requestId, + INTERNAL_ERROR, + 'Task was cancelled, but runtime cleanup failed' + ); } log.info('mcp.stop_subtask.completed', { @@ -486,13 +595,15 @@ export async function handleStopSubtask( childTaskId: taskId, workspaceId: workspace.id, agentSessionId: agentSession.id, - reason: failReason, + reason: stopReason, }); return jsonRpcSuccess(requestId, { - content: [{ - type: 'text', - text: JSON.stringify({ stopped: true, taskId }), - }], + content: [ + { + type: 'text', + text: JSON.stringify({ stopped: true, taskId }), + }, + ], }); } diff --git a/apps/api/src/routes/mcp/tool-definitions-orchestration-tools.ts b/apps/api/src/routes/mcp/tool-definitions-orchestration-tools.ts index f59c219cdb..d2eb6710ff 100644 --- a/apps/api/src/routes/mcp/tool-definitions-orchestration-tools.ts +++ b/apps/api/src/routes/mcp/tool-definitions-orchestration-tools.ts @@ -7,7 +7,7 @@ export const ORCHESTRATION_TOOLS = [ { name: 'send_durable_message', description: - 'Send a durable message to a child task\'s agent. The message is persisted in the mailbox and will be delivered ' + + "Send a durable message to a child task's agent. The message is persisted in the mailbox and will be delivered " + 'even if the child agent is busy. Message classes control urgency: "notify" (best-effort), "deliver" (durable, ack optional), ' + '"interrupt" (preempts current work), "preempt_and_replan" (requires ack + replanning), ' + '"shutdown_with_final_prompt" (delivers final message with highest urgency — session termination is a Phase 2 feature). ' + @@ -25,7 +25,13 @@ export const ORCHESTRATION_TOOLS = [ }, messageClass: { type: 'string', - enum: ['notify', 'deliver', 'interrupt', 'preempt_and_replan', 'shutdown_with_final_prompt'], + enum: [ + 'notify', + 'deliver', + 'interrupt', + 'preempt_and_replan', + 'shutdown_with_final_prompt', + ], description: 'Message urgency class (default: "deliver")', }, metadata: { @@ -40,7 +46,7 @@ export const ORCHESTRATION_TOOLS = [ { name: 'get_pending_messages', description: - 'Get all unacknowledged messages for the calling agent\'s session, ordered by urgency ' + + "Get all unacknowledged messages for the calling agent's session, ordered by urgency " + '(shutdown_with_final_prompt first, then preempt_and_replan, interrupt, deliver, notify). ' + 'Messages are automatically marked as "delivered" when retrieved. ' + 'Call this at turn boundaries to check for orchestrator directives.', @@ -72,7 +78,7 @@ export const ORCHESTRATION_TOOLS = [ { name: 'send_message_to_subtask', description: - 'Send a message to a running child task\'s agent. The message is injected as a user-role prompt into the child\'s ACP session. ' + + "Send a message to a running child task's agent. The message is injected as a user-role prompt into the child's ACP session. " + 'Only the direct parent task can message a child — grandparents and siblings are rejected. ' + 'Returns { delivered: true } on success, or { delivered: false, reason: "agent_busy" } if the child agent is currently processing.', inputSchema: { @@ -84,7 +90,7 @@ export const ORCHESTRATION_TOOLS = [ }, message: { type: 'string', - description: 'The message to inject into the child agent\'s session (max 32768 chars)', + description: "The message to inject into the child agent's session (max 32768 chars)", }, }, required: ['taskId', 'message'], @@ -94,8 +100,8 @@ export const ORCHESTRATION_TOOLS = [ { name: 'stop_subtask', description: - 'Gracefully stop a running child task\'s agent session. If a reason is provided, it is sent as a warning message ' + - 'before the hard stop (with a configurable grace period). The task status is updated to "failed" with the stop reason. ' + + "Gracefully stop a running child task's agent session. If a reason is provided, it is sent as a warning message " + + 'before the hard stop (with a configurable grace period). The task status is updated to "cancelled" with the stop reason. ' + 'Only the direct parent task can stop a child.', inputSchema: { type: 'object' as const, @@ -106,7 +112,8 @@ export const ORCHESTRATION_TOOLS = [ }, reason: { type: 'string', - description: 'Optional reason for stopping — sent as a warning message to the child before the hard stop', + description: + 'Optional reason for stopping — sent as a warning message to the child before the hard stop', }, }, required: ['taskId'], @@ -128,7 +135,8 @@ export const ORCHESTRATION_TOOLS = [ }, newDescription: { type: 'string', - description: 'Optional replacement description. If omitted, the original description is reused with failure context appended.', + description: + 'Optional replacement description. If omitted, the original description is reused with failure context appended.', }, }, required: ['taskId'], diff --git a/apps/api/src/routes/mcp/tool-definitions-trigger-tools.ts b/apps/api/src/routes/mcp/tool-definitions-trigger-tools.ts index 1a88d5a338..3b419c2b37 100644 --- a/apps/api/src/routes/mcp/tool-definitions-trigger-tools.ts +++ b/apps/api/src/routes/mcp/tool-definitions-trigger-tools.ts @@ -1,8 +1,36 @@ /** * MCP tool definitions — trigger management tools. */ +import { TRIGGER_SOURCE_TYPES, TRIGGER_STATUSES } from '@simple-agent-manager/shared'; export const TRIGGER_TOOLS = [ + { + name: 'list_triggers', + description: + 'List automation triggers in the current project. ' + + 'Returns bounded operational metadata for cron, webhook, and GitHub triggers without prompt templates, webhook credentials/configuration, or execution history.', + inputSchema: { + type: 'object' as const, + properties: { + status: { + type: 'string', + description: 'Optional trigger status filter.', + enum: [...TRIGGER_STATUSES], + }, + sourceType: { + type: 'string', + description: 'Optional trigger source filter.', + enum: [...TRIGGER_SOURCE_TYPES], + }, + limit: { + type: 'number', + description: 'Maximum number of triggers to return. The server applies a configured cap.', + minimum: 1, + }, + }, + additionalProperties: false, + }, + }, { name: 'create_trigger', description: diff --git a/apps/api/src/routes/mcp/trigger-create-tool.ts b/apps/api/src/routes/mcp/trigger-create-tool.ts new file mode 100644 index 0000000000..70e04e9085 --- /dev/null +++ b/apps/api/src/routes/mcp/trigger-create-tool.ts @@ -0,0 +1,203 @@ +import { + DEFAULT_CRON_MIN_INTERVAL_MINUTES, + DEFAULT_CRON_TEMPLATE_MAX_LENGTH, + DEFAULT_MAX_TRIGGERS_PER_PROJECT, + DEFAULT_TRIGGER_DEFAULT_MAX_CONCURRENT, + DEFAULT_TRIGGER_NAME_MAX_LENGTH, +} from '@simple-agent-manager/shared'; + +import type { Env } from '../../env'; +import { log } from '../../lib/logger'; +import { parsePositiveInt } from '../../lib/route-helpers'; +import { ulid } from '../../lib/ulid'; +import { + cronToHumanReadable, + cronToNextFire, + validateCronExpression, +} from '../../services/cron-utils'; +import { + INVALID_PARAMS, + jsonRpcError, + type JsonRpcResponse, + jsonRpcSuccess, + type McpTokenData, + sanitizeUserInput, +} from './_helpers'; + +export async function handleCreateTrigger( + requestId: string | number | null, + params: Record, + tokenData: McpTokenData, + env: Env +): Promise { + const maxNameLength = parsePositiveInt( + env.TRIGGER_NAME_MAX_LENGTH, + DEFAULT_TRIGGER_NAME_MAX_LENGTH + ); + const name = + typeof params.name === 'string' + ? sanitizeUserInput(params.name.trim()).slice(0, maxNameLength) + : ''; + if (!name) { + return jsonRpcError( + requestId, + INVALID_PARAMS, + 'name is required and must be a non-empty string' + ); + } + + const cronExpression = + typeof params.cronExpression === 'string' ? params.cronExpression.trim() : ''; + if (!cronExpression) { + return jsonRpcError(requestId, INVALID_PARAMS, 'cronExpression is required'); + } + + const promptTemplate = + typeof params.promptTemplate === 'string' ? params.promptTemplate.trim() : ''; + if (!promptTemplate) { + return jsonRpcError( + requestId, + INVALID_PARAMS, + 'promptTemplate is required and must be non-empty' + ); + } + + const maxTemplateLength = parsePositiveInt( + env.CRON_TEMPLATE_MAX_LENGTH, + DEFAULT_CRON_TEMPLATE_MAX_LENGTH + ); + if (promptTemplate.length > maxTemplateLength) { + return jsonRpcError( + requestId, + INVALID_PARAMS, + `promptTemplate must be ${maxTemplateLength} characters or less` + ); + } + + const minInterval = parsePositiveInt( + env.CRON_MIN_INTERVAL_MINUTES, + DEFAULT_CRON_MIN_INTERVAL_MINUTES + ); + const cronValidation = validateCronExpression(cronExpression, minInterval); + if (!cronValidation.valid) { + return jsonRpcError( + requestId, + INVALID_PARAMS, + `Invalid cron expression: ${cronValidation.error}` + ); + } + + const cronTimezone = typeof params.cronTimezone === 'string' ? params.cronTimezone.trim() : 'UTC'; + try { + Intl.DateTimeFormat('en-US', { timeZone: cronTimezone }); + } catch { + return jsonRpcError(requestId, INVALID_PARAMS, `Invalid timezone: ${cronTimezone}`); + } + + const agentProfileId = + typeof params.agentProfileId === 'string' ? params.agentProfileId.trim() : null; + const taskMode = params.taskMode === 'conversation' ? 'conversation' : 'task'; + const vmSizeOverride = ['small', 'medium', 'large'].includes(params.vmSizeOverride as string) + ? (params.vmSizeOverride as string) + : null; + + if (agentProfileId) { + const profileResult = await env.DATABASE.prepare( + 'SELECT id FROM agent_profiles WHERE id = ? AND project_id = ? LIMIT 1' + ) + .bind(agentProfileId, tokenData.projectId) + .first<{ id: string }>(); + if (!profileResult) { + return jsonRpcError(requestId, INVALID_PARAMS, 'agentProfileId not found in this project'); + } + } + + const existingResult = await env.DATABASE.prepare( + 'SELECT id FROM triggers WHERE project_id = ? AND name = ? LIMIT 1' + ) + .bind(tokenData.projectId, name) + .first<{ id: string }>(); + if (existingResult) { + return jsonRpcError( + requestId, + INVALID_PARAMS, + `Trigger "${name}" already exists in this project` + ); + } + + const maxTriggers = parsePositiveInt( + env.MAX_TRIGGERS_PER_PROJECT, + DEFAULT_MAX_TRIGGERS_PER_PROJECT + ); + const countResult = await env.DATABASE.prepare( + 'SELECT COUNT(*) as cnt FROM triggers WHERE project_id = ?' + ) + .bind(tokenData.projectId) + .first<{ cnt: number }>(); + if ((countResult?.cnt ?? 0) >= maxTriggers) { + return jsonRpcError( + requestId, + INVALID_PARAMS, + `Maximum triggers per project (${maxTriggers}) reached` + ); + } + + const triggerId = ulid(); + const now = new Date().toISOString(); + const nextFireAt = cronToNextFire(cronExpression, cronTimezone); + const humanReadable = cronToHumanReadable(cronExpression, cronTimezone); + + await env.DATABASE.prepare( + `INSERT INTO triggers ( + id, project_id, user_id, name, description, status, source_type, + cron_expression, cron_timezone, skip_if_running, prompt_template, + agent_profile_id, task_mode, vm_size_override, max_concurrent, + next_fire_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, NULL, 'active', 'cron', ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?)` + ) + .bind( + triggerId, + tokenData.projectId, + tokenData.userId, + name, + cronExpression, + cronTimezone, + promptTemplate, + agentProfileId, + taskMode, + vmSizeOverride, + DEFAULT_TRIGGER_DEFAULT_MAX_CONCURRENT, + nextFireAt, + now, + now + ) + .run(); + + log.info('mcp.create_trigger', { + triggerId, + projectId: tokenData.projectId, + userId: tokenData.userId, + cronExpression, + cronTimezone, + }); + + return jsonRpcSuccess(requestId, { + content: [ + { + type: 'text', + text: JSON.stringify({ + triggerId, + name, + status: 'active', + cronExpression, + cronTimezone, + cronHumanReadable: humanReadable, + nextFireAt, + promptTemplate, + taskMode, + vmSizeOverride, + }), + }, + ], + }); +} diff --git a/apps/api/src/routes/mcp/trigger-delete-tool.ts b/apps/api/src/routes/mcp/trigger-delete-tool.ts new file mode 100644 index 0000000000..223851d101 --- /dev/null +++ b/apps/api/src/routes/mcp/trigger-delete-tool.ts @@ -0,0 +1,35 @@ +import type { Env } from '../../env'; +import { log } from '../../lib/logger'; +import { type JsonRpcResponse, jsonRpcSuccess, type McpTokenData } from './_helpers'; +import { resolveOwnedTrigger } from './trigger-tool-shared'; + +export async function handleDeleteTrigger( + requestId: string | number | null, + params: Record, + tokenData: McpTokenData, + env: Env +): Promise { + const ownedTrigger = await resolveOwnedTrigger(requestId, params, tokenData, env, 'delete'); + if (!ownedTrigger.ok) return ownedTrigger.response; + const { triggerId } = ownedTrigger; + + await env.DATABASE.prepare('DELETE FROM github_trigger_configs WHERE trigger_id = ?') + .bind(triggerId) + .run(); + await env.DATABASE.prepare('DELETE FROM trigger_executions WHERE trigger_id = ?') + .bind(triggerId) + .run(); + await env.DATABASE.prepare('DELETE FROM triggers WHERE id = ? AND project_id = ?') + .bind(triggerId, tokenData.projectId) + .run(); + + log.info('mcp.delete_trigger', { + triggerId, + projectId: tokenData.projectId, + userId: tokenData.userId, + }); + + return jsonRpcSuccess(requestId, { + content: [{ type: 'text', text: JSON.stringify({ success: true, triggerId }) }], + }); +} diff --git a/apps/api/src/routes/mcp/trigger-list-tool.ts b/apps/api/src/routes/mcp/trigger-list-tool.ts new file mode 100644 index 0000000000..5dba1d5c50 --- /dev/null +++ b/apps/api/src/routes/mcp/trigger-list-tool.ts @@ -0,0 +1,91 @@ +import { + TRIGGER_SOURCE_TYPES, + TRIGGER_STATUSES, + type TriggerSourceType, + type TriggerStatus, +} from '@simple-agent-manager/shared'; +import { drizzle } from 'drizzle-orm/d1'; + +import * as schema from '../../db/schema'; +import type { Env } from '../../env'; +import { listTriggerRows, toTriggerResponse } from '../../services/trigger-read'; +import { + getMcpLimits, + INVALID_PARAMS, + jsonRpcError, + type JsonRpcResponse, + jsonRpcSuccess, + type McpTokenData, +} from './_helpers'; + +const VALID_TRIGGER_STATUSES = new Set(TRIGGER_STATUSES); +const VALID_TRIGGER_SOURCE_TYPES = new Set(TRIGGER_SOURCE_TYPES); + +export async function handleListTriggers( + requestId: string | number | null, + params: Record, + tokenData: McpTokenData, + env: Env +): Promise { + if ( + params.status !== undefined && + (typeof params.status !== 'string' || + !VALID_TRIGGER_STATUSES.has(params.status as TriggerStatus)) + ) { + return jsonRpcError( + requestId, + INVALID_PARAMS, + 'status must be "active", "paused", or "disabled"' + ); + } + if ( + params.sourceType !== undefined && + (typeof params.sourceType !== 'string' || + !VALID_TRIGGER_SOURCE_TYPES.has(params.sourceType as TriggerSourceType)) + ) { + return jsonRpcError( + requestId, + INVALID_PARAMS, + 'sourceType must be "cron", "webhook", or "github"' + ); + } + if ( + params.limit !== undefined && + (typeof params.limit !== 'number' || !Number.isFinite(params.limit) || params.limit <= 0) + ) { + return jsonRpcError(requestId, INVALID_PARAMS, 'limit must be a positive number'); + } + + const limits = getMcpLimits(env); + const requestedLimit = typeof params.limit === 'number' ? params.limit : limits.triggerListLimit; + const limit = Math.min(Math.max(1, Math.floor(requestedLimit)), limits.triggerListMax); + const status = params.status as TriggerStatus | undefined; + const sourceType = params.sourceType as TriggerSourceType | undefined; + const db = drizzle(env.DATABASE, { schema }); + const rows = await listTriggerRows(db, tokenData.projectId, { status, sourceType, limit }); + const triggers = rows.map((row) => { + const trigger = toTriggerResponse(row); + return { + id: trigger.id, + name: trigger.name, + description: trigger.description, + status: trigger.status, + sourceType: trigger.sourceType, + cronExpression: trigger.cronExpression, + cronTimezone: trigger.cronTimezone, + cronHumanReadable: trigger.cronHumanReadable ?? null, + nextFireAt: trigger.nextFireAt, + lastTriggeredAt: trigger.lastTriggeredAt, + triggerCount: trigger.triggerCount, + taskMode: trigger.taskMode, + agentProfileId: trigger.agentProfileId, + skillId: trigger.skillId, + maxConcurrent: trigger.maxConcurrent, + skipIfRunning: trigger.skipIfRunning, + }; + }); + + return jsonRpcSuccess(requestId, { + content: [{ type: 'text', text: JSON.stringify({ triggers }, null, 2) }], + }); +} diff --git a/apps/api/src/routes/mcp/trigger-tool-shared.ts b/apps/api/src/routes/mcp/trigger-tool-shared.ts new file mode 100644 index 0000000000..19e144c6a5 --- /dev/null +++ b/apps/api/src/routes/mcp/trigger-tool-shared.ts @@ -0,0 +1,132 @@ +import { DEFAULT_TRIGGER_DEFAULT_MAX_CONCURRENT } from '@simple-agent-manager/shared'; + +import type { Env } from '../../env'; +import { log } from '../../lib/logger'; +import { cronToHumanReadable } from '../../services/cron-utils'; +import { INVALID_PARAMS, jsonRpcError, type JsonRpcResponse, type McpTokenData } from './_helpers'; + +export interface TriggerDbRow { + id: string; + project_id: string; + name: string; + description: string | null; + status: string; + source_type: string; + cron_expression: string | null; + cron_timezone: string | null; + skip_if_running: number | boolean; + prompt_template: string; + agent_profile_id: string | null; + skill_id: string | null; + task_mode: string | null; + vm_size_override: string | null; + max_concurrent: number | null; + next_fire_at: string | null; + created_at: string; + updated_at: string; +} + +function normalizeBoolean( + value: number | boolean | null | undefined, + defaultValue: boolean +): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + return defaultValue; +} + +export function triggerResponse(row: TriggerDbRow, cronHumanReadable?: string) { + return { + triggerId: row.id, + id: row.id, + name: row.name, + description: row.description, + status: row.status, + sourceType: row.source_type, + cronExpression: row.cron_expression, + cronTimezone: row.cron_timezone ?? 'UTC', + skipIfRunning: normalizeBoolean(row.skip_if_running, true), + promptTemplate: row.prompt_template, + agentProfileId: row.agent_profile_id, + skillId: row.skill_id, + taskMode: row.task_mode ?? 'task', + vmSizeOverride: row.vm_size_override, + maxConcurrent: row.max_concurrent ?? DEFAULT_TRIGGER_DEFAULT_MAX_CONCURRENT, + nextFireAt: row.next_fire_at, + cronHumanReadable: + cronHumanReadable ?? + (row.cron_expression + ? cronToHumanReadable(row.cron_expression, row.cron_timezone ?? 'UTC') + : undefined), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +async function getTriggerById(env: Env, triggerId: string): Promise { + return env.DATABASE.prepare( + `SELECT id, project_id, name, description, status, source_type, cron_expression, + cron_timezone, skip_if_running, prompt_template, agent_profile_id, skill_id, + task_mode, vm_size_override, max_concurrent, next_fire_at, created_at, updated_at + FROM triggers + WHERE id = ? + LIMIT 1` + ) + .bind(triggerId) + .first(); +} + +function validateTriggerOwnership( + requestId: string | number | null, + trigger: TriggerDbRow | null, + triggerId: string, + tokenData: McpTokenData, + action: 'update' | 'delete' +): JsonRpcResponse | null { + if (!trigger) { + return jsonRpcError(requestId, INVALID_PARAMS, 'Trigger not found in this project'); + } + + if (trigger.project_id !== tokenData.projectId) { + log.warn(`mcp.${action}_trigger_project_mismatch`, { + triggerId, + expectedProjectId: trigger.project_id, + receivedProjectId: tokenData.projectId, + callerProjectId: tokenData.projectId, + action: 'rejected', + }); + return jsonRpcError(requestId, INVALID_PARAMS, 'Trigger not found in this project'); + } + + return null; +} + +type OwnedTriggerResult = + | { ok: true; triggerId: string; trigger: TriggerDbRow } + | { ok: false; response: JsonRpcResponse }; + +export async function resolveOwnedTrigger( + requestId: string | number | null, + params: Record, + tokenData: McpTokenData, + env: Env, + action: 'update' | 'delete' +): Promise { + const triggerId = typeof params.triggerId === 'string' ? params.triggerId.trim() : ''; + if (!triggerId) { + return { + ok: false, + response: jsonRpcError( + requestId, + INVALID_PARAMS, + 'triggerId is required and must be a non-empty string' + ), + }; + } + + const trigger = await getTriggerById(env, triggerId); + const ownershipError = validateTriggerOwnership(requestId, trigger, triggerId, tokenData, action); + if (ownershipError) return { ok: false, response: ownershipError }; + + return { ok: true, triggerId, trigger: trigger as TriggerDbRow }; +} diff --git a/apps/api/src/routes/mcp/trigger-tools.ts b/apps/api/src/routes/mcp/trigger-tools.ts index 9febf06b6b..7fe706af6d 100644 --- a/apps/api/src/routes/mcp/trigger-tools.ts +++ b/apps/api/src/routes/mcp/trigger-tools.ts @@ -1,11 +1,10 @@ /** - * MCP trigger management tools — create_trigger, update_trigger, delete_trigger. + * MCP trigger management tool — update_trigger. + * Create/delete live in focused modules and are re-exported for compatibility. */ import { DEFAULT_CRON_MIN_INTERVAL_MINUTES, DEFAULT_CRON_TEMPLATE_MAX_LENGTH, - DEFAULT_MAX_TRIGGERS_PER_PROJECT, - DEFAULT_TRIGGER_DEFAULT_MAX_CONCURRENT, DEFAULT_TRIGGER_MAX_CONCURRENT_LIMIT, DEFAULT_TRIGGER_NAME_MAX_LENGTH, } from '@simple-agent-manager/shared'; @@ -13,8 +12,11 @@ import { import type { Env } from '../../env'; import { log } from '../../lib/logger'; import { parsePositiveInt } from '../../lib/route-helpers'; -import { ulid } from '../../lib/ulid'; -import { cronToHumanReadable, cronToNextFire, validateCronExpression } from '../../services/cron-utils'; +import { + cronToHumanReadable, + cronToNextFire, + validateCronExpression, +} from '../../services/cron-utils'; import { INVALID_PARAMS, jsonRpcError, @@ -23,256 +25,30 @@ import { type McpTokenData, sanitizeUserInput, } from './_helpers'; +import { resolveOwnedTrigger, type TriggerDbRow, triggerResponse } from './trigger-tool-shared'; const VALID_TRIGGER_STATUSES = new Set(['active', 'paused', 'disabled']); const VALID_TASK_MODES = new Set(['task', 'conversation']); const VALID_VM_SIZES = new Set(['small', 'medium', 'large']); -interface TriggerDbRow { - id: string; - project_id: string; - name: string; - description: string | null; - status: string; - source_type: string; - cron_expression: string | null; - cron_timezone: string | null; - skip_if_running: number | boolean; - prompt_template: string; - agent_profile_id: string | null; - skill_id: string | null; - task_mode: string | null; - vm_size_override: string | null; - max_concurrent: number | null; - next_fire_at: string | null; - created_at: string; - updated_at: string; -} - -function normalizeBoolean(value: number | boolean | null | undefined, defaultValue: boolean): boolean { - if (typeof value === 'boolean') return value; - if (typeof value === 'number') return value !== 0; - return defaultValue; -} - -function triggerResponse(row: TriggerDbRow, cronHumanReadable?: string) { - return { - triggerId: row.id, - id: row.id, - name: row.name, - description: row.description, - status: row.status, - sourceType: row.source_type, - cronExpression: row.cron_expression, - cronTimezone: row.cron_timezone ?? 'UTC', - skipIfRunning: normalizeBoolean(row.skip_if_running, true), - promptTemplate: row.prompt_template, - agentProfileId: row.agent_profile_id, - skillId: row.skill_id, - taskMode: row.task_mode ?? 'task', - vmSizeOverride: row.vm_size_override, - maxConcurrent: row.max_concurrent ?? DEFAULT_TRIGGER_DEFAULT_MAX_CONCURRENT, - nextFireAt: row.next_fire_at, - cronHumanReadable: cronHumanReadable ?? ( - row.cron_expression ? cronToHumanReadable(row.cron_expression, row.cron_timezone ?? 'UTC') : undefined - ), - createdAt: row.created_at, - updatedAt: row.updated_at, - }; -} - -async function getTriggerById(env: Env, triggerId: string): Promise { - return env.DATABASE.prepare( - `SELECT id, project_id, name, description, status, source_type, cron_expression, - cron_timezone, skip_if_running, prompt_template, agent_profile_id, skill_id, - task_mode, vm_size_override, max_concurrent, next_fire_at, created_at, updated_at - FROM triggers - WHERE id = ? - LIMIT 1`, - ).bind(triggerId).first(); -} - -function validateTriggerOwnership( - requestId: string | number | null, - trigger: TriggerDbRow | null, - triggerId: string, - tokenData: McpTokenData, - action: 'update' | 'delete', -): JsonRpcResponse | null { - if (!trigger) { - return jsonRpcError(requestId, INVALID_PARAMS, 'Trigger not found in this project'); - } - - if (trigger.project_id !== tokenData.projectId) { - log.warn(`mcp.${action}_trigger_project_mismatch`, { - triggerId, - expectedProjectId: trigger.project_id, - receivedProjectId: tokenData.projectId, - callerProjectId: tokenData.projectId, - action: 'rejected', - }); - return jsonRpcError(requestId, INVALID_PARAMS, 'Trigger not found in this project'); - } - - return null; -} - -export async function handleCreateTrigger( - requestId: string | number | null, - params: Record, - tokenData: McpTokenData, - env: Env, -): Promise { - // --- Validate required fields --- - const maxNameLength = parsePositiveInt(env.TRIGGER_NAME_MAX_LENGTH, DEFAULT_TRIGGER_NAME_MAX_LENGTH); - const name = typeof params.name === 'string' ? sanitizeUserInput(params.name.trim()).slice(0, maxNameLength) : ''; - if (!name) { - return jsonRpcError(requestId, INVALID_PARAMS, 'name is required and must be a non-empty string'); - } - - const cronExpression = typeof params.cronExpression === 'string' ? params.cronExpression.trim() : ''; - if (!cronExpression) { - return jsonRpcError(requestId, INVALID_PARAMS, 'cronExpression is required'); - } - - const promptTemplate = typeof params.promptTemplate === 'string' ? params.promptTemplate.trim() : ''; - if (!promptTemplate) { - return jsonRpcError(requestId, INVALID_PARAMS, 'promptTemplate is required and must be non-empty'); - } - - const maxTemplateLength = parsePositiveInt(env.CRON_TEMPLATE_MAX_LENGTH, DEFAULT_CRON_TEMPLATE_MAX_LENGTH); - if (promptTemplate.length > maxTemplateLength) { - return jsonRpcError(requestId, INVALID_PARAMS, `promptTemplate must be ${maxTemplateLength} characters or less`); - } - - // --- Validate cron expression --- - const minInterval = parsePositiveInt(env.CRON_MIN_INTERVAL_MINUTES, DEFAULT_CRON_MIN_INTERVAL_MINUTES); - const cronValidation = validateCronExpression(cronExpression, minInterval); - if (!cronValidation.valid) { - return jsonRpcError(requestId, INVALID_PARAMS, `Invalid cron expression: ${cronValidation.error}`); - } - - // --- Validate timezone --- - const cronTimezone = typeof params.cronTimezone === 'string' ? params.cronTimezone.trim() : 'UTC'; - try { - Intl.DateTimeFormat('en-US', { timeZone: cronTimezone }); - } catch { - return jsonRpcError(requestId, INVALID_PARAMS, `Invalid timezone: ${cronTimezone}`); - } - - // --- Optional fields --- - const agentProfileId = typeof params.agentProfileId === 'string' ? params.agentProfileId.trim() : null; - const taskMode = params.taskMode === 'conversation' ? 'conversation' : 'task'; - const vmSizeOverride = ['small', 'medium', 'large'].includes(params.vmSizeOverride as string) - ? (params.vmSizeOverride as string) - : null; - - // --- Validate agentProfileId belongs to the project --- - if (agentProfileId) { - const profileResult = await env.DATABASE.prepare( - 'SELECT id FROM agent_profiles WHERE id = ? AND project_id = ? LIMIT 1', - ).bind(agentProfileId, tokenData.projectId).first<{ id: string }>(); - if (!profileResult) { - return jsonRpcError(requestId, INVALID_PARAMS, 'agentProfileId not found in this project'); - } - } - - // --- Check name uniqueness --- - const existingResult = await env.DATABASE.prepare( - 'SELECT id FROM triggers WHERE project_id = ? AND name = ? LIMIT 1', - ).bind(tokenData.projectId, name).first<{ id: string }>(); - if (existingResult) { - return jsonRpcError(requestId, INVALID_PARAMS, `Trigger "${name}" already exists in this project`); - } - - // --- Enforce MAX_TRIGGERS_PER_PROJECT --- - const maxTriggers = parsePositiveInt(env.MAX_TRIGGERS_PER_PROJECT, DEFAULT_MAX_TRIGGERS_PER_PROJECT); - const countResult = await env.DATABASE.prepare( - 'SELECT COUNT(*) as cnt FROM triggers WHERE project_id = ?', - ).bind(tokenData.projectId).first<{ cnt: number }>(); - if ((countResult?.cnt ?? 0) >= maxTriggers) { - return jsonRpcError(requestId, INVALID_PARAMS, `Maximum triggers per project (${maxTriggers}) reached`); - } - - // --- Create the trigger --- - const triggerId = ulid(); - const now = new Date().toISOString(); - const nextFireAt = cronToNextFire(cronExpression, cronTimezone); - const humanReadable = cronToHumanReadable(cronExpression, cronTimezone); - - await env.DATABASE.prepare( - `INSERT INTO triggers ( - id, project_id, user_id, name, description, status, source_type, - cron_expression, cron_timezone, skip_if_running, prompt_template, - agent_profile_id, task_mode, vm_size_override, max_concurrent, - next_fire_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, NULL, 'active', 'cron', ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?)`, - ).bind( - triggerId, - tokenData.projectId, - tokenData.userId, - name, - cronExpression, - cronTimezone, - promptTemplate, - agentProfileId, - taskMode, - vmSizeOverride, - DEFAULT_TRIGGER_DEFAULT_MAX_CONCURRENT, - nextFireAt, - now, - now, - ).run(); - - log.info('mcp.create_trigger', { - triggerId, - projectId: tokenData.projectId, - userId: tokenData.userId, - cronExpression, - cronTimezone, - }); - - return jsonRpcSuccess(requestId, { - content: [{ - type: 'text', - text: JSON.stringify({ - triggerId, - name, - status: 'active', - cronExpression, - cronTimezone, - cronHumanReadable: humanReadable, - nextFireAt, - promptTemplate, - taskMode, - vmSizeOverride, - }), - }], - }); -} - export async function handleUpdateTrigger( requestId: string | number | null, params: Record, tokenData: McpTokenData, - env: Env, + env: Env ): Promise { - const triggerId = typeof params.triggerId === 'string' ? params.triggerId.trim() : ''; - if (!triggerId) { - return jsonRpcError(requestId, INVALID_PARAMS, 'triggerId is required and must be a non-empty string'); - } - - const trigger = await getTriggerById(env, triggerId); - const ownershipError = validateTriggerOwnership(requestId, trigger, triggerId, tokenData, 'update'); - if (ownershipError) return ownershipError; - - const existingTrigger = trigger as TriggerDbRow; + const ownedTrigger = await resolveOwnedTrigger(requestId, params, tokenData, env, 'update'); + if (!ownedTrigger.ok) return ownedTrigger.response; + const { triggerId, trigger: existingTrigger } = ownedTrigger; const updates: string[] = ['updated_at = ?']; const values: unknown[] = [new Date().toISOString()]; const bodyFields = Object.keys(params).filter((key) => key !== 'triggerId'); if (params.name !== undefined) { - const maxNameLength = parsePositiveInt(env.TRIGGER_NAME_MAX_LENGTH, DEFAULT_TRIGGER_NAME_MAX_LENGTH); + const maxNameLength = parsePositiveInt( + env.TRIGGER_NAME_MAX_LENGTH, + DEFAULT_TRIGGER_NAME_MAX_LENGTH + ); if (typeof params.name !== 'string') { return jsonRpcError(requestId, INVALID_PARAMS, 'name must be a string'); } @@ -281,14 +57,24 @@ export async function handleUpdateTrigger( return jsonRpcError(requestId, INVALID_PARAMS, 'name cannot be empty'); } if (name.length > maxNameLength) { - return jsonRpcError(requestId, INVALID_PARAMS, `name must be ${maxNameLength} characters or less`); + return jsonRpcError( + requestId, + INVALID_PARAMS, + `name must be ${maxNameLength} characters or less` + ); } if (name !== existingTrigger.name) { const existingName = await env.DATABASE.prepare( - 'SELECT id FROM triggers WHERE project_id = ? AND name = ? AND id != ? LIMIT 1', - ).bind(tokenData.projectId, name, triggerId).first<{ id: string }>(); + 'SELECT id FROM triggers WHERE project_id = ? AND name = ? AND id != ? LIMIT 1' + ) + .bind(tokenData.projectId, name, triggerId) + .first<{ id: string }>(); if (existingName) { - return jsonRpcError(requestId, INVALID_PARAMS, `Trigger "${name}" already exists in this project`); + return jsonRpcError( + requestId, + INVALID_PARAMS, + `Trigger "${name}" already exists in this project` + ); } } updates.push('name = ?'); @@ -315,11 +101,14 @@ export async function handleUpdateTrigger( if (params.agentProfileId !== null && typeof params.agentProfileId !== 'string') { return jsonRpcError(requestId, INVALID_PARAMS, 'agentProfileId must be a string or null'); } - const agentProfileId = typeof params.agentProfileId === 'string' ? params.agentProfileId.trim() : null; + const agentProfileId = + typeof params.agentProfileId === 'string' ? params.agentProfileId.trim() : null; if (agentProfileId) { const profile = await env.DATABASE.prepare( - 'SELECT id FROM agent_profiles WHERE id = ? AND project_id = ? LIMIT 1', - ).bind(agentProfileId, tokenData.projectId).first<{ id: string }>(); + 'SELECT id FROM agent_profiles WHERE id = ? AND project_id = ? LIMIT 1' + ) + .bind(agentProfileId, tokenData.projectId) + .first<{ id: string }>(); if (!profile) { return jsonRpcError(requestId, INVALID_PARAMS, 'agentProfileId not found in this project'); } @@ -335,8 +124,10 @@ export async function handleUpdateTrigger( const skillId = typeof params.skillId === 'string' ? params.skillId.trim() : null; if (skillId) { const skill = await env.DATABASE.prepare( - 'SELECT id FROM skills WHERE id = ? AND project_id = ? LIMIT 1', - ).bind(skillId, tokenData.projectId).first<{ id: string }>(); + 'SELECT id FROM skills WHERE id = ? AND project_id = ? LIMIT 1' + ) + .bind(skillId, tokenData.projectId) + .first<{ id: string }>(); if (!skill) { return jsonRpcError(requestId, INVALID_PARAMS, 'skillId not found in this project'); } @@ -354,10 +145,15 @@ export async function handleUpdateTrigger( } if (params.vmSizeOverride !== undefined) { - if (params.vmSizeOverride !== null && ( - typeof params.vmSizeOverride !== 'string' || !VALID_VM_SIZES.has(params.vmSizeOverride) - )) { - return jsonRpcError(requestId, INVALID_PARAMS, 'vmSizeOverride must be "small", "medium", "large", or null'); + if ( + params.vmSizeOverride !== null && + (typeof params.vmSizeOverride !== 'string' || !VALID_VM_SIZES.has(params.vmSizeOverride)) + ) { + return jsonRpcError( + requestId, + INVALID_PARAMS, + 'vmSizeOverride must be "small", "medium", "large", or null' + ); } updates.push('vm_size_override = ?'); values.push(params.vmSizeOverride); @@ -369,10 +165,14 @@ export async function handleUpdateTrigger( } const maxConcurrentLimit = parsePositiveInt( env.TRIGGER_MAX_CONCURRENT_LIMIT, - DEFAULT_TRIGGER_MAX_CONCURRENT_LIMIT, + DEFAULT_TRIGGER_MAX_CONCURRENT_LIMIT ); if (params.maxConcurrent < 1 || params.maxConcurrent > maxConcurrentLimit) { - return jsonRpcError(requestId, INVALID_PARAMS, `maxConcurrent must be between 1 and ${maxConcurrentLimit}`); + return jsonRpcError( + requestId, + INVALID_PARAMS, + `maxConcurrent must be between 1 and ${maxConcurrentLimit}` + ); } updates.push('max_concurrent = ?'); values.push(params.maxConcurrent); @@ -382,9 +182,16 @@ export async function handleUpdateTrigger( if (typeof params.promptTemplate !== 'string') { return jsonRpcError(requestId, INVALID_PARAMS, 'promptTemplate must be a string'); } - const maxTemplateLength = parsePositiveInt(env.CRON_TEMPLATE_MAX_LENGTH, DEFAULT_CRON_TEMPLATE_MAX_LENGTH); + const maxTemplateLength = parsePositiveInt( + env.CRON_TEMPLATE_MAX_LENGTH, + DEFAULT_CRON_TEMPLATE_MAX_LENGTH + ); if (params.promptTemplate.length > maxTemplateLength) { - return jsonRpcError(requestId, INVALID_PARAMS, `promptTemplate must be ${maxTemplateLength} characters or less`); + return jsonRpcError( + requestId, + INVALID_PARAMS, + `promptTemplate must be ${maxTemplateLength} characters or less` + ); } updates.push('prompt_template = ?'); values.push(params.promptTemplate); @@ -393,17 +200,26 @@ export async function handleUpdateTrigger( let recomputeNextFire = false; let newCronExpression = existingTrigger.cron_expression; let newTimezone = existingTrigger.cron_timezone ?? 'UTC'; - let cronHumanReadable = newCronExpression ? cronToHumanReadable(newCronExpression, newTimezone) : undefined; + let cronHumanReadable = newCronExpression + ? cronToHumanReadable(newCronExpression, newTimezone) + : undefined; if (params.cronExpression !== undefined) { if (typeof params.cronExpression !== 'string' || !params.cronExpression.trim()) { return jsonRpcError(requestId, INVALID_PARAMS, 'cronExpression must be a non-empty string'); } const cronExpression = params.cronExpression.trim(); - const minInterval = parsePositiveInt(env.CRON_MIN_INTERVAL_MINUTES, DEFAULT_CRON_MIN_INTERVAL_MINUTES); + const minInterval = parsePositiveInt( + env.CRON_MIN_INTERVAL_MINUTES, + DEFAULT_CRON_MIN_INTERVAL_MINUTES + ); const validation = validateCronExpression(cronExpression, minInterval); if (!validation.valid) { - return jsonRpcError(requestId, INVALID_PARAMS, `Invalid cron expression: ${validation.error}`); + return jsonRpcError( + requestId, + INVALID_PARAMS, + `Invalid cron expression: ${validation.error}` + ); } updates.push('cron_expression = ?'); values.push(cronExpression); @@ -429,7 +245,11 @@ export async function handleUpdateTrigger( if (params.status !== undefined) { if (typeof params.status !== 'string' || !VALID_TRIGGER_STATUSES.has(params.status)) { - return jsonRpcError(requestId, INVALID_PARAMS, 'status must be "active", "paused", or "disabled"'); + return jsonRpcError( + requestId, + INVALID_PARAMS, + 'status must be "active", "paused", or "disabled"' + ); } updates.push('status = ?'); values.push(params.status); @@ -441,9 +261,12 @@ export async function handleUpdateTrigger( } } - const effectiveStatus = typeof params.status === 'string' ? params.status : existingTrigger.status; + const effectiveStatus = + typeof params.status === 'string' ? params.status : existingTrigger.status; if (recomputeNextFire) { - cronHumanReadable = newCronExpression ? cronToHumanReadable(newCronExpression, newTimezone) : undefined; + cronHumanReadable = newCronExpression + ? cronToHumanReadable(newCronExpression, newTimezone) + : undefined; } if (recomputeNextFire && effectiveStatus === 'active' && newCronExpression) { updates.push('next_fire_at = ?'); @@ -452,13 +275,17 @@ export async function handleUpdateTrigger( if (bodyFields.length === 0) { return jsonRpcSuccess(requestId, { - content: [{ type: 'text', text: JSON.stringify(triggerResponse(existingTrigger, cronHumanReadable)) }], + content: [ + { type: 'text', text: JSON.stringify(triggerResponse(existingTrigger, cronHumanReadable)) }, + ], }); } await env.DATABASE.prepare( - `UPDATE triggers SET ${updates.join(', ')} WHERE id = ? AND project_id = ?`, - ).bind(...values, triggerId, tokenData.projectId).run(); + `UPDATE triggers SET ${updates.join(', ')} WHERE id = ? AND project_id = ?` + ) + .bind(...values, triggerId, tokenData.projectId) + .run(); const updated = await env.DATABASE.prepare( `SELECT id, project_id, name, description, status, source_type, cron_expression, @@ -466,8 +293,10 @@ export async function handleUpdateTrigger( task_mode, vm_size_override, max_concurrent, next_fire_at, created_at, updated_at FROM triggers WHERE id = ? AND project_id = ? - LIMIT 1`, - ).bind(triggerId, tokenData.projectId).first(); + LIMIT 1` + ) + .bind(triggerId, tokenData.projectId) + .first(); if (!updated) { log.error('mcp.update_trigger_lost_scope', { @@ -490,34 +319,6 @@ export async function handleUpdateTrigger( }); } -export async function handleDeleteTrigger( - requestId: string | number | null, - params: Record, - tokenData: McpTokenData, - env: Env, -): Promise { - const triggerId = typeof params.triggerId === 'string' ? params.triggerId.trim() : ''; - if (!triggerId) { - return jsonRpcError(requestId, INVALID_PARAMS, 'triggerId is required and must be a non-empty string'); - } - - const trigger = await getTriggerById(env, triggerId); - const ownershipError = validateTriggerOwnership(requestId, trigger, triggerId, tokenData, 'delete'); - if (ownershipError) return ownershipError; - - await env.DATABASE.prepare('DELETE FROM github_trigger_configs WHERE trigger_id = ?').bind(triggerId).run(); - await env.DATABASE.prepare('DELETE FROM trigger_executions WHERE trigger_id = ?').bind(triggerId).run(); - await env.DATABASE.prepare('DELETE FROM triggers WHERE id = ? AND project_id = ?') - .bind(triggerId, tokenData.projectId) - .run(); - - log.info('mcp.delete_trigger', { - triggerId, - projectId: tokenData.projectId, - userId: tokenData.userId, - }); - - return jsonRpcSuccess(requestId, { - content: [{ type: 'text', text: JSON.stringify({ success: true, triggerId }) }], - }); -} +export { handleCreateTrigger } from './trigger-create-tool'; +export { handleDeleteTrigger } from './trigger-delete-tool'; +export { handleListTriggers } from './trigger-list-tool'; diff --git a/apps/api/src/routes/node-diagnostic-incidents.ts b/apps/api/src/routes/node-diagnostic-incidents.ts index 474c041ee1..7b0cc40e2e 100644 --- a/apps/api/src/routes/node-diagnostic-incidents.ts +++ b/apps/api/src/routes/node-diagnostic-incidents.ts @@ -19,6 +19,10 @@ import { redactSensitiveData, } from '../services/observability'; import { persistErrorBatchStrict } from '../services/observability-strict'; +import { + correlateVMErrorsToTasks, + type VMErrorCorrelationResult, +} from '../services/vm-error-correlation'; const nodeDiagnosticIncidentRoutes = new Hono<{ Bindings: Env }>(); const DEFAULT_MAX_VM_ERROR_BODY_BYTES = 32_768; @@ -41,6 +45,14 @@ function truncateString(value: string, maxLength: number): string { return value.length > maxLength ? value.slice(0, maxLength) + '...' : value; } +function parseCorrelationTimestamp(value: unknown): number | null { + if (typeof value === 'string') { + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : null; + } + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + function positiveInteger(value: string | undefined, fallback: number): number { const parsed = Number.parseInt(value ?? '', 10); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; @@ -96,6 +108,8 @@ nodeDiagnosticIncidentRoutes.post('/:id/errors', async (c) => { } const persistInputs: PersistErrorInput[] = []; + const correlationTimestamps: Array = []; + const reportSources: string[] = []; const pendingIncidents: Array<{ incidentId: string; platformErrorId: string; @@ -125,17 +139,8 @@ nodeDiagnosticIncidentRoutes.post('/:id/errors', async (c) => { ? truncateString(String(redactSensitiveData(value.stack)), maxStackLength) : null; const safeContext = redactSensitiveData(maybeJsonRecord(value.context)); - log[level]('vm_agent_error', { - level, - message: safeMessage, - source: safeSource, - stack: safeStack, - workspaceId, - timestamp: typeof value.timestamp === 'string' ? value.timestamp : null, - context: safeContext, - nodeId, - incidentId, - }); + const correlationTimestamp = parseCorrelationTimestamp(value.timestamp); + const timestamp = correlationTimestamp ?? Date.now(); persistInputs.push({ id: incidentId ?? undefined, source: 'vm-agent', @@ -145,21 +150,79 @@ nodeDiagnosticIncidentRoutes.post('/:id/errors', async (c) => { context: safeContext, nodeId, workspaceId, - timestamp: - typeof value.timestamp === 'string' - ? new Date(value.timestamp).getTime() || Date.now() - : Date.now(), + timestamp, }); - if (level === 'error' && incidentId) { - pendingIncidents.push({ - incidentId, - platformErrorId: incidentId, + correlationTimestamps.push(correlationTimestamp); + reportSources.push(safeSource); + } + + let correlations: VMErrorCorrelationResult[] = persistInputs.map(() => ({ + correlation: null, + rejectionReason: 'workspace_not_found', + })); + let correlationLookupCompleted = false; + if (c.env.DATABASE && persistInputs.some((input) => input.workspaceId)) { + try { + correlations = await correlateVMErrorsToTasks( + c.env.DATABASE, nodeId, - workspaceId, + persistInputs.map((input, index) => ({ + workspaceId: input.workspaceId ?? null, + timestamp: correlationTimestamps[index] ?? null, + })) + ); + correlationLookupCompleted = true; + } catch (cause) { + log.warn('observability.vm_error_correlation_failed', { + nodeId, + count: persistInputs.length, + error: cause instanceof Error ? cause.message : String(cause), + action: 'persisted_without_task_session_correlation', }); } } + persistInputs.forEach((input, index) => { + const correlationResult = correlations[index]; + if (correlationResult?.correlation) { + input.taskId = correlationResult.correlation.taskId; + input.sessionId = correlationResult.correlation.sessionId; + } else if ( + correlationLookupCompleted && + input.workspaceId && + correlationResult?.rejectionReason + ) { + log.warn('observability.vm_error_correlation_rejected', { + nodeId, + workspaceId: input.workspaceId, + incidentId: input.id ?? null, + rejectionReason: correlationResult.rejectionReason, + action: 'persisted_without_task_session_correlation', + }); + } + log[input.level ?? 'error']('vm_agent_error', { + level: input.level ?? 'error', + message: input.message, + source: reportSources[index], + stack: input.stack, + workspaceId: input.workspaceId, + taskId: input.taskId ?? null, + sessionId: input.sessionId ?? null, + timestamp: input.timestamp ? new Date(input.timestamp).toISOString() : null, + context: input.context, + nodeId, + incidentId: input.id ?? null, + }); + if (input.level === 'error' && input.id) { + pendingIncidents.push({ + incidentId: input.id, + platformErrorId: input.id, + nodeId, + workspaceId: input.workspaceId ?? null, + }); + } + }); + if (persistInputs.length > 0 && c.env.OBSERVABILITY_DATABASE) { const strictInputs = persistInputs.filter((input) => input.id); const legacyInputs = persistInputs.filter((input) => !input.id); diff --git a/apps/api/src/routes/triggers/crud.ts b/apps/api/src/routes/triggers/crud.ts index 39a67eb264..f1383cbf36 100644 --- a/apps/api/src/routes/triggers/crud.ts +++ b/apps/api/src/routes/triggers/crud.ts @@ -4,7 +4,6 @@ import type { GitHubTriggerEventType, ListTriggersResponse, TriggerResponse, - TriggerStatus, } from '@simple-agent-manager/shared'; import { DEFAULT_CRON_MIN_INTERVAL_MINUTES, @@ -27,13 +26,10 @@ import { getAuth } from '../../middleware/auth'; import { errors } from '../../middleware/error'; import { CreateTriggerSchema, jsonValidator, UpdateTriggerSchema } from '../../schemas'; import { buildCredentialAttributionForTriggers } from '../../services/credential-attribution-health'; -import { - cronToHumanReadable, - cronToNextFire, - validateCronExpression, -} from '../../services/cron-utils'; +import { cronToNextFire, validateCronExpression } from '../../services/cron-utils'; import { parseGitHubTriggerFiltersJson } from '../../services/github-trigger-filter'; import { getProjectMultiplayerState } from '../../services/project-multiplayer'; +import { listTriggerRows, toTriggerResponse } from '../../services/trigger-read'; import { getWebhookTriggerLimits, validateWebhookTriggerConfig, @@ -51,35 +47,6 @@ import { buildWebhookCredential } from './webhooks'; const crudRoutes = new Hono<{ Bindings: Env }>(); type Database = ReturnType>; -function toTriggerResponse(row: schema.TriggerRow): TriggerResponse { - return { - id: row.id, - projectId: row.projectId, - userId: row.userId, - name: row.name, - description: row.description, - status: row.status as TriggerStatus, - sourceType: row.sourceType as TriggerResponse['sourceType'], - cronExpression: row.cronExpression, - cronTimezone: row.cronTimezone ?? 'UTC', - skipIfRunning: row.skipIfRunning, - promptTemplate: row.promptTemplate, - agentProfileId: row.agentProfileId, - skillId: row.skillId, - taskMode: (row.taskMode ?? 'task') as TriggerResponse['taskMode'], - vmSizeOverride: row.vmSizeOverride, - maxConcurrent: row.maxConcurrent, - lastTriggeredAt: row.lastTriggeredAt, - triggerCount: row.triggerCount, - nextFireAt: row.nextFireAt, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - cronHumanReadable: row.cronExpression - ? cronToHumanReadable(row.cronExpression, row.cronTimezone ?? 'UTC') - : undefined, - }; -} - async function attribution( db: Database, env: Env, @@ -327,11 +294,7 @@ crudRoutes.get('/', async (c) => { const projectId = requireRouteParam(c, 'projectId'); const db = drizzle(c.env.DATABASE, { schema }); const project = await requireProjectTaskRead(db, projectId, getAuth(c).user.id); - const rows = await db - .select() - .from(schema.triggers) - .where(eq(schema.triggers.projectId, projectId)) - .orderBy(desc(schema.triggers.createdAt)); + const rows = await listTriggerRows(db, projectId); const ids = rows.map((row) => row.id); const [githubConfigs, webhookConfigs, attributionById] = await Promise.all([ ids.length diff --git a/apps/api/src/scheduled/node-cleanup/node-phases.ts b/apps/api/src/scheduled/node-cleanup/node-phases.ts index 9b64b95043..9f7a326f74 100644 --- a/apps/api/src/scheduled/node-cleanup/node-phases.ts +++ b/apps/api/src/scheduled/node-cleanup/node-phases.ts @@ -134,7 +134,7 @@ export async function sweepStaleWarmNodes( ): Promise { const staleThreshold = new Date(now.getTime() - config.gracePeriodMs).toISOString(); const candidates = await env.DATABASE.prepare( - `SELECT n.id, n.user_id, n.warm_since, + `SELECT n.id, n.user_id, n.status, n.warm_since, COUNT(CASE WHEN w.status IN ('running', 'creating', 'recovery') THEN 1 END) as active_ws_count FROM nodes n LEFT JOIN workspaces w ON w.node_id = n.id @@ -152,6 +152,7 @@ export async function sweepStaleWarmNodes( .all<{ id: string; user_id: string; + status: string; warm_since: string; active_ws_count: number; }>(); @@ -179,9 +180,9 @@ export async function sweepStaleWarmNodes( context: { warmSince: node.warm_since, gracePeriodMs: config.gracePeriodMs }, }); - if (destroyed) { + if (destroyed === 'destroyed') { result.staleDestroyed++; - } else { + } else if (destroyed === 'failed') { result.errors++; } } @@ -277,6 +278,7 @@ export async function sweepMaxLifetimeNodes( : 'max_lifetime_node_cleanup', failureRecoveryType: 'max_lifetime_node_cleanup_failure', failureBackoffMs: config.failureBackoffMs, + allowActiveWorkspaces: viaAbsoluteCeiling, context: { createdAt: node.created_at, lastWorkspaceActivity: node.last_activity, @@ -285,8 +287,10 @@ export async function sweepMaxLifetimeNodes( }, }); - if (destroyed) { + if (destroyed === 'destroyed') { result.lifetimeDestroyed++; + } else if (destroyed === 'skipped') { + result.lifetimeSkipped++; } else { result.errors++; } @@ -359,8 +363,10 @@ export async function sweepStoppedHandoffNodes( }, }); - if (destroyed) { + if (destroyed === 'destroyed') { result.lifetimeDestroyed++; + } else if (destroyed === 'skipped') { + result.lifetimeSkipped++; } else { result.errors++; } @@ -479,8 +485,10 @@ export async function sweepIncompatibleVmAgentNodes( }, }); - if (destroyed) { + if (destroyed === 'destroyed') { result.incompatibleDestroyed++; + } else if (destroyed === 'skipped') { + result.incompatibleSkipped++; } else { result.errors++; } @@ -556,11 +564,11 @@ export async function sweepIdleOrphanNodes( }, }); - if (destroyed) { + if (destroyed === 'destroyed') { result.orphanedNodesDestroyed++; } else { result.orphanedNodesSkipped++; - result.errors++; + if (destroyed === 'failed') result.errors++; } } } diff --git a/apps/api/src/scheduled/node-cleanup/shared.ts b/apps/api/src/scheduled/node-cleanup/shared.ts index 3e98a2d0b8..b5d6d83f11 100644 --- a/apps/api/src/scheduled/node-cleanup/shared.ts +++ b/apps/api/src/scheduled/node-cleanup/shared.ts @@ -30,7 +30,7 @@ import * as schema from '../../db/schema'; import type { Env } from '../../env'; import { log } from '../../lib/logger'; import { getNodeAgentBackgroundRequestTimeoutMs } from '../../services/node-agent'; -import { deleteNodeResources } from '../../services/nodes'; +import { deleteNodeResourcesStrict } from '../../services/nodes'; import { persistError } from '../../services/observability'; export const DEFAULT_CF_CONTAINER_TERMINAL_TASK_SWEEP_LIMIT = 25; @@ -163,7 +163,10 @@ function buildCleanupConfig(env: Env): CleanupConfig { DEFAULT_NODE_ORPHAN_IDLE_TIMEOUT_MS ), stoppedTtlMs: parseMs(env.WORKSPACE_STOPPED_TTL_MS, DEFAULT_WORKSPACE_STOPPED_TTL_MS), - nodeSweepLimit: parsePositiveInt(env.NODE_CLEANUP_SWEEP_LIMIT, DEFAULT_NODE_CLEANUP_SWEEP_LIMIT), + nodeSweepLimit: parsePositiveInt( + env.NODE_CLEANUP_SWEEP_LIMIT, + DEFAULT_NODE_CLEANUP_SWEEP_LIMIT + ), workspaceSweepLimit: parsePositiveInt( env.WORKSPACE_CLEANUP_SWEEP_LIMIT, DEFAULT_WORKSPACE_CLEANUP_SWEEP_LIMIT @@ -181,6 +184,74 @@ function buildCleanupConfig(env: Env): CleanupConfig { } export type CleanupContext = Record; +export type NodeCleanupDestroyResult = 'destroyed' | 'skipped' | 'failed'; +type CleanupNode = { id: string; user_id: string; status: string }; + +interface DestroyNodeForCleanupOptions { + logEvent: string; + failureLogEvent: string; + successMessage: string; + failureMessagePrefix: string; + recoveryType: string; + failureRecoveryType: string; + level?: 'info' | 'warn'; + failureBackoffMs: number; + allowActiveWorkspaces?: boolean; + context: CleanupContext; +} + +export async function claimNodeForCleanup( + env: Env, + node: CleanupNode, + nowIso: string, + options: { allowActiveWorkspaces?: boolean } = {} +): Promise { + const activeWorkspaceGuard = options.allowActiveWorkspaces + ? '' + : `AND NOT EXISTS ( + SELECT 1 + FROM workspaces active_workspace + WHERE active_workspace.node_id = nodes.id + AND active_workspace.status IN ('running', 'creating', 'recovery') + )`; + const result = await env.DATABASE.prepare( + `UPDATE nodes + SET status = 'destroying', updated_at = ? + WHERE id = ? + AND user_id = ? + AND status = ? + AND node_role = 'workspace' + AND node_class != 'user-owned' + ${activeWorkspaceGuard} + AND NOT EXISTS ( + SELECT 1 + FROM tasks active_task + WHERE active_task.auto_provisioned_node_id = nodes.id + AND active_task.status IN ('queued', 'delegated', 'in_progress') + )` + ) + .bind(nowIso, node.id, node.user_id, node.status) + .run(); + + return (result.meta.changes ?? 0) > 0; +} + +async function releaseNodeCleanupClaim( + env: Env, + node: CleanupNode, + nowIso: string, + backoffUntil: string +): Promise { + await env.DATABASE.prepare( + `UPDATE nodes + SET status = ?, cleanup_backoff_until = ?, updated_at = ? + WHERE id = ? + AND user_id = ? + AND status = 'destroying'` + ) + .bind(node.status, backoffUntil, nowIso, node.id, node.user_id) + .run(); +} export async function markNodeCleanupBackoff( env: Env, @@ -222,26 +293,133 @@ export async function markNodeCleanupBackoff( * stale-stopped-workspace phase deletes a row. That delays reaping by at most one * phase-6 window and cannot repeat, since a workspace is deleted only once. */ -export const LAST_WORKSPACE_ACTIVITY_SQL = - "COALESCE(MAX(w.updated_at), n.created_at)"; +export const LAST_WORKSPACE_ACTIVITY_SQL = 'COALESCE(MAX(w.updated_at), n.created_at)'; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function persistCleanupSuccess( + env: Env, + node: CleanupNode, + options: DestroyNodeForCleanupOptions +): Promise { + try { + await persistError( + env.OBSERVABILITY_DATABASE, + { + source: 'api', + level: options.level ?? 'warn', + message: options.successMessage, + context: { + recoveryType: options.recoveryType, + nodeId: node.id, + ...options.context, + }, + userId: node.user_id, + nodeId: node.id, + }, + env + ); + } catch (error) { + log.error('node_cleanup.success_observability_write_failed', { + nodeId: node.id, + error: errorMessage(error), + }); + } +} + +async function releaseCleanupClaimAfterFailure( + env: Env, + node: CleanupNode, + nowIso: string, + backoffUntil: string +): Promise { + try { + await releaseNodeCleanupClaim(env, node, nowIso, backoffUntil); + log.warn('node_cleanup.candidate_backed_off', { nodeId: node.id, backoffUntil }); + } catch (error) { + log.error('node_cleanup.candidate_claim_release_failed', { + nodeId: node.id, + error: errorMessage(error), + }); + } +} + +async function persistCleanupFailure( + env: Env, + node: CleanupNode, + options: DestroyNodeForCleanupOptions, + error: unknown, + backoffUntil: string +): Promise { + try { + await persistError( + env.OBSERVABILITY_DATABASE, + { + source: 'api', + level: 'error', + message: `${options.failureMessagePrefix}: ${errorMessage(error)}`, + stack: error instanceof Error ? error.stack : undefined, + context: { + recoveryType: options.failureRecoveryType, + nodeId: node.id, + backoffUntil, + ...options.context, + }, + userId: node.user_id, + nodeId: node.id, + }, + env + ); + } catch (persistErrorValue) { + log.error('node_cleanup.failure_observability_write_failed', { + nodeId: node.id, + error: errorMessage(persistErrorValue), + }); + } +} + +async function handleCleanupFailure( + env: Env, + node: CleanupNode, + nowIso: string, + options: DestroyNodeForCleanupOptions, + error: unknown +): Promise { + log.error(options.failureLogEvent, { + nodeId: node.id, + userId: node.user_id, + error: errorMessage(error), + }); + + const backoffUntil = new Date( + new Date(nowIso).getTime() + options.failureBackoffMs + ).toISOString(); + await releaseCleanupClaimAfterFailure(env, node, nowIso, backoffUntil); + await persistCleanupFailure(env, node, options, error, backoffUntil); +} export async function destroyNodeForCleanup( db: CleanupDb, env: Env, nowIso: string, - node: { id: string; user_id: string }, - options: { - logEvent: string; - failureLogEvent: string; - successMessage: string; - failureMessagePrefix: string; - recoveryType: string; - failureRecoveryType: string; - level?: 'info' | 'warn'; - failureBackoffMs: number; - context: CleanupContext; + node: CleanupNode, + options: DestroyNodeForCleanupOptions +): Promise { + const claimed = await claimNodeForCleanup(env, node, nowIso, { + allowActiveWorkspaces: options.allowActiveWorkspaces, + }); + if (!claimed) { + log.info('node_cleanup.candidate_claim_lost', { + nodeId: node.id, + userId: node.user_id, + expectedStatus: node.status, + ...options.context, + }); + return 'skipped'; } -): Promise { + try { log.info(options.logEvent, { nodeId: node.id, @@ -249,20 +427,7 @@ export async function destroyNodeForCleanup( ...options.context, }); - await deleteNodeResources(node.id, node.user_id, env); - - await persistError(env.OBSERVABILITY_DATABASE, { - source: 'api', - level: options.level ?? 'warn', - message: options.successMessage, - context: { - recoveryType: options.recoveryType, - nodeId: node.id, - ...options.context, - }, - userId: node.user_id, - nodeId: node.id, - }, env); + await deleteNodeResourcesStrict(node.id, node.user_id, env); await db .update(schema.nodes) @@ -275,42 +440,11 @@ export async function destroyNodeForCleanup( }) .where(eq(schema.nodes.id, node.id)); - return true; - } catch (err) { - log.error(options.failureLogEvent, { - nodeId: node.id, - userId: node.user_id, - error: err instanceof Error ? err.message : String(err), - }); - - const backoffUntil = new Date( - new Date(nowIso).getTime() + options.failureBackoffMs - ).toISOString(); - await markNodeCleanupBackoff(env, node.id, backoffUntil); - - try { - await persistError(env.OBSERVABILITY_DATABASE, { - source: 'api', - level: 'error', - message: - options.failureMessagePrefix + ': ' + (err instanceof Error ? err.message : String(err)), - stack: err instanceof Error ? err.stack : undefined, - context: { - recoveryType: options.failureRecoveryType, - nodeId: node.id, - backoffUntil, - ...options.context, - }, - userId: node.user_id, - nodeId: node.id, - }, env); - } catch (persistErr) { - log.error('node_cleanup.failure_observability_write_failed', { - nodeId: node.id, - error: persistErr instanceof Error ? persistErr.message : String(persistErr), - }); - } + await persistCleanupSuccess(env, node, options); - return false; + return 'destroyed'; + } catch (error) { + await handleCleanupFailure(env, node, nowIso, options, error); + return 'failed'; } } diff --git a/apps/api/src/services/agent-credential-save.ts b/apps/api/src/services/agent-credential-save.ts index 412659a9e0..31d9d2ece1 100644 --- a/apps/api/src/services/agent-credential-save.ts +++ b/apps/api/src/services/agent-credential-save.ts @@ -26,6 +26,7 @@ import { getCredentialEncryptionKey } from '../lib/secrets'; import { ulid } from '../lib/ulid'; import { errors } from '../middleware/error'; import { syncAgentCredentialToCC } from './composable-credentials/agent-sync'; +import { getClaudeOauthTokenMaxLength } from './credential-setup-config'; import { encrypt } from './encryption'; import { CredentialValidator } from './validation'; @@ -64,7 +65,12 @@ export async function saveAgentCredentialForUser( // Defensive format validation (callers should validate too, but this class of // code must never write an unparseable credential). - const validation = CredentialValidator.validateCredential(credential, credentialKind, agentType); + const validation = CredentialValidator.validateCredential( + credential, + credentialKind, + agentType, + getClaudeOauthTokenMaxLength(env) + ); if (!validation.valid) { throw errors.badRequest(validation.error || 'Invalid credential format'); } diff --git a/apps/api/src/services/credential-setup-config.ts b/apps/api/src/services/credential-setup-config.ts index 0f85ab0c85..1dc87f70ab 100644 --- a/apps/api/src/services/credential-setup-config.ts +++ b/apps/api/src/services/credential-setup-config.ts @@ -19,6 +19,12 @@ export const DEFAULT_SETUP_SESSION_SWEEP_MAX_CANDIDATES = 50; * session whose DO died without releasing (rule 47 escape path). */ export const DEFAULT_POOL_LEASE_BUFFER_MS = 5 * 60_000; +export const DEFAULT_CLAUDE_SETUP_VERIFICATION_POLL_MS = 500; +export const DEFAULT_CLAUDE_SETUP_TTY_COLUMNS = 512; +export const DEFAULT_CLAUDE_SETUP_OUTPUT_BUFFER_BYTES = 32_768; +export const DEFAULT_CLAUDE_VERIFICATION_CODE_MAX_LENGTH = 1_024; +export const DEFAULT_CLAUDE_SETUP_ERROR_DETAIL_MAX_LENGTH = 160; +export const DEFAULT_CLAUDE_OAUTH_TOKEN_MAX_LENGTH = 8_192; function parsePositiveInt(raw: string | undefined, fallback: number): number { const parsed = Number.parseInt(raw ?? '', 10); @@ -48,6 +54,42 @@ export function getPoolLeaseBufferMs(env: Env): number { return parsePositiveInt(env.POOL_LEASE_BUFFER_MS, DEFAULT_POOL_LEASE_BUFFER_MS); } +export function getClaudeSetupVerificationPollMs(env: Env): number { + return parsePositiveInt( + env.CLAUDE_SETUP_VERIFICATION_POLL_MS, + DEFAULT_CLAUDE_SETUP_VERIFICATION_POLL_MS + ); +} + +export function getClaudeSetupTtyColumns(env: Env): number { + return parsePositiveInt(env.CLAUDE_SETUP_TTY_COLUMNS, DEFAULT_CLAUDE_SETUP_TTY_COLUMNS); +} + +export function getClaudeSetupOutputBufferBytes(env: Env): number { + return parsePositiveInt( + env.CLAUDE_SETUP_OUTPUT_BUFFER_BYTES, + DEFAULT_CLAUDE_SETUP_OUTPUT_BUFFER_BYTES + ); +} + +export function getClaudeVerificationCodeMaxLength(env: Env): number { + return parsePositiveInt( + env.CLAUDE_VERIFICATION_CODE_MAX_LENGTH, + DEFAULT_CLAUDE_VERIFICATION_CODE_MAX_LENGTH + ); +} + +export function getClaudeSetupErrorDetailMaxLength(env: Env): number { + return parsePositiveInt( + env.CLAUDE_SETUP_ERROR_DETAIL_MAX_LENGTH, + DEFAULT_CLAUDE_SETUP_ERROR_DETAIL_MAX_LENGTH + ); +} + +export function getClaudeOauthTokenMaxLength(env: Env): number { + return parsePositiveInt(env.CLAUDE_OAUTH_TOKEN_MAX_LENGTH, DEFAULT_CLAUDE_OAUTH_TOKEN_MAX_LENGTH); +} + /** Lease age after which the pool self-prunes a leaked lease (TTL + buffer). */ export function getPoolLeaseMaxAgeMs(env: Env): number { return getSetupSessionTtlMs(env) + getPoolLeaseBufferMs(env); @@ -55,13 +97,14 @@ export function getPoolLeaseMaxAgeMs(env: Env): number { /** * Statuses that count as "active" (occupying the one-active-per-user slot and a - * pool lease). Mirrors the partial unique index in migration 0097. + * pool lease). Mirrors the partial unique index updated in migration 0112. */ export const ACTIVE_SETUP_STATUSES = [ 'creating', 'admitting', 'provisioning', 'waiting_for_user', + 'exchanging', 'capturing', 'saving', ] as const; diff --git a/apps/api/src/services/credential-setup-session.ts b/apps/api/src/services/credential-setup-session.ts index 57fc8cd0d0..aa79b87daf 100644 --- a/apps/api/src/services/credential-setup-session.ts +++ b/apps/api/src/services/credential-setup-session.ts @@ -40,3 +40,11 @@ export async function cancelSetupSession( ): Promise { return getStub(env, sessionId).cancel(); } + +export async function submitSetupSessionVerificationCode( + env: Env, + sessionId: string, + code: string +): Promise { + return getStub(env, sessionId).submitVerificationCode(code); +} diff --git a/apps/api/src/services/diagnostic-incidents.ts b/apps/api/src/services/diagnostic-incidents.ts index 4ff4c18975..6fc283fe49 100644 --- a/apps/api/src/services/diagnostic-incidents.ts +++ b/apps/api/src/services/diagnostic-incidents.ts @@ -7,6 +7,7 @@ import type { import * as v from 'valibot'; import type { Env } from '../env'; +import { D1_MAX_BOUND_PARAMETERS } from '../lib/d1-limits'; import { log } from '../lib/logger'; import { maybeJsonRecord } from '../lib/runtime-validation'; import { errors } from '../middleware/error'; @@ -22,7 +23,6 @@ const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/; const SHA256_PATTERN = /^[a-f0-9]{64}$/; const SAFE_ARTIFACT_KIND = 'safe-vm-incident-v1'; const SAFE_CONTENT_TYPE = 'application/gzip'; -const D1_MAX_BOUND_PARAMETERS = 100; const textEncoder = new TextEncoder(); // Mirrors DiagnosticIncidentManifest / DiagnosticCollectorOutcome diff --git a/apps/api/src/services/observability-strict.ts b/apps/api/src/services/observability-strict.ts index 0ab338cf5d..58e93e7f86 100644 --- a/apps/api/src/services/observability-strict.ts +++ b/apps/api/src/services/observability-strict.ts @@ -10,6 +10,17 @@ const DEFAULT_CONTEXT_MAX_LENGTH = 8192; const VALID_SOURCES = new Set(['client', 'vm-agent', 'api']); const VALID_LEVELS = new Set(['error', 'warn', 'info']); +interface StrictErrorRow { + source: string; + level: string; + message: string; + node_id: string | null; + workspace_id: string | null; + task_id: string | null; + session_id: string | null; + timestamp: number; +} + function truncate(value: string, maxLength: number): string { return value.length > maxLength ? value.slice(0, maxLength) + '...' : value; } @@ -24,6 +35,48 @@ function positiveInteger(value: string | undefined, fallback: number): number { return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; } +function expectedLevel(input: PersistErrorInput): string { + return input.level && VALID_LEVELS.has(input.level) ? input.level : 'error'; +} + +function hasCorrelationConflict(row: StrictErrorRow | null, input: PersistErrorInput): boolean { + const taskIdConflict = Boolean(input.taskId && row?.task_id && row.task_id !== input.taskId); + const sessionIdConflict = Boolean( + input.sessionId && row?.session_id && row.session_id !== input.sessionId + ); + return taskIdConflict || sessionIdConflict; +} + +function strictRowMatchesInput( + row: StrictErrorRow | null, + input: PersistErrorInput, + timestamp: number, + messageMaxLength: number +): boolean { + return Boolean( + row && + row.source === input.source && + row.level === expectedLevel(input) && + row.message === truncate(input.message, messageMaxLength) && + row.node_id === (input.nodeId ?? null) && + row.workspace_id === (input.workspaceId ?? null) && + row.timestamp === timestamp && + !hasCorrelationConflict(row, input) + ); +} + +function needsCorrelationEnrichment(row: StrictErrorRow, input: PersistErrorInput): boolean { + return Boolean((input.taskId && !row.task_id) || (input.sessionId && !row.session_id)); +} + +function hasRequestedCorrelation(row: StrictErrorRow | null, input: PersistErrorInput): boolean { + return Boolean( + row && + (!input.taskId || row.task_id === input.taskId) && + (!input.sessionId || row.session_id === input.sessionId) + ); +} + /** Strict, idempotent persistence for restart-safe VM outboxes. */ export async function persistErrorBatchStrict( db: D1Database, @@ -53,7 +106,12 @@ export async function persistErrorBatchStrict( if (inputs.some((input) => !input.id)) { throw new Error('Strict observability persistence requires caller-supplied IDs'); } - const statements = inputs.map((input) => { + const defaultTimestamp = Date.now(); + const stableInputs = inputs.map((input) => ({ + input, + timestamp: input.timestamp ?? defaultTimestamp, + })); + const statements = stableInputs.map(({ input, timestamp }) => { const source = VALID_SOURCES.has(input.source) ? input.source : 'api'; const level = input.level && VALID_LEVELS.has(input.level) ? input.level : 'error'; return db @@ -77,38 +135,51 @@ export async function persistErrorBatchStrict( input.sessionId ?? null, input.ipAddress ?? null, input.userAgent ? truncate(input.userAgent, userAgentMaxLength) : null, - input.timestamp ?? Date.now() + timestamp ); }); if (statements.length > 0) await db.batch(statements); - for (const input of inputs) { - const row = await db - .prepare( - `SELECT source, level, message, node_id, workspace_id, task_id, session_id - FROM platform_errors WHERE id = ?` - ) - .bind(input.id) - .first<{ - source: string; - level: string; - message: string; - node_id: string | null; - workspace_id: string | null; - task_id: string | null; - session_id: string | null; - }>(); - const expectedLevel = input.level && VALID_LEVELS.has(input.level) ? input.level : 'error'; - if ( - !row || - row.source !== input.source || - row.level !== expectedLevel || - row.message !== truncate(input.message, messageMaxLength) || - row.node_id !== (input.nodeId ?? null) || - row.workspace_id !== (input.workspaceId ?? null) || - row.task_id !== (input.taskId ?? null) || - row.session_id !== (input.sessionId ?? null) - ) { + for (const { input, timestamp } of stableInputs) { + const readRow = () => + db + .prepare( + `SELECT source, level, message, node_id, workspace_id, task_id, session_id, timestamp + FROM platform_errors WHERE id = ?` + ) + .bind(input.id) + .first(); + let row = await readRow(); + if (!row || !strictRowMatchesInput(row, input, timestamp, messageMaxLength)) { throw new Error('Observability incident ID is already bound to different metadata'); } + + // Task/session IDs are monotonic enrichment: the VM's durable report is + // stable before the control plane joins it to D1. A retry may therefore add + // missing correlation, but it must never replace an existing non-null ID. + if (needsCorrelationEnrichment(row, input)) { + await db + .prepare( + `UPDATE platform_errors + SET task_id = COALESCE(task_id, ?), + session_id = COALESCE(session_id, ?) + WHERE id = ? + AND (? IS NULL OR task_id IS NULL OR task_id = ?) + AND (? IS NULL OR session_id IS NULL OR session_id = ?)` + ) + .bind( + input.taskId ?? null, + input.sessionId ?? null, + input.id, + input.taskId ?? null, + input.taskId ?? null, + input.sessionId ?? null, + input.sessionId ?? null + ) + .run(); + row = await readRow(); + if (!hasRequestedCorrelation(row, input)) { + throw new Error('Observability incident ID is already bound to different metadata'); + } + } } } diff --git a/apps/api/src/services/strict-node-deletion.ts b/apps/api/src/services/strict-node-deletion.ts index bc7258a011..69afb65cd2 100644 --- a/apps/api/src/services/strict-node-deletion.ts +++ b/apps/api/src/services/strict-node-deletion.ts @@ -13,6 +13,7 @@ import { getCredentialEncryptionKey } from '../lib/secrets'; import { deleteDNSRecord } from './dns'; import { persistError } from './observability'; import { createProviderForUser } from './provider-credentials'; +import { destroyVmAgentContainer } from './vm-agent-container'; type NodeDb = ReturnType>; type NodeRow = typeof schema.nodes.$inferSelect; @@ -194,20 +195,24 @@ async function persistStrictDnsCleanupError( err: unknown; } ): Promise { - await persistError(env.OBSERVABILITY_DATABASE, { - source: 'api', - level: 'error', - message: `Strict node DNS cleanup failed: ${input.err instanceof Error ? input.err.message : String(input.err)}`, - stack: input.err instanceof Error ? input.err.stack : undefined, - context: { - component: 'node-deletion', - recoveryType: 'strict_node_dns_cleanup_failure', + await persistError( + env.OBSERVABILITY_DATABASE, + { + source: 'api', + level: 'error', + message: `Strict node DNS cleanup failed: ${input.err instanceof Error ? input.err.message : String(input.err)}`, + stack: input.err instanceof Error ? input.err.stack : undefined, + context: { + component: 'node-deletion', + recoveryType: 'strict_node_dns_cleanup_failure', + nodeId: input.nodeId, + backendDnsRecordId: input.backendDnsRecordId, + }, nodeId: input.nodeId, - backendDnsRecordId: input.backendDnsRecordId, + userId: input.userId, }, - nodeId: input.nodeId, - userId: input.userId, - }, env); + env + ); } async function deleteStrictNodeDnsRecord(node: NodeRow, userId: string, env: Env): Promise { @@ -255,6 +260,12 @@ export async function deleteNodeResourcesStrict( return { providerVm: 'no-instance' }; } + if (node.runtime === 'cf-container') { + await destroyVmAgentContainer(env, node.id); + await deleteStrictNodeDnsRecord(node, userId, env); + return { providerVm: 'no-instance' }; + } + const providerVm = await deleteStrictProviderInstance(db, node, userId, env); await deleteStrictNodeDnsRecord(node, userId, env); return { providerVm }; diff --git a/apps/api/src/services/trigger-read.ts b/apps/api/src/services/trigger-read.ts new file mode 100644 index 0000000000..3732152cbd --- /dev/null +++ b/apps/api/src/services/trigger-read.ts @@ -0,0 +1,65 @@ +import type { + TriggerResponse, + TriggerSourceType, + TriggerStatus, +} from '@simple-agent-manager/shared'; +import { and, desc, eq, type SQL } from 'drizzle-orm'; +import type { drizzle } from 'drizzle-orm/d1'; + +import * as schema from '../db/schema'; +import { cronToHumanReadable } from './cron-utils'; + +type Database = ReturnType>; + +export interface TriggerListOptions { + status?: TriggerStatus; + sourceType?: TriggerSourceType; + limit?: number; +} + +export function toTriggerResponse(row: schema.TriggerRow): TriggerResponse { + return { + id: row.id, + projectId: row.projectId, + userId: row.userId, + name: row.name, + description: row.description, + status: row.status as TriggerStatus, + sourceType: row.sourceType as TriggerSourceType, + cronExpression: row.cronExpression, + cronTimezone: row.cronTimezone ?? 'UTC', + skipIfRunning: row.skipIfRunning, + promptTemplate: row.promptTemplate, + agentProfileId: row.agentProfileId, + skillId: row.skillId, + taskMode: (row.taskMode ?? 'task') as TriggerResponse['taskMode'], + vmSizeOverride: row.vmSizeOverride, + maxConcurrent: row.maxConcurrent, + lastTriggeredAt: row.lastTriggeredAt, + triggerCount: row.triggerCount, + nextFireAt: row.nextFireAt, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + cronHumanReadable: row.cronExpression + ? cronToHumanReadable(row.cronExpression, row.cronTimezone ?? 'UTC') + : undefined, + }; +} + +export async function listTriggerRows( + db: Database, + projectId: string, + options: TriggerListOptions = {} +): Promise { + const conditions: SQL[] = [eq(schema.triggers.projectId, projectId)]; + if (options.status) conditions.push(eq(schema.triggers.status, options.status)); + if (options.sourceType) conditions.push(eq(schema.triggers.sourceType, options.sourceType)); + + const query = db + .select() + .from(schema.triggers) + .where(and(...conditions)) + .orderBy(desc(schema.triggers.createdAt)); + + return options.limit === undefined ? query : query.limit(options.limit); +} diff --git a/apps/api/src/services/validation.ts b/apps/api/src/services/validation.ts index 3d2dc1c85f..84b461ce56 100644 --- a/apps/api/src/services/validation.ts +++ b/apps/api/src/services/validation.ts @@ -7,6 +7,7 @@ import type { import { DEFAULT_SCALEWAY_ZONE, getAgentDefinition } from '@simple-agent-manager/shared'; import { expectJsonRecord, maybeJsonRecord } from '../lib/runtime-validation'; +import { DEFAULT_CLAUDE_OAUTH_TOKEN_MAX_LENGTH } from './credential-setup-config'; import { fetchWithTimeout } from './fetch-timeout'; const ANTHROPIC_API_KEY_PREFIX = 'sk-ant-api'; @@ -300,7 +301,7 @@ export async function validateUpCloudCredentialWithProvider( export async function validateDigitalOceanCredentialWithProvider( token: string, - options?: CredentialValidationOptions, + options?: CredentialValidationOptions ): Promise { return runProviderCheck( { @@ -309,7 +310,7 @@ export async function validateDigitalOceanCredentialWithProvider( init: { headers: { Authorization: `Bearer ${token}` } }, }, 'DigitalOcean credential validated.', - options, + options ); } @@ -394,7 +395,8 @@ export class CredentialValidator { static validateCredential( credential: string, kind: CredentialKind, - agentType?: AgentType + agentType?: AgentType, + maxClaudeOauthTokenLength = DEFAULT_CLAUDE_OAUTH_TOKEN_MAX_LENGTH ): { valid: boolean; error?: string } { if (!credential || credential.trim().length === 0) { return { valid: false, error: 'Credential cannot be empty' }; @@ -454,6 +456,18 @@ export class CredentialValidator { error: 'Claude OAuth token should start with "sk-ant-oat".', }; } + if (agentType === 'claude-code' && credential.length > maxClaudeOauthTokenLength) { + return { + valid: false, + error: 'Claude OAuth token is too long.', + }; + } + if (agentType === 'claude-code' && !/^[A-Za-z0-9._-]+$/.test(credential)) { + return { + valid: false, + error: 'Claude OAuth token contains invalid characters.', + }; + } } return { valid: true }; diff --git a/apps/api/src/services/vm-error-correlation.ts b/apps/api/src/services/vm-error-correlation.ts new file mode 100644 index 0000000000..cf30970c38 --- /dev/null +++ b/apps/api/src/services/vm-error-correlation.ts @@ -0,0 +1,155 @@ +import { D1_MAX_BOUND_PARAMETERS } from '../lib/d1-limits'; + +export interface VMErrorCorrelationRequest { + workspaceId: string | null; + /** Producer-authored incident time. Null means the report cannot be safely correlated. */ + timestamp: number | null; +} + +export interface VMErrorCorrelation { + taskId: string; + sessionId: string; +} + +export type VMErrorCorrelationRejectionReason = + | 'missing_workspace_id' + | 'invalid_incident_timestamp' + | 'workspace_not_found' + | 'node_mismatch' + | 'canonical_task_missing' + | 'outside_task_lifetime' + | 'ambiguous_task_binding'; + +export type VMErrorCorrelationResult = + | { correlation: VMErrorCorrelation; rejectionReason: null } + | { correlation: null; rejectionReason: VMErrorCorrelationRejectionReason }; + +interface CorrelationRow { + workspace_id: string; + workspace_node_id: string | null; + workspace_session_id: string | null; + task_id: string | null; + task_session_id: string | null; + task_created_at: string | null; + task_started_at: string | null; + task_completed_at: string | null; +} + +function chunk(values: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let index = 0; index < values.length; index += size) { + chunks.push(values.slice(index, index + size)); + } + return chunks; +} + +function parseDatabaseTimestamp(value: string | null): number | null { + if (!value) return null; + const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(value); + const normalized = hasTimezone ? value : `${value.replace(' ', 'T')}Z`; + const parsed = Date.parse(normalized); + return Number.isFinite(parsed) ? parsed : null; +} + +function isWithinTaskLifetime(row: CorrelationRow, timestamp: number): boolean { + const startedAt = parseDatabaseTimestamp(row.task_started_at ?? row.task_created_at); + if (startedAt === null || timestamp < startedAt) return false; + const completedAt = parseDatabaseTimestamp(row.task_completed_at); + return completedAt === null || timestamp <= completedAt; +} + +/** + * Resolve workspace-only VM reports to task/session IDs when D1 proves one + * canonical binding. Missing, stale, cross-node, or inconsistent bindings stay + * uncorrelated rather than risking evidence attachment to the wrong task. + */ +export async function correlateVMErrorsToTasks( + db: D1Database, + nodeId: string, + requests: VMErrorCorrelationRequest[] +): Promise { + const workspaceIds = [ + ...new Set( + requests + .map((request) => request.workspaceId?.trim() ?? '') + .filter((workspaceId) => workspaceId.length > 0) + ), + ]; + if (workspaceIds.length === 0) { + return requests.map((request) => ({ + correlation: null, + rejectionReason: request.workspaceId ? 'workspace_not_found' : 'missing_workspace_id', + })); + } + + const rowsByWorkspace = new Map(); + for (const workspaceBatch of chunk(workspaceIds, D1_MAX_BOUND_PARAMETERS)) { + const placeholders = workspaceBatch.map(() => '?').join(','); + const result = await db + .prepare( + `SELECT w.id AS workspace_id, + w.node_id AS workspace_node_id, + w.chat_session_id AS workspace_session_id, + t.id AS task_id, + t.chat_session_id AS task_session_id, + t.created_at AS task_created_at, + t.started_at AS task_started_at, + t.completed_at AS task_completed_at + FROM workspaces w + LEFT JOIN tasks t + ON t.workspace_id = w.id + AND t.project_id = w.project_id + WHERE w.id IN (${placeholders})` + ) + .bind(...workspaceBatch) + .all(); + + for (const row of result.results) { + const rows = rowsByWorkspace.get(row.workspace_id) ?? []; + rows.push(row); + rowsByWorkspace.set(row.workspace_id, rows); + } + } + + return requests.map((request) => { + if (!request.workspaceId) { + return { correlation: null, rejectionReason: 'missing_workspace_id' }; + } + const incidentTimestamp = request.timestamp; + if (incidentTimestamp === null || !Number.isFinite(incidentTimestamp)) { + return { correlation: null, rejectionReason: 'invalid_incident_timestamp' }; + } + const rows = rowsByWorkspace.get(request.workspaceId) ?? []; + if (rows.length === 0) { + return { correlation: null, rejectionReason: 'workspace_not_found' }; + } + if (rows.some((row) => row.workspace_node_id !== nodeId)) { + return { correlation: null, rejectionReason: 'node_mismatch' }; + } + const canonicalRows = rows.filter( + (row) => + row.task_id !== null && + row.task_session_id !== null && + row.workspace_session_id !== null && + row.workspace_session_id === row.task_session_id + ); + if (canonicalRows.length === 0) { + return { correlation: null, rejectionReason: 'canonical_task_missing' }; + } + const liveRows = canonicalRows.filter((row) => isWithinTaskLifetime(row, incidentTimestamp)); + if (liveRows.length === 0) { + return { correlation: null, rejectionReason: 'outside_task_lifetime' }; + } + if (liveRows.length !== 1) { + return { correlation: null, rejectionReason: 'ambiguous_task_binding' }; + } + const row = liveRows[0]; + if (!row?.task_id || !row.task_session_id) { + return { correlation: null, rejectionReason: 'canonical_task_missing' }; + } + return { + correlation: { taskId: row.task_id, sessionId: row.task_session_id }, + rejectionReason: null, + }; + }); +} diff --git a/apps/api/src/services/workspace-placement.ts b/apps/api/src/services/workspace-placement.ts new file mode 100644 index 0000000000..af5dea7eb4 --- /dev/null +++ b/apps/api/src/services/workspace-placement.ts @@ -0,0 +1,80 @@ +import type { VMLocation, VMSize, WorkspaceProfile } from '@simple-agent-manager/shared'; + +export interface WorkspacePlacementInput { + id: string; + nodeId: string; + projectId: string; + userId: string; + installationId: string; + name: string; + displayName: string; + normalizedDisplayName: string; + repository: string; + branch: string; + vmSize: VMSize; + vmLocation: VMLocation; + workspaceProfile: WorkspaceProfile; + devcontainerConfigName: string | null; + agentProfileHint: string | null; + createdAt: string; +} + +/** + * Atomically reserve one workspace slot and create its durable `creating` row. + * + * Node selection is advisory: another TaskRunner or cleanup loop can change D1 + * before workspace creation. Keeping the node-state and capacity predicates in + * the INSERT makes that final placement decision one D1 statement. Concurrent + * inserts cannot both consume the same final slot, and a cleanup claim that wins + * first changes the node out of `running`, causing this operation to return false. + */ +export async function reserveWorkspacePlacement( + database: D1Database, + input: WorkspacePlacementInput, + maxWorkspaces: number +): Promise { + const result = await database + .prepare( + `INSERT INTO workspaces + (id, node_id, project_id, user_id, installation_id, name, display_name, + normalized_display_name, repository, branch, status, vm_size, vm_location, + workspace_profile, devcontainer_config_name, agent_profile_hint, created_at, updated_at) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'creating', ?, ?, ?, ?, ?, ?, ? + FROM nodes n + WHERE n.id = ? + AND n.user_id = ? + AND n.status = 'running' + AND n.node_role = 'workspace' + AND ( + SELECT COUNT(*) + FROM workspaces active + WHERE active.node_id = n.id + AND active.status IN ('running', 'creating', 'recovery') + ) < ?` + ) + .bind( + input.id, + input.nodeId, + input.projectId, + input.userId, + input.installationId, + input.name, + input.displayName, + input.normalizedDisplayName, + input.repository, + input.branch, + input.vmSize, + input.vmLocation, + input.workspaceProfile, + input.devcontainerConfigName, + input.agentProfileHint, + input.createdAt, + input.createdAt, + input.nodeId, + input.userId, + maxWorkspaces + ) + .run(); + + return (result.meta.changes ?? 0) > 0; +} diff --git a/apps/api/tests/integration/mcp-stop-subtask-lifecycle.test.ts b/apps/api/tests/integration/mcp-stop-subtask-lifecycle.test.ts new file mode 100644 index 0000000000..6d0a3f0b7d --- /dev/null +++ b/apps/api/tests/integration/mcp-stop-subtask-lifecycle.test.ts @@ -0,0 +1,189 @@ +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as schema from '../../src/db/schema'; +import type { Env } from '../../src/env'; +import { createSchemaTables, createSqliteD1 } from '../helpers/sqlite-d1'; + +const mocks = vi.hoisted(() => ({ + sendPromptToAgentOnNode: vi.fn(), + stopAgentSessionOnNode: vi.fn(), + stopSession: vi.fn(), + cleanupTaskRun: vi.fn(), +})); + +vi.mock('../../src/services/node-agent', () => ({ + sendPromptToAgentOnNode: (...args: unknown[]) => mocks.sendPromptToAgentOnNode(...args), + stopAgentSessionOnNode: (...args: unknown[]) => mocks.stopAgentSessionOnNode(...args), +})); + +vi.mock('../../src/services/project-data', () => ({ + enqueueMailboxMessage: vi.fn(), + stopSession: (...args: unknown[]) => mocks.stopSession(...args), +})); + +vi.mock('../../src/services/task-runner', () => ({ + cleanupTaskRun: (...args: unknown[]) => mocks.cleanupTaskRun(...args), +})); + +describe('MCP parent-stop lifecycle boundary', () => { + let sqlite: Database.Database; + let env: Env; + + const tokenData = { + taskId: 'parent-task', + projectId: 'project-1', + userId: 'user-1', + workspaceId: 'parent-workspace', + createdAt: '2026-08-09T09:00:00.000Z', + }; + + beforeEach(() => { + vi.clearAllMocks(); + sqlite = new Database(':memory:'); + createSchemaTables(sqlite, [ + schema.tasks, + schema.taskStatusEvents, + schema.workspaces, + schema.nodes, + schema.agentSessions, + schema.triggerExecutions, + ]); + sqlite.prepare(`INSERT INTO nodes (id, status) VALUES ('node-1', 'running')`).run(); + sqlite + .prepare( + `INSERT INTO workspaces (id, project_id, node_id, chat_session_id) + VALUES ('workspace-1', 'project-1', 'node-1', 'chat-1')` + ) + .run(); + sqlite + .prepare( + `INSERT INTO agent_sessions (id, workspace_id, status, created_at) + VALUES ('agent-session-1', 'workspace-1', 'running', '2026-08-09T08:00:00.000Z')` + ) + .run(); + sqlite + .prepare( + `INSERT INTO trigger_executions (id, status) + VALUES ('trigger-execution-1', 'running')` + ) + .run(); + sqlite + .prepare( + `INSERT INTO tasks + (id, project_id, workspace_id, parent_task_id, status, error_message, + created_at, updated_at, trigger_execution_id) + VALUES + ('child-task', 'project-1', 'workspace-1', 'parent-task', 'in_progress', NULL, + '2026-08-09T08:00:00.000Z', '2026-08-09T08:00:00.000Z', 'trigger-execution-1')` + ) + .run(); + env = { DATABASE: createSqliteD1(sqlite) } as Env; + mocks.stopAgentSessionOnNode.mockImplementation(async () => { + const row = sqlite.prepare(`SELECT status FROM tasks WHERE id = 'child-task'`).get() as { + status: string; + }; + expect(row.status).toBe('in_progress'); + }); + mocks.stopSession.mockResolvedValue(undefined); + mocks.cleanupTaskRun.mockResolvedValue(undefined); + }); + + afterEach(() => { + sqlite.close(); + }); + + it('hard-stops first, then persists cancellation, event, trigger sync, and cleanup', async () => { + const { handleStopSubtask } = await import('../../src/routes/mcp/orchestration-comms'); + env.ORCHESTRATOR_STOP_GRACE_MS = '1'; + + const result = await handleStopSubtask( + 1, + { taskId: 'child-task', reason: 'No longer needed' }, + tokenData, + env + ); + + expect(result.error).toBeUndefined(); + expect( + sqlite + .prepare(`SELECT status, error_message, completed_at FROM tasks WHERE id = 'child-task'`) + .get() + ).toMatchObject({ + status: 'cancelled', + error_message: 'Stopped by parent: No longer needed', + }); + expect( + sqlite + .prepare( + `SELECT from_status, to_status, actor_type, reason + FROM task_status_events WHERE task_id = 'child-task'` + ) + .all() + ).toEqual([ + { + from_status: 'in_progress', + to_status: 'cancelled', + actor_type: 'agent', + reason: 'Stopped by parent: No longer needed', + }, + ]); + expect( + sqlite + .prepare( + `SELECT status, error_message, completed_at + FROM trigger_executions WHERE id = 'trigger-execution-1'` + ) + .get() + ).toMatchObject({ status: 'failed', error_message: null }); + expect(mocks.stopSession).toHaveBeenCalledWith(env, 'project-1', 'chat-1'); + expect(mocks.cleanupTaskRun).toHaveBeenCalledWith('child-task', env, undefined, 'user-1'); + }); + + it('preserves a fatal callback that wins while the runtime is being stopped', async () => { + mocks.stopAgentSessionOnNode.mockImplementationOnce(async () => { + sqlite + .prepare( + `UPDATE tasks + SET status = 'failed', error_message = 'Prompt timed out after 6h0m0s', + completed_at = '2026-08-09T09:34:00.000Z' + WHERE id = 'child-task'` + ) + .run(); + sqlite + .prepare( + `UPDATE trigger_executions + SET status = 'failed', error_message = 'Prompt timed out after 6h0m0s' + WHERE id = 'trigger-execution-1'` + ) + .run(); + sqlite + .prepare( + `INSERT INTO task_status_events + (id, task_id, from_status, to_status, actor_type, reason, created_at) + VALUES + ('fatal-event', 'child-task', 'in_progress', 'failed', 'workspace', + 'Prompt timed out after 6h0m0s', '2026-08-09T09:34:00.000Z')` + ) + .run(); + }); + const { handleStopSubtask } = await import('../../src/routes/mcp/orchestration-comms'); + + const result = await handleStopSubtask(1, { taskId: 'child-task' }, tokenData, env); + + const content = JSON.parse( + (result.result as { content: Array<{ text: string }> }).content[0].text + ); + expect(content).toMatchObject({ stopped: true, terminalStatePreserved: true }); + expect( + sqlite.prepare(`SELECT status, error_message FROM tasks WHERE id = 'child-task'`).get() + ).toEqual({ status: 'failed', error_message: 'Prompt timed out after 6h0m0s' }); + expect( + sqlite + .prepare(`SELECT to_status, reason FROM task_status_events WHERE task_id = 'child-task'`) + .all() + ).toEqual([{ to_status: 'failed', reason: 'Prompt timed out after 6h0m0s' }]); + expect(mocks.stopSession).not.toHaveBeenCalled(); + expect(mocks.cleanupTaskRun).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/tests/integration/observability-ingestion.test.ts b/apps/api/tests/integration/observability-ingestion.test.ts index 8ba64018a6..242b44c8b8 100644 --- a/apps/api/tests/integration/observability-ingestion.test.ts +++ b/apps/api/tests/integration/observability-ingestion.test.ts @@ -25,7 +25,7 @@ import { generateKeyPairSync } from 'node:crypto'; import Database from 'better-sqlite3'; import { Hono } from 'hono'; import { importPKCS8, SignJWT } from 'jose'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Env } from '../../src/env'; import { createInstrumentedLogger } from '../../src/lib/logger'; @@ -770,6 +770,8 @@ describe('observability error ingestion pipeline (behavioral)', () => { describe('VM agent errors route → D1', () => { const NODE_ID = 'node-obs-1'; let authEnv: Env; + let mainSqlite: Database.Database; + let mainDb: D1Database; beforeEach(() => { const { publicKey, privateKey } = generateKeyPairSync('rsa', { @@ -777,14 +779,47 @@ describe('observability error ingestion pipeline (behavioral)', () => { publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, }); + mainSqlite = new Database(':memory:'); + mainSqlite.exec(` + CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + node_id TEXT, + project_id TEXT, + chat_session_id TEXT + ); + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + workspace_id TEXT, + project_id TEXT, + chat_session_id TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT + ); + CREATE TABLE diagnostic_incidents ( + id TEXT PRIMARY KEY, + platform_error_id TEXT NOT NULL, + node_id TEXT NOT NULL, + workspace_id TEXT, + status TEXT NOT NULL, + expires_at TEXT NOT NULL, + delete_after TEXT NOT NULL + ); + `); + mainDb = createTestD1(mainSqlite); authEnv = { BASE_DOMAIN: 'test.example.com', JWT_PUBLIC_KEY: publicKey, JWT_PRIVATE_KEY: privateKey, + DATABASE: mainDb, OBSERVABILITY_DATABASE: obsDb, } as unknown as Env; }); + afterEach(() => { + mainSqlite.close(); + }); + function buildNodeApp() { const app = new Hono(); app.route('/api/nodes', nodeLifecycleRoutes); @@ -835,6 +870,329 @@ describe('observability error ingestion pipeline (behavioral)', () => { expect(errors[0].workspaceId).toBe('ws-77'); }); + it('correlates only authoritative node-workspace-session-task bindings', async () => { + const incidentTime = Date.UTC(2026, 7, 9, 6, 47, 29); + const workspaces = [ + ['ws-match', NODE_ID, 'project-1', 'session-match'], + ['ws-other-node', 'node-other', 'project-1', 'session-other-node'], + ['ws-session-mismatch', NODE_ID, 'project-1', 'session-workspace'], + ['ws-future-task', NODE_ID, 'project-1', 'session-future'], + ['ws-stale-task', NODE_ID, 'project-1', 'session-stale'], + ['ws-ambiguous', NODE_ID, 'project-1', 'session-ambiguous'], + ]; + const insertWorkspace = mainSqlite.prepare( + 'INSERT INTO workspaces (id, node_id, project_id, chat_session_id) VALUES (?, ?, ?, ?)' + ); + for (const workspace of workspaces) insertWorkspace.run(...workspace); + + const insertTask = mainSqlite.prepare( + `INSERT INTO tasks + (id, workspace_id, project_id, chat_session_id, created_at, started_at, completed_at) + VALUES (?, ?, ?, ?, ?, ?, NULL)` + ); + insertTask.run( + 'task-match', + 'ws-match', + 'project-1', + 'session-match', + '2026-08-09T06:00:00.000Z', + '2026-08-09T06:01:00.000Z' + ); + insertTask.run( + 'task-other-node', + 'ws-other-node', + 'project-1', + 'session-other-node', + '2026-08-09T06:00:00.000Z', + '2026-08-09T06:01:00.000Z' + ); + insertTask.run( + 'task-session-mismatch', + 'ws-session-mismatch', + 'project-1', + 'session-task', + '2026-08-09T06:00:00.000Z', + '2026-08-09T06:01:00.000Z' + ); + insertTask.run( + 'task-future', + 'ws-future-task', + 'project-1', + 'session-future', + '2026-08-09T07:00:00.000Z', + '2026-08-09T07:01:00.000Z' + ); + insertTask.run( + 'task-stale', + 'ws-stale-task', + 'project-1', + 'session-stale', + '2026-08-09T05:00:00.000Z', + '2026-08-09T05:01:00.000Z' + ); + mainSqlite + .prepare( + `UPDATE tasks SET completed_at = '2026-08-09T06:30:00.000Z' WHERE id = 'task-stale'` + ) + .run(); + insertTask.run( + 'task-ambiguous-1', + 'ws-ambiguous', + 'project-1', + 'session-ambiguous', + '2026-08-09T06:00:00.000Z', + '2026-08-09T06:01:00.000Z' + ); + insertTask.run( + 'task-ambiguous-2', + 'ws-ambiguous', + 'project-1', + 'session-ambiguous', + '2026-08-09T06:00:00.000Z', + '2026-08-09T06:01:00.000Z' + ); + + const token = await signNodeCallbackToken(NODE_ID, authEnv); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const res = await buildNodeApp().request( + `/api/nodes/${NODE_ID}/errors`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + errors: [...workspaces.map(([workspaceId]) => workspaceId), 'ws-missing'].map( + (workspaceId) => ({ + message: `failure-${workspaceId}`, + source: 'session_host.go', + level: 'error', + workspaceId, + timestamp: new Date(incidentTime).toISOString(), + }) + ), + }), + }, + authEnv + ); + + expect(res.status).toBe(204); + const rows = sqlite + .prepare( + `SELECT workspace_id, task_id, session_id + FROM platform_errors + ORDER BY workspace_id` + ) + .all(); + expect(rows).toEqual([ + { workspace_id: 'ws-ambiguous', task_id: null, session_id: null }, + { workspace_id: 'ws-future-task', task_id: null, session_id: null }, + { workspace_id: 'ws-match', task_id: 'task-match', session_id: 'session-match' }, + { workspace_id: 'ws-missing', task_id: null, session_id: null }, + { workspace_id: 'ws-other-node', task_id: null, session_id: null }, + { workspace_id: 'ws-session-mismatch', task_id: null, session_id: null }, + { workspace_id: 'ws-stale-task', task_id: null, session_id: null }, + ]); + const rejectionByWorkspace = Object.fromEntries( + warnSpy.mock.calls + .map(([entry]) => JSON.parse(entry as string)) + .filter((entry) => entry.event === 'observability.vm_error_correlation_rejected') + .map((entry) => [entry.workspaceId, entry.rejectionReason]) + ); + expect(rejectionByWorkspace).toEqual({ + 'ws-ambiguous': 'ambiguous_task_binding', + 'ws-future-task': 'outside_task_lifetime', + 'ws-missing': 'workspace_not_found', + 'ws-other-node': 'node_mismatch', + 'ws-session-mismatch': 'canonical_task_missing', + 'ws-stale-task': 'outside_task_lifetime', + }); + warnSpy.mockRestore(); + }); + + it('persists receipt time but rejects missing, malformed, and non-finite producer timestamps', async () => { + mainSqlite + .prepare( + `INSERT INTO workspaces (id, node_id, project_id, chat_session_id) + VALUES ('ws-timestamp', ?, 'project-1', 'session-timestamp')` + ) + .run(NODE_ID); + mainSqlite + .prepare( + `INSERT INTO tasks + (id, workspace_id, project_id, chat_session_id, created_at, started_at) + VALUES + ('task-timestamp', 'ws-timestamp', 'project-1', 'session-timestamp', + '2026-08-09T06:00:00.000Z', '2026-08-09T06:01:00.000Z')` + ) + .run(); + const token = await signNodeCallbackToken(NODE_ID, authEnv); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const beforeReceipt = Date.now(); + + const res = await buildNodeApp().request( + `/api/nodes/${NODE_ID}/errors`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + errors: [ + { + message: 'missing timestamp', + source: 'session_host.go', + level: 'warn', + workspaceId: 'ws-timestamp', + }, + { + message: 'malformed timestamp', + source: 'session_host.go', + level: 'warn', + workspaceId: 'ws-timestamp', + timestamp: 'not-a-timestamp', + }, + { + message: 'non-finite timestamp', + source: 'session_host.go', + level: 'warn', + workspaceId: 'ws-timestamp', + timestamp: 'Infinity', + }, + ], + }), + }, + authEnv + ); + const afterReceipt = Date.now(); + + expect(res.status).toBe(204); + const rows = sqlite + .prepare( + `SELECT task_id, session_id, timestamp + FROM platform_errors + ORDER BY message` + ) + .all() as Array<{ task_id: string | null; session_id: string | null; timestamp: number }>; + expect(rows).toHaveLength(3); + for (const row of rows) { + expect(row.task_id).toBeNull(); + expect(row.session_id).toBeNull(); + expect(row.timestamp).toBeGreaterThanOrEqual(beforeReceipt); + expect(row.timestamp).toBeLessThanOrEqual(afterReceipt); + } + const rejectionLogs = warnSpy.mock.calls + .map(([entry]) => JSON.parse(entry as string)) + .filter((entry) => entry.event === 'observability.vm_error_correlation_rejected'); + expect(rejectionLogs).toHaveLength(3); + expect(rejectionLogs).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + nodeId: NODE_ID, + workspaceId: 'ws-timestamp', + rejectionReason: 'invalid_incident_timestamp', + action: 'persisted_without_task_session_correlation', + }), + ]) + ); + warnSpy.mockRestore(); + }); + + it('enriches a retried stable incident and rejects a later conflicting rebind', async () => { + const incidentId = '01KZJMDJT3ET7Z3BZ40TTX81Z5'; + const incidentTimestamp = '2026-08-09T06:47:29.000Z'; + const token = await signNodeCallbackToken(NODE_ID, authEnv); + const postStableIncident = () => + buildNodeApp().request( + `/api/nodes/${NODE_ID}/errors`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + errors: [ + { + incidentId, + message: 'ACP prompt force-stopped', + source: 'session_host.go', + level: 'error', + workspaceId: 'ws-retry', + timestamp: incidentTimestamp, + }, + ], + }), + }, + authEnv + ); + + expect((await postStableIncident()).status).toBe(204); + expect( + sqlite + .prepare('SELECT task_id, session_id FROM platform_errors WHERE id = ?') + .get(incidentId) + ).toEqual({ task_id: null, session_id: null }); + + mainSqlite + .prepare( + `INSERT INTO workspaces (id, node_id, project_id, chat_session_id) + VALUES ('ws-retry', ?, 'project-1', 'session-1')` + ) + .run(NODE_ID); + mainSqlite + .prepare( + `INSERT INTO tasks + (id, workspace_id, project_id, chat_session_id, created_at, started_at) + VALUES + ('task-1', 'ws-retry', 'project-1', 'session-1', + '2026-08-09T06:00:00.000Z', '2026-08-09T06:01:00.000Z')` + ) + .run(); + + expect((await postStableIncident()).status).toBe(204); + expect( + sqlite + .prepare('SELECT task_id, session_id FROM platform_errors WHERE id = ?') + .get(incidentId) + ).toEqual({ task_id: 'task-1', session_id: 'session-1' }); + + mainSqlite + .prepare(`UPDATE workspaces SET chat_session_id = 'session-2' WHERE id = 'ws-retry'`) + .run(); + mainSqlite + .prepare( + `INSERT INTO tasks + (id, workspace_id, project_id, chat_session_id, created_at, started_at) + VALUES + ('task-2', 'ws-retry', 'project-1', 'session-2', + '2026-08-09T06:00:00.000Z', '2026-08-09T06:01:00.000Z')` + ) + .run(); + + expect((await postStableIncident()).status).toBe(500); + expect( + sqlite + .prepare('SELECT task_id, session_id FROM platform_errors WHERE id = ?') + .get(incidentId) + ).toEqual({ task_id: 'task-1', session_id: 'session-1' }); + }); + + it('persists uncorrelated evidence when the main D1 lookup fails', async () => { + const token = await signNodeCallbackToken(NODE_ID, authEnv); + authEnv.DATABASE = createBrokenD1(); + + const res = await postNodeErrors(token); + + expect(res.status).toBe(204); + const row = sqlite + .prepare(`SELECT workspace_id, task_id, session_id FROM platform_errors`) + .get(); + expect(row).toEqual({ workspace_id: 'ws-77', task_id: null, session_id: null }); + }); + it('rejects a legacy no-scope callback token even when its workspace matches the node', async () => { const legacy = await signLegacyNodeCallbackToken(NODE_ID, authEnv); const res = await postNodeErrors(legacy); diff --git a/apps/api/tests/integration/warm-node-pooling.test.ts b/apps/api/tests/integration/warm-node-pooling.test.ts index e4af9059ae..664ade8a15 100644 --- a/apps/api/tests/integration/warm-node-pooling.test.ts +++ b/apps/api/tests/integration/warm-node-pooling.test.ts @@ -14,14 +14,29 @@ import { resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; describe('warm node pooling lifecycle integration', () => { - const taskRunnerFile = readFileSync(resolve(process.cwd(), 'src/services/task-runner.ts'), 'utf8'); - const selectorFile = readFileSync(resolve(process.cwd(), 'src/services/node-selector.ts'), 'utf8'); - const doFile = readFileSync(resolve(process.cwd(), 'src/durable-objects/node-lifecycle.ts'), 'utf8'); + const taskRunnerFile = readFileSync( + resolve(process.cwd(), 'src/services/task-runner.ts'), + 'utf8' + ); + const selectorFile = readFileSync( + resolve(process.cwd(), 'src/services/node-selector.ts'), + 'utf8' + ); + const doFile = readFileSync( + resolve(process.cwd(), 'src/durable-objects/node-lifecycle.ts'), + 'utf8' + ); const cleanupFile = ['index.ts', 'shared.ts', 'node-phases.ts', 'workspace-phases.ts'] .map((f) => readFileSync(resolve(process.cwd(), `src/scheduled/node-cleanup/${f}`), 'utf8')) .join('\n'); - const serviceFile = readFileSync(resolve(process.cwd(), 'src/services/node-lifecycle.ts'), 'utf8'); - const constantsFile = readFileSync(resolve(process.cwd(), '../../packages/shared/src/constants/node-pooling.ts'), 'utf8'); + const serviceFile = readFileSync( + resolve(process.cwd(), 'src/services/node-lifecycle.ts'), + 'utf8' + ); + const constantsFile = readFileSync( + resolve(process.cwd(), '../../packages/shared/src/constants/node-pooling.ts'), + 'utf8' + ); describe('flow: task complete → workspace destroyed → node warm', () => { it('cleanupTaskRun calls cleanupAutoProvisionedNode', () => { @@ -79,8 +94,8 @@ describe('warm node pooling lifecycle integration', () => { expect(doFile).toContain("SET status = 'stopped', warm_since = NULL"); }); - it('cron sweep finds stale warm nodes and calls deleteNodeResources', () => { - expect(cleanupFile).toContain('deleteNodeResources(node.id, node.user_id, env)'); + it('cron sweep finds stale warm nodes and calls strict resource deletion', () => { + expect(cleanupFile).toContain('deleteNodeResourcesStrict(node.id, node.user_id, env)'); }); it('cron sweep also enforces max auto-provisioned node lifetime', () => { diff --git a/apps/api/tests/simulation/README.md b/apps/api/tests/simulation/README.md new file mode 100644 index 0000000000..ec52994946 --- /dev/null +++ b/apps/api/tests/simulation/README.md @@ -0,0 +1,51 @@ +# Scheduler lifecycle simulation + +This credential-free test lab compresses multi-day scheduler behavior into virtual time. It uses +small nodes with deliberately tight capacity so cleanup, placement, session sleep, retry, and stale +observation races occur frequently. + +## Run it + +```bash +# Pull-request profile: 200 generated schedules, also included in the normal API test suite +pnpm --filter @simple-agent-manager/api test:scheduler + +# Deeper local/nightly profile: 2,000 schedules with longer traces and more projects/tasks +pnpm --filter @simple-agent-manager/api test:scheduler:nightly +``` + +Fast-check prints the seed, shrink path, minimized counterexample, and the harness's named event +trace on failure. Replay the exact case with: + +```bash +FC_SEED= FC_PATH='' pnpm --filter @simple-agent-manager/api test:scheduler +``` + +`SCHEDULER_SIM_RUNS`, `SCHEDULER_SIM_MAX_COMMANDS`, `SCHEDULER_SIM_TASK_SLOTS`, and +`SCHEDULER_SIM_PROJECTS` can increase or narrow an exploratory run without changing source. The +nightly profile uses a bounded 60-second test timeout; `SCHEDULER_SIM_TIMEOUT_MS` can tune that +budget for larger on-demand runs. + +## What it checks + +- Active workspaces never exceed node capacity. +- Cleanup never owns a node at the same time as an active task or workspace. +- A task never owns multiple live workspaces. +- Terminal sessions keep a selectable retry path and converge to sleeping after faults stop. +- Missing/incomplete snapshots are reconciled; prompting deferrals do not strand terminal sessions. + +Calibration cases deliberately enable the historical unsafe policies and prove the oracle rejects: + +- the August 14 completed-while-prompting sleep retry gap; +- cleanup of a pre-heartbeat task-owned provisioning node; +- two TaskRunners observing and consuming the same final node slot. + +The adjacent Workerd suite in `tests/workers/scheduler-lifecycle-races.test.ts` uses the real local D1 +and TaskRunner Durable Object to verify the production placement/cleanup claims. The scheduled +cleanup vertical slice also proves a failed external teardown releases the D1 claim with bounded +backoff instead of falsely marking a still-live resource deleted. The VM-agent Go contract verifies +activity from multiple workspaces on one node is routed to each owning project. + +This lab does not emulate cloud-provider behavior, real containers, network throughput, or long-run +resource exhaustion. Those remain integration/soak concerns; no staging or cloud credentials are +used here. diff --git a/apps/api/tests/simulation/scheduler-lifecycle-harness.ts b/apps/api/tests/simulation/scheduler-lifecycle-harness.ts new file mode 100644 index 0000000000..e3b7b6d76b --- /dev/null +++ b/apps/api/tests/simulation/scheduler-lifecycle-harness.ts @@ -0,0 +1,433 @@ +import { + CURRENT_SCHEDULER_POLICY, + type NodeStatus, + type SchedulerSimulationPolicy, + type SessionActivity, + type SimEvent, + type SimNode, + type SimSession, + type SimTask, + type SimulationCommand, + type SimWorkspace, +} from './scheduler-lifecycle-model'; + +export class SchedulerLifecycleWorld { + readonly policy: SchedulerSimulationPolicy; + readonly nodes = new Map(); + readonly workspaces = new Map(); + readonly tasks = new Map(); + readonly sessions = new Map(); + readonly trace: string[] = []; + + now = 60_000; + private eventSequence = 0; + private readonly events: SimEvent[] = []; + private snapshotFailuresRemaining = 0; + + constructor(policy: SchedulerSimulationPolicy = CURRENT_SCHEDULER_POLICY, nodeCapacity = 2) { + this.policy = policy; + this.addNode('node-0', 'running', nodeCapacity, 0); + this.addNode('node-1', 'running', nodeCapacity, 0); + this.record('world initialized'); + } + + apply(command: SimulationCommand): void { + this.record(`command ${JSON.stringify(command)}`); + switch (command.type) { + case 'submit': + this.submitTask(command.task, command.project, command.node); + break; + case 'complete': + this.completeTask(command.task); + break; + case 'activity': + this.setActivity(command.task, command.activity); + break; + case 'sleep-sweep': + this.runSleepSweep(); + break; + case 'cleanup-sweep': + this.runCleanupSweep(); + break; + case 'snapshot-failure': + this.snapshotFailuresRemaining += 1; + break; + case 'advance': + this.now += command.milliseconds; + break; + case 'run-event': + this.runNextEvent(command.choice); + break; + } + } + + submitTask(taskSlot: number, projectSlot: number, nodeChoice: number | 'new'): void { + const taskId = `task-${taskSlot}`; + if (this.tasks.has(taskId)) return; + + const task: SimTask = { + id: taskId, + projectId: `project-${projectSlot}`, + status: 'provisioning', + nodeId: null, + workspaceId: null, + sessionId: null, + }; + this.tasks.set(taskId, task); + + if (nodeChoice === 'new') { + this.provisionNodeForTask(task); + return; + } + + const preferred = this.nodes.get(`node-${nodeChoice % 2}`); + const selected = this.selectNodeWithCapacity(preferred); + if (!selected) { + this.provisionNodeForTask(task); + return; + } + + task.nodeId = selected.id; + if (this.policy.reservePlacementBeforeAsyncCreate) { + selected.reservedTaskIds.add(task.id); + } + this.schedule(0, `workspace-commit:${task.id}`, () => this.commitWorkspace(task.id)); + } + + completeTask(taskSlot: number): void { + const task = this.tasks.get(`task-${taskSlot}`); + if (task?.status !== 'running' || !task.sessionId) return; + const session = this.sessions.get(task.sessionId); + if (!session || session.terminal) return; + + task.status = 'completed'; + session.terminal = true; + session.sleepDueAt = this.now; + this.record(`task completed ${task.id} while activity=${session.activity}`); + + if (this.policy.synchronousTerminalSleep) { + if (session.snapshotStatus === 'missing') session.snapshotStatus = 'pending'; + this.attemptSleep(session); + } + } + + setActivity(taskSlot: number, activity: SessionActivity): void { + const task = this.tasks.get(`task-${taskSlot}`); + if (!task?.sessionId) return; + const session = this.sessions.get(task.sessionId); + if (!session || session.sleeping) return; + session.activity = activity; + } + + runSleepSweep(): void { + for (const session of this.sessions.values()) { + if (!session.terminal || session.sleeping || session.sleepDueAt === null) continue; + if (session.sleepDueAt > this.now) continue; + + if (session.snapshotStatus === 'missing') { + if (!this.policy.reconcileMissingSnapshots) continue; + session.snapshotStatus = 'pending'; + this.record(`snapshot reconciled ${session.id}`); + } + + if (!this.policy.retryIncompleteSnapshots && session.snapshotStatus !== 'available') { + continue; + } + this.attemptSleep(session); + } + } + + runCleanupSweep(): void { + for (const node of this.nodes.values()) { + if (node.status !== 'running' && node.status !== 'provisioning') continue; + if (this.activeWorkspaceCount(node.id) > 0) continue; + if (node.reservedTaskIds.size > 0) continue; + if (this.now - node.idleSince < 30_000) continue; + if (this.policy.protectProvisioningClaims && node.claimedTaskIds.size > 0) continue; + + node.status = 'destroying'; + this.record(`node claimed for cleanup ${node.id}`); + this.schedule(0, `node-delete:${node.id}`, () => { + if (this.activeWorkspaceCount(node.id) > 0) { + node.status = 'running'; + node.idleSince = this.now; + this.record(`node cleanup released ${node.id}`); + return; + } + node.status = 'deleted'; + this.record(`node deleted ${node.id}`); + }); + } + } + + runNextEvent(choice = 0): boolean { + if (this.events.length === 0) return false; + const earliest = Math.min(...this.events.map((event) => event.dueAt)); + if (earliest > this.now) this.now = earliest; + const runnable = this.events + .filter((event) => event.dueAt <= this.now) + .sort((left, right) => left.id - right.id); + const selected = runnable[Math.abs(choice) % runnable.length]; + if (!selected) return false; + this.events.splice(this.events.indexOf(selected), 1); + this.record(`event ${selected.label}`); + selected.run(); + return true; + } + + recover(): void { + this.record('recovery phase started'); + this.snapshotFailuresRemaining = 0; + + let turns = 0; + while (turns < 500) { + turns += 1; + let changed = false; + while (this.runNextEvent(0)) changed = true; + + for (const session of this.sessions.values()) { + if (session.terminal && !session.sleeping && session.activity === 'prompting') { + session.activity = 'idle'; + changed = true; + } + } + + const sleepingBefore = [...this.sessions.values()].filter( + (session) => session.sleeping + ).length; + this.now += 1_000; + this.runSleepSweep(); + const sleepingAfter = [...this.sessions.values()].filter( + (session) => session.sleeping + ).length; + if (sleepingAfter > sleepingBefore) changed = true; + + if (!changed && this.events.length === 0) break; + } + this.record(`recovery phase stopped after ${turns} turns`); + } + + assertSafety(): void { + this.assertNodeCapacities(); + this.assertWorkspaceNodesAreActive(); + this.assertTaskNodesAreActive(); + this.assertSingleLiveWorkspacePerTask(); + } + + private assertNodeCapacities(): void { + for (const node of this.nodes.values()) { + const active = this.activeWorkspaceCount(node.id); + if (active > node.capacity) { + this.fail(`capacity exceeded on ${node.id}: ${active}/${node.capacity}`); + } + } + } + + private assertWorkspaceNodesAreActive(): void { + for (const workspace of this.workspaces.values()) { + if (workspace.status === 'sleeping' || workspace.status === 'deleted') continue; + const node = this.nodes.get(workspace.nodeId); + if (!node || node.status === 'destroying' || node.status === 'deleted') { + this.fail( + `active workspace ${workspace.id} is attached to ${node?.status ?? 'missing'} node ${workspace.nodeId}` + ); + } + } + } + + private assertTaskNodesAreActive(): void { + for (const task of this.tasks.values()) { + if (task.status === 'completed' || !task.nodeId) continue; + const node = this.nodes.get(task.nodeId); + if (!node || node.status === 'destroying' || node.status === 'deleted') { + this.fail( + `active task ${task.id} is attached to ${node?.status ?? 'missing'} node ${task.nodeId}` + ); + } + } + } + + private assertSingleLiveWorkspacePerTask(): void { + const liveWorkspaceOwners = new Set(); + for (const workspace of this.workspaces.values()) { + if (workspace.status === 'deleted') continue; + if (liveWorkspaceOwners.has(workspace.taskId)) { + this.fail(`task ${workspace.taskId} owns multiple live workspaces`); + } + liveWorkspaceOwners.add(workspace.taskId); + } + } + + assertConverged(): void { + for (const session of this.sessions.values()) { + if (session.terminal && !session.sleeping) { + this.fail( + `terminal session ${session.id} did not converge (snapshot=${session.snapshotStatus}, attempts=${session.attempts})` + ); + } + } + for (const workspace of this.workspaces.values()) { + const session = this.sessions.get(workspace.sessionId); + if (session?.terminal && workspace.status !== 'sleeping' && workspace.status !== 'deleted') { + this.fail(`terminal workspace ${workspace.id} remained ${workspace.status}`); + } + } + } + + traceText(): string { + return this.trace.slice(-120).join('\n'); + } + + private addNode(id: string, status: NodeStatus, capacity: number, createdAt: number): SimNode { + const node: SimNode = { + id, + status, + capacity, + createdAt, + idleSince: createdAt, + claimedTaskIds: new Set(), + reservedTaskIds: new Set(), + }; + this.nodes.set(id, node); + return node; + } + + private selectNodeWithCapacity(preferred: SimNode | undefined): SimNode | null { + const ordered = preferred + ? [preferred, ...[...this.nodes.values()].filter((node) => node !== preferred)] + : [...this.nodes.values()]; + for (const node of ordered) { + if (node.status !== 'running') continue; + const reservations = this.policy.reservePlacementBeforeAsyncCreate + ? node.reservedTaskIds.size + node.claimedTaskIds.size + : 0; + if (this.activeWorkspaceCount(node.id) + reservations < node.capacity) return node; + } + return null; + } + + private provisionNodeForTask(task: SimTask): void { + const nodeId = `node-auto-${task.id}`; + const node = this.nodes.get(nodeId) ?? this.addNode(nodeId, 'provisioning', 1, this.now); + node.claimedTaskIds.add(task.id); + task.nodeId = node.id; + this.schedule(5_000, `node-ready:${task.id}`, () => { + const currentTask = this.tasks.get(task.id); + const currentNode = this.nodes.get(node.id); + if (!currentTask || !currentNode) return; + if (currentNode.status === 'deleted') { + this.record(`node readiness lost for ${task.id}`); + return; + } + currentNode.status = 'running'; + this.schedule(0, `workspace-commit:${task.id}`, () => this.commitWorkspace(task.id)); + }); + } + + private commitWorkspace(taskId: string): void { + const task = this.tasks.get(taskId); + if (!task || task.workspaceId || !task.nodeId) return; + const node = this.nodes.get(task.nodeId); + + if ( + this.policy.recheckPlacementAtCommit && + (node?.status !== 'running' || + (!node.reservedTaskIds.has(task.id) && this.activeWorkspaceCount(node.id) >= node.capacity)) + ) { + if (node) node.reservedTaskIds.delete(task.id); + task.nodeId = null; + this.provisionNodeForTask(task); + return; + } + if (!node) return; + + const workspaceId = `workspace-${task.id}`; + const sessionId = `session-${task.id}`; + this.workspaces.set(workspaceId, { + id: workspaceId, + nodeId: node.id, + projectId: task.projectId, + taskId: task.id, + sessionId, + status: 'running', + }); + this.sessions.set(sessionId, { + id: sessionId, + projectId: task.projectId, + taskId: task.id, + workspaceId, + activity: 'prompting', + terminal: false, + sleeping: false, + snapshotStatus: 'missing', + sleepDueAt: null, + attempts: 0, + }); + node.claimedTaskIds.delete(task.id); + node.reservedTaskIds.delete(task.id); + task.workspaceId = workspaceId; + task.sessionId = sessionId; + task.status = 'running'; + this.record(`workspace committed ${workspaceId} to ${node.id}`); + } + + private attemptSleep(session: SimSession): void { + if (session.activity === 'prompting') { + if (this.policy.promptingConsumesSleepAttempt) { + session.attempts += 1; + session.snapshotStatus = 'failed'; + } + session.sleepDueAt = this.now + 1_000; + this.record(`sleep deferred ${session.id} attempts=${session.attempts}`); + return; + } + + session.attempts += 1; + if (this.snapshotFailuresRemaining > 0) { + this.snapshotFailuresRemaining -= 1; + session.snapshotStatus = 'failed'; + session.sleepDueAt = this.now + 1_000; + this.record(`snapshot failed ${session.id}`); + return; + } + + session.snapshotStatus = 'available'; + session.sleeping = true; + session.sleepDueAt = null; + const workspace = this.workspaces.get(session.workspaceId); + if (workspace) { + workspace.status = 'sleeping'; + const node = this.nodes.get(workspace.nodeId); + if (node) node.idleSince = this.now; + } + this.record(`session slept ${session.id}`); + } + + private activeWorkspaceCount(nodeId: string): number { + return [...this.workspaces.values()].filter( + (workspace) => + workspace.nodeId === nodeId && + (workspace.status === 'creating' || workspace.status === 'running') + ).length; + } + + private schedule(delayMs: number, label: string, run: () => void): void { + this.events.push({ + id: this.eventSequence, + dueAt: this.now + delayMs, + label, + run, + }); + this.eventSequence += 1; + this.record(`scheduled ${label} at ${this.now + delayMs}`); + } + + private record(message: string): void { + this.trace.push(`${this.now.toString().padStart(8, '0')} ${message}`); + if (this.trace.length > 400) this.trace.shift(); + } + + private fail(message: string): never { + throw new Error(`${message}\n--- scheduler simulation trace ---\n${this.traceText()}`); + } +} diff --git a/apps/api/tests/simulation/scheduler-lifecycle-model.ts b/apps/api/tests/simulation/scheduler-lifecycle-model.ts new file mode 100644 index 0000000000..94ed02d1e3 --- /dev/null +++ b/apps/api/tests/simulation/scheduler-lifecycle-model.ts @@ -0,0 +1,145 @@ +export type NodeStatus = 'provisioning' | 'running' | 'destroying' | 'deleted'; +export type WorkspaceStatus = 'creating' | 'running' | 'sleeping' | 'deleted'; +export type TaskStatus = 'provisioning' | 'running' | 'completed'; +export type SessionActivity = 'prompting' | 'idle'; +export type SnapshotStatus = 'missing' | 'pending' | 'available' | 'failed'; + +export interface SchedulerSimulationPolicy { + reservePlacementBeforeAsyncCreate: boolean; + protectProvisioningClaims: boolean; + recheckPlacementAtCommit: boolean; + reconcileMissingSnapshots: boolean; + retryIncompleteSnapshots: boolean; + synchronousTerminalSleep: boolean; + promptingConsumesSleepAttempt: boolean; +} + +export const CURRENT_SCHEDULER_POLICY: SchedulerSimulationPolicy = { + reservePlacementBeforeAsyncCreate: true, + protectProvisioningClaims: true, + recheckPlacementAtCommit: true, + reconcileMissingSnapshots: true, + retryIncompleteSnapshots: true, + synchronousTerminalSleep: false, + promptingConsumesSleepAttempt: false, +}; + +export const STRANDED_SLEEP_POLICY: SchedulerSimulationPolicy = { + ...CURRENT_SCHEDULER_POLICY, + reconcileMissingSnapshots: false, + retryIncompleteSnapshots: false, + synchronousTerminalSleep: true, + promptingConsumesSleepAttempt: true, +}; + +export const PREMATURE_CLEANUP_POLICY: SchedulerSimulationPolicy = { + ...CURRENT_SCHEDULER_POLICY, + protectProvisioningClaims: false, +}; + +export const CAPACITY_TOCTOU_POLICY: SchedulerSimulationPolicy = { + ...CURRENT_SCHEDULER_POLICY, + reservePlacementBeforeAsyncCreate: false, + recheckPlacementAtCommit: false, +}; + +export interface SimNode { + id: string; + status: NodeStatus; + capacity: number; + createdAt: number; + idleSince: number; + claimedTaskIds: Set; + reservedTaskIds: Set; +} + +export interface SimWorkspace { + id: string; + nodeId: string; + projectId: string; + taskId: string; + sessionId: string; + status: WorkspaceStatus; +} + +export interface SimTask { + id: string; + projectId: string; + status: TaskStatus; + nodeId: string | null; + workspaceId: string | null; + sessionId: string | null; +} + +export interface SimSession { + id: string; + projectId: string; + taskId: string; + workspaceId: string; + activity: SessionActivity; + terminal: boolean; + sleeping: boolean; + snapshotStatus: SnapshotStatus; + sleepDueAt: number | null; + attempts: number; +} + +export interface SimEvent { + id: number; + dueAt: number; + label: string; + run: () => void; +} + +export type SimulationCommand = + | { type: 'submit'; task: number; project: number; node: number | 'new' } + | { type: 'complete'; task: number } + | { type: 'activity'; task: number; activity: SessionActivity } + | { type: 'sleep-sweep' } + | { type: 'cleanup-sweep' } + | { type: 'snapshot-failure' } + | { type: 'advance'; milliseconds: number } + | { type: 'run-event'; choice: number }; + +export interface SimulationProfile { + numRuns: number; + maxCommands: number; + taskSlots: number; + projectCount: number; + testTimeoutMs: number; +} + +const PR_PROFILE: SimulationProfile = { + numRuns: 200, + maxCommands: 60, + taskSlots: 12, + projectCount: 3, + testTimeoutMs: 5_000, +}; + +const NIGHTLY_PROFILE: SimulationProfile = { + numRuns: 2_000, + maxCommands: 160, + taskSlots: 32, + projectCount: 6, + testTimeoutMs: 60_000, +}; + +function positiveInteger(value: string | undefined, fallback: number): number { + if (!value) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +export function resolveSimulationProfile( + environment: NodeJS.ProcessEnv = process.env +): SimulationProfile { + const base = environment.SCHEDULER_SIM_PROFILE === 'nightly' ? NIGHTLY_PROFILE : PR_PROFILE; + return { + numRuns: positiveInteger(environment.SCHEDULER_SIM_RUNS, base.numRuns), + maxCommands: positiveInteger(environment.SCHEDULER_SIM_MAX_COMMANDS, base.maxCommands), + taskSlots: positiveInteger(environment.SCHEDULER_SIM_TASK_SLOTS, base.taskSlots), + projectCount: positiveInteger(environment.SCHEDULER_SIM_PROJECTS, base.projectCount), + testTimeoutMs: positiveInteger(environment.SCHEDULER_SIM_TIMEOUT_MS, base.testTimeoutMs), + }; +} diff --git a/apps/api/tests/simulation/scheduler-lifecycle-simulation.test.ts b/apps/api/tests/simulation/scheduler-lifecycle-simulation.test.ts new file mode 100644 index 0000000000..b0e307bee9 --- /dev/null +++ b/apps/api/tests/simulation/scheduler-lifecycle-simulation.test.ts @@ -0,0 +1,144 @@ +import fc from 'fast-check'; + +import { SchedulerLifecycleWorld } from './scheduler-lifecycle-harness'; +import { + CAPACITY_TOCTOU_POLICY, + CURRENT_SCHEDULER_POLICY, + PREMATURE_CLEANUP_POLICY, + resolveSimulationProfile, + type SimulationCommand, + STRANDED_SLEEP_POLICY, +} from './scheduler-lifecycle-model'; + +const profile = resolveSimulationProfile(); + +const taskCommand = fc.record({ + type: fc.constant<'submit'>('submit'), + task: fc.integer({ min: 0, max: profile.taskSlots - 1 }), + project: fc.integer({ min: 0, max: profile.projectCount - 1 }), + node: fc.oneof(fc.integer({ min: 0, max: 1 }), fc.constant<'new'>('new')), +}); + +const commandArbitrary: fc.Arbitrary = fc.oneof( + { weight: 5, arbitrary: taskCommand }, + { + weight: 4, + arbitrary: fc.record({ + type: fc.constant<'complete'>('complete'), + task: fc.integer({ min: 0, max: profile.taskSlots - 1 }), + }), + }, + { + weight: 3, + arbitrary: fc.record({ + type: fc.constant<'activity'>('activity'), + task: fc.integer({ min: 0, max: profile.taskSlots - 1 }), + activity: fc.constantFrom<'prompting' | 'idle'>('prompting', 'idle'), + }), + }, + { weight: 3, arbitrary: fc.constant({ type: 'sleep-sweep' }) }, + { weight: 2, arbitrary: fc.constant({ type: 'cleanup-sweep' }) }, + { weight: 1, arbitrary: fc.constant({ type: 'snapshot-failure' }) }, + { + weight: 3, + arbitrary: fc.record({ + type: fc.constant<'advance'>('advance'), + milliseconds: fc.integer({ min: 0, max: 60_000 }), + }), + }, + { + weight: 5, + arbitrary: fc.record({ + type: fc.constant<'run-event'>('run-event'), + choice: fc.nat({ max: profile.taskSlots * 2 }), + }), + } +); + +function execute(world: SchedulerLifecycleWorld, commands: SimulationCommand[]): void { + for (const command of commands) { + world.apply(command); + world.assertSafety(); + } +} + +describe('scheduler lifecycle simulation calibration', () => { + it('rejects the stranded completed-session retry behavior from the sleep incident', () => { + const world = new SchedulerLifecycleWorld(STRANDED_SLEEP_POLICY, 1); + world.submitTask(0, 0, 0); + world.runNextEvent(); + world.completeTask(0); + world.setActivity(0, 'idle'); + world.now += 5_000; + world.runSleepSweep(); + world.recover(); + + expect(() => world.assertConverged()).toThrow(/terminal session .* did not converge/); + }); + + it('rejects cleanup that deletes a pre-heartbeat task-owned provisioning node', () => { + const world = new SchedulerLifecycleWorld(PREMATURE_CLEANUP_POLICY, 1); + world.submitTask(0, 0, 'new'); + world.now += 31_000; + world.runCleanupSweep(); + + expect(() => world.assertSafety()).toThrow(/active task .* destroying node/); + }); + + it('rejects two placements that both observe the final node slot', () => { + const world = new SchedulerLifecycleWorld(CAPACITY_TOCTOU_POLICY, 1); + world.submitTask(0, 0, 0); + world.submitTask(1, 1, 0); + world.runNextEvent(); + world.runNextEvent(); + + expect(() => world.assertSafety()).toThrow(/capacity exceeded/); + }); + + it('accepts the same incident schedules with durable claims and retry coverage', () => { + const world = new SchedulerLifecycleWorld(CURRENT_SCHEDULER_POLICY, 1); + world.submitTask(0, 0, 'new'); + world.runCleanupSweep(); + world.runNextEvent(); + world.runNextEvent(); + world.completeTask(0); + world.runSleepSweep(); + world.setActivity(0, 'idle'); + world.now += 5_000; + world.runSleepSweep(); + world.recover(); + + world.assertSafety(); + world.assertConverged(); + }); +}); + +describe(`scheduler lifecycle generated exploration (${profile.numRuns} runs)`, () => { + it( + 'preserves safety under faults and converges after faults stop', + () => { + const seed = process.env.FC_SEED ? Number.parseInt(process.env.FC_SEED, 10) : undefined; + const path = process.env.FC_PATH; + + fc.assert( + fc.property( + fc.array(commandArbitrary, { minLength: 10, maxLength: profile.maxCommands }), + (commands) => { + const world = new SchedulerLifecycleWorld(CURRENT_SCHEDULER_POLICY); + execute(world, commands); + world.recover(); + world.assertSafety(); + world.assertConverged(); + } + ), + { + numRuns: profile.numRuns, + seed, + path, + verbose: 2, + } + ); + }, + profile.testTimeoutMs + ); +}); diff --git a/apps/api/tests/unit/durable-objects/credential-setup-session.test.ts b/apps/api/tests/unit/durable-objects/credential-setup-session.test.ts index b9f40be662..6114f9da57 100644 --- a/apps/api/tests/unit/durable-objects/credential-setup-session.test.ts +++ b/apps/api/tests/unit/durable-objects/credential-setup-session.test.ts @@ -259,14 +259,14 @@ function createFakeSandbox() { } // eslint-disable-next-line @typescript-eslint/no-explicit-any -function createDO(): { +function createDO(envOverrides: Partial = {}): { instance: InstanceType; ctx: any; database: ReturnType; } { const ctx = createFakeCtx(); const database = createFakeDatabase(); - const env = { DATABASE: database } as unknown as Env; + const env = { DATABASE: database, ...envOverrides } as unknown as Env; // eslint-disable-next-line @typescript-eslint/no-explicit-any const instance = new CredentialSetupSession(ctx as any, env); return { instance, ctx, database }; @@ -424,7 +424,17 @@ describe('CredentialSetupSession — alarm() provisioning step', () => { }); it('provisions Claude Code with an isolated CLAUDE_CONFIG_DIR and optional code', async () => { - const { instance } = createDO(); + const { instance } = createDO({ + CLAUDE_SETUP_ENTER_DELAY_MS: '1100', + CLAUDE_SETUP_EXCHANGE_TIMEOUT_MS: '125000', + CLAUDE_SETUP_REJECTION_SETTLE_MS: '450', + CLAUDE_SETUP_VERIFICATION_POLL_MS: '550', + CLAUDE_SETUP_TTY_COLUMNS: '640', + CLAUDE_SETUP_OUTPUT_BUFFER_BYTES: '65536', + CLAUDE_VERIFICATION_CODE_MAX_LENGTH: '2048', + CLAUDE_SETUP_ERROR_DETAIL_MAX_LENGTH: '200', + CLAUDE_OAUTH_TOKEN_MAX_LENGTH: '16384', + }); await Promise.resolve(); const fakeSandbox = createFakeSandbox(); fakeSandbox.readFile.mockImplementation(async (path: string) => ({ @@ -462,6 +472,12 @@ describe('CredentialSetupSession — alarm() provisioning step', () => { expect.stringContaining('sam-claude-setup-token.mjs'), expect.objectContaining({ timeout: expect.any(Number) }) ); + expect(fakeSandbox.exec).toHaveBeenCalledWith( + expect.stringContaining( + "CLAUDE_SETUP_ENTER_DELAY_MS='1100' CLAUDE_SETUP_EXCHANGE_TIMEOUT_MS='125000' CLAUDE_SETUP_REJECTION_SETTLE_MS='450' CLAUDE_SETUP_VERIFICATION_POLL_MS='550' CLAUDE_SETUP_TTY_COLUMNS='640' CLAUDE_SETUP_OUTPUT_BUFFER_BYTES='65536' CLAUDE_VERIFICATION_CODE_MAX_LENGTH='2048' CLAUDE_SETUP_ERROR_DETAIL_MAX_LENGTH='200' CLAUDE_OAUTH_TOKEN_MAX_LENGTH='16384'" + ), + expect.objectContaining({ timeout: expect.any(Number) }) + ); const state = await instance.getState(); expect(state?.status).toBe('waiting_for_user'); @@ -748,6 +764,250 @@ describe('CredentialSetupSession — alarm() capture polling', () => { expect((await created.instance.getState())?.status).toBe('completed'); }); + it('normalizes and forwards a code#state value to the sandbox, then exchanges', async () => { + const created = createDO(); + await Promise.resolve(); + const fakeSandbox = createFakeSandbox(); + fakeSandbox.readFile.mockImplementation(async (path: string) => ({ + content: path.endsWith('device-auth-state.json') + ? JSON.stringify({ + status: 'waiting_for_user', + verificationUrl: 'https://claude.ai/oauth/device', + }) + : '', + })); + vi.mocked(getSandboxInstance).mockResolvedValue(fakeSandbox as never); + + await created.instance.create({ + id: 'setup-claude-code', + setupHome: '/tmp/claude-setup-code', + ttlMs: 900_000, + ...BASE_PARAMS, + agentType: 'claude-code', + provider: 'anthropic', + agentName: 'Claude Code', + }); + await created.instance.alarm(); + await created.instance.alarm(); + + const result = await created.instance.submitVerificationCode(' abc123 #state456\n'); + + expect(result.status).toBe('exchanging'); + expect(fakeSandbox.writeFile).toHaveBeenCalledWith( + '/tmp/claude-setup-code/verification-code.txt', + 'abc123#state456' + ); + expect(saveAgentCredentialForUser).not.toHaveBeenCalled(); + expect(JSON.stringify(created.database._calls)).not.toContain('abc123#state456'); + }); + + it('guards verification-code agent, state, length, and charset', async () => { + const created = createDO(); + await Promise.resolve(); + const fakeSandbox = createFakeSandbox(); + vi.mocked(getSandboxInstance).mockResolvedValue(fakeSandbox as never); + await created.instance.create({ + id: 'setup-guard', + setupHome: '/tmp/setup-guard', + ttlMs: 900_000, + ...BASE_PARAMS, + }); + await expect(created.instance.submitVerificationCode('abc#state')).rejects.toThrow( + /only supported/ + ); + + const claude = createDO(); + await Promise.resolve(); + vi.mocked(getSandboxInstance).mockResolvedValue(fakeSandbox as never); + await claude.instance.create({ + id: 'setup-claude-guard', + setupHome: '/tmp/setup-claude-guard', + ttlMs: 900_000, + ...BASE_PARAMS, + agentType: 'claude-code', + provider: 'anthropic', + agentName: 'Claude Code', + }); + await expect(claude.instance.submitVerificationCode('abc#state')).rejects.toThrow( + /not waiting/ + ); + await claude.instance.alarm(); + await claude.instance.alarm(); + await expect(claude.instance.submitVerificationCode('bad code!')).rejects.toThrow(/Invalid/); + await expect(claude.instance.submitVerificationCode('x'.repeat(1025))).rejects.toThrow( + /Invalid/ + ); + }); + + it('honors the configured verification-code length limit', async () => { + const created = createDO({ CLAUDE_VERIFICATION_CODE_MAX_LENGTH: '12' }); + await Promise.resolve(); + const fakeSandbox = createFakeSandbox(); + fakeSandbox.readFile.mockImplementation(async (path: string) => ({ + content: path.endsWith('device-auth-state.json') + ? JSON.stringify({ + status: 'waiting_for_user', + verificationUrl: 'https://claude.ai/oauth/device', + }) + : '', + })); + vi.mocked(getSandboxInstance).mockResolvedValue(fakeSandbox as never); + await created.instance.create({ + id: 'setup-configured-code-limit', + setupHome: '/tmp/setup-configured-code-limit', + ttlMs: 900_000, + ...BASE_PARAMS, + agentType: 'claude-code', + provider: 'anthropic', + agentName: 'Claude Code', + }); + await created.instance.alarm(); + await created.instance.alarm(); + + await expect(created.instance.submitVerificationCode('abc123#stateX')).rejects.toThrow( + /Invalid/ + ); + await expect(created.instance.submitVerificationCode('abc123#state')).resolves.toMatchObject({ + status: 'exchanging', + }); + }); + + it('fails fast with a sanitized error when Claude rejects the submitted code', async () => { + const created = createDO(); + await Promise.resolve(); + let driverStatus = 'waiting_for_user'; + const fakeSandbox = createFakeSandbox(); + fakeSandbox.readFile.mockImplementation(async (path: string) => ({ + content: path.endsWith('device-auth-state.json') + ? JSON.stringify({ + status: driverStatus, + verificationUrl: 'https://claude.ai/oauth/device', + error: 'secret provider detail', + }) + : '', + })); + vi.mocked(getSandboxInstance).mockResolvedValue(fakeSandbox as never); + await created.instance.create({ + id: 'setup-rejected', + setupHome: '/tmp/setup-rejected', + ttlMs: 900_000, + ...BASE_PARAMS, + agentType: 'claude-code', + provider: 'anthropic', + agentName: 'Claude Code', + }); + await created.instance.alarm(); + await created.instance.alarm(); + await created.instance.submitVerificationCode('abc123#state456'); + driverStatus = 'failed'; + await created.instance.alarm(); + + const state = await created.instance.getState(); + expect(state).toMatchObject({ status: 'failed', errorCode: 'code_rejected' }); + expect(state?.errorMessage).not.toContain('secret provider detail'); + expect(releaseSetupSlot).toHaveBeenCalledWith(expect.anything(), 'lease-abc'); + }); + + it('reports a driver exchange timeout distinctly from a rejected code', async () => { + const created = createDO(); + await Promise.resolve(); + let driverState: Record = { + status: 'waiting_for_user', + verificationUrl: 'https://claude.ai/oauth/device', + }; + const fakeSandbox = createFakeSandbox(); + fakeSandbox.readFile.mockImplementation(async (path: string) => ({ + content: path.endsWith('device-auth-state.json') ? JSON.stringify(driverState) : '', + })); + vi.mocked(getSandboxInstance).mockResolvedValue(fakeSandbox as never); + await created.instance.create({ + id: 'setup-exchange-timeout', + setupHome: '/tmp/setup-exchange-timeout', + ttlMs: 900_000, + ...BASE_PARAMS, + agentType: 'claude-code', + provider: 'anthropic', + agentName: 'Claude Code', + }); + await created.instance.alarm(); + await created.instance.alarm(); + await created.instance.submitVerificationCode('abc123#state456'); + driverState = { + status: 'failed', + error: 'Claude did not finish the verification code exchange in time', + code: 'exchange_timeout', + }; + await created.instance.alarm(); + + const state = await created.instance.getState(); + expect(state).toMatchObject({ status: 'failed', errorCode: 'exchange_timeout' }); + expect(state?.errorMessage).toContain('did not complete in time'); + expect(releaseSetupSlot).toHaveBeenCalledWith(expect.anything(), 'lease-abc'); + }); + + it('maps incomplete-paste and network driver failures distinctly and surfaces sanitized CLI detail', async () => { + const scenarios = [ + { + id: 'setup-incomplete', + driver: { + status: 'failed', + error: 'Claude reported the pasted verification code was incomplete', + code: 'code_incomplete', + detail: 'Invalidcode. Please makesure the fullcde wascopied', + }, + expectCode: 'code_incomplete', + expectMessage: 'entire code', + expectDetail: '[CLI: Invalidcode. Please makesure the fullcde wascopied]', + }, + { + id: 'setup-network', + driver: { + status: 'failed', + error: 'Claude sign-in failed with a network error during the code exchange', + code: 'exchange_network_error', + detail: `connctECONNREFUSED 10.0.0.1:443 sk-ant-oat${'A'.repeat(20)} junk`, + }, + expectCode: 'exchange_network_error', + expectMessage: 'network error', + expectDetail: '[CLI: connctECONNREFUSED 10.0.0.1:443 [redacted] junk]', + }, + ] as const; + + for (const scenario of scenarios) { + const created = createDO(); + await Promise.resolve(); + let driverState: Record = { + status: 'waiting_for_user', + verificationUrl: 'https://claude.ai/oauth/device', + }; + const fakeSandbox = createFakeSandbox(); + fakeSandbox.readFile.mockImplementation(async (path: string) => ({ + content: path.endsWith('device-auth-state.json') ? JSON.stringify(driverState) : '', + })); + vi.mocked(getSandboxInstance).mockResolvedValue(fakeSandbox as never); + await created.instance.create({ + id: scenario.id, + setupHome: `/tmp/${scenario.id}`, + ttlMs: 900_000, + ...BASE_PARAMS, + agentType: 'claude-code', + provider: 'anthropic', + agentName: 'Claude Code', + }); + await created.instance.alarm(); + await created.instance.alarm(); + await created.instance.submitVerificationCode('abc123#state456'); + driverState = scenario.driver; + await created.instance.alarm(); + + const state = await created.instance.getState(); + expect(state).toMatchObject({ status: 'failed', errorCode: scenario.expectCode }); + expect(state?.errorMessage).toContain(scenario.expectMessage); + expect(state?.errorMessage).toContain(scenario.expectDetail); + expect(state?.errorMessage).not.toContain('sk-ant'); + } + }); + it('tears down as failed when saveAgentCredentialForUser rejects', async () => { const { instance, database, fakeSandbox } = await createAndProvision(); const authJson = validAuthJson(); diff --git a/apps/api/tests/unit/durable-objects/get-account-setup-status.test.ts b/apps/api/tests/unit/durable-objects/get-account-setup-status.test.ts index a31da1bb39..710021fa60 100644 --- a/apps/api/tests/unit/durable-objects/get-account-setup-status.test.ts +++ b/apps/api/tests/unit/durable-objects/get-account-setup-status.test.ts @@ -9,7 +9,8 @@ vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn(), })); -vi.mock('@simple-agent-manager/shared', () => ({ +vi.mock('@simple-agent-manager/shared', async (importOriginal) => ({ + ...(await importOriginal()), DEFAULT_MISSION_MAX_PER_PROJECT: 50, DEFAULT_SAM_SEARCH_LIMIT: 10, DEFAULT_SAM_SEARCH_MAX_LIMIT: 50, diff --git a/apps/api/tests/unit/node-cleanup.test.ts b/apps/api/tests/unit/node-cleanup.test.ts index 9fb887144f..d22f11cfb9 100644 --- a/apps/api/tests/unit/node-cleanup.test.ts +++ b/apps/api/tests/unit/node-cleanup.test.ts @@ -12,9 +12,10 @@ import { runNodeCleanupSweep } from '../../src/scheduled/node-cleanup'; import { sweepTerminalCfContainers } from '../../src/scheduled/node-cleanup/node-phases'; import { emptyResult, resolveCleanupConfig } from '../../src/scheduled/node-cleanup/shared'; -// Mock deleteNodeResources +// Mock strict external teardown. Scheduled cleanup must fail closed when the +// provider/container boundary cannot confirm deletion. vi.mock('../../src/services/nodes', () => ({ - deleteNodeResources: vi.fn().mockResolvedValue(undefined), + deleteNodeResourcesStrict: vi.fn().mockResolvedValue({ providerVm: 'deleted' }), stopNodeResources: vi.fn().mockResolvedValue(undefined), })); @@ -135,7 +136,7 @@ describe('runNodeCleanupSweep', () => { }); it('destroys nodes without active workspaces past max lifetime', async () => { - const { deleteNodeResources } = await import('../../src/services/nodes'); + const { deleteNodeResourcesStrict } = await import('../../src/services/nodes'); const now = Date.now(); const createdAt = new Date(now - 5 * 60 * 60 * 1000).toISOString(); @@ -159,7 +160,46 @@ describe('runNodeCleanupSweep', () => { expect(result.lifetimeDestroyed).toBe(1); expect(result.lifetimeSkipped).toBe(0); - expect(deleteNodeResources).toHaveBeenCalledWith('node-1', 'user-1', env); + expect(deleteNodeResourcesStrict).toHaveBeenCalledWith('node-1', 'user-1', env); + }); + + it('releases the cleanup claim with backoff when strict provider deletion fails', async () => { + const { deleteNodeResourcesStrict } = await import('../../src/services/nodes'); + vi.mocked(deleteNodeResourcesStrict).mockRejectedValueOnce(new Error('provider unavailable')); + const createdAt = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString(); + const responses = new Map(); + responses.set('n.warm_since IS NOT NULL', []); + responses.set('auto_provisioned_node_id', [ + { + node_id: 'node-provider-failure', + id: 'node-provider-failure', + user_id: 'user-1', + status: 'running', + created_at: createdAt, + active_ws_count: 0, + }, + ]); + responses.set("w.status = 'running'", []); + responses.set('n.warm_since IS NULL', []); + + const env = createMockEnv(responses); + const result = await runNodeCleanupSweep(env); + + expect(result.lifetimeDestroyed).toBe(0); + expect(result.errors).toBe(1); + expect(deleteNodeResourcesStrict).toHaveBeenCalledWith( + 'node-provider-failure', + 'user-1', + env + ); + expect( + vi + .mocked(env.DATABASE.prepare) + .mock.calls.some( + ([sql]) => + sql.includes('cleanup_backoff_until = ?') && sql.includes("status = 'destroying'") + ) + ).toBe(true); }); it('always skips nodes with active workspaces (no absolute ceiling)', async () => { @@ -206,7 +246,7 @@ describe('runNodeCleanupSweep', () => { describe('Layer 1: stale warm node destruction', () => { it('destroys stale warm nodes with no active workspaces', async () => { - const { deleteNodeResources } = await import('../../src/services/nodes'); + const { deleteNodeResourcesStrict } = await import('../../src/services/nodes'); const now = Date.now(); const warmSince = new Date(now - 40 * 60 * 1000).toISOString(); // 40 min ago (> 35 min grace) @@ -230,7 +270,7 @@ describe('runNodeCleanupSweep', () => { const result = await runNodeCleanupSweep(env); expect(result.staleDestroyed).toBe(1); - expect(deleteNodeResources).toHaveBeenCalledWith('node-warm', 'user-1', env); + expect(deleteNodeResourcesStrict).toHaveBeenCalledWith('node-warm', 'user-1', env); }); it('skips stale warm nodes that have active workspaces', async () => { @@ -262,7 +302,7 @@ describe('runNodeCleanupSweep', () => { describe('DO alarm handoff cleanup', () => { it('destroys stopped auto-provisioned nodes left behind by the NodeLifecycle alarm', async () => { - const { deleteNodeResources } = await import('../../src/services/nodes'); + const { deleteNodeResourcesStrict } = await import('../../src/services/nodes'); const now = Date.now(); const createdAt = new Date(now - 2 * 60 * 60 * 1000).toISOString(); const updatedAt = new Date(now - 30 * 60 * 1000).toISOString(); @@ -287,11 +327,11 @@ describe('runNodeCleanupSweep', () => { const result = await runNodeCleanupSweep(env); expect(result.lifetimeDestroyed).toBe(1); - expect(deleteNodeResources).toHaveBeenCalledWith('node-stopped-handoff', 'user-1', env); + expect(deleteNodeResourcesStrict).toHaveBeenCalledWith('node-stopped-handoff', 'user-1', env); }); it('does not destroy stopped handoff nodes with active workspaces', async () => { - const { deleteNodeResources } = await import('../../src/services/nodes'); + const { deleteNodeResourcesStrict } = await import('../../src/services/nodes'); const updatedAt = new Date(Date.now() - 30 * 60 * 1000).toISOString(); const responses = new Map(); @@ -314,7 +354,11 @@ describe('runNodeCleanupSweep', () => { expect(result.lifetimeDestroyed).toBe(0); expect(result.lifetimeSkipped).toBe(1); - expect(deleteNodeResources).not.toHaveBeenCalledWith('node-stopped-active', 'user-1', env); + expect(deleteNodeResourcesStrict).not.toHaveBeenCalledWith( + 'node-stopped-active', + 'user-1', + env + ); }); }); diff --git a/apps/api/tests/unit/recovery-resilience.test.ts b/apps/api/tests/unit/recovery-resilience.test.ts index 6c9818c944..c84608451d 100644 --- a/apps/api/tests/unit/recovery-resilience.test.ts +++ b/apps/api/tests/unit/recovery-resilience.test.ts @@ -256,7 +256,7 @@ describe('node-cleanup OBSERVABILITY_DATABASE recording (TDF-7)', () => { nodeCleanupSource.indexOf('return true;') ); const deleteIdx = helper.indexOf('deleteNodeResources'); - const recordIdx = helper.indexOf('persistError(env.OBSERVABILITY_DATABASE'); + const recordIdx = helper.indexOf('await persistError('); expect(deleteIdx).toBeGreaterThan(-1); expect(recordIdx).toBeGreaterThan(deleteIdx); }); diff --git a/apps/api/tests/unit/routes/agent-credential-setup-native-vertical.test.ts b/apps/api/tests/unit/routes/agent-credential-setup-native-vertical.test.ts index a245420739..577fb41d32 100644 --- a/apps/api/tests/unit/routes/agent-credential-setup-native-vertical.test.ts +++ b/apps/api/tests/unit/routes/agent-credential-setup-native-vertical.test.ts @@ -6,6 +6,38 @@ import type { Env } from '../../../src/env'; import { agentCredentialSetupSessionsRoutes } from '../../../src/routes/agent-credential-setup-sessions'; import { createSqliteD1 } from '../../helpers/sqlite-d1'; +vi.mock('cloudflare:workers', () => ({ + DurableObject: class { + constructor( + public ctx: unknown, + public env: unknown + ) {} + }, +})); + +vi.mock('../../../src/services/sandbox', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSandboxInstance: vi.fn(), + destroySandboxInstance: vi.fn().mockResolvedValue(undefined), + }; +}); +vi.mock('../../../src/services/setup-session-pool', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, releaseSetupSlot: vi.fn().mockResolvedValue(undefined) }; +}); +vi.mock('../../../src/services/agent-credential-save', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, saveAgentCredentialForUser: vi.fn() }; +}); + +const { CredentialSetupSession } = + await import('../../../src/durable-objects/credential-setup-session'); +const { getSandboxInstance } = await import('../../../src/services/sandbox'); +const { saveAgentCredentialForUser } = await import('../../../src/services/agent-credential-save'); + vi.mock('../../../src/middleware/auth', () => ({ requireAuth: () => vi.fn((_c: unknown, next: () => unknown) => next()), requireApproved: () => vi.fn((_c: unknown, next: () => unknown) => next()), @@ -35,6 +67,78 @@ function setupDatabase(): Database.Database { return sqlite; } +function createDoContext() { + let row: Record | undefined; + let details: Record | undefined; + const sql = { + exec: vi.fn((query: string, ...args: unknown[]) => { + const normalized = query.trim().toLowerCase(); + if (normalized.startsWith('create table')) return { toArray: () => [] }; + if (normalized.includes('insert or replace into setup_session')) { + const [ + id, + userId, + projectId, + scope, + agentType, + credentialKind, + provider, + agentName, + poolLeaseId, + codexHome, + expiresAt, + capturePollMs, + ] = args; + row = { + id, + user_id: userId, + project_id: projectId, + scope, + agent_type: agentType, + credential_kind: credentialKind, + provider, + agent_name: agentName, + status: 'provisioning', + pool_lease_id: poolLeaseId, + codex_home: codexHome, + expires_at: expiresAt, + capture_poll_ms: capturePollMs, + error_code: null, + error_message: null, + completed_at: null, + }; + } else if (normalized.includes('insert or replace into device_auth_details')) { + details = { verification_url: args[0], user_code: args[1] }; + } else if (normalized.includes('delete from device_auth_details')) { + details = undefined; + } else if (normalized.includes('update setup_session') && row) { + row = { + ...row, + status: args[0], + error_code: args[1] ?? null, + error_message: args[2] ?? null, + completed_at: args[3] ?? row.completed_at, + }; + } + if (normalized.includes('select * from setup_session')) { + return { toArray: () => (row ? [{ ...row }] : []) }; + } + if (normalized.includes('select verification_url, user_code')) { + return { toArray: () => (details ? [{ ...details }] : []) }; + } + return { toArray: () => [] }; + }), + }; + return { + storage: { + sql, + setAlarm: vi.fn().mockResolvedValue(undefined), + deleteAlarm: vi.fn().mockResolvedValue(undefined), + }, + blockConcurrencyWhile: vi.fn(async (callback: () => Promise) => callback()), + }; +} + describe('native Codex setup route vertical slice', () => { it('carries owned D1 session state through the DO boundary without persisting device details', async () => { const sqlite = setupDatabase(); @@ -140,4 +244,181 @@ describe('native Codex setup route vertical slice', () => { expect(env.CREDENTIAL_SETUP_SESSION.idFromName).not.toHaveBeenCalled(); expect(getState).not.toHaveBeenCalled(); }); + + it('submits an owned Claude browser verification code through the DO boundary without persisting the secret in D1', async () => { + const sqlite = setupDatabase(); + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO agent_credential_setup_sessions + (id, user_id, project_id, scope, agent_type, credential_kind, status, + sandbox_id, pool_lease_id, expires_at, created_at, updated_at) + VALUES (?, ?, NULL, 'user', 'claude-code', 'oauth-token', + 'waiting_for_user', ?, 'lease-claude', ?, ?, ?)` + ) + .run( + 'session-claude', + 'owner-user', + 'session-claude', + new Date(Date.now() + 60_000).toISOString(), + now, + now + ); + + const code = 'abc123#state456'; + const token = `sk-ant-oat${'V'.repeat(48)}`; + const sandbox = { + exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }), + writeFile: vi.fn().mockResolvedValue(undefined), + exists: vi.fn().mockImplementation(async (path: string) => ({ + exists: path.endsWith('device-auth-state.json') || path.endsWith('claude-oauth-token.txt'), + })), + readFile: vi.fn().mockImplementation(async (path: string) => ({ + content: path.endsWith('device-auth-state.json') + ? JSON.stringify({ + status: 'waiting_for_user', + verificationUrl: 'https://claude.ai/oauth/device', + }) + : `${token}\n`, + })), + }; + vi.mocked(getSandboxInstance).mockResolvedValue(sandbox as never); + vi.mocked(saveAgentCredentialForUser).mockResolvedValue({ + created: true, + createdAt: now, + updatedAt: now, + }); + const env = { + DATABASE: createSqliteD1(sqlite), + KV: { get: vi.fn().mockResolvedValue(null) }, + } as unknown as Env; + const setupSession = new CredentialSetupSession(createDoContext() as never, env); + await Promise.resolve(); + await setupSession.create({ + id: 'session-claude', + userId: 'owner-user', + projectId: null, + scope: 'user', + agentType: 'claude-code', + credentialKind: 'oauth-token', + provider: 'anthropic', + agentName: 'Claude Code', + poolLeaseId: 'lease-claude', + setupHome: '/tmp/claude-route-vertical', + ttlMs: 60_000, + capturePollMs: 10, + }); + await setupSession.alarm(); + await setupSession.alarm(); + Object.assign(env, { + CREDENTIAL_SETUP_SESSION: { + idFromName: vi.fn(() => ({ toString: () => 'do-session-claude' })), + get: vi.fn(() => setupSession), + }, + }); + const app = new Hono<{ Bindings: Env }>(); + app.onError((error, c) => { + const status = 'statusCode' in error ? Number(error.statusCode) : 500; + return c.json({ error: error.message }, status as 400 | 404 | 409 | 500); + }); + app.route('/api/agent-credential-setup-sessions', agentCredentialSetupSessionsRoutes); + + const response = await app.request( + '/api/agent-credential-setup-sessions/session-claude/verification-code', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: ` abc123 #state456\n` }), + }, + env + ); + + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ + id: 'session-claude', + status: 'exchanging', + agentType: 'claude-code', + }); + expect(sandbox.writeFile).toHaveBeenCalledWith( + '/tmp/claude-route-vertical/verification-code.txt', + code + ); + const persisted = sqlite + .prepare('SELECT * FROM agent_credential_setup_sessions WHERE id = ?') + .get('session-claude') as Record; + expect(JSON.stringify(persisted)).not.toContain(code); + + await setupSession.alarm(); + expect(saveAgentCredentialForUser).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'owner-user', + agentType: 'claude-code', + credential: token, + }) + ); + const completedResponse = await app.request( + '/api/agent-credential-setup-sessions/session-claude', + {}, + env + ); + expect(completedResponse.status).toBe(200); + expect(await completedResponse.json()).toMatchObject({ status: 'completed' }); + }); + + it('passes code shape validation to the DO boundary for authoritative validation', async () => { + const sqlite = setupDatabase(); + const now = new Date().toISOString(); + sqlite + .prepare( + `INSERT INTO agent_credential_setup_sessions + (id, user_id, project_id, scope, agent_type, credential_kind, status, + sandbox_id, pool_lease_id, expires_at, created_at, updated_at) + VALUES (?, ?, NULL, 'user', 'claude-code', 'oauth-token', + 'waiting_for_user', ?, 'lease-claude', ?, ?, ?)` + ) + .run( + 'session-bad-token', + 'owner-user', + 'session-bad-token', + new Date(Date.now() + 60_000).toISOString(), + now, + now + ); + + const submitVerificationCode = vi.fn().mockResolvedValue({ + id: 'session-bad-token', + status: 'exchanging', + expiresAt: Date.now() + 60_000, + errorCode: null, + errorMessage: null, + verificationUrl: 'https://claude.ai/oauth/device', + userCode: null, + }); + const env = { + DATABASE: createSqliteD1(sqlite), + CREDENTIAL_SETUP_SESSION: { + idFromName: vi.fn(() => ({ toString: () => 'do-session-bad-token' })), + get: vi.fn(() => ({ submitVerificationCode })), + }, + } as unknown as Env; + const app = new Hono<{ Bindings: Env }>(); + app.onError((error, c) => { + const status = 'statusCode' in error ? Number(error.statusCode) : 500; + return c.json({ error: error.message }, status as 400 | 404 | 409 | 500); + }); + app.route('/api/agent-credential-setup-sessions', agentCredentialSetupSessionsRoutes); + + const response = await app.request( + '/api/agent-credential-setup-sessions/session-bad-token/verification-code', + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: 'invalid code!' }), + }, + env + ); + + expect(response.status).toBe(200); + expect(submitVerificationCode).toHaveBeenCalledWith('invalid code!'); + }); }); diff --git a/apps/api/tests/unit/routes/mcp-list-triggers.test.ts b/apps/api/tests/unit/routes/mcp-list-triggers.test.ts new file mode 100644 index 0000000000..62436374e0 --- /dev/null +++ b/apps/api/tests/unit/routes/mcp-list-triggers.test.ts @@ -0,0 +1,288 @@ +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import * as schema from '../../../src/db/schema'; +import type { Env } from '../../../src/env'; +import type { McpTokenData } from '../../../src/routes/mcp/_helpers'; +import { handleListTriggers } from '../../../src/routes/mcp/trigger-list-tool'; +import { cronToHumanReadable } from '../../../src/services/cron-utils'; +import { createSchemaTables, createSqliteD1 } from '../../helpers/sqlite-d1'; + +interface TriggerFixture { + id: string; + projectId?: string; + name?: string; + description?: string | null; + status?: string; + sourceType?: string; + cronExpression?: string | null; + cronTimezone?: string | null; + skipIfRunning?: boolean; + promptTemplate?: string; + agentProfileId?: string | null; + skillId?: string | null; + taskMode?: string; + maxConcurrent?: number; + lastTriggeredAt?: string | null; + triggerCount?: number; + nextFireAt?: string | null; + createdAt?: string; +} + +interface ListedTrigger { + id: string; + name: string; + description: string | null; + status: string; + sourceType: string; + cronExpression: string | null; + cronTimezone: string; + cronHumanReadable: string | null; + nextFireAt: string | null; + lastTriggeredAt: string | null; + triggerCount: number; + taskMode: string; + agentProfileId: string | null; + skillId: string | null; + maxConcurrent: number; + skipIfRunning: boolean; +} + +const tokenData: McpTokenData = { + taskId: 'task-1', + projectId: 'project-1', + userId: 'user-1', + workspaceId: 'workspace-1', + createdAt: '2026-08-08T00:00:00.000Z', +}; + +function parseContent(response: Awaited>): ListedTrigger[] { + expect(response.error).toBeUndefined(); + const result = response.result as { content: Array<{ text: string }> }; + const payload = JSON.parse(result.content[0]?.text ?? '{}') as { triggers: ListedTrigger[] }; + return payload.triggers; +} + +describe('MCP list_triggers handler', () => { + let sqlite: Database.Database; + let env: Env; + + beforeEach(() => { + sqlite = new Database(':memory:'); + createSchemaTables(sqlite, [schema.triggers, schema.webhookTriggerConfigs]); + env = { DATABASE: createSqliteD1(sqlite) } as Env; + }); + + afterEach(() => { + sqlite.close(); + }); + + function insertTrigger(fixture: TriggerFixture): void { + sqlite + .prepare( + `INSERT INTO triggers ( + id, project_id, user_id, name, description, status, source_type, + cron_expression, cron_timezone, skip_if_running, prompt_template, + agent_profile_id, skill_id, task_mode, vm_size_override, max_concurrent, + last_triggered_at, trigger_count, next_execution_sequence, next_fire_at, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, 1, ?, ?, ?)` + ) + .run( + fixture.id, + fixture.projectId ?? tokenData.projectId, + 'user-1', + fixture.name ?? fixture.id, + fixture.description ?? null, + fixture.status ?? 'active', + fixture.sourceType ?? 'cron', + fixture.cronExpression === undefined ? '0 9 * * *' : fixture.cronExpression, + fixture.cronTimezone === undefined ? 'UTC' : fixture.cronTimezone, + fixture.skipIfRunning === false ? 0 : 1, + fixture.promptTemplate ?? 'Review the project', + fixture.agentProfileId ?? null, + fixture.skillId ?? null, + fixture.taskMode ?? 'task', + fixture.maxConcurrent ?? 1, + fixture.lastTriggeredAt ?? null, + fixture.triggerCount ?? 0, + fixture.nextFireAt ?? null, + fixture.createdAt ?? '2026-08-08T00:00:00.000Z', + fixture.createdAt ?? '2026-08-08T00:00:00.000Z' + ); + } + + it('returns an empty list for a project with no triggers', async () => { + const response = await handleListTriggers('req-1', {}, tokenData, env); + + expect(parseContent(response)).toEqual([]); + }); + + it('returns multiple triggers with the operational field contract', async () => { + insertTrigger({ + id: 'cron-trigger', + name: 'Daily review', + description: 'Review open changes', + cronExpression: '0 9 * * 1-5', + cronTimezone: 'America/New_York', + skipIfRunning: false, + agentProfileId: 'profile-1', + skillId: 'skill-1', + taskMode: 'conversation', + maxConcurrent: 3, + lastTriggeredAt: '2026-08-07T13:00:00.000Z', + triggerCount: 7, + nextFireAt: '2026-08-10T13:00:00.000Z', + createdAt: '2026-08-08T02:00:00.000Z', + }); + insertTrigger({ + id: 'webhook-trigger', + sourceType: 'webhook', + cronExpression: null, + cronTimezone: null, + createdAt: '2026-08-08T01:00:00.000Z', + }); + + const response = await handleListTriggers('req-1', {}, tokenData, env); + const triggers = parseContent(response); + + expect(triggers).toHaveLength(2); + expect(triggers[0]).toEqual({ + id: 'cron-trigger', + name: 'Daily review', + description: 'Review open changes', + status: 'active', + sourceType: 'cron', + cronExpression: '0 9 * * 1-5', + cronTimezone: 'America/New_York', + cronHumanReadable: cronToHumanReadable('0 9 * * 1-5', 'America/New_York'), + nextFireAt: '2026-08-10T13:00:00.000Z', + lastTriggeredAt: '2026-08-07T13:00:00.000Z', + triggerCount: 7, + taskMode: 'conversation', + agentProfileId: 'profile-1', + skillId: 'skill-1', + maxConcurrent: 3, + skipIfRunning: false, + }); + expect(triggers[1]).toMatchObject({ + id: 'webhook-trigger', + sourceType: 'webhook', + cronExpression: null, + cronHumanReadable: null, + }); + }); + + it('filters triggers by status in SQL', async () => { + insertTrigger({ id: 'active-trigger', status: 'active' }); + insertTrigger({ id: 'paused-trigger', status: 'paused' }); + insertTrigger({ id: 'disabled-trigger', status: 'disabled' }); + + const response = await handleListTriggers('req-1', { status: 'paused' }, tokenData, env); + + expect(parseContent(response).map((trigger) => trigger.id)).toEqual(['paused-trigger']); + }); + + it('filters triggers by source type in SQL', async () => { + insertTrigger({ id: 'cron-trigger', sourceType: 'cron' }); + insertTrigger({ id: 'webhook-trigger', sourceType: 'webhook', cronExpression: null }); + insertTrigger({ id: 'github-trigger', sourceType: 'github', cronExpression: null }); + + const response = await handleListTriggers('req-1', { sourceType: 'github' }, tokenData, env); + + expect(parseContent(response).map((trigger) => trigger.id)).toEqual(['github-trigger']); + }); + + it('isolates projects and omits webhook secret/config material', async () => { + const promptCanary = 'PROMPT_SECRET_CANARY'; + const tokenHashCanary = 'TOKEN_HASH_SECRET_CANARY'; + const headerCanary = 'X-SECRET-HEADER-CANARY'; + const filterCanary = 'FILTER_SECRET_CANARY'; + insertTrigger({ + id: 'own-webhook', + sourceType: 'webhook', + cronExpression: null, + promptTemplate: promptCanary, + }); + insertTrigger({ + id: 'foreign-trigger', + projectId: 'project-2', + name: 'CROSS_PROJECT_SECRET_CANARY', + }); + sqlite + .prepare( + `INSERT INTO webhook_trigger_configs ( + trigger_id, token_hash, token_last_four, token_created_at, token_rotated_at, + source_label, filter_mode, filters_json, included_headers_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, NULL, ?, 'all', ?, ?, ?, ?)` + ) + .run( + 'own-webhook', + tokenHashCanary, + '9876', + '2026-08-08T00:00:00.000Z', + 'SOURCE_LABEL_SECRET_CANARY', + JSON.stringify([{ value: filterCanary }]), + JSON.stringify([headerCanary]), + '2026-08-08T00:00:00.000Z', + '2026-08-08T00:00:00.000Z' + ); + + const response = await handleListTriggers('req-1', {}, tokenData, env); + const result = response.result as { content: Array<{ text: string }> }; + const serialized = result.content[0]?.text ?? ''; + const triggers = parseContent(response); + + expect(triggers.map((trigger) => trigger.id)).toEqual(['own-webhook']); + expect(serialized).not.toContain('CROSS_PROJECT_SECRET_CANARY'); + expect(serialized).not.toContain(promptCanary); + expect(serialized).not.toContain(tokenHashCanary); + expect(serialized).not.toContain('9876'); + expect(serialized).not.toContain(headerCanary); + expect(serialized).not.toContain(filterCanary); + expect(serialized).not.toContain('SOURCE_LABEL_SECRET_CANARY'); + expect(serialized).not.toContain('promptTemplate'); + expect(serialized).not.toContain('webhookConfig'); + expect(serialized).not.toContain('webhookCredential'); + expect(serialized).not.toContain('includedHeaders'); + expect(serialized).not.toContain('tokenHash'); + expect(serialized).not.toContain('tokenLastFour'); + }); + + it('enforces configurable default and maximum result bounds', async () => { + env.MCP_TRIGGER_LIST_LIMIT = '2'; + env.MCP_TRIGGER_LIST_MAX = '3'; + for (let index = 0; index < 5; index += 1) { + insertTrigger({ + id: `trigger-${index}`, + createdAt: `2026-08-08T00:00:0${index}.000Z`, + }); + } + + const defaultResponse = await handleListTriggers('req-1', {}, tokenData, env); + const cappedResponse = await handleListTriggers('req-2', { limit: 99 }, tokenData, env); + const requestedResponse = await handleListTriggers('req-3', { limit: 1.9 }, tokenData, env); + + expect(parseContent(defaultResponse).map((trigger) => trigger.id)).toEqual([ + 'trigger-4', + 'trigger-3', + ]); + expect(parseContent(cappedResponse)).toHaveLength(3); + expect(parseContent(requestedResponse)).toHaveLength(1); + }); + + it('rejects invalid filters and limits', async () => { + const invalidStatus = await handleListTriggers('req-1', { status: 'running' }, tokenData, env); + const invalidSource = await handleListTriggers( + 'req-2', + { sourceType: 'email' }, + tokenData, + env + ); + const invalidLimit = await handleListTriggers('req-3', { limit: 0 }, tokenData, env); + + expect(invalidStatus.error?.message).toContain('status must be'); + expect(invalidSource.error?.message).toContain('sourceType must be'); + expect(invalidLimit.error?.message).toContain('limit must be'); + }); +}); diff --git a/apps/api/tests/unit/routes/mcp-orchestration-comms.test.ts b/apps/api/tests/unit/routes/mcp-orchestration-comms.test.ts index 22b8a5b16a..6a41e805f6 100644 --- a/apps/api/tests/unit/routes/mcp-orchestration-comms.test.ts +++ b/apps/api/tests/unit/routes/mcp-orchestration-comms.test.ts @@ -9,6 +9,8 @@ const mockSendPromptToAgentOnNode = vi.fn(); const mockStopAgentSessionOnNode = vi.fn(); const mockPersistOrchestrationPrompt = vi.fn(); const mockEnqueueMailboxMessage = vi.fn(); +const mockCleanupTerminalTaskResources = vi.fn(); +const mockSyncTriggerExecutionStatus = vi.fn(); const mockAcceptPromptDelivery = vi.fn(); vi.mock('../../../src/services/node-agent', () => ({ @@ -25,6 +27,14 @@ vi.mock('../../../src/services/project-data', () => ({ acceptPromptDelivery: (...args: unknown[]) => mockAcceptPromptDelivery(...args), })); +vi.mock('../../../src/services/task-terminal-cleanup', () => ({ + cleanupTerminalTaskResources: (...args: unknown[]) => mockCleanupTerminalTaskResources(...args), +})); + +vi.mock('../../../src/services/trigger-execution-sync', () => ({ + syncTriggerExecutionStatus: (...args: unknown[]) => mockSyncTriggerExecutionStatus(...args), +})); + // Mock ulid for deterministic IDs vi.mock('../../../src/lib/ulid', () => ({ ulid: () => 'mock-ulid-001', @@ -40,7 +50,10 @@ function createMockD1() { }; return { prepare: vi.fn().mockReturnValue(stmt), - batch: vi.fn(), + batch: vi.fn().mockResolvedValue([ + { success: true, meta: { changes: 1 } }, + { success: true, meta: { changes: 1 } }, + ]), _stmt: stmt, }; } @@ -97,6 +110,8 @@ describe('MCP Orchestration Communication Tools', () => { mockStopAgentSessionOnNode.mockResolvedValue(undefined); mockPersistOrchestrationPrompt.mockResolvedValue('persisted-msg-001'); mockEnqueueMailboxMessage.mockResolvedValue({ id: 'mailbox-msg-001' }); + mockCleanupTerminalTaskResources.mockResolvedValue(undefined); + mockSyncTriggerExecutionStatus.mockResolvedValue(undefined); mockAcceptPromptDelivery.mockResolvedValue({ message: { id: 'durable-message-001' } }); const mod = await import('../../../src/routes/mcp/orchestration-comms'); @@ -782,7 +797,7 @@ describe('MCP Orchestration Communication Tools', () => { expect(result.error?.message).toContain("'completed' status"); }); - it('should update task status to failed with stop reason', async () => { + it('should stop the runtime before atomically cancelling and cleaning up the task', async () => { mockD1ResultSequence([ [ { @@ -808,10 +823,237 @@ describe('MCP Orchestration Communication Tools', () => { ], ]); - await handleStopSubtask(1, { taskId: 'child-001' }, parentTokenData, mockEnv as Env); + await handleStopSubtask( + 1, + { taskId: 'child-001', reason: 'No longer needed' }, + parentTokenData, + { ...mockEnv, ORCHESTRATOR_STOP_GRACE_MS: '1' } as Env + ); + + expect(mockStopAgentSessionOnNode).toHaveBeenCalled(); + expect(mockStopAgentSessionOnNode.mock.invocationCallOrder[0]).toBeLessThan( + mockD1.batch.mock.invocationCallOrder[0] + ); + const preparedSql = mockD1.prepare.mock.calls.map(([sql]) => String(sql)); + expect(preparedSql).toEqual( + expect.arrayContaining([ + expect.stringContaining("SET status = 'cancelled'"), + expect.stringContaining("'cancelled', 'agent'"), + ]) + ); + expect(mockSyncTriggerExecutionStatus).toHaveBeenCalledWith( + mockEnv.DATABASE, + 'child-001', + 'cancelled' + ); + expect(mockCleanupTerminalTaskResources).toHaveBeenCalledWith( + expect.anything(), + 'child-001', + expect.objectContaining({ + status: 'cancelled', + errorMessage: 'Stopped by parent: No longer needed', + requiredUserId: 'user-001', + }) + ); + }); + + it('preserves a concurrent fatal terminal state instead of overwriting it with cancellation', async () => { + mockD1ResultSequence([ + [ + { + id: 'child-001', + status: 'in_progress', + workspace_id: 'ws-child-001', + project_id: 'proj-001', + parent_task_id: 'parent-task-001', + }, + ], + [ + { + id: 'ws-child-001', + node_id: 'node-001', + chat_session_id: 'chat-child-001', + status: 'running', + }, + ], + [{ id: 'agent-session-001' }], + ]); + mockD1.batch.mockResolvedValueOnce([ + { success: true, meta: { changes: 0 } }, + { success: true, meta: { changes: 0 } }, + ]); + mockD1._stmt.first.mockResolvedValueOnce({ status: 'failed' }); + + const result = await handleStopSubtask( + 1, + { taskId: 'child-001' }, + parentTokenData, + mockEnv as Env + ); - expect(mockD1.prepare.mock.calls.length).toBeGreaterThanOrEqual(4); + const content = JSON.parse( + (result.result as { content: Array<{ text: string }> }).content[0].text + ); + expect(content).toMatchObject({ stopped: true, terminalStatePreserved: true }); expect(mockStopAgentSessionOnNode).toHaveBeenCalled(); + expect(mockCleanupTerminalTaskResources).not.toHaveBeenCalled(); + }); + + it('retries cancellation when a concurrent transition keeps the child active', async () => { + mockD1ResultSequence([ + [ + { + id: 'child-001', + status: 'in_progress', + workspace_id: 'ws-child-001', + project_id: 'proj-001', + parent_task_id: 'parent-task-001', + }, + ], + [ + { + id: 'ws-child-001', + node_id: 'node-001', + chat_session_id: 'chat-child-001', + status: 'running', + }, + ], + [{ id: 'agent-session-001' }], + ]); + mockD1.batch + .mockResolvedValueOnce([ + { success: true, meta: { changes: 0 } }, + { success: true, meta: { changes: 0 } }, + ]) + .mockResolvedValueOnce([ + { success: true, meta: { changes: 1 } }, + { success: true, meta: { changes: 1 } }, + ]); + mockD1._stmt.first.mockResolvedValueOnce({ status: 'delegated' }); + + const result = await handleStopSubtask( + 1, + { taskId: 'child-001' }, + parentTokenData, + mockEnv as Env + ); + + expect(result.error).toBeUndefined(); + expect(mockD1.batch).toHaveBeenCalledTimes(2); + expect(mockCleanupTerminalTaskResources).toHaveBeenCalledWith( + expect.anything(), + 'child-001', + expect.objectContaining({ status: 'cancelled' }) + ); + }); + + it('honors the configured task-status CAS attempt bound', async () => { + mockD1ResultSequence([ + [ + { + id: 'child-001', + status: 'in_progress', + workspace_id: 'ws-child-001', + project_id: 'proj-001', + parent_task_id: 'parent-task-001', + }, + ], + [ + { + id: 'ws-child-001', + node_id: 'node-001', + chat_session_id: 'chat-child-001', + status: 'running', + }, + ], + [{ id: 'agent-session-001' }], + ]); + mockD1.batch.mockResolvedValueOnce([ + { success: true, meta: { changes: 0 } }, + { success: true, meta: { changes: 0 } }, + ]); + mockD1._stmt.first.mockResolvedValueOnce({ status: 'delegated' }); + + const result = await handleStopSubtask(1, { taskId: 'child-001' }, parentTokenData, { + ...mockEnv, + ORCHESTRATOR_STOP_CAS_MAX_ATTEMPTS: '1', + } as Env); + + expect(result.error?.message).toContain('task cancellation failed'); + expect(mockD1.batch).toHaveBeenCalledTimes(1); + expect(mockCleanupTerminalTaskResources).not.toHaveBeenCalled(); + }); + + it('reports cancellation persistence failure when D1 returns no transition result', async () => { + mockD1ResultSequence([ + [ + { + id: 'child-001', + status: 'in_progress', + workspace_id: 'ws-child-001', + project_id: 'proj-001', + parent_task_id: 'parent-task-001', + }, + ], + [ + { + id: 'ws-child-001', + node_id: 'node-001', + chat_session_id: 'chat-child-001', + status: 'running', + }, + ], + [{ id: 'agent-session-001' }], + ]); + mockD1.batch.mockResolvedValueOnce([]); + + const result = await handleStopSubtask( + 1, + { taskId: 'child-001' }, + parentTokenData, + mockEnv as Env + ); + + expect(result.error?.message).toContain('task cancellation failed'); + expect(mockCleanupTerminalTaskResources).not.toHaveBeenCalled(); + }); + + it('reports cleanup failure after persisting cancellation', async () => { + mockD1ResultSequence([ + [ + { + id: 'child-001', + status: 'in_progress', + workspace_id: 'ws-child-001', + project_id: 'proj-001', + parent_task_id: 'parent-task-001', + }, + ], + [ + { + id: 'ws-child-001', + node_id: 'node-001', + chat_session_id: 'chat-child-001', + status: 'running', + }, + ], + [{ id: 'agent-session-001' }], + ]); + mockCleanupTerminalTaskResources.mockRejectedValueOnce(new Error('cleanup unavailable')); + + const result = await handleStopSubtask( + 1, + { taskId: 'child-001' }, + parentTokenData, + mockEnv as Env + ); + + expect(result.error?.message).toContain('runtime cleanup failed'); + expect(mockSyncTriggerExecutionStatus).toHaveBeenCalledWith( + mockEnv.DATABASE, + 'child-001', + 'cancelled' + ); }); it('should reject when node is in destroyed state', async () => { diff --git a/apps/api/tests/unit/routes/mcp.test.ts b/apps/api/tests/unit/routes/mcp.test.ts index 3558f2b549..7b30ae2573 100644 --- a/apps/api/tests/unit/routes/mcp.test.ts +++ b/apps/api/tests/unit/routes/mcp.test.ts @@ -527,6 +527,7 @@ describe('MCP Routes', () => { expect(deploymentGuideTool.inputSchema.properties).toEqual({}); expect(deploymentGuideTool.inputSchema.required).toBeUndefined(); // Trigger tools + expect(toolNames).toContain('list_triggers'); expect(toolNames).toContain('create_trigger'); expect(toolNames).toContain('update_trigger'); expect(toolNames).toContain('delete_trigger'); @@ -562,7 +563,7 @@ describe('MCP Routes', () => { expect(toolNames).toContain('list_deployment_routes'); expect(toolNames).toContain('list_deployment_environment_config'); expect(toolNames).toContain('set_deployment_environment_config'); - expect(body.result.tools).toHaveLength(99); + expect(body.result.tools).toHaveLength(100); }); it('should include MUST call directive in get_instructions description', async () => { @@ -585,6 +586,35 @@ describe('MCP Routes', () => { } }); + it('should advertise list_triggers with only optional bounded filter inputs', async () => { + const res = await mcpRequest(app, jsonRpcRequest('tools/list')); + + const body = await res.json(); + const listTriggers = body.result.tools.find( + (tool: { name: string }) => tool.name === 'list_triggers' + ); + + expect(listTriggers.inputSchema.required).toBeUndefined(); + expect(listTriggers.inputSchema.additionalProperties).toBe(false); + expect(Object.keys(listTriggers.inputSchema.properties)).toEqual([ + 'status', + 'sourceType', + 'limit', + ]); + expect(listTriggers.inputSchema.properties.status.enum).toEqual([ + 'active', + 'paused', + 'disabled', + ]); + expect(listTriggers.inputSchema.properties.sourceType.enum).toEqual([ + 'cron', + 'webhook', + 'github', + ]); + expect(listTriggers.inputSchema.properties.limit.type).toBe('number'); + expect(listTriggers.inputSchema.properties.limit.minimum).toBe(1); + }); + it('should require message parameter for update_task_status', async () => { const res = await mcpRequest(app, jsonRpcRequest('tools/list')); diff --git a/apps/api/tests/unit/scheduled/cleanup-config.test.ts b/apps/api/tests/unit/scheduled/cleanup-config.test.ts index 76055eb255..b5e861d920 100644 --- a/apps/api/tests/unit/scheduled/cleanup-config.test.ts +++ b/apps/api/tests/unit/scheduled/cleanup-config.test.ts @@ -9,7 +9,11 @@ import { describe, expect, it, vi } from 'vitest'; import type { Env } from '../../../src/env'; -import { parseMs, parsePositiveInt, resolveCleanupConfig } from '../../../src/scheduled/node-cleanup/shared'; +import { + parseMs, + parsePositiveInt, + resolveCleanupConfig, +} from '../../../src/scheduled/node-cleanup/shared'; const logWarn = vi.fn(); @@ -21,7 +25,7 @@ vi.mock('../../../src/services/node-agent', () => ({ deleteWorkspaceOnNode: vi.fn(), stopWorkspaceOnNode: vi.fn(), })); -vi.mock('../../../src/services/nodes', () => ({ deleteNodeResources: vi.fn() })); +vi.mock('../../../src/services/nodes', () => ({ deleteNodeResourcesStrict: vi.fn() })); vi.mock('../../../src/services/observability', () => ({ persistError: vi.fn() })); describe('parseMs', () => { diff --git a/apps/api/tests/unit/scripts/claude-setup-token.test.ts b/apps/api/tests/unit/scripts/claude-setup-token.test.ts index dd5d418f37..6ba8caef16 100644 --- a/apps/api/tests/unit/scripts/claude-setup-token.test.ts +++ b/apps/api/tests/unit/scripts/claude-setup-token.test.ts @@ -4,9 +4,12 @@ import { PassThrough } from 'node:stream'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { + classifyOauthError, extractClaudeSetupOutput, + extractOauthErrorDetail, resolveClaudeSetupPaths, runClaudeSetupToken, + runClaudeSetupTokenCli, validateClaudeOauthToken, validateClaudeVerificationUrl, } from '../../../scripts/claude-setup-token.mjs'; @@ -16,10 +19,12 @@ function fakeClaudeProcess() { stdout: PassThrough; stderr: PassThrough; kill: ReturnType; + stdin: PassThrough; }; process.stdout = new PassThrough(); process.stderr = new PassThrough(); process.kill = vi.fn(); + process.stdin = new PassThrough(); return process; } @@ -27,10 +32,15 @@ const CLAUDE_TOKEN = `sk-ant-oat${'A'.repeat(48)}`; const CLAUDE_SETUP_HOME = '/tmp/sam-claude-setup-test'; const CLAUDE_STATE_PATH = `${CLAUDE_SETUP_HOME}/device-auth-state.json`; const CLAUDE_CREDENTIAL_PATH = `${CLAUDE_SETUP_HOME}/claude-oauth-token.txt`; +const CLAUDE_VERIFICATION_CODE_PATH = `${CLAUDE_SETUP_HOME}/verification-code.txt`; function validSetupPaths() { vi.stubEnv('CLAUDE_CONFIG_DIR', CLAUDE_SETUP_HOME); - return { statePath: CLAUDE_STATE_PATH, credentialPath: CLAUDE_CREDENTIAL_PATH }; + return { + statePath: CLAUDE_STATE_PATH, + credentialPath: CLAUDE_CREDENTIAL_PATH, + verificationCodePath: CLAUDE_VERIFICATION_CODE_PATH, + }; } afterEach(() => { @@ -50,6 +60,34 @@ describe('Claude setup-token driver', () => { }); }); + it('joins a token wrapped by the PTY instead of accepting a truncated fragment', () => { + const wrapped = `${CLAUDE_TOKEN.slice(0, 28)}\n${CLAUDE_TOKEN.slice(28)}`; + const ttyColumns = 'Your token: '.length + 28; + expect(extractClaudeSetupOutput(`Your token: ${wrapped}\n`, ttyColumns).token).toBe( + CLAUDE_TOKEN + ); + }); + + it('joins a legitimate short final PTY-wrapped token segment', () => { + const splitAt = CLAUDE_TOKEN.length - 15; + const wrapped = `${CLAUDE_TOKEN.slice(0, splitAt)}\n${CLAUDE_TOKEN.slice(splitAt)}`; + const ttyColumns = 'Your token: '.length + splitAt; + expect(extractClaudeSetupOutput(`Your token: ${wrapped}\n`, ttyColumns).token).toBe( + CLAUDE_TOKEN + ); + }); + + it('does not append token-like terminal prose after a wrapped token', () => { + const wrapped = `${CLAUDE_TOKEN.slice(0, 28)}\n${CLAUDE_TOKEN.slice(28)}`; + const ttyColumns = 'Your token: '.length + 28; + expect(extractClaudeSetupOutput(`Your token: ${wrapped}\nDone`, ttyColumns).token).toBe( + CLAUDE_TOKEN + ); + expect( + extractClaudeSetupOutput(`Your token: ${CLAUDE_TOKEN}\nAuthenticationComplete`).token + ).toBe(CLAUDE_TOKEN); + }); + it('extracts URLs from Claude terminal hyperlink output', () => { const output = extractClaudeSetupOutput( '\u001b]8;id=abc;https://claude.com/cai/oauth/authorize?code=abc&state=def\u0007' + @@ -92,6 +130,45 @@ describe('Claude setup-token driver', () => { expect(() => validateClaudeOauthToken('sk-ant-api03-not-oauth')).toThrow(/invalid OAuth token/); }); + it('extracts the last OAuth error line, strips the retry suffix, and redacts token-like runs', () => { + expect( + extractOauthErrorDetail( + 'OAuth error: transient thing\nredraw\nOAuth error: Requstfailed withstatus code 400PressEntertoretry.' + ) + ).toBe('Requstfailed withstatus code 400'); + expect(extractOauthErrorDetail(`OAuth error: leaked ${CLAUDE_TOKEN} value`)).toBe( + 'leaked [redacted] value' + ); + expect(extractOauthErrorDetail(`OAuth error: ${'x'.repeat(500)}`)).toHaveLength(160); + expect(extractOauthErrorDetail('OAth eror: Invlidcode. fullcde wascopied')).toBe( + 'Invlidcode. fullcde wascopied' + ); + expect(extractOauthErrorDetail('no marker at all')).toBeNull(); + }); + + it('classifies OAuth error wordings, tolerating Ink overwrite mangling', () => { + // Live-captured renders from claude v2.1.220 (characters dropped by redraws). + expect(classifyOauthError('Invalidcode. Please makesure the fullcde wascopied').code).toBe( + 'code_incomplete' + ); + expect(classifyOauthError('Invlidcode. Please makesure the fullcde wascopied').code).toBe( + 'code_incomplete' + ); + expect(classifyOauthError('Requstfailed withstatus code 400').code).toBe('code_rejected'); + expect(classifyOauthError('Request failed with status code 429').code).toBe('code_rejected'); + expect(classifyOauthError('connctECONNREFUSED 127.0.0.1:9').code).toBe( + 'exchange_network_error' + ); + expect(classifyOauthError('Prxy conncion ended before receving CONNECT response').code).toBe( + 'exchange_network_error' + ); + // Server-side "Invalid authorization code" is a rejection, not an incomplete paste. + expect(classifyOauthError('Authentication failed: Invalid authorization code').code).toBe( + 'code_rejected' + ); + expect(classifyOauthError(null).code).toBe('code_rejected'); + }); + it('derives setup file paths from CLAUDE_CONFIG_DIR and rejects path escapes', async () => { vi.stubEnv('CLAUDE_CONFIG_DIR', CLAUDE_SETUP_HOME); @@ -99,11 +176,13 @@ describe('Claude setup-token driver', () => { resolveClaudeSetupPaths({ statePath: CLAUDE_STATE_PATH, credentialPath: CLAUDE_CREDENTIAL_PATH, + verificationCodePath: CLAUDE_VERIFICATION_CODE_PATH, }) ).toEqual({ statePath: CLAUDE_STATE_PATH, temporaryStatePath: `${CLAUDE_SETUP_HOME}/device-auth-state.json.tmp`, credentialPath: CLAUDE_CREDENTIAL_PATH, + verificationCodePath: CLAUDE_VERIFICATION_CODE_PATH, temporaryCredentialPath: `${CLAUDE_SETUP_HOME}/claude-oauth-token.txt.tmp`, }); @@ -111,23 +190,49 @@ describe('Claude setup-token driver', () => { resolveClaudeSetupPaths({ statePath: `${CLAUDE_SETUP_HOME}/../device-auth-state.json`, credentialPath: CLAUDE_CREDENTIAL_PATH, + verificationCodePath: CLAUDE_VERIFICATION_CODE_PATH, }) ).toThrow(/expected setup state file/); expect(() => resolveClaudeSetupPaths({ statePath: CLAUDE_STATE_PATH, credentialPath: `${CLAUDE_SETUP_HOME}/../claude-oauth-token.txt`, + verificationCodePath: CLAUDE_VERIFICATION_CODE_PATH, }) ).toThrow(/expected OAuth token file/); expect(() => resolveClaudeSetupPaths({ statePath: '/device-auth-state.json', credentialPath: '/claude-oauth-token.txt', + verificationCodePath: '/verification-code.txt', configDir: '/', }) ).toThrow(/must not resolve to the filesystem root/); }); + it('forwards all three executable arguments into the driver', async () => { + const runner = vi.fn().mockResolvedValue(undefined); + + await runClaudeSetupTokenCli( + [ + 'node', + 'claude-setup-token.mjs', + CLAUDE_STATE_PATH, + CLAUDE_CREDENTIAL_PATH, + CLAUDE_VERIFICATION_CODE_PATH, + ], + runner + ); + + expect(runner).toHaveBeenCalledWith( + expect.objectContaining({ + statePath: CLAUDE_STATE_PATH, + credentialPath: CLAUDE_CREDENTIAL_PATH, + verificationCodePath: CLAUDE_VERIFICATION_CODE_PATH, + }) + ); + }); + it('publishes non-secret actionable state and writes only the token to the credential file', async () => { const fake = fakeClaudeProcess(); const states: Array> = []; @@ -144,6 +249,10 @@ describe('Claude setup-token driver', () => { }, writeState: async (state) => states.push(state), writeCredential: async (token) => credentials.push(token), + readVerificationCode: vi.fn().mockResolvedValue(' abc 123#state\n'), + deleteVerificationCode: vi.fn().mockResolvedValue(undefined), + verificationCodePollMs: 1, + verificationEnterDelayMs: 1, }); expect(spawnedCommand).toBe('script'); @@ -165,6 +274,12 @@ describe('Claude setup-token driver', () => { }, ]); expect(JSON.stringify(states)).not.toContain(CLAUDE_TOKEN); + // The code and the Enter keypress MUST be separate stdin writes: Claude + // Code's prompt treats one large chunk as a paste and absorbs an inline + // trailing \r, leaving the code typed but never submitted. + const stdinWrites: string[] = []; + fake.stdin.on('data', (chunk) => stdinWrites.push(chunk.toString())); + await vi.waitFor(() => expect(stdinWrites).toEqual(['abc123#state', '\r'])); fake.stdout.write(`Your token: ${CLAUDE_TOKEN}\n`); await vi.waitFor(() => expect(credentials).toEqual([CLAUDE_TOKEN])); @@ -173,6 +288,265 @@ describe('Claude setup-token driver', () => { expect(fake.kill).toHaveBeenCalledWith('SIGTERM'); }); + it('publishes a sanitized failure when Claude rejects a forwarded code but stays open', async () => { + const fake = fakeClaudeProcess(); + const states: Array> = []; + + const ready = runClaudeSetupToken({ + ...validSetupPaths(), + spawnProcess: () => fake, + writeState: async (state) => states.push(state), + writeCredential: vi.fn().mockResolvedValue(undefined), + readVerificationCode: vi.fn().mockResolvedValue('garbage#rejected-code'), + deleteVerificationCode: vi.fn().mockResolvedValue(undefined), + verificationCodePollMs: 1, + verificationEnterDelayMs: 1, + rejectionSettleMs: 1, + }); + + fake.stdout.write('Open https://claude.com/cai/oauth/authorize\n'); + await ready; + const stdinWrites: string[] = []; + fake.stdin.on('data', (chunk) => stdinWrites.push(chunk.toString())); + await vi.waitFor(() => expect(stdinWrites).toEqual(['garbage#rejected-code', '\r'])); + fake.stdout.write('OAuth error: Request failed with status code 400\nPress Enter to retry.'); + + await vi.waitFor(() => + expect(states.at(-1)).toEqual({ + status: 'failed', + error: 'Claude rejected the verification code', + code: 'code_rejected', + detail: 'Request failed with status code 400', + }) + ); + expect(JSON.stringify(states)).not.toContain('garbage#rejected-code'); + expect(fake.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('rejects an overlong handoff before writing it to Claude stdin', async () => { + const fake = fakeClaudeProcess(); + const states: Array> = []; + const stdinWrites: string[] = []; + fake.stdin.on('data', (chunk) => stdinWrites.push(chunk.toString())); + + const ready = runClaudeSetupToken({ + ...validSetupPaths(), + spawnProcess: () => fake, + writeState: async (state) => states.push(state), + writeCredential: vi.fn().mockResolvedValue(undefined), + readVerificationCode: vi.fn().mockResolvedValue('too-long#state'), + deleteVerificationCode: vi.fn().mockResolvedValue(undefined), + verificationCodePollMs: 1, + verificationCodeMaxLength: 8, + }); + + fake.stdout.write('Open https://claude.com/cai/oauth/authorize\n'); + await ready; + await vi.waitFor(() => expect(states.at(-1)).toMatchObject({ status: 'failed' })); + expect(stdinWrites).toEqual([]); + }); + + it('submits realistic-length codes even though the CLI paste widget absorbs an inline carriage return', async () => { + // Discriminating regression for the 2026-07-26 production hang: model the + // real Claude Code prompt, which inserts a large single chunk as pasted + // TEXT (an inline trailing \r is absorbed, not executed) and only submits + // on a subsequent standalone Enter keypress. The pre-fix driver (one + // `code\r` write) never submits here and this test times out. + const fake = fakeClaudeProcess(); + const states: Array> = []; + const realisticCode = `${'A'.repeat(60)}#${'B'.repeat(43)}`; + const stdinWrites: string[] = []; + let pastedBuffer = ''; + + fake.stdin.on('data', (chunk) => { + const text = chunk.toString(); + stdinWrites.push(text); + if (text === '\r' && pastedBuffer.length > 0) { + // Standalone Enter after pasted text: the CLI submits and the exchange + // fails upstream (invalid test code), rendering the Ink error screen. + fake.stdout.write( + 'OAuth error: Request failed with status code 400\nPress Enter to retry.' + ); + return; + } + // Large chunk (with or without inline \r): inserted as text, not submitted. + pastedBuffer += text.replace(/\r/g, ''); + }); + + const ready = runClaudeSetupToken({ + ...validSetupPaths(), + spawnProcess: () => fake, + writeState: async (state) => states.push(state), + writeCredential: vi.fn().mockResolvedValue(undefined), + readVerificationCode: vi.fn().mockResolvedValue(realisticCode), + deleteVerificationCode: vi.fn().mockResolvedValue(undefined), + verificationCodePollMs: 1, + verificationEnterDelayMs: 5, + rejectionSettleMs: 1, + }); + + fake.stdout.write('Open https://claude.com/cai/oauth/authorize\n'); + await ready; + + await vi.waitFor(() => + expect(states.at(-1)).toEqual({ + status: 'failed', + error: 'Claude rejected the verification code', + code: 'code_rejected', + detail: 'Request failed with status code 400', + }) + ); + expect(pastedBuffer).toBe(realisticCode); + expect(stdinWrites).toEqual([realisticCode, '\r']); + expect(fake.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('treats any post-forward OAuth error screen as terminal, not only status-code wordings', async () => { + const fake = fakeClaudeProcess(); + const states: Array> = []; + + const ready = runClaudeSetupToken({ + ...validSetupPaths(), + spawnProcess: () => fake, + writeState: async (state) => states.push(state), + writeCredential: vi.fn().mockResolvedValue(undefined), + readVerificationCode: vi.fn().mockResolvedValue('expired#code'), + deleteVerificationCode: vi.fn().mockResolvedValue(undefined), + verificationCodePollMs: 1, + verificationEnterDelayMs: 1, + rejectionSettleMs: 1, + }); + + fake.stdout.write('Open https://claude.com/cai/oauth/authorize\n'); + await ready; + const stdinWrites: string[] = []; + fake.stdin.on('data', (chunk) => stdinWrites.push(chunk.toString())); + await vi.waitFor(() => expect(stdinWrites).toEqual(['expired#code', '\r'])); + // Real 401 wording from claude v2.1.220 — contains no "status code" suffix. + // "Invalid authorization code" is a SERVER rejection and must NOT be + // classified as the local incomplete-paste error ("Invalid code. Please + // make sure the full code was copied"). + fake.stdout.write( + 'OAuth error: Authentication failed: Invalid authorization code\nPress Enter to retry.' + ); + + await vi.waitFor(() => + expect(states.at(-1)).toEqual({ + status: 'failed', + error: 'Claude rejected the verification code', + code: 'code_rejected', + detail: 'Authentication failed: Invalid authorization code', + }) + ); + expect(fake.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('classifies the local incomplete-paste error distinctly, tolerating Ink overwrite mangling', async () => { + // Reproduced live against claude v2.1.220: a code pasted WITHOUT its + // `#state` half fails instantly and locally with "Invalid code. Please + // make sure the full code was copied" — advice the driver previously + // swallowed, reporting "code rejected … use a fresh code" instead. The + // rendered line arrives with characters overwritten by Ink redraws. + const fake = fakeClaudeProcess(); + const states: Array> = []; + + const ready = runClaudeSetupToken({ + ...validSetupPaths(), + spawnProcess: () => fake, + writeState: async (state) => states.push(state), + writeCredential: vi.fn().mockResolvedValue(undefined), + readVerificationCode: vi.fn().mockResolvedValue('A'.repeat(64)), + deleteVerificationCode: vi.fn().mockResolvedValue(undefined), + verificationCodePollMs: 1, + verificationEnterDelayMs: 1, + rejectionSettleMs: 1, + }); + + fake.stdout.write('Open https://claude.com/cai/oauth/authorize\n'); + await ready; + const stdinWrites: string[] = []; + fake.stdin.on('data', (chunk) => stdinWrites.push(chunk.toString())); + await vi.waitFor(() => expect(stdinWrites).toEqual(['A'.repeat(64), '\r'])); + fake.stdout.write( + 'OAuth error: Invalidcode. Please makesure the fullcde wascopiedPressEntertoretry.' + ); + + await vi.waitFor(() => + expect(states.at(-1)).toEqual({ + status: 'failed', + error: 'Claude reported the pasted verification code was incomplete', + code: 'code_incomplete', + detail: 'Invalidcode. Please makesure the fullcde wascopied', + }) + ); + expect(fake.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('classifies connection failures as exchange_network_error, not a rejected code', async () => { + const fake = fakeClaudeProcess(); + const states: Array> = []; + + const ready = runClaudeSetupToken({ + ...validSetupPaths(), + spawnProcess: () => fake, + writeState: async (state) => states.push(state), + writeCredential: vi.fn().mockResolvedValue(undefined), + readVerificationCode: vi.fn().mockResolvedValue('real#code'), + deleteVerificationCode: vi.fn().mockResolvedValue(undefined), + verificationCodePollMs: 1, + verificationEnterDelayMs: 1, + rejectionSettleMs: 1, + }); + + fake.stdout.write('Open https://claude.com/cai/oauth/authorize\n'); + await ready; + const stdinWrites: string[] = []; + fake.stdin.on('data', (chunk) => stdinWrites.push(chunk.toString())); + await vi.waitFor(() => expect(stdinWrites).toEqual(['real#code', '\r'])); + // Mangled connection-failure render observed live (chars dropped by Ink). + fake.stdout.write('OAuth error: connctECONNREFUSED 10.0.0.1:443PressEntertoretry.'); + + await vi.waitFor(() => + expect(states.at(-1)).toEqual({ + status: 'failed', + error: 'Claude sign-in failed with a network error during the code exchange', + code: 'exchange_network_error', + detail: 'connctECONNREFUSED 10.0.0.1:443', + }) + ); + expect(fake.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('fails fast with exchange_timeout when the exchange produces no recognizable outcome', async () => { + const fake = fakeClaudeProcess(); + const states: Array> = []; + + runClaudeSetupToken({ + ...validSetupPaths(), + spawnProcess: () => fake, + writeState: async (state) => states.push(state), + writeCredential: vi.fn().mockResolvedValue(undefined), + readVerificationCode: vi.fn().mockResolvedValue('hung#exchange'), + deleteVerificationCode: vi.fn().mockResolvedValue(undefined), + verificationCodePollMs: 1, + verificationEnterDelayMs: 1, + exchangeTimeoutMs: 25, + }); + + fake.stdout.write('Open https://claude.com/cai/oauth/authorize\n'); + // The CLI accepts the code + Enter and then goes silent (hung request, + // unknown error wording, dead spinner): the driver must not stall until the + // session TTL. + await vi.waitFor(() => + expect(states.at(-1)).toEqual({ + status: 'failed', + error: 'Claude did not finish the verification code exchange in time', + code: 'exchange_timeout', + }) + ); + expect(fake.kill).toHaveBeenCalledWith('SIGTERM'); + }); + it('fails safely when the CLI exits before returning an OAuth token', async () => { const fake = fakeClaudeProcess(); const states: Array> = []; diff --git a/apps/api/tests/unit/services/node-cleanup-deployment-node-exemption.test.ts b/apps/api/tests/unit/services/node-cleanup-deployment-node-exemption.test.ts index 34ed66a7ea..7791c2ef0b 100644 --- a/apps/api/tests/unit/services/node-cleanup-deployment-node-exemption.test.ts +++ b/apps/api/tests/unit/services/node-cleanup-deployment-node-exemption.test.ts @@ -30,8 +30,9 @@ const deleteCalls: string[] = []; const stopCalls: string[] = []; vi.mock('../../../src/services/nodes', () => ({ - deleteNodeResources: vi.fn(async (nodeId: string) => { + deleteNodeResourcesStrict: vi.fn(async (nodeId: string) => { deleteCalls.push(nodeId); + return { providerVm: 'deleted' as const }; }), stopNodeResources: vi.fn(async (nodeId: string) => { stopCalls.push(nodeId); diff --git a/apps/api/tests/unit/services/node-cleanup-idle-signal.test.ts b/apps/api/tests/unit/services/node-cleanup-idle-signal.test.ts index 5ab5811bec..5a6ced8530 100644 --- a/apps/api/tests/unit/services/node-cleanup-idle-signal.test.ts +++ b/apps/api/tests/unit/services/node-cleanup-idle-signal.test.ts @@ -19,14 +19,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Env } from '../../../src/env'; import { runNodeCleanupSweep } from '../../../src/scheduled/node-cleanup'; -import { deleteNodeResources } from '../../../src/services/nodes'; +import { deleteNodeResourcesStrict } from '../../../src/services/nodes'; import { createSqliteD1 } from '../../helpers/sqlite-d1'; const deleteCalls: string[] = []; vi.mock('../../../src/services/nodes', () => ({ - deleteNodeResources: vi.fn(async (nodeId: string) => { + deleteNodeResourcesStrict: vi.fn(async (nodeId: string) => { deleteCalls.push(nodeId); + return { providerVm: 'deleted' as const }; }), stopNodeResources: vi.fn().mockResolvedValue(undefined), })); @@ -101,8 +102,9 @@ function makeEnv(): Env { beforeEach(() => { deleteCalls.length = 0; - vi.mocked(deleteNodeResources).mockImplementation(async (nodeId: string) => { + vi.mocked(deleteNodeResourcesStrict).mockImplementation(async (nodeId: string) => { deleteCalls.push(nodeId); + return { providerVm: 'deleted' }; }); sqlite = new Database(':memory:'); sqlite.exec(` @@ -234,8 +236,8 @@ describe('idle reaping is immune to heartbeat activity', () => { it('two-sweep zombie check: a permanently failing candidate is not retried forever', async () => { // rule 47 — a candidate that can never be destroyed must still leave the // candidate set, or every sweep re-attempts it and the loop never converges. - const { deleteNodeResources } = await import('../../../src/services/nodes'); - vi.mocked(deleteNodeResources).mockRejectedValue(new Error('provider unreachable')); + const { deleteNodeResourcesStrict } = await import('../../../src/services/nodes'); + vi.mocked(deleteNodeResourcesStrict).mockRejectedValue(new Error('provider unreachable')); seedNode({ id: 'doomed', createdAt: ago(10 * HOUR), updatedAt: ago(1000) }); seedWorkspace({ diff --git a/apps/api/tests/unit/services/node-cleanup-user-owned-zombie.test.ts b/apps/api/tests/unit/services/node-cleanup-user-owned-zombie.test.ts index 9e1ec2830a..302f99908a 100644 --- a/apps/api/tests/unit/services/node-cleanup-user-owned-zombie.test.ts +++ b/apps/api/tests/unit/services/node-cleanup-user-owned-zombie.test.ts @@ -18,15 +18,9 @@ const deleteCalls: string[] = []; const stopCalls: string[] = []; vi.mock('../../../src/services/nodes', () => ({ - deleteNodeResources: vi.fn(async (nodeId: string) => { + deleteNodeResourcesStrict: vi.fn(async (nodeId: string) => { deleteCalls.push(nodeId); - return { - nodeFound: true, - providerVmDeleted: true, - providerVmDeleteSkippedReason: null, - backendDnsDeleted: false, - errors: [], - }; + return { providerVm: 'deleted' as const }; }), stopNodeResources: vi.fn(async (nodeId: string) => { stopCalls.push(nodeId); diff --git a/apps/api/tests/unit/services/nodes-delete.test.ts b/apps/api/tests/unit/services/nodes-delete.test.ts index ec68e7d058..0527a8551c 100644 --- a/apps/api/tests/unit/services/nodes-delete.test.ts +++ b/apps/api/tests/unit/services/nodes-delete.test.ts @@ -196,6 +196,33 @@ describe('node resource deletion services', () => { expect(destroyVmAgentContainer).not.toHaveBeenCalled(); }); + it('deleteNodeResourcesStrict requires managed container teardown to succeed', async () => { + nodeRows.push({ + id: 'cf-strict', + userId: 'user-1', + name: 'strict cf node', + status: 'destroying', + nodeClass: 'managed', + runtime: 'cf-container', + providerInstanceId: null, + cloudProvider: null, + backendDnsRecordId: null, + credentialAttributionUserId: null, + credentialAttributionSource: 'user', + credentialAttributionProjectId: null, + }); + + await expect(deleteNodeResourcesStrict('cf-strict', 'user-1', ENV)).resolves.toEqual({ + providerVm: 'no-instance', + }); + expect(destroyVmAgentContainer).toHaveBeenCalledWith(ENV, 'cf-strict'); + + destroyVmAgentContainer.mockRejectedValueOnce(new Error('container teardown unavailable')); + await expect(deleteNodeResourcesStrict('cf-strict', 'user-1', ENV)).rejects.toThrow( + /container teardown unavailable/ + ); + }); + it('deleteNodeResourcesStrict is a no-op for a user-owned node (nothing to delete, no throw)', async () => { nodeRows.push( userOwnedNode({ providerInstanceId: 'srv-should-not-touch', cloudProvider: 'hetzner' }) diff --git a/apps/api/tests/unit/services/observability-strict.test.ts b/apps/api/tests/unit/services/observability-strict.test.ts index 91f7a62d15..6a75dbdd81 100644 --- a/apps/api/tests/unit/services/observability-strict.test.ts +++ b/apps/api/tests/unit/services/observability-strict.test.ts @@ -67,6 +67,100 @@ describe('strict observability persistence', () => { }); }); + it('monotonically enriches a stable VM incident with task and session correlation', async () => { + const base = { + id: '01KZ8V0GMXQ4ZCSERPRT2X2K6T', + source: 'vm-agent' as const, + level: 'error' as const, + message: 'prompt force-stopped', + nodeId: 'node-1', + workspaceId: 'workspace-1', + timestamp: 1_786_000_000_000, + }; + + await persistErrorBatchStrict(d1, [base]); + await persistErrorBatchStrict(d1, [{ ...base, taskId: 'task-1', sessionId: 'session-1' }]); + // A later retry whose main-D1 lookup is temporarily unavailable still + // acknowledges the durable VM report without clearing prior enrichment. + await persistErrorBatchStrict(d1, [base]); + + expect(sqlite.prepare('SELECT task_id, session_id FROM platform_errors').get()).toEqual({ + task_id: 'task-1', + session_id: 'session-1', + }); + }); + + it('rejects rebinding an already-correlated stable incident', async () => { + const base = { + id: '01KZ8V0GMXQ4ZCSERPRT2X2K6U', + source: 'vm-agent' as const, + level: 'error' as const, + message: 'prompt force-stopped', + nodeId: 'node-1', + workspaceId: 'workspace-1', + taskId: 'task-1', + sessionId: 'session-1', + timestamp: 1_786_000_000_000, + }; + await persistErrorBatchStrict(d1, [base]); + await expect( + persistErrorBatchStrict(d1, [{ ...base, taskId: 'task-2', sessionId: 'session-2' }]) + ).rejects.toThrow('different metadata'); + }); + + it.each([ + ['task', { taskId: 'task-2', sessionId: 'session-1' }], + ['session', { taskId: 'task-1', sessionId: 'session-2' }], + ])('rejects an independent %s-only correlation conflict', async (_field, conflicting) => { + const base = { + id: '01KZ8V0GMXQ4ZCSERPRT2X2K6V', + source: 'vm-agent' as const, + level: 'error' as const, + message: 'prompt force-stopped', + nodeId: 'node-1', + workspaceId: 'workspace-1', + taskId: 'task-1', + sessionId: 'session-1', + timestamp: 1_786_000_000_000, + }; + await persistErrorBatchStrict(d1, [base]); + + await expect(persistErrorBatchStrict(d1, [{ ...base, ...conflicting }])).rejects.toThrow( + 'different metadata' + ); + expect(sqlite.prepare('SELECT task_id, session_id FROM platform_errors').get()).toEqual({ + task_id: 'task-1', + session_id: 'session-1', + }); + }); + + it('rejects timestamp-shifted replay before correlation enrichment', async () => { + const base = { + id: '01KZ8V0GMXQ4ZCSERPRT2X2K6W', + source: 'vm-agent' as const, + level: 'error' as const, + message: 'prompt force-stopped', + nodeId: 'node-1', + workspaceId: 'workspace-1', + timestamp: 1_786_000_000_000, + }; + await persistErrorBatchStrict(d1, [base]); + + await expect( + persistErrorBatchStrict(d1, [ + { + ...base, + timestamp: base.timestamp + 3_600_000, + taskId: 'task-1', + sessionId: 'session-1', + }, + ]) + ).rejects.toThrow('different metadata'); + expect( + sqlite.prepare('SELECT timestamp, task_id, session_id FROM platform_errors').get() + ).toEqual({ timestamp: base.timestamp, task_id: null, session_id: null }); + }); + it('rejects an oversized strict batch instead of silently acknowledging a prefix', async () => { const inputs = ['Q', 'R'].map((suffix) => ({ id: `01KZ8V0GMXQ4ZCSERPRT2X2K6${suffix}`, diff --git a/apps/api/tests/unit/services/validation.test.ts b/apps/api/tests/unit/services/validation.test.ts index 94574f164a..6ac558bff0 100644 --- a/apps/api/tests/unit/services/validation.test.ts +++ b/apps/api/tests/unit/services/validation.test.ts @@ -1,6 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { CredentialValidator, validateAgentApiKeyCredentialWithProvider, validateHetznerCredentialWithProvider, validateOpenAICodexAuthJson, validateScalewayCredentialWithProvider } from '../../../src/services/validation'; +import { + CredentialValidator, + validateAgentApiKeyCredentialWithProvider, + validateHetznerCredentialWithProvider, + validateOpenAICodexAuthJson, + validateScalewayCredentialWithProvider, +} from '../../../src/services/validation'; describe('CredentialValidator', () => { describe('detectCredentialKind', () => { @@ -39,10 +45,7 @@ describe('CredentialValidator', () => { }); it('rejects obvious OAuth tokens in API key mode', () => { - const validation = CredentialValidator.validateCredential( - 'sk-ant-oat01-abcdef', - 'api-key' - ); + const validation = CredentialValidator.validateCredential('sk-ant-oat01-abcdef', 'api-key'); expect(validation.valid).toBe(false); expect(validation.error).toContain('OAuth token'); }); @@ -61,6 +64,37 @@ describe('CredentialValidator', () => { ); expect(validation.valid).toBe(true); }); + + it('rejects Claude OAuth tokens with invalid characters for Claude Code', () => { + const validation = CredentialValidator.validateCredential( + 'sk-ant-oat01-valid-prefix-but-bad-char! ', + 'oauth-token', + 'claude-code' + ); + expect(validation.valid).toBe(false); + expect(validation.error).toContain('invalid characters'); + }); + + it('rejects overlong Claude OAuth tokens for Claude Code', () => { + const validation = CredentialValidator.validateCredential( + `sk-ant-oat${'A'.repeat(8193)}`, + 'oauth-token', + 'claude-code' + ); + expect(validation.valid).toBe(false); + expect(validation.error).toContain('too long'); + }); + + it('honors a configured Claude OAuth token length limit', () => { + const validation = CredentialValidator.validateCredential( + 'sk-ant-oat01-abcdef', + 'oauth-token', + 'claude-code', + 12 + ); + expect(validation.valid).toBe(false); + expect(validation.error).toContain('too long'); + }); }); describe('validateCredential for OpenAI Codex OAuth', () => { @@ -175,7 +209,11 @@ describe('CredentialValidator', () => { describe('getCredentialErrorMessage', () => { it('returns OpenAI-specific message for codex unauthorized', () => { - const msg = CredentialValidator.getCredentialErrorMessage('oauth-token', '401 unauthorized', 'openai-codex'); + const msg = CredentialValidator.getCredentialErrorMessage( + 'oauth-token', + '401 unauthorized', + 'openai-codex' + ); expect(msg).toContain('OpenAI'); expect(msg).toContain('codex login'); }); @@ -236,7 +274,7 @@ describe('validateOpenAICodexAuthJson', () => { expect(result.valid).toBe(true); expect(result.metadata?.isExpired).toBe(true); expect(result.warnings).toBeDefined(); - expect(result.warnings!.some(w => w.includes('expired'))).toBe(true); + expect(result.warnings!.some((w) => w.includes('expired'))).toBe(true); }); it('accepts auth.json with missing id_token (warns)', () => { @@ -251,7 +289,7 @@ describe('validateOpenAICodexAuthJson', () => { const result = validateOpenAICodexAuthJson(json); expect(result.valid).toBe(true); expect(result.warnings).toBeDefined(); - expect(result.warnings!.some(w => w.includes('id_token'))).toBe(true); + expect(result.warnings!.some((w) => w.includes('id_token'))).toBe(true); }); it('rejects non-JSON input', () => { @@ -277,7 +315,6 @@ describe('validateOpenAICodexAuthJson', () => { }); }); - describe('provider credential validation helpers', () => { afterEach(() => { vi.unstubAllGlobals(); @@ -286,7 +323,9 @@ describe('provider credential validation helpers', () => { it('validates Hetzner credentials against the servers endpoint', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))); - const result = await validateHetznerCredentialWithProvider('hetzner-token', { timeoutMs: 1000 }); + const result = await validateHetznerCredentialWithProvider('hetzner-token', { + timeoutMs: 1000, + }); expect(result.valid).toBe(true); expect(globalThis.fetch).toHaveBeenCalledWith( @@ -313,7 +352,9 @@ describe('provider credential validation helpers', () => { it('validates Scaleway credentials against a project-scoped servers endpoint', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))); - const result = await validateScalewayCredentialWithProvider('scw-secret', 'project-id', { timeoutMs: 1000 }); + const result = await validateScalewayCredentialWithProvider('scw-secret', 'project-id', { + timeoutMs: 1000, + }); expect(result.valid).toBe(true); expect(globalThis.fetch).toHaveBeenCalledWith( @@ -327,7 +368,11 @@ describe('provider credential validation helpers', () => { it('validates Anthropic agent API keys with x-api-key', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))); - const result = await validateAgentApiKeyCredentialWithProvider('claude-code', 'sk-ant-api03-valid', { timeoutMs: 1000 }); + const result = await validateAgentApiKeyCredentialWithProvider( + 'claude-code', + 'sk-ant-api03-valid', + { timeoutMs: 1000 } + ); expect(result.valid).toBe(true); expect(globalThis.fetch).toHaveBeenCalledWith( @@ -344,7 +389,9 @@ describe('provider credential validation helpers', () => { it('validates OpenAI agent API keys with bearer auth', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('{}', { status: 200 }))); - const result = await validateAgentApiKeyCredentialWithProvider('openai-codex', 'openai-key', { timeoutMs: 1000 }); + const result = await validateAgentApiKeyCredentialWithProvider('openai-codex', 'openai-key', { + timeoutMs: 1000, + }); expect(result.valid).toBe(true); expect(globalThis.fetch).toHaveBeenCalledWith( diff --git a/apps/api/tests/unit/services/vm-error-correlation.test.ts b/apps/api/tests/unit/services/vm-error-correlation.test.ts new file mode 100644 index 0000000000..964da57224 --- /dev/null +++ b/apps/api/tests/unit/services/vm-error-correlation.test.ts @@ -0,0 +1,168 @@ +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { correlateVMErrorsToTasks } from '../../../src/services/vm-error-correlation'; +import { createSqliteD1 } from '../../helpers/sqlite-d1'; + +describe('VM error task correlation', () => { + let sqlite: Database.Database; + let d1: D1Database; + + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.exec(` + CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + node_id TEXT, + project_id TEXT, + chat_session_id TEXT + ); + CREATE TABLE tasks ( + id TEXT PRIMARY KEY, + workspace_id TEXT, + project_id TEXT, + chat_session_id TEXT, + created_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT + ); + `); + d1 = createSqliteD1(sqlite); + }); + + afterEach(() => { + sqlite.close(); + }); + + function insertWorkspace(id: string, nodeId: string = 'node-1', sessionId: string = 'session-1') { + sqlite + .prepare( + `INSERT INTO workspaces (id, node_id, project_id, chat_session_id) + VALUES (?, ?, 'project-1', ?)` + ) + .run(id, nodeId, sessionId); + } + + function insertTask( + id: string, + workspaceId: string, + sessionId: string = 'session-1', + startedAt: string = '2026-08-09T06:00:00.000Z', + completedAt: string | null = null + ) { + sqlite + .prepare( + `INSERT INTO tasks + (id, workspace_id, project_id, chat_session_id, created_at, started_at, completed_at) + VALUES (?, ?, 'project-1', ?, ?, ?, ?)` + ) + .run(id, workspaceId, sessionId, startedAt, startedAt, completedAt); + } + + it('returns the canonical task/session when node, workspace, project, and session agree', async () => { + insertWorkspace('workspace-1'); + insertTask('task-1', 'workspace-1'); + + await expect( + correlateVMErrorsToTasks(d1, 'node-1', [ + { workspaceId: 'workspace-1', timestamp: Date.parse('2026-08-09T06:47:29.000Z') }, + ]) + ).resolves.toEqual([ + { + correlation: { taskId: 'task-1', sessionId: 'session-1' }, + rejectionReason: null, + }, + ]); + }); + + it('fails closed when corrupted state produces more than one candidate', async () => { + insertWorkspace('workspace-1'); + insertTask('task-1', 'workspace-1'); + insertTask('task-2', 'workspace-1'); + + await expect( + correlateVMErrorsToTasks(d1, 'node-1', [ + { workspaceId: 'workspace-1', timestamp: Date.parse('2026-08-09T06:47:29.000Z') }, + ]) + ).resolves.toEqual([{ correlation: null, rejectionReason: 'ambiguous_task_binding' }]); + }); + + it('does not attach stale evidence outside the task lifetime', async () => { + insertWorkspace('workspace-1'); + insertTask('task-1', 'workspace-1', 'session-1', '2026-08-09 06:00:00', '2026-08-09 06:30:00'); + + await expect( + correlateVMErrorsToTasks(d1, 'node-1', [ + { workspaceId: 'workspace-1', timestamp: Date.parse('2026-08-09T06:47:29.000Z') }, + ]) + ).resolves.toEqual([{ correlation: null, rejectionReason: 'outside_task_lifetime' }]); + }); + + it('reports node and session identity rejection reasons', async () => { + insertWorkspace('workspace-other-node', 'node-2'); + insertTask('task-other-node', 'workspace-other-node'); + insertWorkspace('workspace-session-mismatch'); + insertTask('task-session-mismatch', 'workspace-session-mismatch', 'session-2'); + + await expect( + correlateVMErrorsToTasks(d1, 'node-1', [ + { + workspaceId: 'workspace-other-node', + timestamp: Date.parse('2026-08-09T06:47:29.000Z'), + }, + { + workspaceId: 'workspace-session-mismatch', + timestamp: Date.parse('2026-08-09T06:47:29.000Z'), + }, + ]) + ).resolves.toEqual([ + { correlation: null, rejectionReason: 'node_mismatch' }, + { correlation: null, rejectionReason: 'canonical_task_missing' }, + ]); + }); + + it('rejects missing or invalid producer timestamps', async () => { + insertWorkspace('workspace-1'); + insertTask('task-1', 'workspace-1'); + + await expect( + correlateVMErrorsToTasks(d1, 'node-1', [ + { workspaceId: 'workspace-1', timestamp: null }, + { workspaceId: 'workspace-1', timestamp: Number.NaN }, + ]) + ).resolves.toEqual([ + { correlation: null, rejectionReason: 'invalid_incident_timestamp' }, + { correlation: null, rejectionReason: 'invalid_incident_timestamp' }, + ]); + }); + + it('chunks operator-sized batches below the D1 bind-parameter ceiling', async () => { + insertWorkspace('workspace-160'); + insertTask('task-160', 'workspace-160'); + const requests = Array.from({ length: 161 }, (_, index) => ({ + workspaceId: `workspace-${index}`, + timestamp: Date.parse('2026-08-09T06:47:29.000Z'), + })); + let prepareCalls = 0; + const trackingD1 = new Proxy(d1, { + get(target, property, receiver) { + if (property === 'prepare') { + return (query: string) => { + prepareCalls += 1; + return target.prepare(query); + }; + } + return Reflect.get(target, property, receiver); + }, + }); + + const result = await correlateVMErrorsToTasks(trackingD1, 'node-1', requests); + + expect(prepareCalls).toBe(2); + expect(result[160]).toEqual({ + correlation: { taskId: 'task-160', sessionId: 'session-1' }, + rejectionReason: null, + }); + expect(result.filter((entry) => entry.correlation)).toHaveLength(1); + }); +}); diff --git a/apps/api/tests/workers/agent-credential-setup-sessions-routes.test.ts b/apps/api/tests/workers/agent-credential-setup-sessions-routes.test.ts index 6735314125..e3962b1d55 100644 --- a/apps/api/tests/workers/agent-credential-setup-sessions-routes.test.ts +++ b/apps/api/tests/workers/agent-credential-setup-sessions-routes.test.ts @@ -33,6 +33,7 @@ describe('agent-credential-setup-sessions REST routes reject unauthenticated req { method: 'GET', path: '/api/agent-credential-setup-sessions/config' }, { method: 'POST', path: '/api/agent-credential-setup-sessions' }, { method: 'GET', path: '/api/agent-credential-setup-sessions/fake-id' }, + { method: 'POST', path: '/api/agent-credential-setup-sessions/fake-id/verification-code' }, { method: 'POST', path: '/api/agent-credential-setup-sessions/fake-id/cancel' }, ]; diff --git a/apps/api/tests/workers/mcp-trigger-tools.test.ts b/apps/api/tests/workers/mcp-trigger-tools.test.ts index 16853e4653..a5d38aa797 100644 --- a/apps/api/tests/workers/mcp-trigger-tools.test.ts +++ b/apps/api/tests/workers/mcp-trigger-tools.test.ts @@ -136,7 +136,7 @@ async function countRows(table: 'github_trigger_configs' | 'trigger_executions' } describe('MCP trigger management tools', () => { - it('lists update_trigger and delete_trigger in tools/list', async () => { + it('lists trigger management tools in tools/list', async () => { const { userId, projectId } = await seedProjectGraph('list'); const token = `${TEST_PREFIX}-list-token`; await storeToken(token, projectId, userId); @@ -144,11 +144,58 @@ describe('MCP trigger management tools', () => { const response = await listTools(token); const toolNames = response.result?.tools?.map((tool) => tool.name) ?? []; + expect(toolNames).toContain('list_triggers'); expect(toolNames).toContain('create_trigger'); expect(toolNames).toContain('update_trigger'); expect(toolNames).toContain('delete_trigger'); }); + it('dispatches list_triggers with project scoping and filters', async () => { + const caller = await seedProjectGraph('list-caller'); + const other = await seedProjectGraph('list-other'); + const token = `${TEST_PREFIX}-list-call-token`; + await storeToken(token, caller.projectId, caller.userId); + await seedTrigger(`${TEST_PREFIX}-caller-active`, caller.projectId, caller.userId, { + name: 'Caller active trigger', + status: 'active', + sourceType: 'cron', + }); + await seedTrigger(`${TEST_PREFIX}-caller-paused`, caller.projectId, caller.userId, { + name: 'Caller paused trigger', + status: 'paused', + sourceType: 'webhook', + }); + await seedTrigger(`${TEST_PREFIX}-caller-paused-cron`, caller.projectId, caller.userId, { + name: 'Caller paused cron trigger', + status: 'paused', + sourceType: 'cron', + }); + await seedTrigger(`${TEST_PREFIX}-caller-active-webhook`, caller.projectId, caller.userId, { + name: 'Caller active webhook trigger', + status: 'active', + sourceType: 'webhook', + }); + await seedTrigger(`${TEST_PREFIX}-foreign-paused`, other.projectId, other.userId, { + name: 'Foreign paused trigger', + status: 'paused', + sourceType: 'webhook', + }); + + const response = await callMcpTool(token, 'list_triggers', { + status: 'paused', + sourceType: 'webhook', + }); + const payload = parseToolContent(response) as { triggers: Array> }; + + expect(payload.triggers).toHaveLength(1); + expect(payload.triggers[0]).toMatchObject({ + id: `${TEST_PREFIX}-caller-paused`, + name: 'Caller paused trigger', + status: 'paused', + sourceType: 'webhook', + }); + }); + it('updates a trigger and recomputes next_fire_at', async () => { const { userId, projectId } = await seedProjectGraph('update'); const token = `${TEST_PREFIX}-update-token`; diff --git a/apps/api/tests/workers/scheduled-node-cleanup.test.ts b/apps/api/tests/workers/scheduled-node-cleanup.test.ts index 7eb59d733a..09429fa7f2 100644 --- a/apps/api/tests/workers/scheduled-node-cleanup.test.ts +++ b/apps/api/tests/workers/scheduled-node-cleanup.test.ts @@ -428,6 +428,41 @@ describe('runNodeCleanupSweep — vertical slice', () => { }); describe('stale warm node cleanup (Phase 1)', () => { + it('releases a real D1 cleanup claim when container teardown fails', async () => { + await seedBaseData(); + const nodeId = 'node-nc-stale-warm-container-failure'; + const warmSince = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const destroyForUser = vi.fn().mockRejectedValue(new Error('container teardown unavailable')); + + await seedNode(nodeId, USER_ID, { status: 'running', warmSince }); + await env.DATABASE.prepare(`UPDATE nodes SET runtime = 'cf-container' WHERE id = ?`) + .bind(nodeId) + .run(); + + const testEnv = { + ...env, + CF_CONTAINER_ENABLED: 'true', + VM_AGENT_CONTAINER: { + idFromName: (id: string) => id, + get: () => ({ destroyForUser }), + }, + NODE_WARM_GRACE_PERIOD_MS: '1000', + } as unknown as Env; + + const result = await runNodeCleanupSweep(testEnv); + const node = await env.DATABASE.prepare( + 'SELECT status, cleanup_backoff_until FROM nodes WHERE id = ?' + ) + .bind(nodeId) + .first<{ status: string; cleanup_backoff_until: string | null }>(); + + expect(destroyForUser).toHaveBeenCalledTimes(1); + expect(result.staleDestroyed).toBe(0); + expect(result.errors).toBeGreaterThanOrEqual(1); + expect(node?.status).toBe('running'); + expect(node?.cleanup_backoff_until).not.toBeNull(); + }); + it('attempts to destroy stale warm node and counts error (no Hetzner in test)', async () => { await seedBaseData(); const nodeId = 'node-nc-stale-warm'; @@ -446,8 +481,7 @@ describe('runNodeCleanupSweep — vertical slice', () => { const result = await runNodeCleanupSweep(testEnv); - // deleteNodeResources will fail (no Hetzner credentials in test env) - // but the error should be caught and counted + // A record with no provider instance is safe to finalize without credentials. expect(result.staleDestroyed + result.errors).toBeGreaterThanOrEqual(1); }); diff --git a/apps/api/tests/workers/scheduler-lifecycle-races.test.ts b/apps/api/tests/workers/scheduler-lifecycle-races.test.ts new file mode 100644 index 0000000000..7c4803108d --- /dev/null +++ b/apps/api/tests/workers/scheduler-lifecycle-races.test.ts @@ -0,0 +1,250 @@ +/** + * Real workerd/D1 race slices for scheduler lifecycle ownership. + * + * These tests deliberately race the production placement and cleanup CAS + * statements. They do not mock D1, and they require no provider credentials. + */ +import { env, runInDurableObject } from 'cloudflare:test'; +import { beforeAll, describe, expect, it } from 'vitest'; + +import type { + StartTaskInput, + TaskRunner, + TaskRunnerState, +} from '../../src/durable-objects/task-runner'; +import type { Env } from '../../src/env'; +import { claimNodeForCleanup } from '../../src/scheduled/node-cleanup/shared'; +import { + reserveWorkspacePlacement, + type WorkspacePlacementInput, +} from '../../src/services/workspace-placement'; +import { + seedInstallation, + seedNode, + seedProject, + seedTask, + seedUser, + seedWorkspace, +} from './helpers/seed-d1'; + +const USER_ID = 'user-scheduler-races'; +const INSTALLATION_ID = 'installation-scheduler-races'; +const PROJECT_ID = 'project-scheduler-races'; +const RACE_REPETITIONS = 24; + +beforeAll(async () => { + await seedUser(USER_ID); + await seedInstallation(INSTALLATION_ID, USER_ID); + await seedProject(PROJECT_ID, USER_ID, INSTALLATION_ID); +}); + +function placement( + workspaceId: string, + nodeId: string, + createdAt = new Date().toISOString() +): WorkspacePlacementInput { + return { + id: workspaceId, + nodeId, + projectId: PROJECT_ID, + userId: USER_ID, + installationId: INSTALLATION_ID, + name: `Workspace ${workspaceId}`, + displayName: `Workspace ${workspaceId}`, + normalizedDisplayName: workspaceId, + repository: 'test-org/scheduler-races', + branch: 'main', + vmSize: 'medium', + vmLocation: 'nbg1', + workspaceProfile: 'full', + devcontainerConfigName: null, + agentProfileHint: null, + createdAt, + }; +} + +async function nodeState(nodeId: string): Promise<{ status: string; active: number }> { + const node = await env.DATABASE.prepare('SELECT status FROM nodes WHERE id = ?') + .bind(nodeId) + .first<{ status: string }>(); + const workspaces = await env.DATABASE.prepare( + `SELECT COUNT(*) AS active + FROM workspaces + WHERE node_id = ? AND status IN ('running', 'creating', 'recovery')` + ) + .bind(nodeId) + .first<{ active: number }>(); + return { status: node?.status ?? 'missing', active: workspaces?.active ?? 0 }; +} + +function taskRunnerInput(taskId: string): StartTaskInput { + return { + taskId, + projectId: PROJECT_ID, + userId: USER_ID, + config: { + vmSize: 'medium', + vmLocation: 'nbg1', + branch: 'main', + preferredNodeId: null, + userName: 'Scheduler Race Test', + userEmail: 'scheduler-race@example.com', + githubId: null, + taskTitle: taskId, + taskDescription: null, + repository: 'test-org/scheduler-races', + installationId: INSTALLATION_ID, + outputBranch: null, + defaultBranch: 'main', + projectDefaultVmSize: null, + chatSessionId: null, + agentType: 'openai-codex', + workspaceProfile: 'full', + devcontainerConfigName: null, + cloudProvider: null, + credentialAttributionUserId: USER_ID, + credentialAttributionProjectId: null, + credentialAttributionSource: 'user', + taskMode: 'task', + model: null, + effort: null, + permissionMode: null, + opencodeProvider: null, + opencodeBaseUrl: null, + systemPromptAppend: null, + agentProfileHint: null, + attachments: null, + projectScaling: { maxWorkspacesPerNode: 1 }, + }, + }; +} + +describe('scheduler lifecycle D1 races', () => { + it('allows only one concurrent placement to consume the final node slot', async () => { + for (let iteration = 0; iteration < RACE_REPETITIONS; iteration += 1) { + const nodeId = `node-scheduler-final-slot-${iteration}`; + await seedNode(nodeId, USER_ID); + + const placements = [ + () => + reserveWorkspacePlacement( + env.DATABASE, + placement(`workspace-scheduler-final-slot-${iteration}-a`, nodeId), + 1 + ), + () => + reserveWorkspacePlacement( + env.DATABASE, + placement(`workspace-scheduler-final-slot-${iteration}-b`, nodeId), + 1 + ), + ]; + if (iteration % 2 === 1) placements.reverse(); + const outcomes = await Promise.all(placements.map((reserve) => reserve())); + + expect(outcomes.filter(Boolean)).toHaveLength(1); + expect(await nodeState(nodeId)).toEqual({ status: 'running', active: 1 }); + } + }); + + it('serializes cleanup and placement so both cannot own the node', async () => { + for (let iteration = 0; iteration < RACE_REPETITIONS; iteration += 1) { + const nodeId = `node-scheduler-cleanup-placement-${iteration}`; + const old = new Date(Date.now() - 60_000).toISOString(); + await seedNode(nodeId, USER_ID, { createdAt: old, updatedAt: old }); + + const claimCleanup = () => + claimNodeForCleanup( + env as unknown as Env, + { id: nodeId, user_id: USER_ID, status: 'running' }, + new Date().toISOString() + ); + const reservePlacement = () => + reserveWorkspacePlacement( + env.DATABASE, + placement(`workspace-scheduler-cleanup-placement-${iteration}`, nodeId), + 1 + ); + let cleanupClaimed: boolean; + let placementReserved: boolean; + if (iteration % 2 === 0) { + [cleanupClaimed, placementReserved] = await Promise.all([ + claimCleanup(), + reservePlacement(), + ]); + } else { + [placementReserved, cleanupClaimed] = await Promise.all([ + reservePlacement(), + claimCleanup(), + ]); + } + const state = await nodeState(nodeId); + + expect(Number(cleanupClaimed) + Number(placementReserved)).toBe(1); + expect(state).toEqual( + cleanupClaimed ? { status: 'destroying', active: 0 } : { status: 'running', active: 1 } + ); + } + }); + + it('keeps an active provisioning task claim out of cleanup', async () => { + const nodeId = 'node-scheduler-provisioning-claim'; + const taskId = 'task-scheduler-provisioning-claim'; + await seedNode(nodeId, USER_ID); + await seedTask(taskId, PROJECT_ID, USER_ID, { + status: 'queued', + autoProvisionedNodeId: nodeId, + executionStep: 'node_provisioning', + }); + + const claimed = await claimNodeForCleanup( + env as unknown as Env, + { id: nodeId, user_id: USER_ID, status: 'running' }, + new Date().toISOString() + ); + + expect(claimed).toBe(false); + expect(await nodeState(nodeId)).toEqual({ status: 'running', active: 0 }); + }); + + it('makes the real TaskRunner reselect when its advisory node slot was consumed', async () => { + const nodeId = 'node-scheduler-task-runner-reselect'; + const taskId = 'task-scheduler-task-runner-reselect'; + await seedNode(nodeId, USER_ID); + await seedWorkspace('workspace-scheduler-existing-occupant', nodeId, USER_ID, { + projectId: PROJECT_ID, + status: 'running', + }); + await seedTask(taskId, PROJECT_ID, USER_ID, { + status: 'queued', + executionStep: 'workspace_creation', + }); + + const stub = env.TASK_RUNNER.get( + env.TASK_RUNNER.idFromName(taskId) + ) as DurableObjectStub; + await runInDurableObject(stub, async (instance) => { + await instance.start(taskRunnerInput(taskId)); + await instance.ctx.storage.deleteAlarm(); + const state = await instance.ctx.storage.get('state'); + if (!state) throw new Error('TaskRunner state was not initialized'); + state.currentStep = 'workspace_creation'; + state.stepResults.nodeId = nodeId; + await instance.ctx.storage.put('state', state); + await instance.alarm(); + }); + + const status = await stub.getStatus(); + const task = await env.DATABASE.prepare('SELECT status, workspace_id FROM tasks WHERE id = ?') + .bind(taskId) + .first<{ status: string; workspace_id: string | null }>(); + + expect(status).toMatchObject({ + currentStep: 'node_selection', + stepResults: { nodeId: null, workspaceId: null }, + completed: false, + }); + expect(task).toEqual({ status: 'queued', workspace_id: null }); + expect(await nodeState(nodeId)).toEqual({ status: 'running', active: 1 }); + }); +}); diff --git a/apps/web/.env.example b/apps/web/.env.example index 9cf99ad93f..d079958f1b 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -40,6 +40,18 @@ VITE_CMD_PALETTE_CHAT_FETCH_LIMIT=20 # Maximum number of results shown per category in the command palette (default: 10) VITE_CMD_PALETTE_MAX_RESULTS_PER_CATEGORY=10 +# Project query cache, refresh, and intent-prefetch behavior +# Maximum number of projects loaded into shared list caches (default: 50) +# VITE_PROJECT_LIST_LIMIT=50 +# Background refresh cadence for project-list pages; 0 disables polling (default: 30s) +# VITE_PROJECT_POLL_INTERVAL_MS=30000 +# Background refresh cadence for the app-shell project list; 0 disables polling (default: 60s) +# VITE_SIDEBAR_PROJECT_POLL_INTERVAL_MS=60000 +# Mouse-hover dwell before project detail prefetch; focus/touch stay immediate (default: 120ms) +# VITE_PROJECT_PREFETCH_DELAY_MS=120 +# Delay before background query activity is shown and announced (default: 150ms) +# VITE_BACKGROUND_FETCH_DELAY_MS=150 + # Maximum event pages fetched for one durable diagnosis timeline (default: 100) # VITE_DEBUG_DIAGNOSIS_EVENT_MAX_PAGES=100 diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index b25bd90d5f..f27bdfbdc8 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router'; import { AppShell } from './components/AppShell'; import { AuthProvider, useAuth } from './components/AuthProvider'; +import { BackgroundFetchIndicator } from './components/BackgroundFetchIndicator'; import { ErrorBoundary } from './components/ErrorBoundary'; import { PageViewTracker } from './components/PageViewTracker'; import { ProtectedRoute } from './components/ProtectedRoute'; @@ -126,6 +127,7 @@ export default function App() { + diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index ed50f93751..0a9fb5bc29 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -1,5 +1,13 @@ import { Menu, Monitor, Search, Server, Shield } from 'lucide-react'; -import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; import { Link, Outlet, useLocation, useNavigate } from 'react-router'; import { useGlobalCommandPalette } from '../hooks/useGlobalCommandPalette'; @@ -14,6 +22,7 @@ import { nextFocusMode, } from '../lib/focus-mode'; import { isMacPlatform } from '../lib/keyboard-shortcuts'; +import { PROJECT_LIST_LIMIT, SIDEBAR_PROJECT_POLL_INTERVAL_MS } from '../lib/project-query-config'; import { useAuth } from './AuthProvider'; import { CredentialHealthNavItem } from './CredentialHealthNavItem'; import { FocusModeToggle } from './FocusModeToggle'; @@ -64,9 +73,14 @@ export function AppShell({ children }: AppShellProps) { const [showGlobalNav, setShowGlobalNav] = useState(false); const [focusModeState, setFocusModeState] = useState('default'); const commandPalette = useGlobalCommandPalette(); - const { projects: sidebarProjects, loading: sidebarProjectsLoading } = useProjectList({ - limit: 50, - pollInterval: 60000, + const { + projects: sidebarProjects, + loading: sidebarProjectsLoading, + error: sidebarProjectsError, + } = useProjectList({ + queryScope: user?.id ?? '', + limit: PROJECT_LIST_LIMIT, + pollInterval: SIDEBAR_PROJECT_POLL_INTERVAL_MS, }); const setProjectName = useCallback((name: string | undefined) => { @@ -118,12 +132,7 @@ export function AppShell({ children }: AppShellProps) { if (e.key.toLowerCase() !== 'f' || e.metaKey || e.ctrlKey || e.altKey) return; const target = e.target as HTMLElement | null; const tag = target?.tagName; - if ( - tag === 'INPUT' || - tag === 'TEXTAREA' || - tag === 'SELECT' || - target?.isContentEditable - ) + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) return; e.preventDefault(); cycleFocusMode(); @@ -180,7 +189,7 @@ export function AppShell({ children }: AppShellProps) { navigate(path); setDrawerOpen(false); }, - [navigate], + [navigate] ); const mobileProjectListSection = useMemo( @@ -188,12 +197,21 @@ export function AppShell({ children }: AppShellProps) { ), - [sidebarProjects, sidebarProjectsLoading, projectId, handleProjectNavigate], + [ + sidebarProjects, + sidebarProjectsLoading, + sidebarProjectsError, + projectId, + handleProjectNavigate, + user?.id, + ] ); const desktopProjectListSection = useMemo( @@ -201,17 +219,24 @@ export function AppShell({ children }: AppShellProps) { ), - [sidebarProjects, sidebarProjectsLoading, projectId, handleProjectNavigate], + [ + sidebarProjects, + sidebarProjectsLoading, + sidebarProjectsError, + projectId, + handleProjectNavigate, + user?.id, + ] ); - const projectHealthElement = projectId ? ( - - ) : null; + const projectHealthElement = projectId ? : null; const mobileProjectHealthElement = projectId ? ( @@ -239,11 +264,7 @@ export function AppShell({ children }: AppShellProps) { }; const avatarElement = user?.image ? ( - {user.name + {user.name ) : (
{(user?.name || user?.email || 'U').charAt(0).toUpperCase()} @@ -256,216 +277,256 @@ export function AppShell({ children }: AppShellProps) { const shellContext = useMemo( () => ({ setProjectName, focusMode, setFocusMode, cycleFocusMode }), - [setProjectName, focusMode, setFocusMode, cycleFocusMode], + [setProjectName, focusMode, setFocusMode, cycleFocusMode] ); if (isMobile) { return ( - - -
- -
- {/* Title on the left */} - - SAM - - {/* Search + Notifications + Hamburger on the right */} -
- - - - + + + +
+
+ +
- - + {children ?? } +
+ +
+ +
+ + {drawerOpen && user && ( + setDrawerOpen(false)} + user={{ name: user.name, email: user.email, image: user.image }} + navItems={mobileNavItems} + globalNavItems={projectId ? mobileGlobalNavItems : undefined} + currentPath={location.pathname} + onNavigate={(path) => { + navigate(path); + setDrawerOpen(false); + }} + onSignOut={handleSignOut} + projectName={projectId ? projectName || 'Project' : undefined} + infraSection={mobileInfraSection} + projectListSection={mobileProjectListSection} + projectHealthElement={mobileProjectHealthElement} + showGlobalNav={showGlobalNav} + onToggleGlobalNav={projectId ? handleToggleGlobalNav : undefined} + /> + )} + + {commandPalette.isOpen && }
- - -
- {children ?? } -
- - - - {drawerOpen && user && ( - setDrawerOpen(false)} - user={{ name: user.name, email: user.email, image: user.image }} - navItems={mobileNavItems} - globalNavItems={projectId ? mobileGlobalNavItems : undefined} - currentPath={location.pathname} - onNavigate={(path) => { navigate(path); setDrawerOpen(false); }} - onSignOut={handleSignOut} - projectName={projectId ? (projectName || 'Project') : undefined} - infraSection={mobileInfraSection} - projectListSection={mobileProjectListSection} - projectHealthElement={mobileProjectHealthElement} - showGlobalNav={showGlobalNav} - onToggleGlobalNav={projectId ? handleToggleGlobalNav : undefined} - /> - )} - - {commandPalette.isOpen && ( - - )} -
- + ); } return ( - - -
- - {/* Announce Focus Mode changes to assistive tech (mode is cycled via the - "F" key or the toggle, so screen readers need a live region). */} -
- {focusMode === 'default' - ? 'Default view' - : focusMode === 'focus' - ? 'Focus mode: sidebars collapsed' - : 'Zen mode: sidebars hidden'} -
- {focusMode === 'zen' ? ( - setFocusMode('default')} - gridRow="1" + + +
- - - ) : ( - )} - - )} - -
- {children ?? } -
-
- -
+
+ {children ?? } +
+ +
+ +
- {commandPalette.isOpen && ( - - )} -
-
+ {commandPalette.isOpen && } +
+
); } diff --git a/apps/web/src/components/AuthProvider.tsx b/apps/web/src/components/AuthProvider.tsx index b8072991de..fbbeb63918 100644 --- a/apps/web/src/components/AuthProvider.tsx +++ b/apps/web/src/components/AuthProvider.tsx @@ -1,10 +1,24 @@ import type { UserRole, UserStatus } from '@simple-agent-manager/shared'; -import { createContext, type ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { + createContext, + type ReactNode, + useContext, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { setUserId } from '../lib/analytics'; import { GITHUB_REAUTH_REQUIRED_EVENT } from '../lib/api/client'; import { signOut, useSession } from '../lib/auth'; -import { buildLibraryCacheNamespace, clearLegacyLibraryCache, clearLibraryCache } from '../lib/library-cache'; +import { + buildLibraryCacheNamespace, + clearLegacyLibraryCache, + clearLibraryCache, +} from '../lib/library-cache'; +import { queryClient } from '../lib/query-client'; import { broadcastAuthRevocation, cleanupTerminalSecrets, @@ -53,7 +67,9 @@ export function AuthProvider({ children }: AuthProviderProps) { const { data: session, isPending, error, isRefetching } = useSession(); const lastGoodSessionRef = useRef(null); const [githubReauthMessage, setGitHubReauthMessage] = useState(null); - const previousCacheNamespaceRef = useRef(undefined); + const [activeCacheNamespace, setActiveCacheNamespace] = useState( + undefined + ); // Cache every successful session if (session?.user) { @@ -65,12 +81,11 @@ export function AuthProvider({ children }: AuthProviderProps) { } // Use cached session when a refetch error wipes the current one - const effectiveSession = - session?.user - ? session - : error && lastGoodSessionRef.current - ? lastGoodSessionRef.current - : session; + const effectiveSession = session?.user + ? session + : error && lastGoodSessionRef.current + ? lastGoodSessionRef.current + : session; const user = effectiveSession?.user ?? null; const sessionUser = user as (Record & NonNullable) | null; @@ -88,28 +103,32 @@ export function AuthProvider({ children }: AuthProviderProps) { return () => teardownAuthBroadcastListener(); }, []); - useEffect(() => { - if (isPending) return; + const nextCacheNamespace = buildLibraryCacheNamespace(enrichedUser?.id); + const canResolveCacheNamespace = !isPending || Boolean(enrichedUser?.id); + const isCacheNamespaceTransitioning = + activeCacheNamespace === undefined + ? canResolveCacheNamespace + : !canResolveCacheNamespace || activeCacheNamespace !== nextCacheNamespace; - const nextNamespace = buildLibraryCacheNamespace(enrichedUser?.id); - const previousNamespace = previousCacheNamespaceRef.current; + useLayoutEffect(() => { + if (!canResolveCacheNamespace || activeCacheNamespace === nextCacheNamespace) return; - if (previousNamespace === undefined) { - previousCacheNamespaceRef.current = nextNamespace; - if (nextNamespace) clearLegacyLibraryCache(); - if (nextNamespace) resetAuthRevoked(); - return; - } + const previousNamespace = activeCacheNamespace; + const isInitialNamespaceResolution = previousNamespace === undefined; - if (previousNamespace !== nextNamespace) { + if (!isInitialNamespaceResolution) { cleanupTerminalSecrets(); broadcastAuthRevocation(); if (previousNamespace) clearLibraryCache(previousNamespace); + } + + queryClient.clear(); + if (!isInitialNamespaceResolution || nextCacheNamespace) { clearLegacyLibraryCache(); - previousCacheNamespaceRef.current = nextNamespace; - if (nextNamespace) resetAuthRevoked(); } - }, [enrichedUser?.id, isPending]); + setActiveCacheNamespace(nextCacheNamespace); + if (nextCacheNamespace) resetAuthRevoked(); + }, [activeCacheNamespace, canResolveCacheNamespace, nextCacheNamespace]); // Handle bfcache restoration: when the page is restored from cache, // proactively clean up terminal state. cleanupTerminalSecrets() is @@ -133,7 +152,7 @@ export function AuthProvider({ children }: AuthProviderProps) { useEffect(() => { const onGitHubReauthRequired = (event: Event) => { - const detail = event instanceof CustomEvent ? event.detail as { message?: unknown } : null; + const detail = event instanceof CustomEvent ? (event.detail as { message?: unknown }) : null; setGitHubReauthMessage( typeof detail?.message === 'string' && detail.message.length > 0 ? detail.message @@ -168,9 +187,12 @@ export function AuthProvider({ children }: AuthProviderProps) { return ( - {children} + {isCacheNamespaceTransitioning ? null : children} {githubReauthMessage && ( -
+

GitHub sign-in required

{githubReauthMessage}

diff --git a/apps/web/src/components/BackgroundFetchIndicator.tsx b/apps/web/src/components/BackgroundFetchIndicator.tsx new file mode 100644 index 0000000000..9f08c80a54 --- /dev/null +++ b/apps/web/src/components/BackgroundFetchIndicator.tsx @@ -0,0 +1,38 @@ +import { useIsFetching } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; + +import { BACKGROUND_FETCH_DELAY_MS } from '../lib/project-query-config'; + +export function BackgroundFetchIndicator() { + const backgroundFetchCount = useIsFetching({ + predicate: (query) => query.state.data !== undefined, + }); + const isRefreshing = backgroundFetchCount > 0; + const [isVisible, setIsVisible] = useState(false); + + useEffect(() => { + if (!isRefreshing) { + setIsVisible(false); + return; + } + + const timer = window.setTimeout(() => setIsVisible(true), BACKGROUND_FETCH_DELAY_MS); + return () => window.clearTimeout(timer); + }, [isRefreshing]); + + return ( + <> + )} + {canSubmitClaudeCode && ( +
{ + event.preventDefault(); + void handleSubmitVerificationCode(); + }} + > +
+ + { + setVerificationCode(event.currentTarget.value); + setSubmitError(null); + }} + placeholder="code#state" + className="min-h-11 w-full rounded-md border border-border-default bg-bg-primary px-3 py-2 text-sm text-fg-primary outline-none focus:border-accent focus:ring-2 focus:ring-accent/20" + /> +

+ Approve access in the browser, then paste the code Claude shows you. It may + be two parts joined with a #. +

+
+ {submitError && {submitError}} + +
+ )}

{copy.manualReturnHint}

)} @@ -314,7 +447,7 @@ export function AgentCredentialConnectModal({ {message && phase === 'created' && {message}}
- {isActive && status !== 'saving' && ( + {isActive && status !== 'saving' && status !== 'exchanging' && ( diff --git a/apps/web/src/components/ProjectSummaryCard.tsx b/apps/web/src/components/ProjectSummaryCard.tsx index c150523ff5..66924ab935 100644 --- a/apps/web/src/components/ProjectSummaryCard.tsx +++ b/apps/web/src/components/ProjectSummaryCard.tsx @@ -2,8 +2,11 @@ import type { ProjectSummary } from '@simple-agent-manager/shared'; import { Card, DropdownMenu, type DropdownMenuItem, StatusBadge } from '@simple-agent-manager/ui'; import { useNavigate } from 'react-router'; +import { useProjectIntentPrefetch } from '../hooks/useProjectIntentPrefetch'; + interface ProjectSummaryCardProps { project: ProjectSummary; + queryScope: string; onDelete?: (id: string) => void; } @@ -20,8 +23,10 @@ function formatRelativeTime(dateStr: string | null): string { return new Date(dateStr).toLocaleDateString(); } -export function ProjectSummaryCard({ project, onDelete }: ProjectSummaryCardProps) { +export function ProjectSummaryCard({ project, queryScope, onDelete }: ProjectSummaryCardProps) { const navigate = useNavigate(); + const { cancelHoverPrefetch, prefetchProject, scheduleHoverPrefetch } = + useProjectIntentPrefetch(queryScope); const workspaceCount = project.activeWorkspaceCount ?? 0; const sessionCount = project.activeSessionCount ?? 0; @@ -58,6 +63,10 @@ export function ProjectSummaryCard({ project, onDelete }: ProjectSummaryCardProp className="cursor-pointer" role="button" tabIndex={0} + onMouseEnter={() => scheduleHoverPrefetch(project.id)} + onMouseLeave={cancelHoverPrefetch} + onFocus={() => prefetchProject(project.id)} + onTouchStart={() => prefetchProject(project.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); diff --git a/apps/web/src/components/SidebarProjectList.tsx b/apps/web/src/components/SidebarProjectList.tsx index 387e6b366c..757c48ab39 100644 --- a/apps/web/src/components/SidebarProjectList.tsx +++ b/apps/web/src/components/SidebarProjectList.tsx @@ -2,9 +2,10 @@ import type { ProjectSummary } from '@simple-agent-manager/shared'; import { ChevronDown, ChevronRight, Search, X } from 'lucide-react'; import { type ChangeEvent, useCallback, useMemo, useState } from 'react'; +import { useProjectIntentPrefetch } from '../hooks/useProjectIntentPrefetch'; + const FOCUS_RING = 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-focus-ring'; - /** Maximum projects visible before scrolling kicks in */ const DEFAULT_MAX_VISIBLE = 8; const MAX_VISIBLE = parseInt( @@ -35,8 +36,10 @@ function relativeTime(dateStr: string | null | undefined): string { interface SidebarProjectListProps { projects: ProjectSummary[]; loading: boolean; + error?: string | null; currentProjectId?: string; onNavigate: (path: string) => void; + queryScope?: string; /** Render variant: 'mobile' uses larger touch targets, 'desktop' uses compact sizing */ variant?: 'mobile' | 'desktop'; } @@ -44,10 +47,14 @@ interface SidebarProjectListProps { export function SidebarProjectList({ projects, loading, + error = null, currentProjectId, onNavigate, + queryScope = '', variant = 'mobile', }: SidebarProjectListProps) { + const { cancelHoverPrefetch, prefetchProject, scheduleHoverPrefetch } = + useProjectIntentPrefetch(queryScope); const [open, setOpen] = useState(true); const [filter, setFilter] = useState(''); @@ -69,7 +76,6 @@ export function SidebarProjectList({ const isMobile = variant === 'mobile'; const sectionId = 'sidebar-projects-panel'; - return (
{/* Section header */} @@ -128,6 +134,10 @@ export function SidebarProjectList({
Loading...
+ ) : error && projects.length === 0 ? ( +
+ Projects unavailable +
) : filtered.length === 0 ? (
{filter ? `No projects match "${filter}"` : 'No projects yet'} @@ -141,6 +151,10 @@ export function SidebarProjectList({
); } - diff --git a/apps/web/src/components/debug/FailureCard.tsx b/apps/web/src/components/debug/FailureCard.tsx index 010bebb603..688a664d4b 100644 --- a/apps/web/src/components/debug/FailureCard.tsx +++ b/apps/web/src/components/debug/FailureCard.tsx @@ -36,12 +36,12 @@ interface FailureCardProps { function ClassificationIcon({ code }: { code: string }) { const size = 14; - if (code === 'cancelled') return ; + if (code === 'cancelled' || code === 'input-expired') return ; return ; } function getClassificationStyle(classification: FailureClassification) { - if (classification.code === 'cancelled') { + if (!classification.diagnosable) { return { border: 'var(--sam-color-fg-muted)', bg: 'color-mix(in srgb, var(--sam-color-fg-muted) 6%, transparent)', @@ -128,17 +128,19 @@ export function FailureCard({ setTimeout(() => setCopyState('idle'), 2000); }, [taskEmbed, sessionId, workspaceId, nodeId, projectId, classification.code, events]); - const adminErrorsUrl = isSuperadmin - ? `/admin/errors?${[ - sessionId && `sessionId=${sessionId}`, - taskEmbed.id && `taskId=${taskEmbed.id}`, - ] - .filter(Boolean) - .join('&')}` - : null; + const adminErrorsUrl = + classification.diagnosable && isSuperadmin + ? `/admin/errors?${[ + sessionId && `sessionId=${sessionId}`, + taskEmbed.id && `taskId=${taskEmbed.id}`, + ] + .filter(Boolean) + .join('&')}` + : null; return (
)} - {classification.retryable && !recoverable && classification.code !== 'cancelled' && ( - + {classification.retryable && !recoverable && classification.diagnosable && ( + Retryable )}
-

+

{classification.explanation}

@@ -196,20 +205,26 @@ export function FailureCard({ Next step -

+

{classification.guidance}

- {/* Error message */} + {/* Failure reason */} {taskEmbed.errorMessage && (
- Error + + {classification.diagnosable ? 'Error' : 'Reason'} +
 
           {/* Actions */}
-          
- - - {adminErrorsUrl && ( - +
+ {copyState === 'copied' ? ( + <> + Copied + + ) : copyState === 'failed' ? ( + <> + Copy failed + + ) : ( + <> + Copy debug report + + )} + + + {adminErrorsUrl && ( + + + View in admin errors + + )} +
+ )} {/* Recoverable guidance */} {recoverable && (

- This session is still active. Send another message to retry — your workspace is preserved. + This session is still active. Send another message to retry — your workspace is + preserved.

)}
diff --git a/apps/web/src/components/project-message-view/index.tsx b/apps/web/src/components/project-message-view/index.tsx index e5de469b98..6b746cea11 100644 --- a/apps/web/src/components/project-message-view/index.tsx +++ b/apps/web/src/components/project-message-view/index.tsx @@ -13,7 +13,7 @@ import type { ToolCallContentItem, } from '@simple-agent-manager/acp-client'; import { mapToolCallContent, PlanModal } from '@simple-agent-manager/acp-client'; -import type { AgentProfile } from '@simple-agent-manager/shared'; +import { type AgentProfile,classifyFailure } from '@simple-agent-manager/shared'; import { Button, Spinner } from '@simple-agent-manager/ui'; import { ChevronDown } from 'lucide-react'; import { type FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -115,6 +115,15 @@ function FloatingHeader({ taskStatus !== 'cancelled' && taskStatus !== 'completed' ); + const failureClassification = lc.taskEmbed?.errorMessage + ? classifyFailure(lc.taskEmbed.errorMessage, lc.taskEmbed.executionStep ?? undefined) + : null; + const failureShellClassName = failureClassification?.diagnosable + ? "glass-chrome px-3 py-2 rounded-b-2xl relative after:content-[''] after:absolute after:bottom-0 after:left-[8%] after:right-[8%] after:h-[3px] after:bg-[radial-gradient(ellipse_at_center,rgba(239,68,68,0.55)_0%,transparent_70%)] after:blur-[2px] after:pointer-events-none after:z-10" + : 'glass-chrome px-3 py-2 rounded-b-2xl relative'; + const failureShellBoxShadow = failureClassification?.diagnosable + ? '0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(239, 68, 68, 0.08)' + : '0 4px 24px rgba(0, 0, 0, 0.4)'; return (
@@ -142,8 +151,9 @@ function FloatingHeader({ /> {lc.taskEmbed?.errorMessage && (
- ) : projects.length === 0 ? ( + ) : projectsError && projects.length === 0 ? null : projects.length === 0 ? ( {projects.map((project) => ( - + ))}
)} diff --git a/apps/web/src/pages/Project.tsx b/apps/web/src/pages/Project.tsx index 34f5c81eac..53ecc578ad 100644 --- a/apps/web/src/pages/Project.tsx +++ b/apps/web/src/pages/Project.tsx @@ -1,11 +1,17 @@ import type { GitHubInstallation, ProjectDetailResponse } from '@simple-agent-manager/shared'; import { Alert, PageLayout, Spinner } from '@simple-agent-manager/ui'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo } from 'react'; import { Outlet, useLocation, useParams } from 'react-router'; import { useAppShell } from '../components/AppShell'; +import { useAuth } from '../components/AuthProvider'; import { useIsMobile } from '../hooks/useIsMobile'; -import { getProject, listGitHubInstallations } from '../lib/api'; +import { + githubInstallationsQueryOptions, + projectDetailQueryOptions, + projectQueryKeys, +} from '../lib/query-options'; import { ProjectContext } from './ProjectContext'; export function Project() { @@ -13,49 +19,42 @@ export function Project() { const location = useLocation(); const isMobile = useIsMobile(); const { setProjectName } = useAppShell(); - - const [project, setProject] = useState(null); - const [installations, setInstallations] = useState([]); - const [projectLoading, setProjectLoading] = useState(true); - const [error, setError] = useState(null); + const { user } = useAuth(); + const queryScope = user?.id ?? ''; + const queryClient = useQueryClient(); + const projectQuery = useQuery({ + ...projectDetailQueryOptions(queryScope, projectId ?? ''), + enabled: Boolean(projectId && queryScope), + }); + const installationsQuery = useQuery({ + ...githubInstallationsQueryOptions(queryScope), + enabled: Boolean(queryScope), + }); + const project = (projectQuery.data ?? null) as ProjectDetailResponse | null; + const installations = useMemo( + () => (installationsQuery.data ?? []) as GitHubInstallation[], + [installationsQuery.data] + ); + const refetchProject = projectQuery.refetch; + const projectLoading = Boolean(projectId) && projectQuery.isPending && project === null; + const error = + project === null + ? projectQuery.error instanceof Error + ? projectQuery.error.message + : projectQuery.error + ? 'Failed to load project' + : null + : null; // Chat routes get a full-bleed layout (no PageLayout wrapper) const isChatRoute = /\/(chat|agent)(\/|$)/.test(location.pathname); - // Track whether we have successfully loaded data at least once for the - // current projectId. After the first load, reloads (e.g. after saving - // settings) skip the loading spinner so the existing Outlet tree stays - // mounted (stale-while-revalidate). - const hasLoadedForIdRef = useRef(null); - const loadProject = useCallback(async () => { - if (!projectId) return; - try { - setError(null); - // Only show the full-screen spinner on the very first load for this - // projectId. Subsequent reloads keep existing content visible. - if (hasLoadedForIdRef.current !== projectId) { - setProjectLoading(true); - } - setProject(await getProject(projectId)); - hasLoadedForIdRef.current = projectId; - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load project'); - hasLoadedForIdRef.current = projectId; - } finally { - setProjectLoading(false); - } - }, [projectId]); - - useEffect(() => { - void loadProject(); - }, [loadProject]); - - useEffect(() => { - void listGitHubInstallations() - .then((response) => setInstallations(response)) - .catch(() => setInstallations([])); - }, []); + await Promise.all([ + refetchProject(), + queryClient.invalidateQueries({ queryKey: projectQueryKeys.lists(queryScope) }), + ]); + }, [queryClient, queryScope, refetchProject]); // Push project name up to AppShell for sidebar display useEffect(() => { @@ -96,20 +95,21 @@ export function Project() { Loading project...
- ) : error ? ( -
- setError(null)}> - {error} - -
) : !project ? (
- Project not found. + {error ?? 'Project not found.'}
) : ( - - - + <> + {error && ( +
+ {error} +
+ )} + + + + )}
); @@ -129,14 +129,6 @@ export function Project() { : { padding: 'var(--sam-space-8) clamp(var(--sam-space-3), 3vw, var(--sam-space-4))' } } > - {error && ( -
- setError(null)}> - {error} - -
- )} - {projectLoading ? (
@@ -144,7 +136,7 @@ export function Project() {
) : !project ? (
- Project not found. + {error ?? 'Project not found.'}
) : ( /* diff --git a/apps/web/src/pages/Projects.tsx b/apps/web/src/pages/Projects.tsx index 3553588777..d19404a8e4 100644 --- a/apps/web/src/pages/Projects.tsx +++ b/apps/web/src/pages/Projects.tsx @@ -1,14 +1,20 @@ -import { Alert, Button, EmptyState, PageLayout, SkeletonCard, Spinner } from '@simple-agent-manager/ui'; +import { Alert, Button, EmptyState, PageLayout, SkeletonCard } from '@simple-agent-manager/ui'; import { useState } from 'react'; import { useNavigate } from 'react-router'; +import { useAuth } from '../components/AuthProvider'; import { ProjectSummaryCard } from '../components/ProjectSummaryCard'; import { useProjectList } from '../hooks/useProjectData'; import { deleteProject } from '../lib/api'; +import { PROJECT_LIST_LIMIT } from '../lib/project-query-config'; export function Projects() { const navigate = useNavigate(); - const { projects, loading, isRefreshing, error, refresh } = useProjectList({ sort: 'last_activity', limit: 50 }); + const { user } = useAuth(); + const { projects, loading, error, refresh } = useProjectList({ + queryScope: user?.id ?? '', + limit: PROJECT_LIST_LIMIT, + }); const [deleteError, setDeleteError] = useState(null); const handleDelete = async (id: string) => { @@ -26,7 +32,6 @@ export function Projects() {

Projects are repository-backed planning spaces for backlog tasks and delegation. - {isRefreshing && }

- ) : projects.length === 0 ? ( + ) : error && projects.length === 0 ? null : projects.length === 0 ? ( {projects.map((project) => ( - + ))} )} diff --git a/apps/web/src/pages/project-chat/FocusStrip.tsx b/apps/web/src/pages/project-chat/FocusStrip.tsx index b9afdc44f7..ab526098c3 100644 --- a/apps/web/src/pages/project-chat/FocusStrip.tsx +++ b/apps/web/src/pages/project-chat/FocusStrip.tsx @@ -30,13 +30,19 @@ const CLOSE_DELAY_MS = 140; function enrichSession( session: ChatSessionListItem, - taskInfoMap: Map, + taskInfoMap: Map ): ChatSessionResponse { const taskInfo = session.taskId ? taskInfoMap.get(session.taskId) : undefined; if (!taskInfo) return session; return { ...session, - task: { id: taskInfo.id, status: taskInfo.status, taskMode: taskInfo.taskMode }, + task: { + id: taskInfo.id, + status: taskInfo.status, + errorMessage: taskInfo.errorMessage, + executionStep: taskInfo.executionStep, + taskMode: taskInfo.taskMode, + }, }; } @@ -82,7 +88,7 @@ export function FocusStrip({ const rect = el.getBoundingClientRect(); setTooltip({ session, top: rect.top, left: rect.right + 6 }); }, - [cancelClose], + [cancelClose] ); return ( @@ -108,9 +114,7 @@ export function FocusStrip({ const config = ATTENTION_ICON[attention]; const StatusIcon = config.icon; const isSelected = selectedSessionId === session.id; - const label = session.topic - ? session.topic - : `Chat ${session.id.slice(0, 8)}`; + const label = session.topic ? session.topic : `Chat ${session.id.slice(0, 8)}`; return ( `, + ` ) .join(''); @@ -131,7 +131,12 @@ test.describe('Failure Card Visual Audit', () => { { toStatus: 'ready', actorType: 'system', time: '10m ago' }, { toStatus: 'queued', actorType: 'system', time: '10m ago' }, { toStatus: 'in progress', actorType: 'system', time: '9m ago' }, - { toStatus: 'failed', actorType: 'system', reason: 'Agent process exited unexpectedly with code 137', time: '1m ago' }, + { + toStatus: 'failed', + actorType: 'system', + reason: 'Agent process exited unexpectedly with code 137', + time: '1m ago', + }, ], ids: { Task: '01KZF49HNP8HNJDGWB4KWXHEZR', @@ -140,7 +145,7 @@ test.describe('Failure Card Visual Audit', () => { Node: '01KZFNODE1234567890', }, showAdmin: true, - }), + }) ); await screenshot(page, 'failure-card-normal'); @@ -165,10 +170,15 @@ test.describe('Failure Card Visual Audit', () => { events: [ { toStatus: 'ready', actorType: 'system', time: '30m ago' }, { toStatus: 'in progress', actorType: 'agent', time: '28m ago' }, - { toStatus: 'failed', actorType: 'system', reason: longError.slice(0, 200), time: 'Just now' }, + { + toStatus: 'failed', + actorType: 'system', + reason: longError.slice(0, 200), + time: 'Just now', + }, ], ids: { Task: '01KZF49HNP8HNJDGWB4KWXHEZR' }, - }), + }) ); await screenshot(page, 'failure-card-long-error'); @@ -179,7 +189,12 @@ test.describe('Failure Card Visual Audit', () => { const events = Array.from({ length: 30 }, (_, i) => ({ toStatus: i === 29 ? 'failed' : i < 5 ? 'queued' : 'in progress', actorType: i % 3 === 0 ? 'agent' : 'system', - reason: i === 29 ? 'Final failure after 30 transitions' : i % 5 === 0 ? `Checkpoint ${i}` : undefined, + reason: + i === 29 + ? 'Final failure after 30 transitions' + : i % 5 === 0 + ? `Checkpoint ${i}` + : undefined, time: `${30 - i}m ago`, })); @@ -192,7 +207,7 @@ test.describe('Failure Card Visual Audit', () => { guidance: 'Review the error details and retry with adjusted parameters.', events, ids: { Task: '01ABC123', Session: '01DEF456', Workspace: '01GHI789', Node: '01JKL012' }, - }), + }) ); await screenshot(page, 'failure-card-many-events'); @@ -209,7 +224,7 @@ test.describe('Failure Card Visual Audit', () => { guidance: 'Retry the task or contact support.', events: [], ids: { Task: '01ABC' }, - }), + }) ); await screenshot(page, 'failure-card-empty'); @@ -234,7 +249,7 @@ test.describe('Failure Card Visual Audit', () => { }, ], ids: { Task: '01ABC-"inject"' }, - }), + }) ); await screenshot(page, 'failure-card-special-chars'); @@ -252,7 +267,7 @@ test.describe('Failure Card Visual Audit', () => {

The task was stopped intentionally by a user or a parent agent.

- `, + ` ); await screenshot(page, 'failure-card-cancelled'); diff --git a/apps/web/tests/playwright/frontend-cache-audit.spec.ts b/apps/web/tests/playwright/frontend-cache-audit.spec.ts new file mode 100644 index 0000000000..cfa06a38d5 --- /dev/null +++ b/apps/web/tests/playwright/frontend-cache-audit.spec.ts @@ -0,0 +1,239 @@ +import { expect, type Page, type Route, test } from '@playwright/test'; +import type { ProjectDetailResponse, ProjectSummary } from '@simple-agent-manager/shared'; + +import { assertNoOverflow, makeMockUser, screenshot } from './audit-helpers'; + +const MOCK_USER = makeMockUser({ + email: 'cache-audit@example.com', + name: 'Cache Audit User', + sessionId: 'cache-session-1', + userId: 'cache-user-1', +}); + +const BASE_PROJECT = { + id: 'cache-project-1', + name: 'Responsive Cache Project', + repository: 'acme/responsive-cache-project', + githubRepoId: 101, + defaultBranch: 'main', + repoProvider: 'github', + status: 'active', + activeWorkspaceCount: 2, + activeSessionCount: 1, + lastActivityAt: '2026-08-07T20:00:00.000Z', + taskCountsByStatus: { in_progress: 1 }, + linkedWorkspaces: 2, + createdAt: '2026-08-07T19:00:00.000Z', +} satisfies ProjectSummary; + +const BASE_PROJECT_DETAIL = { + id: BASE_PROJECT.id, + userId: 'cache-user-1', + name: BASE_PROJECT.name, + description: 'A project used to verify cached responsive navigation.', + installationId: 'installation-1', + repository: BASE_PROJECT.repository, + defaultBranch: BASE_PROJECT.defaultBranch, + repoProvider: 'github', + status: 'active', + createdAt: BASE_PROJECT.createdAt, + updatedAt: '2026-08-07T20:00:00.000Z', + summary: { + repoProvider: 'github', + activeWorkspaceCount: 2, + activeSessionCount: 1, + lastActivityAt: '2026-08-07T20:00:00.000Z', + taskCountsByStatus: { in_progress: 1 }, + linkedWorkspaces: 2, + }, +} satisfies ProjectDetailResponse; + +interface MockOptions { + backgroundRefreshDelayMs?: number; + projectListError?: boolean; + projects?: ProjectSummary[]; +} + +async function setupApiMocks(page: Page, options: MockOptions = {}) { + const projects = options.projects ?? [BASE_PROJECT]; + let listRequestCount = 0; + let detailRequestCount = 0; + + await page.addInitScript((userId) => { + window.localStorage.setItem(`sam-onboarding-wizard-dismissed-${userId}`, 'true'); + }, MOCK_USER.user.id); + + await page.route('**/api/**', async (route: Route) => { + const url = new URL(route.request().url()); + const path = url.pathname; + const method = route.request().method(); + const respond = (status: number, body: unknown) => + route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); + + if (path.includes('/api/auth/')) return respond(200, MOCK_USER); + if (path === '/api/projects' && method === 'GET') { + listRequestCount += 1; + if (options.projectListError) { + return respond(500, { + error: 'PROJECT_LIST_UNAVAILABLE', + message: 'Project list unavailable', + }); + } + if (listRequestCount > 1 && options.backgroundRefreshDelayMs) { + await new Promise((resolve) => setTimeout(resolve, options.backgroundRefreshDelayMs)); + } + return respond(200, { projects, nextCursor: null }); + } + if (path === `/api/projects/${BASE_PROJECT.id}` && method === 'GET') { + detailRequestCount += 1; + return respond(200, BASE_PROJECT_DETAIL); + } + if (path === `/api/projects/${BASE_PROJECT.id}` && method === 'DELETE') { + return respond(200, { success: true }); + } + if (path === '/api/github/installations') return respond(200, []); + if (path === '/api/dashboard/active-tasks') return respond(200, { tasks: [] }); + if (path.startsWith('/api/notifications')) { + return respond(200, { notifications: [], unreadCount: 0 }); + } + if (path.startsWith('/api/credentials')) return respond(200, []); + if (path === '/api/agents') return respond(200, { agents: [] }); + if (path === '/api/trial/status') return respond(200, { available: false }); + if (path.includes('/sessions')) return respond(200, { sessions: [], total: 0 }); + if (path.includes('/tasks')) return respond(200, { tasks: [], nextCursor: null }); + if (path.includes('/agent-profiles')) return respond(200, { items: [] }); + if (path.includes('/commands')) return respond(200, { commands: [] }); + return respond(200, {}); + }); + + return { + get detailRequestCount() { + return detailRequestCount; + }, + get listRequestCount() { + return listRequestCount; + }, + }; +} + +test('phone rotation preserves the loaded project list without another request', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'iPhone 14 (390x844)'); + const requests = await setupApiMocks(page); + + await page.goto('/projects'); + await expect(page.locator('#main-content').getByText(BASE_PROJECT.name)).toBeVisible(); + expect(requests.listRequestCount).toBe(1); + await screenshot(page, 'frontend-cache-projects-portrait'); + + await page.setViewportSize({ width: 844, height: 390 }); + await expect(page.locator('#main-content').getByText(BASE_PROJECT.name)).toBeVisible(); + await page.waitForTimeout(300); + + expect(requests.listRequestCount).toBe(1); + await assertNoOverflow(page); + await screenshot(page, 'frontend-cache-projects-landscape'); +}); + +test('intent prefetch feeds navigation and stale data stays visible during refresh', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'Desktop (1280x800)'); + const requests = await setupApiMocks(page, { backgroundRefreshDelayMs: 900 }); + + await page.goto('/projects'); + await expect(page.locator('#main-content').getByText(BASE_PROJECT.name)).toBeVisible(); + expect(requests.listRequestCount).toBe(1); + + const projectCard = page + .locator('#main-content') + .getByText(BASE_PROJECT.name) + .locator('xpath=ancestor::*[@role="button"][1]'); + await projectCard.hover(); + await expect.poll(() => requests.detailRequestCount).toBe(1); + + await page.getByRole('button', { name: `Actions for ${BASE_PROJECT.name}`, exact: true }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + + const indicator = page.getByTestId('background-fetch-indicator'); + await expect(indicator).toHaveAttribute('data-refreshing', 'true'); + await page.waitForTimeout(220); + await expect(page.locator('#main-content').getByText(BASE_PROJECT.name)).toBeVisible(); + await screenshot(page, 'frontend-cache-background-refresh'); + await expect(indicator).toHaveAttribute('data-refreshing', 'false'); + + await projectCard.click({ position: { x: 12, y: 12 } }); + await expect(page).toHaveURL(new RegExp(`/projects/${BASE_PROJECT.id}/chat`)); + expect(requests.detailRequestCount).toBe(1); + await assertNoOverflow(page); +}); + +test('mobile background refresh stays delayed and preserves the loaded project card', async ({ page }, testInfo) => { + test.skip(testInfo.project.name !== 'iPhone 14 (390x844)'); + await setupApiMocks(page, { backgroundRefreshDelayMs: 900 }); + + await page.goto('/projects'); + await expect(page.locator('#main-content').getByText(BASE_PROJECT.name)).toBeVisible(); + await page.getByRole('button', { name: `Actions for ${BASE_PROJECT.name}`, exact: true }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); + + const indicator = page.getByTestId('background-fetch-indicator'); + await expect(indicator).toHaveAttribute('data-refreshing', 'false'); + await page.waitForTimeout(170); + await expect(indicator).toHaveAttribute('data-refreshing', 'true'); + await expect(page.locator('#main-content').getByText(BASE_PROJECT.name)).toBeVisible(); + await assertNoOverflow(page); + await screenshot(page, 'frontend-cache-mobile-background-refresh'); +}); + +const MANY_PROJECTS = Array.from({ length: 30 }, (_, index) => ({ + ...BASE_PROJECT, + id: `cache-project-${index + 1}`, + name: `Cached Project ${index + 1}`, + repository: `acme/cached-project-${index + 1}`, +})); + +const VISUAL_STATES = [ + { name: 'normal', projects: [BASE_PROJECT] }, + { name: 'single-character', projects: [{ ...BASE_PROJECT, name: 'A', repository: 'a/b' }] }, + { + name: 'long-special', + projects: [{ + ...BASE_PROJECT, + name: `日本語 🚀 ${'very-long-project-name-'.repeat(8)}`, + repository: `acme/${'unbroken'.repeat(30)}`, + }], + }, + { name: 'empty', projects: [] }, + { name: 'many', projects: MANY_PROJECTS }, +] as const; + +for (const state of VISUAL_STATES) { + test(`project cache surface: ${state.name}`, async ({ page }, testInfo) => { + test.skip( + !['iPhone SE (375x667)', 'Desktop (1280x800)'].includes(testInfo.project.name), + ); + await setupApiMocks(page, { projects: [...state.projects] }); + + await page.goto('/projects'); + await expect(page.getByRole('heading', { name: 'Projects', exact: true })).toBeVisible(); + if (state.name === 'empty') { + await expect(page.getByRole('heading', { name: 'No projects yet' })).toBeVisible(); + } + + await assertNoOverflow(page); + await screenshot(page, `frontend-cache-projects-${state.name}`); + }); +} + +test('project cache surface: initial error', async ({ page }, testInfo) => { + test.skip(!['iPhone SE (375x667)', 'Desktop (1280x800)'].includes(testInfo.project.name)); + await setupApiMocks(page, { projectListError: true }); + + await page.goto('/projects'); + await expect(page.getByText('Project list unavailable')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'No projects yet' })).toHaveCount(0); + if (testInfo.project.name === 'Desktop (1280x800)') { + await expect(page.getByText('Projects unavailable', { exact: true })).toBeVisible(); + await expect(page.getByText('No projects yet', { exact: true })).toHaveCount(0); + } + await assertNoOverflow(page); + await screenshot(page, 'frontend-cache-projects-error'); +}); diff --git a/apps/web/tests/playwright/project-chat-recoverable-error-audit.spec.ts b/apps/web/tests/playwright/project-chat-recoverable-error-audit.spec.ts index 5e43a7e0d8..9c846b3efb 100644 --- a/apps/web/tests/playwright/project-chat-recoverable-error-audit.spec.ts +++ b/apps/web/tests/playwright/project-chat-recoverable-error-audit.spec.ts @@ -103,7 +103,22 @@ const MOCK_MESSAGES = [ }, ]; -async function setupApiMocks(page: Page) { +async function setupApiMocks(page: Page, task = MOCK_TASK) { + const isTerminalLifecycle = + task.status === 'failed' && task.executionStep === 'awaiting_human_input'; + const session = { + ...MOCK_SESSION, + ...(isTerminalLifecycle + ? { + status: 'stopped', + endedAt: NOW - 20_000, + isIdle: false, + isTerminated: true, + } + : {}), + taskId: task.id, + task, + }; await page.route('**/api/**', async (route: Route) => { const url = new URL(route.request().url()); const path = url.pathname; @@ -112,7 +127,8 @@ async function setupApiMocks(page: Page) { route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }); if (path.includes('/api/auth/')) return respond(200, MOCK_USER); - if (path.startsWith('/api/notifications')) return respond(200, { notifications: [], unreadCount: 0 }); + if (path.startsWith('/api/notifications')) + return respond(200, { notifications: [], unreadCount: 0 }); if (path.startsWith('/api/credentials')) return respond(200, []); if (path.startsWith('/api/provider-catalog')) return respond(200, { catalogs: [] }); if (path === '/api/trial/status') return respond(200, { available: false }); @@ -123,7 +139,7 @@ async function setupApiMocks(page: Page) { return respond(200, { id: 'workspace-recoverable-1', projectId: MOCK_PROJECT.id, - status: 'running', + status: isTerminalLifecycle ? 'stopped' : 'running', url: 'https://ws-recoverable.example.test', errorMessage: null, }); @@ -134,19 +150,20 @@ async function setupApiMocks(page: Page) { const subPath = projectMatch[2] || ''; if (subPath === '/sessions') { - return respond(200, { sessions: [MOCK_SESSION], total: 1, hasMore: false }); + return respond(200, { sessions: [session], total: 1, hasMore: false }); } if (subPath === `/sessions/${MOCK_SESSION.id}`) { - return respond(200, { session: MOCK_SESSION, messages: MOCK_MESSAGES, hasMore: false }); + return respond(200, { session, messages: MOCK_MESSAGES, hasMore: false }); } if (subPath.match(/\/sessions\/[^/]+\/messages/)) { return respond(200, { messages: MOCK_MESSAGES, hasMore: false }); } - if (subPath === '/tasks') return respond(200, { tasks: [MOCK_TASK], total: 1, nextCursor: null }); - if (subPath === `/tasks/${MOCK_TASK.id}`) return respond(200, MOCK_TASK); + if (subPath === '/tasks') return respond(200, { tasks: [task], total: 1, nextCursor: null }); + if (subPath === `/tasks/${task.id}/events`) return respond(200, { events: [] }); + if (subPath === `/tasks/${task.id}`) return respond(200, task); if (subPath === '/agent-profiles') return respond(200, { items: [] }); if (subPath.match(/\/commands/)) return respond(200, { commands: [] }); if (subPath === '/activity') return respond(200, { events: [], total: 0 }); @@ -154,7 +171,8 @@ async function setupApiMocks(page: Page) { return respond(200, MOCK_PROJECT); } - if (path === '/api/projects') return respond(200, { projects: [MOCK_PROJECT], nextCursor: null }); + if (path === '/api/projects') + return respond(200, { projects: [MOCK_PROJECT], nextCursor: null }); return respond(200, {}); }); @@ -162,29 +180,34 @@ async function setupApiMocks(page: Page) { async function screenshot(page: Page, name: string) { await page.waitForTimeout(600); + const viewport = page.viewportSize(); + const suffix = viewport ? `${viewport.width}x${viewport.height}` : 'unknown'; await page.screenshot({ - path: `../../.codex/tmp/playwright-screenshots/${name}.png`, + path: `../../.codex/tmp/playwright-screenshots/${name}-${suffix}.png`, fullPage: true, }); } async function assertNoHorizontalOverflow(page: Page) { const overflow = await page.evaluate( - () => document.documentElement.scrollWidth > window.innerWidth, + () => document.documentElement.scrollWidth > window.innerWidth ); expect(overflow).toBe(false); } test.describe('Project chat recoverable error banner', () => { - test('renders recoverable error guidance and keeps the composer enabled', async ({ page }, testInfo) => { + test('renders recoverable error guidance and keeps the composer enabled', async ({ + page, + }, testInfo) => { await setupApiMocks(page); await page.goto('/projects/proj-test-1/chat/session-recoverable-1'); await page.waitForTimeout(1200); - await expect(page.getByText('Agent error:')).toBeVisible(); - await expect(page.getByText('You can send another message to retry')).toBeVisible(); + const recoverableCard = page.locator('[data-failure-kind="diagnosable"]'); + await expect(recoverableCard.getByText('Cloud capacity')).toBeVisible(); + await expect(recoverableCard.getByText('Recoverable')).toBeVisible(); - const composer = page.getByPlaceholder('Send a message to resume the agent...'); + const composer = page.getByRole('combobox'); await expect(composer).toBeVisible(); await expect(composer).toBeEnabled(); @@ -193,7 +216,52 @@ test.describe('Project chat recoverable error banner', () => { page, testInfo.project.name.includes('Desktop') ? 'project-chat-recoverable-error-desktop' - : 'project-chat-recoverable-error-mobile', + : 'project-chat-recoverable-error-mobile' + ); + }); + + test('renders input expiry as a neutral lifecycle outcome in the real chat shell', async ({ + page, + }, testInfo) => { + await setupApiMocks(page, { + ...MOCK_TASK, + status: 'failed', + executionStep: 'awaiting_human_input', + errorMessage: 'Human input request expired after timeout', + taskMode: 'task', + }); + await page.goto('/projects/proj-test-1/chat/session-recoverable-1'); + + const lifecycleCard = page.locator('[data-failure-kind="lifecycle"]'); + await expect(lifecycleCard).toBeVisible(); + await expect(lifecycleCard.getByText('Input request expired')).toBeVisible(); + await expect(lifecycleCard.getByText('Retryable')).toHaveCount(0); + await expect(lifecycleCard.getByText('Recoverable')).toHaveCount(0); + await expect( + page.getByLabel('Conversation').getByText('Stopped', { exact: true }) + ).toBeVisible(); + if ((page.viewportSize()?.width ?? 0) >= 768) { + await expect(page.getByTitle('Stopped')).toBeVisible(); + await expect(page.getByTitle('Failed')).toHaveCount(0); + } + await expect(page.getByTestId('failure-card-shell')).not.toHaveClass(/after:bg/); + await expect(page.getByTestId('failure-card-shell')).toHaveCSS( + 'box-shadow', + 'rgba(0, 0, 0, 0.4) 0px 4px 24px 0px' + ); + + await lifecycleCard.getByRole('button').click(); + await expect(page.getByText(/No debugging is needed/i)).toBeVisible(); + await expect(lifecycleCard.getByText('Reason')).toBeVisible(); + await expect(lifecycleCard.getByText('Error', { exact: true })).toHaveCount(0); + await expect(lifecycleCard.getByText('Copy debug report')).toHaveCount(0); + await expect(lifecycleCard.getByText('View in admin errors')).toHaveCount(0); + await assertNoHorizontalOverflow(page); + await screenshot( + page, + testInfo.project.name.includes('Desktop') + ? 'project-chat-input-expired-desktop' + : 'project-chat-input-expired-mobile' ); }); }); diff --git a/apps/web/tests/unit/AppShell.test.tsx b/apps/web/tests/unit/AppShell.test.tsx index 81599224a4..dc800ac979 100644 --- a/apps/web/tests/unit/AppShell.test.tsx +++ b/apps/web/tests/unit/AppShell.test.tsx @@ -1,16 +1,25 @@ import { act, fireEvent, render as baseRender, type RenderOptions, screen, within } from '@testing-library/react'; -import type { ReactElement } from 'react'; +import { type ReactElement, useEffect, useState } from 'react'; import { MemoryRouter, useNavigate } from 'react-router'; import { afterEach, beforeAll, beforeEach,describe, expect, it, vi } from 'vitest'; import { AppShell } from '../../src/components/AppShell'; import { GLOBAL_NAV_ITEMS, PROJECT_NAV_ITEMS } from '../../src/components/NavSidebar'; import { ThemeProvider } from '../../src/contexts/ThemeContext'; +import { QueryTestWrapper } from '../test-utils/query-test-utils'; // AppShell renders the shared (desktop sidebar footer and the // mobile drawer), which calls useTheme and requires a ThemeProvider ancestor. function render(ui: ReactElement, options?: Omit) { - return baseRender(ui, { wrapper: ThemeProvider, ...options }); + function Wrapper({ children }: { children: ReactElement }) { + return ( + + {children} + + ); + } + + return baseRender(ui, { wrapper: Wrapper, ...options }); } // Mutable auth state so individual tests can override @@ -21,6 +30,14 @@ let mockAuthState: Record = { // jsdom does not implement window.matchMedia — stub it for useIsMobile hook let matchMediaMatches = false; +const matchMediaListeners = new Set<(event: MediaQueryListEvent) => void>(); + +function setMatchMediaMatches(matches: boolean) { + matchMediaMatches = matches; + const event = { matches, media: '(max-width: 767px)' } as MediaQueryListEvent; + for (const listener of matchMediaListeners) listener(event); +} + beforeAll(() => { Object.defineProperty(window, 'matchMedia', { writable: true, @@ -30,8 +47,16 @@ beforeAll(() => { onchange: null, addListener: vi.fn(), removeListener: vi.fn(), - addEventListener: vi.fn(), - removeEventListener: vi.fn(), + addEventListener: vi.fn( + (_type: string, listener: (event: MediaQueryListEvent) => void) => { + matchMediaListeners.add(listener); + }, + ), + removeEventListener: vi.fn( + (_type: string, listener: (event: MediaQueryListEvent) => void) => { + matchMediaListeners.delete(listener); + }, + ), dispatchEvent: vi.fn(), })), }); @@ -81,6 +106,7 @@ vi.mock('../../src/components/GlobalCommandPalette', () => ({ })); beforeEach(() => { + matchMediaListeners.clear(); matchMediaMatches = false; mockAuthState = { user: { name: 'Test User', email: 'test@example.com', image: null }, @@ -419,6 +445,41 @@ describe('AppShell (mobile)', () => { expect(screen.queryByRole('dialog', { name: 'Navigation menu' })).not.toBeInTheDocument(); vi.useRealTimers(); }); + + it('preserves routed child state when rotation crosses the mobile breakpoint', () => { + let mountCount = 0; + + function StatefulPage() { + const [draft, setDraft] = useState(''); + useEffect(() => { + mountCount += 1; + }, []); + + return ( + + ); + } + + render( + + + + + , + ); + + fireEvent.change(screen.getByLabelText('Draft'), { target: { value: 'keep this' } }); + + act(() => { + setMatchMediaMatches(false); + }); + + expect(screen.getByLabelText('Draft')).toHaveValue('keep this'); + expect(mountCount).toBe(1); + }); }); describe('AppShell (Focus Mode — desktop)', () => { diff --git a/apps/web/tests/unit/BackgroundFetchIndicator.test.tsx b/apps/web/tests/unit/BackgroundFetchIndicator.test.tsx new file mode 100644 index 0000000000..d4e06b5e30 --- /dev/null +++ b/apps/web/tests/unit/BackgroundFetchIndicator.test.tsx @@ -0,0 +1,92 @@ +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { BackgroundFetchIndicator } from '../../src/components/BackgroundFetchIndicator'; + +const QUERY_KEY = ['indicator-test'] as const; + +function QueryConsumer({ queryFn }: { queryFn: () => Promise }) { + const query = useQuery({ queryKey: QUERY_KEY, queryFn }); + return {query.data ?? 'No data'}; +} + +describe('BackgroundFetchIndicator', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 60_000 } }, + }); + }); + + it('only becomes visible for a delayed background refresh with cached data', async () => { + const queryFn = vi.fn().mockResolvedValue('Cached data'); + render( + + + + , + ); + + const indicator = screen.getByTestId('background-fetch-indicator'); + expect(indicator).toHaveAttribute('data-refreshing', 'false'); + expect(indicator).toHaveClass('opacity-0'); + expect(await screen.findByText('Cached data')).toBeInTheDocument(); + + let resolveRefresh: ((value: string) => void) | undefined; + queryFn.mockImplementationOnce( + () => new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + + act(() => { + void queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + }); + + expect(indicator).toHaveAttribute('data-refreshing', 'false'); + expect(screen.getByRole('status')).toBeEmptyDOMElement(); + await waitFor(() => expect(indicator).toHaveAttribute('data-refreshing', 'true')); + expect(indicator).toHaveClass('opacity-100'); + expect(screen.getByRole('status')).toHaveTextContent('Refreshing data'); + expect(screen.getByText('Cached data')).toBeInTheDocument(); + + await act(async () => { + resolveRefresh?.('Fresh data'); + }); + + await waitFor(() => expect(indicator).toHaveAttribute('data-refreshing', 'false')); + expect(screen.getByText('Fresh data')).toBeInTheDocument(); + }); + + it('never shows or announces a background refresh that finishes before the delay', async () => { + const queryFn = vi.fn().mockResolvedValue('Cached data'); + render( + + + + , + ); + await screen.findByText('Cached data'); + + const indicator = screen.getByTestId('background-fetch-indicator'); + const observedStates: string[] = []; + const observer = new MutationObserver(() => { + observedStates.push(indicator.getAttribute('data-refreshing') ?? 'missing'); + }); + observer.observe(indicator, { attributes: true, attributeFilter: ['data-refreshing'] }); + + queryFn.mockResolvedValueOnce('Fresh data'); + await act(async () => { + await queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + }); + await screen.findByText('Fresh data'); + await new Promise((resolve) => window.setTimeout(resolve, 180)); + observer.disconnect(); + + expect(observedStates).not.toContain('true'); + expect(indicator).toHaveAttribute('data-refreshing', 'false'); + expect(screen.getByRole('status')).toBeEmptyDOMElement(); + }); +}); diff --git a/apps/web/tests/unit/Project.test.tsx b/apps/web/tests/unit/Project.test.tsx index 0d1bb7fefb..4f62b7430e 100644 --- a/apps/web/tests/unit/Project.test.tsx +++ b/apps/web/tests/unit/Project.test.tsx @@ -1,15 +1,17 @@ -import { act, render, screen, waitFor } from '@testing-library/react'; +import type { ProjectDetailResponse } from '@simple-agent-manager/shared'; +import { act, screen, waitFor } from '@testing-library/react'; import { useEffect } from 'react'; -import { MemoryRouter, Route,Routes } from 'react-router'; +import { MemoryRouter, Route, Routes } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { Project } from '../../src/pages/Project'; import { useProjectContext } from '../../src/pages/ProjectContext'; +import { renderWithQuery } from '../test-utils/query-test-utils'; // Mock AuthProvider vi.mock('../../src/components/AuthProvider', () => ({ useAuth: () => ({ - user: { name: 'Test User', email: 'test@example.com', image: null }, + user: { id: 'user-1', name: 'Test User', email: 'test@example.com', image: null }, }), })); @@ -40,22 +42,24 @@ const defaultProject = { description: 'A test project', repository: 'owner/repo', defaultBranch: 'main', + repoProvider: 'github', installationId: 'inst-1', status: 'active', createdAt: '2026-01-01T00:00:00Z', updatedAt: '2026-01-01T00:00:00Z', userId: 'user-1', summary: { + repoProvider: 'github', activeWorkspaceCount: 2, activeSessionCount: 3, lastActivityAt: '2026-01-15T12:00:00Z', taskCountsByStatus: { ready: 1, in_progress: 2 }, linkedWorkspaces: 2, }, -}; +} satisfies ProjectDetailResponse; function renderProject(path = '/projects/proj-1/overview') { - return render( + return renderWithQuery( }> @@ -91,6 +95,15 @@ describe('Project shell (non-chat routes)', () => { renderProject('/projects/proj-1/overview'); expect(await screen.findByTestId('overview-content')).toBeInTheDocument(); }); + + it('renders one truthful initial error without also claiming the project was not found', async () => { + mockGetProject.mockRejectedValueOnce(new Error('Project service unavailable')); + renderProject('/projects/proj-1/overview'); + + expect(await screen.findByRole('alert')).toHaveTextContent('Project service unavailable'); + expect(screen.getAllByRole('alert')).toHaveLength(1); + expect(screen.queryByText('Project not found.')).not.toBeInTheDocument(); + }); }); describe('Project shell (chat route — full-bleed)', () => { @@ -137,7 +150,7 @@ describe('Project reload (stale-while-revalidate)', () => { // Initial load resolves immediately mockGetProject.mockResolvedValueOnce(defaultProject); - const { findByTestId, getByTestId } = render( + const { findByTestId, getByTestId } = renderWithQuery( }> @@ -178,4 +191,36 @@ describe('Project reload (stale-while-revalidate)', () => { expect(getByTestId('child-content')).toBeInTheDocument(); expect(unmountSpy).not.toHaveBeenCalled(); }); + + it('keeps child content mounted and does not replace it with an alert when reload fails', async () => { + function ReloadChild() { + const { reload } = useProjectContext(); + return ( +
+
Cached project content
+ +
+ ); + } + + const { findByTestId, getByTestId, queryByRole } = renderWithQuery( + + + }> + } /> + + + , + ); + + await findByTestId('stale-child-content'); + mockGetProject.mockRejectedValueOnce(new Error('Background reload failed')); + await act(async () => { + getByTestId('failed-reload-btn').click(); + }); + + await waitFor(() => expect(mockGetProject).toHaveBeenCalledTimes(2)); + expect(getByTestId('stale-child-content')).toHaveTextContent('Cached project content'); + expect(queryByRole('alert')).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/tests/unit/ProjectPrefetch.test.tsx b/apps/web/tests/unit/ProjectPrefetch.test.tsx new file mode 100644 index 0000000000..f19486e44c --- /dev/null +++ b/apps/web/tests/unit/ProjectPrefetch.test.tsx @@ -0,0 +1,137 @@ +import type { ProjectDetailResponse, ProjectSummary } from '@simple-agent-manager/shared'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { MemoryRouter } from 'react-router'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ProjectSummaryCard } from '../../src/components/ProjectSummaryCard'; +import { SidebarProjectList } from '../../src/components/SidebarProjectList'; +import { queryClient } from '../../src/lib/query-client'; +import { projectDetailQueryOptions, projectQueryKeys } from '../../src/lib/query-options'; + +const mocks = vi.hoisted(() => ({ + getProject: vi.fn(), +})); + +vi.mock('../../src/lib/api', async (importOriginal) => ({ + ...(await importOriginal()), + getProject: mocks.getProject, +})); + +const PROJECT = { + id: 'project-1', + name: 'Prefetched project', + repository: 'acme/prefetched-project', + githubRepoId: 101, + defaultBranch: 'main', + repoProvider: 'github', + status: 'active', + activeWorkspaceCount: 0, + activeSessionCount: 0, + lastActivityAt: '2026-08-07T20:00:00.000Z', + taskCountsByStatus: {}, + linkedWorkspaces: 0, + createdAt: '2026-08-07T19:00:00.000Z', +} satisfies ProjectSummary; + +const PROJECT_DETAIL = { + id: PROJECT.id, + userId: 'user-1', + name: PROJECT.name, + description: null, + installationId: 'installation-1', + repository: PROJECT.repository, + defaultBranch: PROJECT.defaultBranch, + repoProvider: 'github', + status: 'active', + createdAt: PROJECT.createdAt, + updatedAt: '2026-08-07T20:00:00.000Z', + summary: { + repoProvider: 'github', + activeWorkspaceCount: 0, + activeSessionCount: 0, + lastActivityAt: '2026-08-07T20:00:00.000Z', + taskCountsByStatus: {}, + linkedWorkspaces: 0, + }, +} satisfies ProjectDetailResponse; + +function renderWithQuery(ui: ReactElement) { + return render({ui}); +} + +describe('project detail intent prefetch', () => { + beforeEach(() => { + queryClient.clear(); + mocks.getProject.mockReset(); + mocks.getProject.mockResolvedValue(PROJECT_DETAIL); + }); + + afterEach(() => { + queryClient.clear(); + }); + + it('prefetches the exact destination query from a project-card hover', async () => { + renderWithQuery( + + + , + ); + + const projectCard = screen.getByText('Prefetched project').closest('[role="button"]'); + if (!projectCard) throw new Error('Project card was not rendered'); + fireEvent.mouseEnter(projectCard); + + await waitFor(() => expect(mocks.getProject).toHaveBeenCalledWith('project-1')); + expect(queryClient.getQueryData(projectQueryKeys.detail('user-1', 'project-1'))).toEqual(PROJECT_DETAIL); + + await queryClient.fetchQuery(projectDetailQueryOptions('user-1', 'project-1')); + expect(mocks.getProject).toHaveBeenCalledTimes(1); + }); + + it('prefetches on keyboard focus from the sidebar destination', async () => { + renderWithQuery( + , + ); + + fireEvent.focus(screen.getByRole('button', { name: /Prefetched project/ })); + + await waitFor(() => expect(mocks.getProject).toHaveBeenCalledWith('project-1')); + }); + + it('prefetches on touch intent from a project card', async () => { + renderWithQuery( + + + , + ); + + const projectCard = screen.getByText('Prefetched project').closest('[role="button"]'); + if (!projectCard) throw new Error('Project card was not rendered'); + fireEvent.touchStart(projectCard); + + await waitFor(() => expect(mocks.getProject).toHaveBeenCalledWith('project-1')); + }); + + it('cancels speculative hover when the pointer only sweeps across a card', async () => { + renderWithQuery( + + + , + ); + + const projectCard = screen.getByText('Prefetched project').closest('[role="button"]'); + if (!projectCard) throw new Error('Project card was not rendered'); + fireEvent.mouseEnter(projectCard); + fireEvent.mouseLeave(projectCard); + + await new Promise((resolve) => window.setTimeout(resolve, 150)); + expect(mocks.getProject).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/tests/unit/SidebarProjectList.test.tsx b/apps/web/tests/unit/SidebarProjectList.test.tsx index 299bf3bc51..5933db9458 100644 --- a/apps/web/tests/unit/SidebarProjectList.test.tsx +++ b/apps/web/tests/unit/SidebarProjectList.test.tsx @@ -1,10 +1,17 @@ import type { ProjectSummary } from '@simple-agent-manager/shared'; -import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { render as rtlRender, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { ReactElement } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { SidebarProjectList } from '../../src/components/SidebarProjectList'; +function render(ui: ReactElement) { + const queryClient = new QueryClient(); + return rtlRender({ui}); +} + function makeProject(overrides: Partial = {}): ProjectSummary { return { id: overrides.id ?? 'p1', @@ -116,6 +123,21 @@ describe('SidebarProjectList', () => { expect(screen.getByText('No projects yet')).toBeInTheDocument(); }); + it('shows an unavailable state instead of an empty state when loading failed', () => { + const onNavigate = vi.fn(); + render( + , + ); + + expect(screen.getByText('Projects unavailable')).toBeInTheDocument(); + expect(screen.queryByText('No projects yet')).not.toBeInTheDocument(); + }); + it('navigates to project chat on click', async () => { const user = userEvent.setup(); const onNavigate = vi.fn(); diff --git a/apps/web/tests/unit/accessibility.test.tsx b/apps/web/tests/unit/accessibility.test.tsx index 4d9ec6a63c..2f02ba0505 100644 --- a/apps/web/tests/unit/accessibility.test.tsx +++ b/apps/web/tests/unit/accessibility.test.tsx @@ -1,5 +1,6 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render as baseRender, type RenderOptions, screen } from '@testing-library/react'; -import type { ReactElement } from 'react'; +import type { ReactElement, ReactNode } from 'react'; import { MemoryRouter } from 'react-router'; import { beforeAll, describe, expect, it, vi } from 'vitest'; @@ -7,8 +8,19 @@ import { AppShell } from '../../src/components/AppShell'; import { SkipToContent } from '../../src/components/SkipToContent'; import { ThemeProvider } from '../../src/contexts/ThemeContext'; +function TestProviders({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + + {children} + + ); +} + function render(ui: ReactElement, options?: Omit) { - return baseRender(ui, { wrapper: ThemeProvider, ...options }); + return baseRender(ui, { wrapper: TestProviders, ...options }); } let matchMediaMatches = false; diff --git a/apps/web/tests/unit/components/CodexConnectModal.test.tsx b/apps/web/tests/unit/components/CodexConnectModal.test.tsx index 9f780d60e9..c114eee95e 100644 --- a/apps/web/tests/unit/components/CodexConnectModal.test.tsx +++ b/apps/web/tests/unit/components/CodexConnectModal.test.tsx @@ -6,6 +6,7 @@ const h = vi.hoisted(() => ({ getAgentCredentialSetupSession: vi.fn(), cancelAgentCredentialSetupSession: vi.fn(), getAgentCredentialSetupConfig: vi.fn(), + submitAgentCredentialSetupVerificationCode: vi.fn(), })); vi.mock('../../../src/lib/api', async (importOriginal) => ({ @@ -14,6 +15,7 @@ vi.mock('../../../src/lib/api', async (importOriginal) => ({ getAgentCredentialSetupSession: h.getAgentCredentialSetupSession, cancelAgentCredentialSetupSession: h.cancelAgentCredentialSetupSession, getAgentCredentialSetupConfig: h.getAgentCredentialSetupConfig, + submitAgentCredentialSetupVerificationCode: h.submitAgentCredentialSetupVerificationCode, })); import { @@ -30,6 +32,7 @@ const SESSION_ID = 'sess_setup_01'; const USER_CODE = 'ABCD-EFGH'; const OPENAI_VERIFICATION_URL = 'https://auth.openai.com/device'; const CLAUDE_VERIFICATION_URL = 'https://claude.ai/oauth/device'; +const CLAUDE_VERIFICATION_CODE = 'abc123#state456'; function makeSession( status: AgentCredentialSetupStatus, @@ -53,6 +56,7 @@ describe('AgentCredentialConnectModal', () => { h.getAgentCredentialSetupSession.mockReset(); h.cancelAgentCredentialSetupSession.mockReset(); h.getAgentCredentialSetupConfig.mockReset(); + h.submitAgentCredentialSetupVerificationCode.mockReset(); vi.stubEnv('VITE_CODEX_SETUP_POLL_MS', '20'); vi.stubEnv('VITE_CODEX_SETUP_SUCCESS_CLOSE_MS', '10'); h.cancelAgentCredentialSetupSession.mockResolvedValue({ id: SESSION_ID, status: 'cancelled' }); @@ -110,6 +114,125 @@ describe('AgentCredentialConnectModal', () => { expect(screen.queryByTestId('codex-terminal')).not.toBeInTheDocument(); }); + it('lets Claude users paste the browser verification code and complete setup', async () => { + const onConnected = vi.fn(); + h.createAgentCredentialSetupSession.mockResolvedValue({ + kind: 'created', + session: makeSession('provisioning', { agentType: 'claude-code' }), + }); + h.getAgentCredentialSetupSession.mockResolvedValue( + makeSession('waiting_for_user', { + agentType: 'claude-code', + verificationUrl: CLAUDE_VERIFICATION_URL, + userCode: null, + }) + ); + h.submitAgentCredentialSetupVerificationCode.mockResolvedValue( + makeSession('completed', { agentType: 'claude-code' }) + ); + + render( + + ); + + await screen.findByRole('link', { name: /open claude sign-in/i }); + const tokenInput = screen.getByLabelText(/paste the code claude shows you/i); + fireEvent.change(tokenInput, { + target: { + value: ` ${CLAUDE_VERIFICATION_CODE} +`, + }, + }); + fireEvent.click(screen.getByRole('button', { name: /continue sign-in/i })); + + await waitFor(() => + expect(h.submitAgentCredentialSetupVerificationCode).toHaveBeenCalledWith( + SESSION_ID, + CLAUDE_VERIFICATION_CODE + ) + ); + await waitFor(() => expect(onConnected).toHaveBeenCalledOnce()); + expect(await screen.findByText(/Claude Code connected/)).toBeInTheDocument(); + }); + + it('ignores an older poll result after Claude code submission completes', async () => { + let resolvePoll: ((session: AgentCredentialSetupSession) => void) | undefined; + h.createAgentCredentialSetupSession.mockResolvedValue({ + kind: 'created', + session: makeSession('waiting_for_user', { + agentType: 'claude-code', + verificationUrl: CLAUDE_VERIFICATION_URL, + userCode: null, + }), + }); + h.getAgentCredentialSetupSession.mockReturnValue( + new Promise((resolve) => { + resolvePoll = resolve; + }) + ); + h.submitAgentCredentialSetupVerificationCode.mockResolvedValue( + makeSession('completed', { agentType: 'claude-code' }) + ); + + render(); + await screen.findByRole('link', { name: /open claude sign-in/i }); + await waitFor(() => expect(h.getAgentCredentialSetupSession).toHaveBeenCalledOnce()); + + fireEvent.change(screen.getByLabelText(/paste the code claude shows you/i), { + target: { value: CLAUDE_VERIFICATION_CODE }, + }); + fireEvent.click(screen.getByRole('button', { name: /continue sign-in/i })); + expect(await screen.findByText(/Claude Code connected/)).toBeInTheDocument(); + + resolvePoll?.( + makeSession('waiting_for_user', { + agentType: 'claude-code', + verificationUrl: CLAUDE_VERIFICATION_URL, + }) + ); + await Promise.resolve(); + expect(screen.getByText(/Claude Code connected/)).toBeInTheDocument(); + }); + + it('blocks a Claude code paste missing its #state half before any server round-trip', async () => { + // Claude's browser page shows `#`; copying only the code half + // is guaranteed to fail inside the CLI, so the modal must catch it with + // actionable guidance instead of burning the setup session. + h.createAgentCredentialSetupSession.mockResolvedValue({ + kind: 'created', + session: makeSession('provisioning', { agentType: 'claude-code' }), + }); + h.getAgentCredentialSetupSession.mockResolvedValue( + makeSession('waiting_for_user', { + agentType: 'claude-code', + verificationUrl: CLAUDE_VERIFICATION_URL, + userCode: null, + }) + ); + + render( + + ); + + await screen.findByRole('link', { name: /open claude sign-in/i }); + const tokenInput = screen.getByLabelText(/paste the code claude shows you/i); + fireEvent.change(tokenInput, { target: { value: 'abc123-no-state-half' } }); + fireEvent.click(screen.getByRole('button', { name: /continue sign-in/i })); + + expect(await screen.findByText(/copy the entire code claude shows/i)).toBeInTheDocument(); + expect(h.submitAgentCredentialSetupVerificationCode).not.toHaveBeenCalled(); + }); + it('reports completion without exposing a terminal surface', async () => { const onConnected = vi.fn(); h.createAgentCredentialSetupSession.mockResolvedValue({ diff --git a/apps/web/tests/unit/components/auth-provider.test.tsx b/apps/web/tests/unit/components/auth-provider.test.tsx index 4d42d244f1..d753dd18da 100644 --- a/apps/web/tests/unit/components/auth-provider.test.tsx +++ b/apps/web/tests/unit/components/auth-provider.test.tsx @@ -1,14 +1,33 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { beforeEach,describe, expect, it, vi } from 'vitest'; +import type { ProjectSummary } from '@simple-agent-manager/shared'; +import { QueryClientProvider, useQuery } from '@tanstack/react-query'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthProvider, useAuth } from '../../../src/components/AuthProvider'; import { GITHUB_REAUTH_REQUIRED_EVENT } from '../../../src/lib/api/client'; - -const { mockUseSession, mockSignOut, mockClearLibraryCache, mockClearLegacyLibraryCache } = vi.hoisted(() => ({ +import { queryClient } from '../../../src/lib/query-client'; +import { projectQueryKeys } from '../../../src/lib/query-options'; + +const { + mockUseSession, + mockSignOut, + mockClearLibraryCache, + mockClearLegacyLibraryCache, + mockBroadcastAuthRevocation, + mockCleanupTerminalSecrets, + mockInitAuthBroadcastListener, + mockResetAuthRevoked, + mockTeardownAuthBroadcastListener, +} = vi.hoisted(() => ({ mockUseSession: vi.fn(), mockSignOut: vi.fn(), mockClearLibraryCache: vi.fn(), mockClearLegacyLibraryCache: vi.fn(), + mockBroadcastAuthRevocation: vi.fn(), + mockCleanupTerminalSecrets: vi.fn(), + mockInitAuthBroadcastListener: vi.fn(), + mockResetAuthRevoked: vi.fn(), + mockTeardownAuthBroadcastListener: vi.fn(), })); vi.mock('../../../src/lib/auth', () => ({ @@ -22,6 +41,16 @@ vi.mock('../../../src/lib/library-cache', async (importOriginal) => ({ clearLegacyLibraryCache: mockClearLegacyLibraryCache, })); +vi.mock('../../../src/lib/terminal-cleanup', () => ({ + broadcastAuthRevocation: mockBroadcastAuthRevocation, + cleanupTerminalSecrets: mockCleanupTerminalSecrets, + initAuthBroadcastListener: mockInitAuthBroadcastListener, + resetAuthRevoked: mockResetAuthRevoked, + teardownAuthBroadcastListener: mockTeardownAuthBroadcastListener, +})); + +const clearQueryCacheSpy = vi.spyOn(queryClient, 'clear'); + function AuthConsumer() { const auth = useAuth(); return ( @@ -34,12 +63,8 @@ function AuthConsumer() { ); } -function renderWithAuth() { - return render( - - - , - ); +function renderWithAuth(children = ) { + return render({children}); } const validSession = { @@ -47,9 +72,48 @@ const validSession = { session: { id: 's1' }, }; +const PRIVATE_PROJECT = { + id: 'private-project', + name: 'User one private project', + repository: 'private/repository', + githubRepoId: 101, + defaultBranch: 'main', + repoProvider: 'github', + status: 'active', + activeWorkspaceCount: 1, + activeSessionCount: 0, + lastActivityAt: '2026-08-07T20:00:00.000Z', + createdAt: '2026-08-07T19:00:00.000Z', + taskCountsByStatus: {}, + linkedWorkspaces: 1, +} satisfies ProjectSummary; + +const cacheRenderLog: string[] = []; + +function ScopedProjectCacheConsumer() { + const { user } = useAuth(); + const queryScope = user?.id ?? ''; + const { data = [] } = useQuery({ + queryKey: projectQueryKeys.list(queryScope, 50), + queryFn: async (): Promise => [], + enabled: Boolean(queryScope), + }); + const renderedProject = data[0]?.name ?? 'none'; + cacheRenderLog.push(`${queryScope}:${renderedProject}`); + return ( +
+ {queryScope} + {renderedProject} +
+ ); +} + describe('AuthProvider', () => { beforeEach(() => { + queryClient.clear(); + clearQueryCacheSpy.mockClear(); vi.clearAllMocks(); + cacheRenderLog.length = 0; }); it('shows authenticated when session is valid', () => { @@ -97,7 +161,7 @@ describe('AuthProvider', () => { rerender( - , +
); // Should still show authenticated using cached session @@ -149,7 +213,7 @@ describe('AuthProvider', () => { rerender( - , + ); // Must NOT use cached session — this was an intentional signout @@ -177,7 +241,7 @@ describe('AuthProvider', () => { rerender( - , + ); // Cached session used expect(screen.getByTestId('authenticated')).toHaveTextContent('true'); @@ -196,14 +260,12 @@ describe('AuthProvider', () => { rerender( - , + ); expect(screen.getByTestId('authenticated')).toHaveTextContent('true'); expect(screen.getByTestId('user-name')).toHaveTextContent('Updated User'); }); - - it('does not clear the same user namespace during transient refetch errors', () => { mockUseSession.mockReturnValue({ data: validSession, @@ -213,6 +275,10 @@ describe('AuthProvider', () => { }); const { rerender } = renderWithAuth(); expect(mockClearLegacyLibraryCache).toHaveBeenCalledTimes(1); + clearQueryCacheSpy.mockClear(); + mockBroadcastAuthRevocation.mockClear(); + mockCleanupTerminalSecrets.mockClear(); + mockResetAuthRevoked.mockClear(); mockUseSession.mockReturnValue({ data: null, @@ -223,12 +289,16 @@ describe('AuthProvider', () => { rerender( - , + ); expect(screen.getByTestId('authenticated')).toHaveTextContent('true'); expect(mockClearLibraryCache).not.toHaveBeenCalled(); expect(mockClearLegacyLibraryCache).toHaveBeenCalledTimes(1); + expect(clearQueryCacheSpy).not.toHaveBeenCalled(); + expect(mockCleanupTerminalSecrets).not.toHaveBeenCalled(); + expect(mockBroadcastAuthRevocation).not.toHaveBeenCalled(); + expect(mockResetAuthRevoked).not.toHaveBeenCalled(); }); it('clears the previous user namespace and legacy cache on clean null session expiry', () => { @@ -241,6 +311,10 @@ describe('AuthProvider', () => { const { rerender } = renderWithAuth(); mockClearLibraryCache.mockClear(); mockClearLegacyLibraryCache.mockClear(); + clearQueryCacheSpy.mockClear(); + mockBroadcastAuthRevocation.mockClear(); + mockCleanupTerminalSecrets.mockClear(); + mockResetAuthRevoked.mockClear(); mockUseSession.mockReturnValue({ data: null, @@ -251,12 +325,16 @@ describe('AuthProvider', () => { rerender( - , + ); expect(screen.getByTestId('authenticated')).toHaveTextContent('false'); expect(mockClearLibraryCache).toHaveBeenCalledWith('user:u1'); expect(mockClearLegacyLibraryCache).toHaveBeenCalledOnce(); + expect(clearQueryCacheSpy).toHaveBeenCalledOnce(); + expect(mockCleanupTerminalSecrets).toHaveBeenCalledOnce(); + expect(mockBroadcastAuthRevocation).toHaveBeenCalledOnce(); + expect(mockResetAuthRevoked).not.toHaveBeenCalled(); }); it('clears the previous user namespace on account switch without clearing the new user cache', () => { @@ -269,6 +347,10 @@ describe('AuthProvider', () => { const { rerender } = renderWithAuth(); mockClearLibraryCache.mockClear(); mockClearLegacyLibraryCache.mockClear(); + clearQueryCacheSpy.mockClear(); + mockBroadcastAuthRevocation.mockClear(); + mockCleanupTerminalSecrets.mockClear(); + mockResetAuthRevoked.mockClear(); mockUseSession.mockReturnValue({ data: { @@ -282,7 +364,7 @@ describe('AuthProvider', () => { rerender( - , + ); expect(screen.getByTestId('user-name')).toHaveTextContent('Other User'); @@ -290,6 +372,90 @@ describe('AuthProvider', () => { expect(mockClearLibraryCache).toHaveBeenCalledWith('user:u1'); expect(mockClearLibraryCache).not.toHaveBeenCalledWith('user:u2'); expect(mockClearLegacyLibraryCache).toHaveBeenCalledOnce(); + expect(clearQueryCacheSpy).toHaveBeenCalledOnce(); + expect(mockCleanupTerminalSecrets).toHaveBeenCalledOnce(); + expect(mockBroadcastAuthRevocation).toHaveBeenCalledOnce(); + expect(mockResetAuthRevoked).toHaveBeenCalledOnce(); + }); + + it('never renders the previous user query cache during a direct account switch', async () => { + mockUseSession.mockReturnValue({ + data: validSession, + isPending: false, + error: null, + isRefetching: false, + }); + + const renderTree = () => ( + + + + + + ); + const { rerender } = render(renderTree()); + await waitFor(() => expect(screen.getByTestId('cache-user')).toHaveTextContent('u1')); + + act(() => { + queryClient.setQueryData(projectQueryKeys.list('u1', 50), [PRIVATE_PROJECT]); + }); + await waitFor(() => { + expect(screen.getByTestId('cached-project')).toHaveTextContent(PRIVATE_PROJECT.name); + }); + cacheRenderLog.length = 0; + + mockUseSession.mockReturnValue({ + data: { + ...validSession, + user: { ...validSession.user, id: 'u2', email: 'other@test.com', name: 'Other User' }, + }, + isPending: false, + error: null, + isRefetching: false, + }); + rerender(renderTree()); + + await waitFor(() => expect(screen.getByTestId('cache-user')).toHaveTextContent('u2')); + expect(screen.getByTestId('cached-project')).toHaveTextContent('none'); + expect(cacheRenderLog).not.toContain(`u2:${PRIVATE_PROJECT.name}`); + expect(queryClient.getQueryData(projectQueryKeys.list('u1', 50))).toBeUndefined(); + }); + + it('gates protected cache consumers while the next identity is unresolved', async () => { + mockUseSession.mockReturnValue({ + data: validSession, + isPending: false, + error: null, + isRefetching: false, + }); + + const renderTree = () => ( + + + + + + ); + const { rerender } = render(renderTree()); + await waitFor(() => expect(screen.getByTestId('cache-user')).toHaveTextContent('u1')); + + act(() => { + queryClient.setQueryData(projectQueryKeys.list('u1', 50), [PRIVATE_PROJECT]); + }); + await waitFor(() => { + expect(screen.getByTestId('cached-project')).toHaveTextContent(PRIVATE_PROJECT.name); + }); + + mockUseSession.mockReturnValue({ + data: null, + isPending: true, + error: null, + isRefetching: false, + }); + rerender(renderTree()); + + expect(screen.queryByTestId('cache-user')).not.toBeInTheDocument(); + expect(screen.queryByText(PRIVATE_PROJECT.name)).not.toBeInTheDocument(); }); it('shows a GitHub reauth prompt and signs out when reconnect is clicked', () => { @@ -301,14 +467,19 @@ describe('AuthProvider', () => { }); renderWithAuth(); - fireEvent(window, new CustomEvent(GITHUB_REAUTH_REQUIRED_EVENT, { - detail: { - message: 'Your GitHub authorization has expired — please sign out and back in', - }, - })); + fireEvent( + window, + new CustomEvent(GITHUB_REAUTH_REQUIRED_EVENT, { + detail: { + message: 'Your GitHub authorization has expired — please sign out and back in', + }, + }) + ); expect(screen.getByRole('alert')).toHaveTextContent('GitHub sign-in required'); - expect(screen.getByText('Your GitHub authorization has expired — please sign out and back in')).toBeInTheDocument(); + expect( + screen.getByText('Your GitHub authorization has expired — please sign out and back in') + ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Sign out and reconnect' })); diff --git a/apps/web/tests/unit/debug-failure-card.test.tsx b/apps/web/tests/unit/debug-failure-card.test.tsx index abed91a0f5..b43c20d73c 100644 --- a/apps/web/tests/unit/debug-failure-card.test.tsx +++ b/apps/web/tests/unit/debug-failure-card.test.tsx @@ -67,13 +67,7 @@ describe('FailureCard', () => { }); it('renders classification label and explanation for agent crash', () => { - render( - - ); + render(); expect(screen.getByText('Agent crashed')).toBeInTheDocument(); expect(screen.getByText(/exited unexpectedly/i)).toBeInTheDocument(); @@ -108,15 +102,34 @@ describe('FailureCard', () => { expect(screen.getByText('Cancelled')).toBeInTheDocument(); }); - it('shows Recoverable badge when recoverable', () => { + it('renders expected human-input expiry as a neutral lifecycle outcome', async () => { + mocks.useAuth.mockReturnValue({ isSuperadmin: true }); render( ); + expect(screen.getByText('Input request expired')).toBeInTheDocument(); + expect(screen.queryByText('Retryable')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /Input request expired/i })); + await waitFor(() => { + expect(screen.getByText(/No debugging is needed/i)).toBeInTheDocument(); + }); + expect(screen.getByText('Reason')).toBeInTheDocument(); + expect(screen.queryByText('Error')).not.toBeInTheDocument(); + expect(screen.queryByText('Copy debug report')).not.toBeInTheDocument(); + expect(screen.queryByText('View in admin errors')).not.toBeInTheDocument(); + }); + + it('shows Recoverable badge when recoverable', () => { + render(); + expect(screen.getByText('Recoverable')).toBeInTheDocument(); }); @@ -147,13 +160,7 @@ describe('FailureCard', () => { }); it('renders lifecycle timeline events from listTaskEvents', async () => { - render( - - ); + render(); fireEvent.click(screen.getByRole('button', { name: /Agent crashed/i })); @@ -248,18 +255,16 @@ describe('FailureCard', () => { const link = screen.getByText('View in admin errors'); expect(link).toBeInTheDocument(); expect(link.closest('a')).toHaveAttribute('href', expect.stringContaining('/admin/errors')); - expect(link.closest('a')).toHaveAttribute('href', expect.stringContaining('sessionId=sess-1')); + expect(link.closest('a')).toHaveAttribute( + 'href', + expect.stringContaining('sessionId=sess-1') + ); + expect(link.closest('a')).toHaveAttribute('href', expect.stringContaining('taskId=task-001')); }); }); it('shows recoverable guidance when expanded', async () => { - render( - - ); + render(); fireEvent.click(screen.getByRole('button', { name: /Agent crashed/i })); @@ -287,13 +292,7 @@ describe('FailureCard', () => { it('handles empty events gracefully', async () => { mocks.listTaskEvents.mockResolvedValue({ events: [] }); - render( - - ); + render(); fireEvent.click(screen.getByRole('button', { name: /Agent crashed/i })); @@ -305,13 +304,7 @@ describe('FailureCard', () => { it('handles event loading error gracefully', async () => { mocks.listTaskEvents.mockRejectedValue(new Error('Network error')); - render( - - ); + render(); fireEvent.click(screen.getByRole('button', { name: /Agent crashed/i })); @@ -328,13 +321,7 @@ describe('FailureCard', () => { configurable: true, }); - render( - - ); + render(); fireEvent.click(screen.getByRole('button', { name: /Agent crashed/i })); await waitFor(() => { @@ -355,13 +342,7 @@ describe('FailureCard', () => { configurable: true, }); - render( - - ); + render(); fireEvent.click(screen.getByRole('button', { name: /Agent crashed/i })); await waitFor(() => { diff --git a/apps/web/tests/unit/hooks/useProjectData.test.ts b/apps/web/tests/unit/hooks/useProjectData.test.ts index 580ad4b9f2..1563846279 100644 --- a/apps/web/tests/unit/hooks/useProjectData.test.ts +++ b/apps/web/tests/unit/hooks/useProjectData.test.ts @@ -1,5 +1,7 @@ import type { ProjectSummary } from '@simple-agent-manager/shared'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, renderHook, waitFor } from '@testing-library/react'; +import { createElement, type ReactNode } from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const mocks = vi.hoisted(() => ({ @@ -49,6 +51,23 @@ function deferred() { return { promise, resolve, reject }; } +function createWrapper() { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 60_000, + }, + }, + }); + + function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client }, children); + } + + return Wrapper; +} + describe('useProjectList', () => { beforeEach(() => { vi.clearAllMocks(); @@ -57,7 +76,9 @@ describe('useProjectList', () => { it('shows first-load loading before any data resolves', () => { mocks.listProjects.mockReturnValue(deferred().promise); - const { result } = renderHook(() => useProjectList({ pollInterval: 0 })); + const { result } = renderHook(() => useProjectList({ queryScope: 'user-1', pollInterval: 0 }), { + wrapper: createWrapper(), + }); expect(result.current.projects).toEqual([]); expect(result.current.loading).toBe(true); @@ -69,7 +90,9 @@ describe('useProjectList', () => { const summary = makeProjectSummary(); mocks.listProjects.mockResolvedValue({ projects: [summary], nextCursor: null }); - const { result } = renderHook(() => useProjectList({ pollInterval: 0 })); + const { result } = renderHook(() => useProjectList({ queryScope: 'user-1', pollInterval: 0 }), { + wrapper: createWrapper(), + }); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -91,7 +114,9 @@ describe('useProjectList', () => { it('loads an empty successful response as an explicit empty state', async () => { mocks.listProjects.mockResolvedValue({ projects: [], nextCursor: null }); - const { result } = renderHook(() => useProjectList({ pollInterval: 0 })); + const { result } = renderHook(() => useProjectList({ queryScope: 'user-1', pollInterval: 0 }), { + wrapper: createWrapper(), + }); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -107,7 +132,9 @@ describe('useProjectList', () => { .mockResolvedValueOnce({ projects: [oldSummary], nextCursor: null }) .mockReturnValueOnce(refresh.promise); - const { result } = renderHook(() => useProjectList({ pollInterval: 0 })); + const { result } = renderHook(() => useProjectList({ queryScope: 'user-1', pollInterval: 0 }), { + wrapper: createWrapper(), + }); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.projects).toMatchObject([{ id: 'proj-old' }]); @@ -129,14 +156,16 @@ describe('useProjectList', () => { expect(result.current.projects).toMatchObject([{ id: 'proj-new' }]); }); - it('surfaces refresh failure without blanking stale data', async () => { + it('suppresses refresh failure errors without blanking stale data', async () => { const oldSummary = makeProjectSummary(); const refresh = deferred<{ projects: ProjectSummary[]; nextCursor: string | null }>(); mocks.listProjects .mockResolvedValueOnce({ projects: [oldSummary], nextCursor: null }) .mockReturnValueOnce(refresh.promise); - const { result } = renderHook(() => useProjectList({ pollInterval: 0 })); + const { result } = renderHook(() => useProjectList({ queryScope: 'user-1', pollInterval: 0 }), { + wrapper: createWrapper(), + }); await waitFor(() => expect(result.current.loading).toBe(false)); @@ -152,14 +181,16 @@ describe('useProjectList', () => { }); await waitFor(() => expect(result.current.isRefreshing).toBe(false)); - expect(result.current.error).toBe('network failed'); + expect(result.current.error).toBeNull(); expect(result.current.projects).toMatchObject([{ id: 'proj-1' }]); }); it('surfaces first-load failure with no projects', async () => { mocks.listProjects.mockRejectedValue(new Error('network failed')); - const { result } = renderHook(() => useProjectList({ pollInterval: 0 })); + const { result } = renderHook(() => useProjectList({ queryScope: 'user-1', pollInterval: 0 }), { + wrapper: createWrapper(), + }); await waitFor(() => expect(result.current.loading).toBe(false)); diff --git a/apps/web/tests/unit/hooks/useProjectData.test.tsx b/apps/web/tests/unit/hooks/useProjectData.test.tsx new file mode 100644 index 0000000000..d9f1db22d5 --- /dev/null +++ b/apps/web/tests/unit/hooks/useProjectData.test.tsx @@ -0,0 +1,163 @@ +import type { ProjectSummary } from '@simple-agent-manager/shared'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useProjectList } from '../../../src/hooks/useProjectData'; + +const mocks = vi.hoisted(() => ({ + listProjects: vi.fn(), +})); + +vi.mock('../../../src/lib/api', async (importOriginal) => ({ + ...(await importOriginal()), + listProjects: mocks.listProjects, +})); + +const PROJECT = { + id: 'project-1', + name: 'Cached project', + repository: 'acme/cached-project', + githubRepoId: 101, + defaultBranch: 'main', + repoProvider: 'github', + status: 'active', + activeWorkspaceCount: 1, + activeSessionCount: 0, + lastActivityAt: '2026-08-07T20:00:00.000Z', + taskCountsByStatus: {}, + linkedWorkspaces: 1, + createdAt: '2026-08-07T19:00:00.000Z', +} satisfies ProjectSummary; + +function createWrapper() { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 60_000, + }, + }, + }); + + function Wrapper({ children }: { children: ReactNode }) { + return {children}; + } + + return { client, Wrapper }; +} + +describe('useProjectList query cache', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.listProjects.mockResolvedValue({ projects: [PROJECT] }); + }); + + it('deduplicates concurrent consumers of the same project list', async () => { + const { Wrapper } = createWrapper(); + const { result } = renderHook( + () => ({ + sidebar: useProjectList({ queryScope: 'user-1', limit: 50, pollInterval: 0 }), + page: useProjectList({ queryScope: 'user-1', limit: 50, pollInterval: 0 }), + }), + { wrapper: Wrapper }, + ); + + await waitFor(() => { + expect(result.current.sidebar.projects).toEqual([PROJECT]); + expect(result.current.page.projects).toEqual([PROJECT]); + }); + expect(mocks.listProjects).toHaveBeenCalledTimes(1); + }); + + it('reuses fresh cached data when a consumer remounts', async () => { + const { Wrapper } = createWrapper(); + const first = renderHook( + () => useProjectList({ queryScope: 'user-1', limit: 50, pollInterval: 0 }), + { wrapper: Wrapper }, + ); + await waitFor(() => expect(first.result.current.projects).toEqual([PROJECT])); + first.unmount(); + + const second = renderHook( + () => useProjectList({ queryScope: 'user-1', limit: 50, pollInterval: 0 }), + { wrapper: Wrapper }, + ); + + expect(second.result.current.projects).toEqual([PROJECT]); + expect(second.result.current.loading).toBe(false); + expect(mocks.listProjects).toHaveBeenCalledTimes(1); + }); + + it('keeps cached projects visible during a background refresh', async () => { + const { Wrapper } = createWrapper(); + const { result } = renderHook( + () => useProjectList({ queryScope: 'user-1', limit: 50, pollInterval: 0 }), + { wrapper: Wrapper }, + ); + await waitFor(() => expect(result.current.projects).toEqual([PROJECT])); + + let resolveRefresh: ((value: { projects: ProjectSummary[] }) => void) | undefined; + mocks.listProjects.mockImplementationOnce( + () => new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + + act(() => { + result.current.refresh(); + }); + + await waitFor(() => expect(result.current.isRefreshing).toBe(true)); + expect(result.current.projects).toEqual([PROJECT]); + expect(result.current.loading).toBe(false); + + await act(async () => { + resolveRefresh?.({ projects: [{ ...PROJECT, name: 'Updated project' }] }); + }); + + await waitFor(() => expect(result.current.projects[0]?.name).toBe('Updated project')); + }); + + it('keeps cached projects visible and suppresses page errors when background refresh fails', async () => { + const { Wrapper } = createWrapper(); + const { result } = renderHook( + () => useProjectList({ queryScope: 'user-1', limit: 50, pollInterval: 0 }), + { wrapper: Wrapper }, + ); + await waitFor(() => expect(result.current.projects).toEqual([PROJECT])); + + mocks.listProjects.mockRejectedValueOnce(new Error('Background refresh failed')); + act(() => { + result.current.refresh(); + }); + + await waitFor(() => expect(result.current.isRefreshing).toBe(false)); + expect(result.current.projects).toEqual([PROJECT]); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('isolates identical list parameters by authenticated query scope', async () => { + const { client, Wrapper } = createWrapper(); + mocks.listProjects + .mockResolvedValueOnce({ projects: [{ ...PROJECT, name: 'User one project' }] }) + .mockResolvedValueOnce({ projects: [{ ...PROJECT, name: 'User two project' }] }); + + const { result } = renderHook( + () => ({ + userOne: useProjectList({ queryScope: 'user-1', limit: 50, pollInterval: 0 }), + userTwo: useProjectList({ queryScope: 'user-2', limit: 50, pollInterval: 0 }), + }), + { wrapper: Wrapper }, + ); + + await waitFor(() => { + expect(result.current.userOne.projects[0]?.name).toBe('User one project'); + expect(result.current.userTwo.projects[0]?.name).toBe('User two project'); + }); + expect(client.getQueryData(['auth', 'user-1', 'projects', 'list', { limit: 50 }])).toBeDefined(); + expect(client.getQueryData(['auth', 'user-2', 'projects', 'list', { limit: 50 }])).toBeDefined(); + }); +}); diff --git a/apps/web/tests/unit/lib/chat-session-utils.test.ts b/apps/web/tests/unit/lib/chat-session-utils.test.ts index 8fa9e53075..fd503ac11e 100644 --- a/apps/web/tests/unit/lib/chat-session-utils.test.ts +++ b/apps/web/tests/unit/lib/chat-session-utils.test.ts @@ -304,6 +304,21 @@ describe('getAttentionState', () => { ).toBe('active'); }); + it('renders a failed task with an expected lifecycle reason as stopped', () => { + expect( + getAttentionState( + makeSession({ + status: 'stopped', + task: { + id: 't-1', + status: 'failed', + errorMessage: 'Human input request expired after timeout', + }, + }) + ) + ).toBe('stopped'); + }); + it('returns stopped when no attention marker and null attention field', () => { expect( getAttentionState( diff --git a/apps/web/tests/unit/pages/dashboard.test.tsx b/apps/web/tests/unit/pages/dashboard.test.tsx index 60dcde3bac..3b23b553e7 100644 --- a/apps/web/tests/unit/pages/dashboard.test.tsx +++ b/apps/web/tests/unit/pages/dashboard.test.tsx @@ -32,7 +32,7 @@ vi.mock('../../../src/hooks/useProjectData', () => ({ projects: result?.projects ?? [], loading: false, isRefreshing: false, - error: null, + error: result?.error ?? null, refresh: vi.fn(), }; }, @@ -96,6 +96,13 @@ describe('Dashboard page', () => { expect(screen.getByText('Import your first project')).toBeInTheDocument(); }); + it('does not present a failed project request as an empty account', () => { + mocks.listProjects.mockReturnValue({ projects: [], error: 'Network error' }); + renderDashboard(); + expect(screen.getByText('Network error')).toBeInTheDocument(); + expect(screen.queryByText('Import your first project')).not.toBeInTheDocument(); + }); + it('renders project cards when projects exist', () => { mocks.listProjects.mockReturnValue({ projects: [sampleProject] }); renderDashboard(); diff --git a/apps/web/tests/unit/pages/project-chat.test.tsx b/apps/web/tests/unit/pages/project-chat.test.tsx index 738b844cff..60b573f1e7 100644 --- a/apps/web/tests/unit/pages/project-chat.test.tsx +++ b/apps/web/tests/unit/pages/project-chat.test.tsx @@ -1043,7 +1043,7 @@ describe('ProjectChat profile setup wizard', () => { }, }); - const textarea = screen.getByPlaceholderText('Describe what you want the agent to do...'); + const textarea = await screen.findByPlaceholderText('Describe what you want the agent to do...'); fireEvent.change(textarea, { target: { value: 'Build a profile-first chat' } }); fireEvent.click(screen.getByRole('button', { name: 'Send' })); diff --git a/apps/web/tests/unit/pages/project.test.tsx b/apps/web/tests/unit/pages/project.test.tsx index 5acf9f3a5c..21bdcac9b5 100644 --- a/apps/web/tests/unit/pages/project.test.tsx +++ b/apps/web/tests/unit/pages/project.test.tsx @@ -1,8 +1,9 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Navigate, Route, Routes } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ToastProvider } from '../../../src/hooks/useToast'; +import { renderWithQuery } from '../../test-utils/query-test-utils'; const mocks = vi.hoisted(() => ({ getProject: vi.fn(), @@ -101,7 +102,7 @@ import { import { ProjectTasks } from '../../../src/pages/ProjectTasks'; function renderProjectPage(path = '/projects/proj-1/tasks') { - return render( + return renderWithQuery( diff --git a/apps/web/tests/unit/pages/projects.test.tsx b/apps/web/tests/unit/pages/projects.test.tsx index 15347a5e11..8a25f8bae1 100644 --- a/apps/web/tests/unit/pages/projects.test.tsx +++ b/apps/web/tests/unit/pages/projects.test.tsx @@ -1,4 +1,5 @@ import type { ProjectSummary } from '@simple-agent-manager/shared'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Route, Routes } from 'react-router'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -14,6 +15,10 @@ vi.mock('../../../src/hooks/useProjectData', () => ({ useProjectList: mocks.useProjectList, })); +vi.mock('../../../src/components/AuthProvider', () => ({ + useAuth: () => ({ user: { id: 'user-1', email: 'owner@example.com', name: 'Project Owner' } }), +})); + vi.mock('../../../src/lib/api', async (importOriginal) => ({ ...(await importOriginal()), deleteProject: mocks.deleteProject, @@ -25,34 +30,35 @@ vi.mock('../../../src/components/UserMenu', () => ({ import { Projects } from '../../../src/pages/Projects'; -const PROJECT_SUMMARY: ProjectSummary = { +const PROJECT_SUMMARY = { id: 'proj-1', - userId: 'user-1', name: 'Project One', - description: 'First project', - installationId: 'inst-1', repository: 'acme/repo-one', + githubRepoId: 101, defaultBranch: 'main', + repoProvider: 'github', status: 'active', activeWorkspaceCount: 2, activeSessionCount: 1, lastActivityAt: '2026-02-18T12:00:00.000Z', taskCountsByStatus: {}, - linkedWorkspaces: [], + linkedWorkspaces: 2, createdAt: '2026-02-18T00:00:00.000Z', - updatedAt: '2026-02-18T00:00:00.000Z', -}; +} satisfies ProjectSummary; function renderPage() { + const queryClient = new QueryClient(); return render( - - - - } /> - create} /> - - - + + + + + } /> + create} /> + + + + ); } @@ -110,6 +116,7 @@ describe('Projects page', () => { await waitFor(() => { expect(screen.getByText('Network error')).toBeInTheDocument(); }); + expect(screen.queryByText('No projects yet')).not.toBeInTheDocument(); }); it('shows skeleton cards during loading', () => { diff --git a/apps/web/tests/unit/pages/session-icon-data-flow.test.tsx b/apps/web/tests/unit/pages/session-icon-data-flow.test.tsx index b3dd0671b2..aa4c735681 100644 --- a/apps/web/tests/unit/pages/session-icon-data-flow.test.tsx +++ b/apps/web/tests/unit/pages/session-icon-data-flow.test.tsx @@ -70,6 +70,7 @@ describe('Session icon data flow: list session + task status → correct icon', taskStatus: string; sessionStatus: string; expectedTitle: string; + errorMessage?: string; }> = [ { label: 'completed task shows checkmark', @@ -83,6 +84,13 @@ describe('Session icon data flow: list session + task status → correct icon', sessionStatus: 'stopped', expectedTitle: 'Failed', }, + { + label: 'input expiry shows neutral stopped icon', + taskStatus: 'failed', + sessionStatus: 'stopped', + expectedTitle: 'Stopped', + errorMessage: 'Human input request expired after timeout', + }, { label: 'cancelled task shows pause', taskStatus: 'cancelled', @@ -97,7 +105,7 @@ describe('Session icon data flow: list session + task status → correct icon', }, ]; - for (const { label, taskStatus, sessionStatus, expectedTitle } of cases) { + for (const { label, taskStatus, sessionStatus, expectedTitle, errorMessage } of cases) { it(label, () => { const session = makeListSession({ taskId: 'task-1', @@ -105,16 +113,20 @@ describe('Session icon data flow: list session + task status → correct icon', }); const taskInfoMap = new Map([ - ['task-1', { - id: 'task-1', - title: 'Test task', - parentTaskId: null, - status: taskStatus as TaskInfo['status'], - blocked: false, - triggeredBy: 'user', - dispatchDepth: 0, - taskMode: 'task', - }], + [ + 'task-1', + { + id: 'task-1', + title: 'Test task', + parentTaskId: null, + status: taskStatus as TaskInfo['status'], + errorMessage, + blocked: false, + triggeredBy: 'user', + dispatchDepth: 0, + taskMode: 'task', + }, + ], ]); const { container } = render( @@ -123,7 +135,7 @@ describe('Session icon data flow: list session + task status → correct icon', selectedSessionId={null} onSelect={() => {}} taskInfoMap={taskInfoMap} - />, + /> ); const iconSpan = container.querySelector(`[title="${expectedTitle}"]`); @@ -144,7 +156,7 @@ describe('Session icon data flow: list session + task status → correct icon', selectedSessionId={null} onSelect={() => {}} taskInfoMap={new Map()} - />, + /> ); const iconSpan = container.querySelector('[title="Idle"]'); @@ -155,20 +167,28 @@ describe('Session icon data flow: list session + task status → correct icon', const session = makeListSession({ taskId: 'task-1', status: 'active', - attention: { kind: 'needs_input', createdAt: Date.now(), expiresAt: null, reason: 'Waiting for approval' }, + attention: { + kind: 'needs_input', + createdAt: Date.now(), + expiresAt: null, + reason: 'Waiting for approval', + }, }); const taskInfoMap = new Map([ - ['task-1', { - id: 'task-1', - title: 'Test task', - parentTaskId: null, - status: 'in_progress', - blocked: false, - triggeredBy: 'user', - dispatchDepth: 0, - taskMode: 'task', - }], + [ + 'task-1', + { + id: 'task-1', + title: 'Test task', + parentTaskId: null, + status: 'in_progress', + blocked: false, + triggeredBy: 'user', + dispatchDepth: 0, + taskMode: 'task', + }, + ], ]); const { container } = render( @@ -177,7 +197,7 @@ describe('Session icon data flow: list session + task status → correct icon', selectedSessionId={null} onSelect={() => {}} taskInfoMap={taskInfoMap} - />, + /> ); const iconSpan = container.querySelector('[title="Needs input"]'); @@ -203,16 +223,19 @@ describe('Session with existing task embed (detail endpoint)', () => { }); const taskInfoMap = new Map([ - ['task-1', { - id: 'task-1', - title: 'Test task', - parentTaskId: null, - status: 'completed', - blocked: false, - triggeredBy: 'user', - dispatchDepth: 0, - taskMode: 'task', - }], + [ + 'task-1', + { + id: 'task-1', + title: 'Test task', + parentTaskId: null, + status: 'completed', + blocked: false, + triggeredBy: 'user', + dispatchDepth: 0, + taskMode: 'task', + }, + ], ]); const { container } = render( @@ -221,7 +244,7 @@ describe('Session with existing task embed (detail endpoint)', () => { selectedSessionId={null} onSelect={() => {}} taskInfoMap={taskInfoMap} - />, + /> ); const iconSpan = container.querySelector('[title="Completed"]'); @@ -240,22 +263,37 @@ describe('SessionItem renders correct icon for each attention state', () => { expectedTitle: string; }> = [ { label: 'active', session: { status: 'active' }, expectedTitle: 'Running' }, - { label: 'idle', session: { status: 'active', isIdle: true, agentCompletedAt: Date.now() }, expectedTitle: 'Idle' }, - { label: 'completed', session: { status: 'stopped', task: { id: 't', status: 'completed' } }, expectedTitle: 'Completed' }, - { label: 'failed', session: { status: 'stopped', task: { id: 't', status: 'failed' } }, expectedTitle: 'Failed' }, + { + label: 'idle', + session: { status: 'active', isIdle: true, agentCompletedAt: Date.now() }, + expectedTitle: 'Idle', + }, + { + label: 'completed', + session: { status: 'stopped', task: { id: 't', status: 'completed' } }, + expectedTitle: 'Completed', + }, + { + label: 'failed', + session: { status: 'stopped', task: { id: 't', status: 'failed' } }, + expectedTitle: 'Failed', + }, { label: 'stopped', session: { status: 'stopped' }, expectedTitle: 'Stopped' }, { label: 'error', session: { status: 'failed' }, expectedTitle: 'Error' }, - { label: 'needs_input', session: { status: 'active', attention: { kind: 'needs_input', createdAt: Date.now(), expiresAt: null, reason: null } }, expectedTitle: 'Needs input' }, + { + label: 'needs_input', + session: { + status: 'active', + attention: { kind: 'needs_input', createdAt: Date.now(), expiresAt: null, reason: null }, + }, + expectedTitle: 'Needs input', + }, ]; for (const { label, session, expectedTitle } of iconCases) { it(`renders "${expectedTitle}" icon for ${label} state`, () => { const { container } = render( - {}} - />, + {}} /> ); const iconSpan = container.querySelector(`[title="${expectedTitle}"]`); @@ -276,16 +314,19 @@ describe('Session mode enrichment: conversation vs task', () => { }); const taskInfoMap = new Map([ - ['task-conv', { - id: 'task-conv', - title: 'Conversation task', - parentTaskId: null, - status: 'in_progress', - blocked: false, - triggeredBy: 'user', - dispatchDepth: 0, - taskMode: 'conversation', - }], + [ + 'task-conv', + { + id: 'task-conv', + title: 'Conversation task', + parentTaskId: null, + status: 'in_progress', + blocked: false, + triggeredBy: 'user', + dispatchDepth: 0, + taskMode: 'conversation', + }, + ], ]); const { container } = render( @@ -294,7 +335,7 @@ describe('Session mode enrichment: conversation vs task', () => { selectedSessionId={null} onSelect={() => {}} taskInfoMap={taskInfoMap} - />, + /> ); const modeLabel = container.querySelector('[title="Conversation"]'); @@ -308,16 +349,19 @@ describe('Session mode enrichment: conversation vs task', () => { }); const taskInfoMap = new Map([ - ['task-auto', { - id: 'task-auto', - title: 'Autonomous task', - parentTaskId: null, - status: 'in_progress', - blocked: false, - triggeredBy: 'user', - dispatchDepth: 0, - taskMode: 'task', - }], + [ + 'task-auto', + { + id: 'task-auto', + title: 'Autonomous task', + parentTaskId: null, + status: 'in_progress', + blocked: false, + triggeredBy: 'user', + dispatchDepth: 0, + taskMode: 'task', + }, + ], ]); const { container } = render( @@ -326,7 +370,7 @@ describe('Session mode enrichment: conversation vs task', () => { selectedSessionId={null} onSelect={() => {}} taskInfoMap={taskInfoMap} - />, + /> ); const modeLabel = container.querySelector('[title="Task"]'); diff --git a/apps/web/tests/unit/pages/workspace.test.tsx b/apps/web/tests/unit/pages/workspace.test.tsx index 2eaee82ea9..edbff7a3b1 100644 --- a/apps/web/tests/unit/pages/workspace.test.tsx +++ b/apps/web/tests/unit/pages/workspace.test.tsx @@ -198,7 +198,7 @@ async function findCloseTerminalButton() { const closeButtons = await screen.findAllByRole( 'button', { name: /Close Terminal/ }, - { timeout: 5_000 } + { timeout: 10_000 } ); return closeButtons[0]; } @@ -338,18 +338,21 @@ describe('Workspace page', () => { renderWorkspace('/workspaces/ws-123', true); - expect(await findCloseTerminalButton()).toBeInTheDocument(); + const closeTerminalButton = await findCloseTerminalButton(); + expect(closeTerminalButton).toBeInTheDocument(); expect(screen.getByRole('tab', { name: 'Chat tab: Claude Chat' })).toBeInTheDocument(); - fireEvent.click(await findCloseTerminalButton()); + fireEvent.click(closeTerminalButton); await waitFor(() => { const probe = screen.getByTestId('location-probe').textContent ?? ''; expect(probe).toContain('view=conversation'); expect(probe).toContain('sessionId=sess-1'); }); - expect(screen.queryByRole('tab', { name: 'Terminal tab: Terminal 1' })).not.toBeInTheDocument(); - }, 10_000); + expect( + screen.queryByRole('tab', { name: 'Terminal tab: Terminal 1' }) + ).not.toBeInTheDocument(); + }, 15_000); it('allows creating a new terminal from + menu after closing the last terminal tab', async () => { mocks.featureFlags.multiTerminal = true; @@ -357,8 +360,9 @@ describe('Workspace page', () => { renderWorkspace('/workspaces/ws-123', true); - expect(await findCloseTerminalButton()).toBeInTheDocument(); - fireEvent.click(await findCloseTerminalButton()); + const closeTerminalButton = await findCloseTerminalButton(); + expect(closeTerminalButton).toBeInTheDocument(); + fireEvent.click(closeTerminalButton); await waitFor(() => { expect(screen.queryByRole('tab', { name: /Terminal tab:/ })).not.toBeInTheDocument(); @@ -370,7 +374,7 @@ describe('Workspace page', () => { await waitFor(() => { expect(screen.getByRole('tab', { name: /Terminal tab: Terminal/ })).toBeInTheDocument(); }); - }); + }, 15_000); }); it('renders workspace detail with terminal and session sidebar', async () => { @@ -499,7 +503,7 @@ describe('Workspace page', () => { await waitFor(() => { const resolvers = mocks.useAcpSession.mock.calls .map(([options]) => options?.resolveWsUrl) - .filter((value): value is (() => Promise) => typeof value === 'function'); + .filter((value): value is () => Promise => typeof value === 'function'); expect(resolvers.length).toBeGreaterThan(0); }); @@ -583,13 +587,11 @@ describe('Workspace page', () => { }); it('retries initial git status fetch and updates the header badge when retry succeeds', async () => { - mocks.getGitStatus - .mockRejectedValueOnce(new Error('temporary failure')) - .mockResolvedValueOnce({ - staged: [{ path: 'src/app.ts', status: 'M' }], - unstaged: [], - untracked: [], - }); + mocks.getGitStatus.mockRejectedValueOnce(new Error('temporary failure')).mockResolvedValueOnce({ + staged: [{ path: 'src/app.ts', status: 'M' }], + unstaged: [], + untracked: [], + }); renderWorkspace('/workspaces/ws-123'); await screen.findByText('Workspace A'); @@ -634,7 +636,9 @@ describe('Workspace page', () => { await screen.findByText('Workspace A'); await waitFor(() => { expect(mocks.listAgents).toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Create terminal or chat session' })).not.toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Create terminal or chat session' }) + ).not.toBeDisabled(); }); fireEvent.click(screen.getByRole('button', { name: 'Create terminal or chat session' })); @@ -685,7 +689,9 @@ describe('Workspace page', () => { await screen.findByText('Workspace A'); await waitFor(() => { expect(mocks.listAgents).toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Create terminal or chat session' })).not.toBeDisabled(); + expect( + screen.getByRole('button', { name: 'Create terminal or chat session' }) + ).not.toBeDisabled(); }); fireEvent.click(screen.getByRole('button', { name: 'Create terminal or chat session' })); @@ -719,7 +725,9 @@ describe('Workspace page', () => { await waitFor( () => { - expect(screen.getByRole('button', { name: /Switch worktree \(feature\/auth\)/i })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Switch worktree \(feature\/auth\)/i }) + ).toBeInTheDocument(); }, { timeout: 5_000 } ); @@ -736,7 +744,9 @@ describe('Workspace page', () => { await waitFor( () => { - expect(screen.getByRole('button', { name: /Switch worktree \(feature\/auth\)/i })).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /Switch worktree \(feature\/auth\)/i }) + ).toBeInTheDocument(); }, { timeout: 5_000 } ); @@ -788,7 +798,9 @@ describe('Workspace page', () => { }); // Should show the recovery banner - expect(screen.getByText(/Recovered 1 hidden session still running on VM/)).toBeInTheDocument(); + expect( + screen.getByText(/Recovered 1 hidden session still running on VM/) + ).toBeInTheDocument(); // Should auto-resume in DB await waitFor(() => { @@ -841,7 +853,9 @@ describe('Workspace page', () => { renderWorkspace('/workspaces/ws-123'); await screen.findByText('Workspace A'); - expect(await screen.findByRole('button', { name: 'Open command palette' })).toBeInTheDocument(); + expect( + await screen.findByRole('button', { name: 'Open command palette' }) + ).toBeInTheDocument(); }); it('opens command palette when mobile button is tapped', async () => { diff --git a/apps/www/public/images/docs/agent-guided-login.png b/apps/www/public/images/docs/agent-guided-login.png index 347448c79d..f2faaa4161 100644 Binary files a/apps/www/public/images/docs/agent-guided-login.png and b/apps/www/public/images/docs/agent-guided-login.png differ diff --git a/apps/www/src/content/blog/agents-managing-agents.md b/apps/www/src/content/blog/agents-managing-agents.md index ef433c8e92..b6973a8f86 100644 --- a/apps/www/src/content/blog/agents-managing-agents.md +++ b/apps/www/src/content/blog/agents-managing-agents.md @@ -1,9 +1,9 @@ --- -title: "Agents Managing Agents" +title: 'Agents Managing Agents' date: 2026-04-08 author: Raphaël Titsworth-Morin category: devlog -tags: ["ai-agents", "open-source", "architecture", "mcp", "orchestration"] +tags: ['ai-agents', 'open-source', 'architecture', 'mcp', 'orchestration'] excerpt: "We built agent-to-agent orchestration into SAM. Here's what we learned about the surprisingly hard problems hiding inside 'just let agents coordinate.'" --- @@ -31,7 +31,7 @@ We built six MCP tools that give parent agents real control over their children. **`send_message_to_subtask`** injects a user-role message into a running child agent's session. The parent can course-correct a child mid-execution without stopping it. This goes directly to the child's agent session over HTTP. No polling, no queue. The child sees it as if a human typed something. -**`stop_subtask`** shuts down a child agent. But not abruptly. It sends an optional warning message first ("wrap up, you're about to be stopped"), waits a configurable grace period (default 5 seconds), then hard-stops the session. The child gets a chance to commit its work. The task status gets updated to failed with the reason. +**`stop_subtask`** shuts down a child agent. But not abruptly. It sends an optional warning message first ("wrap up, you're about to be stopped"), waits a configurable grace period (default 5 seconds), then hard-stops the session. The child gets a chance to commit its work. The task status gets updated to cancelled with the reason. **`retry_subtask`** stops a failed child and dispatches a fresh replacement. The new task description automatically includes what went wrong last time, so the retry agent has context about the failure. Retries count against the parent's child limit to prevent infinite loops. diff --git a/apps/www/src/content/docs/docs/guides/agents.md b/apps/www/src/content/docs/docs/guides/agents.md index 57498d4410..0f7dbbd1f8 100644 --- a/apps/www/src/content/docs/docs/guides/agents.md +++ b/apps/www/src/content/docs/docs/guides/agents.md @@ -16,7 +16,7 @@ SAM supports six AI coding agents. You connect the ones you want to use, then ch | **OAuth Support** | Yes (Claude Max/Pro subscriptions) | | **Get a Key** | [Anthropic Console](https://console.anthropic.com/settings/keys) | -Claude Code supports two authentication methods: an **API key** (pay-per-use) or your **Claude Max/Pro subscription**. To use a subscription, choose **Connect with Claude Code** for the [guided sign-in](#connecting-a-subscription-with-guided-sign-in) — SAM opens a Claude sign-in page and shows a copyable verification code, so you never run `claude setup-token` or paste a token by hand. Pasting a `claude setup-token` value manually is still available as a fallback. +Claude Code supports two authentication methods: an **API key** (pay-per-use) or your **Claude Max/Pro subscription**. To use a subscription, choose **Connect with Claude Code** for the [guided sign-in](#connecting-a-subscription-with-guided-sign-in) — SAM opens a Claude sign-in page, then you paste Claude's browser-displayed `code#state` value back into SAM, so you never run `claude setup-token` or paste a token by hand. Pasting a `claude setup-token` value manually is still available as a fallback. ### OpenAI Codex @@ -88,10 +88,10 @@ If you pay for **Claude Max/Pro** or a **ChatGPT** plan, you can connect that su 1. Go to **Settings → Connections** (or **Settings → Agents**) and start the connect flow for Claude Code or OpenAI Codex. 2. Choose the OAuth / subscription authentication method (rather than API key), then click **Connect with Claude Code** or **Connect with Codex**. -3. SAM opens a secure sign-in page for the provider and shows a short **verification code**. Click the **Open sign-in** link, approve access on the provider's page, and enter the code when asked. +3. Click the **Open sign-in** link and approve access on the provider's page. For Codex, enter the short code SAM displays when the provider asks. For Claude Code, copy the `code#state` value Claude displays, return to SAM, paste the complete value into the dialog, and click **Continue sign-in**. 4. Leave the SAM window open — it updates on its own. When the provider confirms, the panel shows **Connected** and your subscription credential is saved, encrypted at rest. -![The guided sign-in dialog for Claude Code: a status line reading "Waiting for sign-in", an "Open Claude sign-in" button, and a large copyable verification code with a "Copy code" button. No terminal is shown.](/images/docs/agent-guided-login.png) +![The guided sign-in dialog for Claude Code: a status line reading "Waiting for sign-in", an "Open Claude sign-in" button, and a protected field for the complete browser-displayed code. No terminal is shown.](/images/docs/agent-guided-login.png) A few things worth knowing: @@ -191,6 +191,7 @@ Running agents have access to project-aware MCP tools: | `list_sessions` | View chat sessions | | `get_session_messages` | Read conversation history (consecutive streaming tokens are concatenated into logical messages) | | `search_messages` | Search messages by keyword — uses FTS5 full-text search for completed sessions; keyword matching for active sessions | +| `list_triggers` | List this project's automation triggers, optionally filtered by status or source type | | `update_task_status` | Report progress | | `get_task_details` | Inspect task state, persisted output fields, PR/error details, session id, and bounded recent assistant diagnostics | | `complete_task` | Mark current work as done, optionally with structured completion evidence | diff --git a/apps/www/src/content/docs/docs/guides/recent-product-changes.md b/apps/www/src/content/docs/docs/guides/recent-product-changes.md index c7f0b85faf..ca05702634 100644 --- a/apps/www/src/content/docs/docs/guides/recent-product-changes.md +++ b/apps/www/src/content/docs/docs/guides/recent-product-changes.md @@ -101,7 +101,7 @@ Before anything reaches the model, SAM strips user IDs, IP addresses, user-agent ### Connect a subscription without a terminal -Connecting Claude Code or OpenAI Codex to a paid subscription used to mean running `claude setup-token` or pasting the contents of `~/.codex/auth.json` — steps that are awkward on mobile and impossible without a local terminal. There's now a **guided sign-in**: choose **Connect with Claude Code** or **Connect with Codex**, open the provider's sign-in page, enter the short verification code SAM shows you, and the panel connects itself once the provider confirms. +Connecting Claude Code or OpenAI Codex to a paid subscription used to mean running `claude setup-token` or pasting the contents of `~/.codex/auth.json` — steps that are awkward on mobile and impossible without a local terminal. There's now a **guided sign-in**: choose **Connect with Claude Code** or **Connect with Codex** and open the provider's sign-in page. Codex uses the short code SAM displays; Claude displays a `code#state` value that you paste back into SAM. The panel connects itself once the provider confirms. The manual token/`auth.json` fields are still there as a fallback, but you no longer need them for the common case. See [Connecting a subscription with guided sign-in](/docs/guides/agents/#connecting-a-subscription-with-guided-sign-in). diff --git a/apps/www/src/content/docs/docs/reference/configuration.md b/apps/www/src/content/docs/docs/reference/configuration.md index 56277e72d4..d7bf6f7d2d 100644 --- a/apps/www/src/content/docs/docs/reference/configuration.md +++ b/apps/www/src/content/docs/docs/reference/configuration.md @@ -94,21 +94,30 @@ These variables affect the local `sam` CLI process only. They are not Worker run ## Feature Flags -Codex guided subscription login has no feature-on environment variable. It is +Codex and Claude Code guided subscription login have no feature-on environment variable. They are available by default when the deployment includes the `SANDBOX`, `CREDENTIAL_SETUP_SESSION`, and `SETUP_SESSION_POOL` Worker bindings generated by SAM's deployment configuration. Omitting one of those bindings disables the guided flow. `SANDBOX_ENABLED` continues to control separate administrative -Sandbox runtime surfaces and is not required for guided Codex login. - -| Variable | Default | Description | -| -------------------------------------- | -------- | -------------------------------------------------------------------------- | -| `MAX_CONCURRENT_SETUP_SESSIONS` | `2` | Maximum concurrent guided credential-setup sessions. | -| `SETUP_SESSION_TTL_MS` | `900000` | Guided session lifetime before automatic teardown. | -| `SETUP_SESSION_CAPTURE_POLL_MS` | `3000` | Interval for checking device-login and credential-capture state. | -| `CODEX_DEVICE_AUTH_REQUEST_TIMEOUT_MS` | `30000` | Timeout for each Codex app-server JSON-RPC request. | -| `SETUP_SESSION_SWEEP_MAX_CANDIDATES` | `50` | Maximum expired sessions cleaned up by one scheduled sweep. | -| `POOL_LEASE_BUFFER_MS` | `300000` | Grace period after session TTL before a leaked capacity lease self-prunes. | +Sandbox runtime surfaces and is not required for guided login. + +| Variable | Default | Description | +| -------------------------------------- | -------- | --------------------------------------------------------------------------------------------------- | +| `MAX_CONCURRENT_SETUP_SESSIONS` | `2` | Maximum concurrent guided credential-setup sessions. | +| `SETUP_SESSION_TTL_MS` | `900000` | Guided session lifetime before automatic teardown. | +| `SETUP_SESSION_CAPTURE_POLL_MS` | `3000` | Interval for checking device-login and credential-capture state. | +| `CODEX_DEVICE_AUTH_REQUEST_TIMEOUT_MS` | `30000` | Timeout for each Codex app-server JSON-RPC request. | +| `CLAUDE_SETUP_ENTER_DELAY_MS` | `1000` | Delay before sending Enter as a separate stdin write after pasting Claude's browser-displayed code. | +| `CLAUDE_SETUP_EXCHANGE_TIMEOUT_MS` | `120000` | Maximum wait for Claude's CLI code exchange before a visible timeout. | +| `CLAUDE_SETUP_REJECTION_SETTLE_MS` | `400` | Wait for Claude CLI Ink redraws to settle before classifying an OAuth error. | +| `CLAUDE_SETUP_VERIFICATION_POLL_MS` | `500` | Interval for checking the sandbox handoff file for Claude's browser-displayed code. | +| `CLAUDE_SETUP_TTY_COLUMNS` | `512` | PTY width for `claude setup-token`, reducing opaque-token wrapping. | +| `CLAUDE_SETUP_OUTPUT_BUFFER_BYTES` | `32768` | Maximum in-memory Claude PTY output retained for parsing. | +| `CLAUDE_VERIFICATION_CODE_MAX_LENGTH` | `1024` | Maximum accepted length of Claude's browser-displayed `code#state` value. | +| `CLAUDE_SETUP_ERROR_DETAIL_MAX_LENGTH` | `160` | Maximum sanitized Claude CLI diagnostic length shown to the user. | +| `CLAUDE_OAUTH_TOKEN_MAX_LENGTH` | `8192` | Maximum captured Claude OAuth token length. | +| `SETUP_SESSION_SWEEP_MAX_CANDIDATES` | `50` | Maximum expired sessions cleaned up by one scheduled sweep. | +| `POOL_LEASE_BUFFER_MS` | `300000` | Grace period after session TTL before a leaked capacity lease self-prunes. | The variables below tune the **Instant** (Cloudflare Container) runtime — how long a session stays awake, how long a wake may take, and how many snapshot restores are attempted before a session is failed. See [Instant Sessions](/docs/guides/instant-sessions/) for what each of these means to a user. @@ -547,6 +556,7 @@ Webhook damping uses Cloudflare KV's eventually consistent read-update-write beh | `MCP_DISPATCH_MAX_DEPTH` | `3` | Max recursion depth for dispatch_task | | `MCP_DISPATCH_MAX_PER_TASK` | `5` | Max dispatched tasks per parent task | | `MCP_DISPATCH_MAX_ACTIVE_PER_PROJECT` | `10` | Max active dispatched tasks per project | +| `ORCHESTRATOR_STOP_CAS_MAX_ATTEMPTS` | `2` | Task-status CAS attempts after a hard stop | ## Voice & Text-to-Speech @@ -586,7 +596,7 @@ Webhook damping uses Cloudflare KV's eventually consistent read-update-write beh | `TASK_LIVENESS_MAX_ACP_SESSIONS` | `5` | Maximum task-scoped ACP sessions inspected per liveness probe | | `TASK_LIVENESS_PROBE_TIMEOUT_MS` | `5000` (5 sec) | Per-candidate timeout for ACP and Instant lifecycle probes used by ProjectData heartbeat deferral, idle cleanup, and stuck-task reconciliation; a timeout is inconclusive and preserves the task and workspace | | `IDLE_CLEANUP_MAX_CANDIDATES_PER_SWEEP` | `5` | Maximum exact-session task candidates inspected by a ProjectData idle-cleanup pass; workspace deletion is deferred when this bound cannot prove every reporter-scoped runtime conclusively dead | -| `IDLE_CLEANUP_MAX_RESIDENCE_MS` | `7200000` (2 hr) | Maximum residence for a ProjectData idle-cleanup schedule before repeated preserved/error outcomes stop re-arming, preserve the workspace, and surface an attention marker | +| `IDLE_CLEANUP_MAX_RESIDENCE_MS` | `7200000` (2 hr) | Maximum residence for a ProjectData idle-cleanup schedule before repeated preserved/error outcomes stop re-arming, preserve the workspace, and surface an attention marker | | `TASK_RUN_ABSOLUTE_CEILING_MS` | `86400000` (24 hr) | Absolute runaway-cost ceiling; fails even a task with a demonstrably live runtime | | `CLAUDE_CODE_COMPACTION_LOOP_DETECTOR_ENABLED` | `true` | Enable Claude Code compaction-loop shutdown from recent message evidence | | `CLAUDE_CODE_COMPACTION_LOOP_RECENT_MESSAGE_LIMIT` | `40` | Recent task-session messages to inspect for compaction-loop evidence | @@ -806,7 +816,7 @@ Applied via cloud-init on each node: | `REPO_BROWSE_MAX_INLINE_BYTES` | `1000000` (1 MB) | Max bytes to inline as text in the file viewer; larger stream raw | | `REPO_BROWSE_MAX_COMPARE_FILES` | `300` | Max changed files in an Artifacts diff before truncation | -## MCP Idea Tools +## MCP Tool Limits | Variable | Default | Description | | ----------------------------- | ------- | ----------------------------------------------- | @@ -817,17 +827,24 @@ Applied via cloud-init on each node: | `MCP_MESSAGE_SEARCH_MAX` | `20` | Max results from `search_messages` | | `MCP_MESSAGE_LIST_LIMIT` | `50` | Default page size for `get_session_messages` | | `MCP_MESSAGE_LIST_MAX` | `200` | Max messages per `get_session_messages` request | +| `MCP_TRIGGER_LIST_LIMIT` | `20` | Default page size for `list_triggers` | +| `MCP_TRIGGER_LIST_MAX` | `100` | Max triggers per `list_triggers` request | ## Web UI (Build-Time) -| Variable | Default | Description | -| -------------------------------------- | ------------------ | -------------------------------------------------------------------- | -| `VITE_FILE_PREVIEW_INLINE_MAX_BYTES` | `10485760` (10 MB) | Images below this size render inline automatically | -| `VITE_FILE_PREVIEW_LOAD_MAX_BYTES` | `52428800` (50 MB) | Images below this size show click-to-load; above shows download link | -| `VITE_ANALYTICS_MAX_QUEUE_SIZE` | `100` | Max client-side analytics events retained before oldest events drop | -| `VITE_ANALYTICS_FLUSH_THRESHOLD` | `10` | Client event count that triggers an immediate analytics flush | -| `VITE_ANALYTICS_FLUSH_INTERVAL_MS` | `5000` | Client analytics background flush interval in milliseconds | -| `VITE_DEBUG_DIAGNOSIS_EVENT_MAX_PAGES` | `100` | Max paginated diagnosis-event pages loaded per browser request | +| Variable | Default | Description | +| --------------------------------------- | ------------------ | --------------------------------------------------------------------- | +| `VITE_FILE_PREVIEW_INLINE_MAX_BYTES` | `10485760` (10 MB) | Images below this size render inline automatically | +| `VITE_FILE_PREVIEW_LOAD_MAX_BYTES` | `52428800` (50 MB) | Images below this size show click-to-load; above shows download link | +| `VITE_ANALYTICS_MAX_QUEUE_SIZE` | `100` | Max client-side analytics events retained before oldest events drop | +| `VITE_ANALYTICS_FLUSH_THRESHOLD` | `10` | Client event count that triggers an immediate analytics flush | +| `VITE_ANALYTICS_FLUSH_INTERVAL_MS` | `5000` | Client analytics background flush interval in milliseconds | +| `VITE_DEBUG_DIAGNOSIS_EVENT_MAX_PAGES` | `100` | Max paginated diagnosis-event pages loaded per browser request | +| `VITE_PROJECT_LIST_LIMIT` | `50` | Projects loaded into each shared list-cache entry | +| `VITE_PROJECT_POLL_INTERVAL_MS` | `30000` | Project-list page refresh cadence in milliseconds; `0` disables | +| `VITE_SIDEBAR_PROJECT_POLL_INTERVAL_MS` | `60000` | App-shell project-list refresh cadence in milliseconds; `0` disables | +| `VITE_PROJECT_PREFETCH_DELAY_MS` | `120` | Mouse dwell before project-detail prefetch; focus/touch are immediate | +| `VITE_BACKGROUND_FETCH_DELAY_MS` | `150` | Delay before background query activity is shown and announced | ## Analytics diff --git a/packages/shared/src/failure-classification.ts b/packages/shared/src/failure-classification.ts index c243020c26..0451a3663f 100644 --- a/packages/shared/src/failure-classification.ts +++ b/packages/shared/src/failure-classification.ts @@ -12,6 +12,7 @@ export type FailureCode = | 'cancelled' + | 'input-expired' | 'capacity' | 'provisioning' | 'agent-install' @@ -34,6 +35,8 @@ export interface FailureClassification { guidance: string; /** Whether retrying the same task/session is likely to help. */ retryable: boolean; + /** False for expected lifecycle outcomes that are not themselves bugs. */ + diagnosable: boolean; } interface FailureRule { @@ -42,6 +45,7 @@ interface FailureRule { explanation: string; guidance: string; retryable: boolean; + diagnosable: boolean; patterns: RegExp[]; } @@ -57,14 +61,32 @@ const FAILURE_RULES: FailureRule[] = [ explanation: 'The task was stopped intentionally by a user or a parent agent.', guidance: 'No action needed. Start a new task or retry if this was unintended.', retryable: true, - patterns: [/\bcancell?ed\b/, /stopped by (the )?(user|parent|orchestrator)/, /stop_subtask/], + diagnosable: false, + patterns: [ + /\bcancell?ed\b/, + /stopped[ _]by[ _](the[ _])?(user|parent|orchestrator)/, + /stop_subtask/, + ], + }, + { + code: 'input-expired', + label: 'Input request expired', + explanation: + 'No human reply arrived before the configured input window ended, so SAM closed the task.', + guidance: 'No debugging is needed. Retry the task if you still want to continue the work.', + retryable: true, + diagnosable: false, + patterns: [/human input request expired/, /input request expired after timeout/], }, { code: 'capacity', label: 'Cloud capacity', - explanation: 'The cloud provider refused to create a VM because an account or datacenter limit was reached.', - guidance: 'Free unused nodes or wait for capacity, then retry. Admins can check provider limits.', + explanation: + 'The cloud provider refused to create a VM because an account or datacenter limit was reached.', + guidance: + 'Free unused nodes or wait for capacity, then retry. Admins can check provider limits.', retryable: true, + diagnosable: true, patterns: [ /server limit reached/, /resource_unavailable/, @@ -78,8 +100,10 @@ const FAILURE_RULES: FailureRule[] = [ code: 'credentials', label: 'Credentials / billing', explanation: 'An API key, OAuth token, or account balance problem stopped the agent.', - guidance: 'Check the agent credential in Settings (API key validity, OAuth login, or provider credit balance), then retry.', + guidance: + 'Check the agent credential in Settings (API key validity, OAuth login, or provider credit balance), then retry.', retryable: true, + diagnosable: true, patterns: [ /credit balance/, /insufficient (credit|funds|quota)/, @@ -98,6 +122,7 @@ const FAILURE_RULES: FailureRule[] = [ explanation: 'The LLM provider was temporarily overloaded or rate-limited the request.', guidance: 'This is usually transient. Wait a few minutes and retry.', retryable: true, + diagnosable: true, patterns: [ /\boverloaded\b/, /rate.?limit/, @@ -112,8 +137,10 @@ const FAILURE_RULES: FailureRule[] = [ code: 'agent-install', label: 'Agent install failed', explanation: 'The coding agent could not be installed inside the workspace.', - guidance: 'Retry the task. If it persists, check the node network/debug package or try a different agent type.', + guidance: + 'Retry the task. If it persists, check the node network/debug package or try a different agent type.', retryable: true, + diagnosable: true, patterns: [ /(install|installation).*(agent|claude|codex|gemini|opencode|amp)/, /(agent|claude|codex|gemini|opencode|amp).*(install|installation) (failed|error|timed out)/, @@ -124,8 +151,10 @@ const FAILURE_RULES: FailureRule[] = [ code: 'provisioning', label: 'Provisioning failed', explanation: 'The workspace or VM did not finish starting up.', - guidance: 'Retry the task — a fresh node will be provisioned. If it repeats, check the node debug package or provider status.', + guidance: + 'Retry the task — a fresh node will be provisioned. If it repeats, check the node debug package or provider status.', retryable: true, + diagnosable: true, patterns: [ /provision(ing)? (failed|error|timed? ?out)/, /node provisioning may have failed/, @@ -141,16 +170,25 @@ const FAILURE_RULES: FailureRule[] = [ code: 'prompt-timeout', label: 'Prompt timed out', explanation: 'The agent ran a single turn longer than the allowed time and was force-stopped.', - guidance: 'Break the work into smaller prompts, or retry. Long orchestration should report progress between turns.', + guidance: + 'Break the work into smaller prompts, or retry. Long orchestration should report progress between turns.', retryable: true, - patterns: [/prompt.*(timed? ?out|force.?stopped)/, /force.?stopped.*prompt/, /acp_task_prompt_timeout/], + diagnosable: true, + patterns: [ + /prompt.*(timed? ?out|force.?stopped)/, + /force.?stopped.*prompt/, + /acp_task_prompt_timeout/, + ], }, { code: 'runtime-lost', label: 'Runtime lost', - explanation: 'The container or VM running the agent died and automatic recovery could not restore it.', - guidance: 'Your chat history is preserved. Send a follow-up message to resume on a fresh runtime, or retry the task.', + explanation: + 'The container or VM running the agent died and automatic recovery could not restore it.', + guidance: + 'Your chat history is preserved. Send a follow-up message to resume on a fresh runtime, or retry the task.', retryable: true, + diagnosable: true, patterns: [ /runtime recovery exhausted/, /instant runtime recovery/, @@ -167,8 +205,10 @@ const FAILURE_RULES: FailureRule[] = [ code: 'agent-crash', label: 'Agent crashed', explanation: 'The agent process exited unexpectedly while working.', - guidance: 'SAM usually recovers crashed sessions automatically. If it did not, send a follow-up message or retry.', + guidance: + 'SAM usually recovers crashed sessions automatically. If it did not, send a follow-up message or retry.', retryable: true, + diagnosable: true, patterns: [ /peer disconnected/, /process (exited|crashed|terminated)/, @@ -182,16 +222,25 @@ const FAILURE_RULES: FailureRule[] = [ code: 'stalled', label: 'Stalled', explanation: 'The task stopped making progress and was terminated by the platform watchdog.', - guidance: 'Retry the task. If this repeats, check whether the agent was waiting on something (input, network, a long tool call).', + guidance: + 'Retry the task. If this repeats, check whether the agent was waiting on something (input, network, a long tool call).', retryable: true, - patterns: [/task stuck in/, /\bstuck\b.*(threshold|timeout)/, /no (progress|activity|output) (for|since)/, /watchdog/], + diagnosable: true, + patterns: [ + /task stuck in/, + /\bstuck\b.*(threshold|timeout)/, + /no (progress|activity|output) (for|since)/, + /watchdog/, + ], }, { code: 'network', label: 'Network error', - explanation: 'A network problem interrupted communication between SAM and the workspace or provider.', + explanation: + 'A network problem interrupted communication between SAM and the workspace or provider.', guidance: 'Usually transient — retry. If it persists, check the node status page.', retryable: true, + diagnosable: true, patterns: [ /\betimedout\b|\beconnrefused\b|\benotfound\b/, /network (error|failure|unreachable)/, @@ -207,8 +256,10 @@ const UNKNOWN_CLASSIFICATION: FailureClassification = { code: 'unknown', label: 'Failed', explanation: 'The task failed for a reason SAM could not automatically classify.', - guidance: 'Read the error details below. Copy the debug report and paste it to an agent to investigate.', + guidance: + 'Read the error details below. Copy the debug report and paste it to an agent to investigate.', retryable: true, + diagnosable: true, }; /** @@ -231,6 +282,7 @@ export function classifyFailure( explanation: rule.explanation, guidance: rule.guidance, retryable: rule.retryable, + diagnosable: rule.diagnosable, }; } } diff --git a/packages/shared/tests/unit/failure-classification.test.ts b/packages/shared/tests/unit/failure-classification.test.ts index 1530051350..2d708356a3 100644 --- a/packages/shared/tests/unit/failure-classification.test.ts +++ b/packages/shared/tests/unit/failure-classification.test.ts @@ -5,6 +5,7 @@ import { classifyFailure } from '../../src/failure-classification'; describe('classifyFailure', () => { it.each([ ['cancelled', 'Task was cancelled by the user'], + ['input-expired', 'Human input request expired after timeout'], ['capacity', 'Cloud provider reported server limit reached'], ['credentials', 'Authentication failed: token expired'], ['provider-overload', 'Provider returned 529 overloaded'], @@ -44,6 +45,14 @@ describe('classifyFailure', () => { ); }); + it.each([ + ['stopped_by_parent: Session stalled', 'cancelled'], + ['Stopped by parent: No longer needed', 'cancelled'], + ['Human input request expired after timeout', 'input-expired'], + ] as const)('treats normal lifecycle outcome %s as non-diagnosable', (message, code) => { + expect(classifyFailure(message)).toMatchObject({ code, diagnosable: false }); + }); + it.each([undefined, null, '', 'an entirely novel failure mode'])( 'falls back to unknown for unclassified input %s', (message) => { @@ -54,6 +63,7 @@ describe('classifyFailure', () => { guidance: 'Read the error details below. Copy the debug report and paste it to an agent to investigate.', retryable: true, + diagnosable: true, }); } ); diff --git a/packages/vm-agent/internal/acp/session_host_crash.go b/packages/vm-agent/internal/acp/session_host_crash.go index 87b819c5b5..44dec772f2 100644 --- a/packages/vm-agent/internal/acp/session_host_crash.go +++ b/packages/vm-agent/internal/acp/session_host_crash.go @@ -19,7 +19,9 @@ const crashRecoveredStopReason = "recovered" // cannot continue (rapid exit, max restarts, unrecoverable crash, prompt timeout). // The control plane maps this to terminal task failure; plain "error" stopReasons // are recoverable and map to awaiting_followup in conversation mode. -const fatalErrorStopReason = "fatal_error" +const FatalErrorStopReason = "fatal_error" + +const fatalErrorStopReason = FatalErrorStopReason var diagnosticRedactionPatterns = []*regexp.Regexp{ regexp.MustCompile(`(?i)(bearer\s+)[A-Za-z0-9._~+/=-]{16,}`), diff --git a/packages/vm-agent/internal/acp/session_host_test.go b/packages/vm-agent/internal/acp/session_host_test.go index 953832fb12..754cff0524 100644 --- a/packages/vm-agent/internal/acp/session_host_test.go +++ b/packages/vm-agent/internal/acp/session_host_test.go @@ -30,6 +30,30 @@ func (w *bufferWriteCloser) Close() error { return nil } +func syntheticSecretForRedactionTest() string { + return "sk-" + "secret1234567890" +} + +func syntheticOpenAIKeyEnvLine() string { + return "OPENAI_API_" + "KEY=" + syntheticSecretForRedactionTest() +} + +func syntheticGitHubTokenForRedactionTest() string { + return "ghp_" + "secret1234567890" +} + +func syntheticGitHubTokenEnvLine() string { + return "GH_" + "TOKEN=" + syntheticGitHubTokenForRedactionTest() +} + +func syntheticSmokeTestTokenForRedactionTest() string { + return "sam_test_" + "secret-token-123456" +} + +func syntheticSmokeTestTokenEnvLine() string { + return "SMOKE_TEST_" + "TOKEN=" + syntheticSmokeTestTokenForRedactionTest() +} + // testWSPair creates a connected client+server WebSocket pair using httptest. func testWSPair(t *testing.T) (serverConn *websocket.Conn, clientConn *websocket.Conn) { t.Helper() @@ -1057,18 +1081,18 @@ func TestRedactAgentDiagnosticText(t *testing.T) { input := strings.Join([]string{ "Authorization: Bearer secret-bearer-token-123456", - "OPENAI_API_KEY=sk-secret1234567890", - "GH_TOKEN=ghp_secret1234567890", - "SMOKE_TEST_TOKEN=sam_test_secret-token-123456", + syntheticOpenAIKeyEnvLine(), + syntheticGitHubTokenEnvLine(), + syntheticSmokeTestTokenEnvLine(), "safe diagnostic line", }, "\n") got := redactAgentDiagnosticText(input) for _, leaked := range []string{ "secret-bearer-token-123456", - "sk-secret1234567890", - "ghp_secret1234567890", - "sam_test_secret-token-123456", + syntheticSecretForRedactionTest(), + syntheticGitHubTokenForRedactionTest(), + syntheticSmokeTestTokenForRedactionTest(), } { if strings.Contains(got, leaked) { t.Fatalf("redacted text leaked %q: %s", leaked, got) @@ -1257,7 +1281,7 @@ func TestSessionHost_FinishPromptWithUnrecoverablePeerDisconnectReportsActionabl host.agentSupportsLoadSession = false host.mu.Unlock() host.stderrMu.Lock() - host.stderrBuf.WriteString("fatal: peer disconnected before response\nOPENAI_API_KEY=sk-secret1234567890") + host.stderrBuf.WriteString("fatal: peer disconnected before response\n" + syntheticOpenAIKeyEnvLine()) host.stderrMu.Unlock() host.finishPromptWithError( @@ -1290,7 +1314,7 @@ func TestSessionHost_FinishPromptWithUnrecoverablePeerDisconnectReportsActionabl if report.Recovered { t.Fatal("crash report recovered = true, want false") } - if strings.Contains(report.Stderr, "sk-secret1234567890") { + if strings.Contains(report.Stderr, syntheticSecretForRedactionTest()) { t.Fatalf("crash report leaked secret: %q", report.Stderr) } if report.RecoveryError != "LoadSession recovery is unavailable; missing prerequisites: loadSessionCapability" { @@ -1475,6 +1499,123 @@ func TestSessionHost_FinishPromptDeadlineExceededReportsFatalWithoutCrashRecover } } +func TestSessionHost_ForceStoppedPromptReportsFatalCompletionExactlyOnce(t *testing.T) { + t.Parallel() + + host := newTestSessionHost(t) + defer host.Stop() + + type completion struct { + stopReason string + err error + } + completed := make(chan completion, 2) + host.config.OnPromptComplete = func(stopReason string, err error) { + completed <- completion{stopReason: stopReason, err: err} + } + + const promptID = uint64(42) + const timeoutReason = "Prompt timed out after 6h0m0s" + host.promptCancelMu.Lock() + host.activePromptID = promptID + host.promptCancelMu.Unlock() + host.promptMu.Lock() + host.promptInFlight = true + host.promptMu.Unlock() + if host.promptAttemptForID(promptID) == nil { + t.Fatal("promptAttemptForID returned nil for in-flight prompt") + } + host.mu.Lock() + host.status = HostPrompting + host.agentType = "openai-codex" + host.mu.Unlock() + + host.triggerPromptForceStopIfStuck(promptID, timeoutReason) + + select { + case got := <-completed: + if got.stopReason != fatalErrorStopReason { + t.Fatalf("stopReason = %q, want %q", got.stopReason, fatalErrorStopReason) + } + if got.err == nil || got.err.Error() != timeoutReason { + t.Fatalf("completion error = %v, want %q", got.err, timeoutReason) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for force-stop completion callback") + } + + // A late watchdog/retry for the same prompt must not terminalize twice. + host.triggerPromptForceStopIfStuck(promptID, timeoutReason) + select { + case got := <-completed: + t.Fatalf("received duplicate force-stop completion: %+v", got) + case <-time.After(50 * time.Millisecond): + } +} + +func TestSessionHost_CompetingPromptCompletionPathsClaimExactlyOnce(t *testing.T) { + t.Parallel() + + host := newTestSessionHost(t) + defer host.Stop() + + type completion struct { + stopReason string + err error + } + completed := make(chan completion, 2) + host.config.OnPromptComplete = func(stopReason string, err error) { + completed <- completion{stopReason: stopReason, err: err} + } + + const promptID = uint64(43) + const timeoutReason = "Prompt timed out after 6h0m0s" + host.promptCancelMu.Lock() + host.activePromptID = promptID + host.promptCancelMu.Unlock() + host.promptMu.Lock() + host.promptInFlight = true + host.promptMu.Unlock() + attempt := host.promptAttemptForID(promptID) + if attempt == nil { + t.Fatal("promptAttemptForID returned nil for in-flight prompt") + } + host.mu.Lock() + host.status = HostPrompting + host.agentType = "openai-codex" + host.mu.Unlock() + + start := make(chan struct{}) + var contenders sync.WaitGroup + contenders.Add(2) + go func() { + defer contenders.Done() + <-start + attempt.completeWith(host, "normal_return", nil, host.markPromptDone) + }() + go func() { + defer contenders.Done() + <-start + host.triggerPromptForceStopIfStuck(promptID, timeoutReason) + }() + close(start) + contenders.Wait() + + select { + case got := <-completed: + if got.stopReason != "normal_return" && got.stopReason != fatalErrorStopReason { + t.Fatalf("unexpected completion owner: %+v", got) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for completion owner") + } + select { + case got := <-completed: + t.Fatalf("received duplicate competing completion: %+v", got) + case <-time.After(50 * time.Millisecond): + } +} + func TestSessionHost_BroadcastAgentCrashReport(t *testing.T) { t.Parallel() @@ -1482,7 +1623,7 @@ func TestSessionHost_BroadcastAgentCrashReport(t *testing.T) { defer host.Stop() report := host.crashReport(crashRecoverySnapshot{ - stderr: "write_stdin failed: stdin is closed\nOPENAI_API_KEY=sk-secret1234567890", + stderr: "write_stdin failed: stdin is closed\n" + syntheticOpenAIKeyEnvLine(), agentType: "openai-codex", promptReqID: json.RawMessage(`"req-1"`), }, true, "") @@ -1514,7 +1655,7 @@ func TestSessionHost_BroadcastAgentCrashReport(t *testing.T) { if !strings.Contains(got.Stderr, "stdin is closed") { t.Fatalf("stderr = %q, want captured stderr", got.Stderr) } - if strings.Contains(got.Stderr, "sk-secret1234567890") { + if strings.Contains(got.Stderr, syntheticSecretForRedactionTest()) { t.Fatalf("stderr leaked secret: %q", got.Stderr) } if !strings.Contains(got.Suggestion, "OpenAI") { @@ -1560,7 +1701,7 @@ func TestSessionHost_MonitorRapidExitCrashRecoveryFailsWithReport(t *testing.T) host.sessionID = "acp-session-1" host.crashRecoveryInProgress = true host.crashAgentType = "openai-codex" - host.crashStderr = "write_stdin failed: stdin is closed\nOPENAI_API_KEY=sk-secret1234567890" + host.crashStderr = "write_stdin failed: stdin is closed\n" + syntheticOpenAIKeyEnvLine() host.mu.Unlock() host.monitorProcessExit(context.Background(), process, "openai-codex", nil, nil) @@ -1593,7 +1734,7 @@ func TestSessionHost_MonitorRapidExitCrashRecoveryFailsWithReport(t *testing.T) if report.Recovered { t.Fatal("recovered = true, want false for rapid exit") } - if strings.Contains(report.Stderr, "sk-secret1234567890") { + if strings.Contains(report.Stderr, syntheticSecretForRedactionTest()) { t.Fatalf("crash report leaked secret: %q", report.Stderr) } } diff --git a/packages/vm-agent/internal/pty/manager_test.go b/packages/vm-agent/internal/pty/manager_test.go index e74c595b5e..a82f94c427 100644 --- a/packages/vm-agent/internal/pty/manager_test.go +++ b/packages/vm-agent/internal/pty/manager_test.go @@ -6,6 +6,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/workspace/vm-agent/internal/testutil/fakedocker" ) func TestOrphanSession_SetsStateCorrectly(t *testing.T) { @@ -495,6 +497,7 @@ func TestSetContainerUser_AffectsNewSessions(t *testing.T) { // This test would have caught the regression in 6f08afe where // server.New() was moved before bootstrap.Run() but the detected // container user was never propagated to the PTY manager. + fakedocker.InstallExec(t) m := NewManager(ManagerConfig{ DefaultShell: "/bin/sh", @@ -534,6 +537,8 @@ func TestSetContainerUser_AffectsNewSessions(t *testing.T) { } func TestSetContainerUser_DoesNotAffectExistingSessions(t *testing.T) { + fakedocker.InstallExec(t) + m := NewManager(ManagerConfig{ DefaultShell: "/bin/sh", DefaultRows: 24, diff --git a/packages/vm-agent/internal/server/agent_ws.go b/packages/vm-agent/internal/server/agent_ws.go index a1ca52433d..f117fdbd52 100644 --- a/packages/vm-agent/internal/server/agent_ws.go +++ b/packages/vm-agent/internal/server/agent_ws.go @@ -249,6 +249,12 @@ func (s *Server) getOrCreateSessionHost(hostKey, workspaceID, sessionID string, cfg := s.acpConfig cfg.WorkspaceID = workspaceID cfg.SessionID = sessionID + // Activity is project-scoped. Never inherit the boot workspace's project on + // a shared node; bind every SessionHost to its owning workspace runtime. + cfg.ProjectID = "" + if runtime != nil { + cfg.ProjectID = strings.TrimSpace(runtime.ProjectID) + } cfg.OnPromptComplete = nil cfg.GitTokenFetcher = s.gitHubTokenFetcherForWorkspace(workspaceID) @@ -306,6 +312,12 @@ func (s *Server) getOrCreateSessionHost(hostKey, workspaceID, sessionID string, } hasTaskCtx = true } + if cfg.ProjectID == "" && hasTaskCtx { + cfg.ProjectID = strings.TrimSpace(taskCtx.ProjectID) + } + if cfg.ProjectID == "" && s.config != nil && workspaceID == strings.TrimSpace(s.config.WorkspaceID) { + cfg.ProjectID = strings.TrimSpace(s.config.ProjectID) + } if hasTaskCtx && s.config != nil && taskCtx.ProjectID != "" && taskCtx.TaskID != "" && taskCtx.WorkspaceID != "" { cfg.OnPromptComplete = s.makeTaskCompletionCallback( s.config.ControlPlaneURL, diff --git a/packages/vm-agent/internal/server/health_test.go b/packages/vm-agent/internal/server/health_test.go index 0a662588c8..7bd6f442fc 100644 --- a/packages/vm-agent/internal/server/health_test.go +++ b/packages/vm-agent/internal/server/health_test.go @@ -452,7 +452,9 @@ func TestDeploymentHeartbeatExplicitRetireEnvironment(t *testing.T) { deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - if _, err := os.Stat(filepath.Join(h.sitesDir, "env-a.caddy")); os.IsNotExist(err) { + _, enginePresent := h.server.deploymentEnginesSnapshot()["env-a"] + _, snippetErr := os.Stat(filepath.Join(h.sitesDir, "env-a.caddy")) + if os.IsNotExist(snippetErr) && !enginePresent { break } time.Sleep(10 * time.Millisecond) diff --git a/packages/vm-agent/internal/server/server.go b/packages/vm-agent/internal/server/server.go index b779ae5cf4..4ac76ab3a9 100644 --- a/packages/vm-agent/internal/server/server.go +++ b/packages/vm-agent/internal/server/server.go @@ -58,7 +58,7 @@ type taskCallbackContext struct { TaskMode string } -const fatalErrorStopReason = "fatal_error" +const fatalErrorStopReason = acp.FatalErrorStopReason var taskCallbackDiagnosticRedactionPatterns = []*regexp.Regexp{ regexp.MustCompile(`(?i)(bearer\s+)[A-Za-z0-9._~+/=-]{16,}`), diff --git a/packages/vm-agent/internal/server/session_activity_routing_test.go b/packages/vm-agent/internal/server/session_activity_routing_test.go new file mode 100644 index 0000000000..6fcde73210 --- /dev/null +++ b/packages/vm-agent/internal/server/session_activity_routing_test.go @@ -0,0 +1,99 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "sort" + "sync" + "testing" + "time" + + "github.com/workspace/vm-agent/internal/acp" + "github.com/workspace/vm-agent/internal/agentsessions" + "github.com/workspace/vm-agent/internal/config" + "github.com/workspace/vm-agent/internal/messagereport" +) + +func TestSessionHostActivityUsesOwningWorkspaceProject(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + paths := make([]string, 0, 2) + reported := make(chan struct{}, 2) + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + paths = append(paths, r.URL.Path) + mu.Unlock() + reported <- struct{}{} + w.WriteHeader(http.StatusNoContent) + })) + defer controlPlane.Close() + + serverConfig := &config.Config{ + NodeID: "node-routing", + ControlPlaneURL: controlPlane.URL, + CallbackToken: "node-callback-token", + ACPMessageBufferSize: 32, + ACPViewerSendBuffer: 8, + } + s := &Server{ + config: serverConfig, + acpConfig: acp.GatewayConfig{ + NodeID: serverConfig.NodeID, + ControlPlaneURL: controlPlane.URL, + CallbackToken: serverConfig.CallbackToken, + HTTPClient: controlPlane.Client(), + }, + workspaces: map[string]*WorkspaceRuntime{ + "workspace-a": {ID: "workspace-a", ProjectID: "project-a"}, + "workspace-b": {ID: "workspace-b", ProjectID: "project-b"}, + }, + agentSessions: agentsessions.NewManager(), + sessionHosts: map[string]*acp.SessionHost{}, + sessionMcpServers: map[string][]acp.McpServerEntry{}, + sessionProfileOvr: map[string]profileOverrides{}, + sessionTaskCtx: map[string]taskCallbackContext{}, + messageReporters: map[string]*messagereport.Reporter{}, + } + + for _, tc := range []struct { + workspaceID string + sessionID string + }{ + {workspaceID: "workspace-a", sessionID: "session-a"}, + {workspaceID: "workspace-b", sessionID: "session-b"}, + } { + runtime := s.workspaces[tc.workspaceID] + host := s.getOrCreateSessionHost( + tc.workspaceID+":"+tc.sessionID, + tc.workspaceID, + tc.sessionID, + agentsessions.Session{ID: tc.sessionID, WorkspaceID: tc.workspaceID}, + runtime, + "", + ) + host.Stop() + } + + for range 2 { + select { + case <-reported: + case <-time.After(500 * time.Millisecond): + t.Fatal("timed out waiting for project-scoped activity callbacks") + } + } + + mu.Lock() + sort.Strings(paths) + got := append([]string(nil), paths...) + mu.Unlock() + want := []string{ + "/api/projects/project-a/acp-sessions/session-a/activity", + "/api/projects/project-b/acp-sessions/session-b/activity", + } + for index := range want { + if got[index] != want[index] { + t.Fatalf("activity paths = %v, want %v", got, want) + } + } +} diff --git a/packages/vm-agent/internal/server/task_callback_scoping_test.go b/packages/vm-agent/internal/server/task_callback_scoping_test.go index 8ed844bdda..cf31028704 100644 --- a/packages/vm-agent/internal/server/task_callback_scoping_test.go +++ b/packages/vm-agent/internal/server/task_callback_scoping_test.go @@ -8,10 +8,21 @@ import ( "net/http/httptest" "strings" "testing" + "time" + "github.com/workspace/vm-agent/internal/acp" + "github.com/workspace/vm-agent/internal/agentsessions" "github.com/workspace/vm-agent/internal/config" ) +func syntheticProviderSecretForRedactionTest() string { + return "sk-" + "secret1234567890" +} + +func syntheticProviderAPIKeyError() string { + return "provider exhausted credits with api_" + "key=" + syntheticProviderSecretForRedactionTest() +} + func TestBootMessageReporterWorkspaceIDRequiresRealWorkspace(t *testing.T) { t.Parallel() @@ -111,7 +122,7 @@ func TestTaskCompletionCallbackTreatsConversationErrorStopReasonAsRecoverable(t t, config.TaskModeConversation, "error", - errors.New("provider exhausted credits with api_key=sk-secret1234567890"), + errors.New(syntheticProviderAPIKeyError()), ) if body["toStatus"] != nil { @@ -124,7 +135,7 @@ func TestTaskCompletionCallbackTreatsConversationErrorStopReasonAsRecoverable(t if !ok || errorMessage == "" { t.Fatalf("errorMessage = %v, want non-empty string", body["errorMessage"]) } - if strings.Contains(errorMessage, "sk-secret1234567890") { + if strings.Contains(errorMessage, syntheticProviderSecretForRedactionTest()) { t.Fatalf("errorMessage leaked secret: %q", errorMessage) } } @@ -142,6 +153,70 @@ func TestTaskCompletionCallbackTreatsFatalErrorStopReasonAsTerminalFailure(t *te } } +func TestTaskSessionHostBindsFatalCompletionToControlPlaneCallback(t *testing.T) { + t.Parallel() + + type callbackRequest struct { + Path string + Body map[string]interface{} + } + received := make(chan callbackRequest, 1) + controlPlane := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decode callback body: %v", err) + } + received <- callbackRequest{Path: r.URL.Path, Body: body} + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(controlPlane.Close) + + const hostKey = "workspace-a/session-a" + s := &Server{ + config: &config.Config{ + ControlPlaneURL: controlPlane.URL, + HTTPCallbackTimeout: 0, + CallbackToken: "node-token", + }, + sessionHosts: make(map[string]*acp.SessionHost), + sessionTaskCtx: map[string]taskCallbackContext{hostKey: {ProjectID: "project-1", TaskID: "task-a", WorkspaceID: "workspace-a", TaskMode: config.TaskModeConversation}}, + sessionMcpServers: make(map[string][]acp.McpServerEntry), + sessionProfileOvr: make(map[string]profileOverrides), + agentSessions: agentsessions.NewManager(), + workspaces: make(map[string]*WorkspaceRuntime), + } + host := s.getOrCreateSessionHost( + hostKey, + "workspace-a", + "session-a", + agentsessions.Session{ID: "session-a", WorkspaceID: "workspace-a"}, + nil, + "", + ) + t.Cleanup(host.Stop) + + callback := host.OnPromptCompleteCallback() + if callback == nil { + t.Fatal("task-owned SessionHost has no completion callback") + } + callback(acp.FatalErrorStopReason, errors.New("Prompt timed out after 6h0m0s")) + + select { + case got := <-received: + if !strings.Contains(got.Path, "/tasks/task-a/status/callback") { + t.Fatalf("callback path = %q, want task-a status callback", got.Path) + } + if got.Body["toStatus"] != "failed" { + t.Fatalf("toStatus = %v, want failed", got.Body["toStatus"]) + } + if got.Body["errorMessage"] != "Prompt timed out after 6h0m0s" { + t.Fatalf("errorMessage = %v, want exact timeout reason", got.Body["errorMessage"]) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for bound SessionHost callback") + } +} + func TestTaskCompletionCallbackTreatsTaskModeErrorStopReasonAsTerminalFailure(t *testing.T) { t.Parallel() diff --git a/packages/vm-agent/internal/server/update_after_bootstrap_test.go b/packages/vm-agent/internal/server/update_after_bootstrap_test.go index 32697063dd..648d9e3b8b 100644 --- a/packages/vm-agent/internal/server/update_after_bootstrap_test.go +++ b/packages/vm-agent/internal/server/update_after_bootstrap_test.go @@ -9,6 +9,7 @@ import ( "github.com/workspace/vm-agent/internal/config" "github.com/workspace/vm-agent/internal/errorreport" "github.com/workspace/vm-agent/internal/pty" + "github.com/workspace/vm-agent/internal/testutil/fakedocker" ) // newTestServerPreBootstrap creates a Server in the state it would be in @@ -126,6 +127,8 @@ func TestUpdateAfterBootstrap_SkipsEmptyContainerUser(t *testing.T) { // // If step 3 is removed or broken, this test fails. func TestBootstrapLifecycle_SessionsUseDetectedUser(t *testing.T) { + fakedocker.InstallExec(t) + containerID := "test-container-lifecycle" resolver := func() (string, error) { return containerID, nil diff --git a/packages/vm-agent/internal/testutil/fakedocker/fakedocker.go b/packages/vm-agent/internal/testutil/fakedocker/fakedocker.go new file mode 100644 index 0000000000..aad8d78d1d --- /dev/null +++ b/packages/vm-agent/internal/testutil/fakedocker/fakedocker.go @@ -0,0 +1,53 @@ +package fakedocker + +import ( + "os" + "path/filepath" + "testing" +) + +// InstallExec puts a tiny fake docker binary at the front of PATH for tests +// that need to exercise docker-exec argument construction without requiring a +// Docker daemon or container on the test runner. +func InstallExec(t testing.TB) { + t.Helper() + + dir := t.TempDir() + dockerPath := filepath.Join(dir, "docker") + script := `#!/bin/sh +if [ "$1" != "exec" ]; then + echo "fake docker only supports exec" >&2 + exit 1 +fi +shift +while [ "$#" -gt 0 ]; do + case "$1" in + -i|-t|-it|-ti) + shift + ;; + -u|-w|-e) + shift 2 + ;; + --) + shift + break + ;; + -*) + shift + ;; + *) + shift + break + ;; + esac +done +if [ "$#" -eq 0 ]; then + exit 0 +fi +exec "$@" +` + if err := os.WriteFile(dockerPath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake docker: %v", err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} diff --git a/scripts/quality/deploy-reusable-workflow.test.ts b/scripts/quality/deploy-reusable-workflow.test.ts index 352f5fd347..c7bfbb00cb 100644 --- a/scripts/quality/deploy-reusable-workflow.test.ts +++ b/scripts/quality/deploy-reusable-workflow.test.ts @@ -276,12 +276,19 @@ describe('deploy reusable workflow', () => { } }); - it('passes the bounded diagnosis event-page limit into the web build', () => { + it('passes documented frontend limits and timing overrides into the web build', () => { const build = stepBlock('Build Applications'); - expect(build).toContain( - "VITE_DEBUG_DIAGNOSIS_EVENT_MAX_PAGES: ${{ vars.VITE_DEBUG_DIAGNOSIS_EVENT_MAX_PAGES || '100' }}" - ); + for (const mapping of [ + "VITE_DEBUG_DIAGNOSIS_EVENT_MAX_PAGES: ${{ vars.VITE_DEBUG_DIAGNOSIS_EVENT_MAX_PAGES || '100' }}", + "VITE_PROJECT_LIST_LIMIT: ${{ vars.VITE_PROJECT_LIST_LIMIT || '50' }}", + "VITE_PROJECT_POLL_INTERVAL_MS: ${{ vars.VITE_PROJECT_POLL_INTERVAL_MS || '30000' }}", + "VITE_SIDEBAR_PROJECT_POLL_INTERVAL_MS: ${{ vars.VITE_SIDEBAR_PROJECT_POLL_INTERVAL_MS || '60000' }}", + "VITE_PROJECT_PREFETCH_DELAY_MS: ${{ vars.VITE_PROJECT_PREFETCH_DELAY_MS || '120' }}", + "VITE_BACKGROUND_FETCH_DELAY_MS: ${{ vars.VITE_BACKGROUND_FETCH_DELAY_MS || '150' }}", + ]) { + expect(build).toContain(mapping); + } }); it('does not fail preflight when GitHub integration secrets are missing', () => { diff --git a/tasks/active/2026-08-09-correlate-vm-incidents-with-task-lifecycle.md b/tasks/active/2026-08-09-correlate-vm-incidents-with-task-lifecycle.md new file mode 100644 index 0000000000..b89bcadd72 --- /dev/null +++ b/tasks/active/2026-08-09-correlate-vm-incidents-with-task-lifecycle.md @@ -0,0 +1,148 @@ +# Correlate VM Incidents with Task Lifecycle + +**Status:** Active +**SAM task:** `01KZJYFB33CYE956414QT4P94S` +**SAM idea:** `01KZK6NG4MTWNSVJAZB2B0YENY` +**Branch:** `sam/came-across-screenshot-tell-t4p94s` +**PR:** https://github.com/raphaeltm/simple-agent-manager/pull/1779 + +## Problem + +A fatal VM runtime event can capture useful safe evidence without producing a coherent task-level failure story. Production incident `01KZJMDJT3ET7Z3BZ40TTX81Z5` recorded `ACP prompt force-stopped` after a six-hour timeout and uploaded a 1,138-byte allowlisted diagnostic artifact, but the task remained active until its parent manually stopped it. That later control action was stored as a generic failed task and became the visible red failure. + +The evidence and lifecycle records also use different correlation dimensions: + +- VM incident ingestion persists node/workspace IDs in `apps/api/src/routes/node-diagnostic-incidents.ts`. +- Task and chat failure surfaces deep-link the admin error list by task/session IDs in `apps/web/src/components/debug/FailureCard.tsx`. +- Admin error filtering combines supplied identifiers with `AND` in `apps/api/src/services/observability.ts`. + +As a result, the already-captured incident and artifact are not reliably reachable from the visible task failure. + +## Preflight + +### Change classification + +- `cross-component-change`: VM agent → Worker callback → D1/observability D1 → web failure surface. +- `business-logic-change`: fatal prompt timeout and parent-stop terminal-state semantics. +- `public-surface-change`: expected lifecycle endings must render neutrally rather than as unknown platform failures. +- `docs-sync-change`: task post-mortem and cross-boundary prevention rule. +- `ui-change`: failure-card classification styling/labels change through the shared classifier. + +No external API, migration, credential, billing, or deployment configuration change is planned. One advanced environment override bounds task-status compare-and-set attempts after a parent hard stop. + +### Data-flow trace + +1. A task-driven VM session configures its completion callback in `packages/vm-agent/internal/server/server.go:makeTaskCompletionCallback()`. +2. The prompt watchdog expires in `packages/vm-agent/internal/acp/session_host_prompt_state.go:watchPromptTimeout()` and `triggerPromptForceStopIfStuck()` force-stops the agent. +3. `SessionHost.reportLifecycle()` sends the error to the durable VM outbox; `packages/vm-agent/internal/errorreport/reporter.go:Report()` assigns the stable incident ID and requests a safe snapshot. +4. The VM posts the durable report to `POST /api/nodes/:id/errors`; `apps/api/src/routes/node-diagnostic-incidents.ts` persists the exact observability row, then `ensurePendingIncidents()` creates incident metadata for artifact registration. +5. The task completion callback posts `toStatus=failed` to `apps/api/src/routes/tasks/callback.ts`, making the genuine prompt timeout the task outcome. +6. The task failure card builds its admin link in `apps/web/src/components/debug/FailureCard.tsx`; correlated task/session IDs on the platform error allow the admin error route to enrich the row with its diagnostic incident and artifact. +7. If a parent intentionally stops an otherwise-active child, `apps/api/src/routes/mcp/orchestration-comms.ts:handleStopSubtask()` must write `cancelled`, not `failed`. + +### Assumptions and verification + +- **Verified from production data:** the exact VM error and its artifact existed; task/session IDs were null; the task remained active until the later parent stop. +- **Verified in code:** the force-stop branch clears `activePromptID`, causing `HandlePrompt()` to return without calling `finishPrompt()`, and does not independently invoke `notifyPromptComplete()`. +- **Verified in code:** a task and its workspace share a unique `chatSessionId`, providing an authoritative correlation only when workspace, node, and session binding agree. +- **Verified in code:** intentional SAM stop paths already use `cancelled`; the MCP orchestration communication path is the inconsistent writer. +- **Not verified on staging:** explicitly excluded by the user's request. Automated boundary and behavioral tests must close the local verification gap; the sweep will own staging. + +### Impact and risk analysis + +- Fatal prompt timeouts will now terminalize task state instead of silently stranding it. +- VM incident ingestion will perform a bounded set of main-D1 correlation lookups per batch, chunked at the shared D1 100-bind protocol ceiling (the configured ingestion batch limit remains authoritative). +- Correlation must fail open for observability delivery and fail closed for identity attachment: an absent, stale, mismatched, or ambiguous binding leaves task/session null. +- Missing or malformed producer timestamps must use receipt time only for evidence persistence and must never participate in task-lifetime correlation; every rejected attachment emits a bounded structured reason and action. +- Stable incident retry semantics must allow null correlation fields to be enriched without allowing a non-null ID to be rebound. +- Parent-stop control must stop the runtime before accepting the cancelled terminal state and must run standard terminal cleanup/synchronization. + +### Constitution alignment + +- No new URL, timeout, arbitrary limit, or identifier is hardcoded. D1 chunking derives from the shared protocol ceiling, and parent-stop CAS attempts use a bounded operator override. +- Existing configurable prompt timeout and VM error batch limits remain authoritative. +- Canonical IDs are validated through node/workspace/session relationships; display names are never used for correlation. + +### Documentation plan + +- Keep this task record as the bug post-mortem and evidence trace. +- Add a cross-boundary rule covering fatal-runtime completion callbacks, correlation joins, and intentional termination state consistency. +- Update the public agents-managing-agents article so `stop_subtask` documents the canonical cancelled result. +- Document the advanced parent-stop CAS attempt override in the environment example and configuration reference. + +## Implementation Plan + +- [x] Make watchdog force-stop notify task completion exactly once with the fatal timeout reason. +- [x] Correlate VM reports to task/session only when the callback node owns the workspace and task/workspace session bindings agree for the incident timestamp. +- [x] Make strict stable-incident persistence permit monotonic null → correlated enrichment while rejecting conflicting non-null rebinding. +- [x] Record MCP parent stops as `cancelled`, including completed timestamp, status event, trigger synchronization, and terminal cleanup. +- [x] Classify `stopped_by_parent` and expected human-input expiry as non-bug lifecycle outcomes with neutral failure-card presentation. +- [x] Add the preventive cross-boundary quality rule. +- [x] Run focused tests, impacted package gates, full repository gates, and all applicable specialist reviews. +- [x] Open a PR, get required CI green, and leave it unmerged. +- [x] Skip staging deployment and verification at the user's explicit request; the sweep owns that phase. + +## Acceptance Criteria + +- [x] A forced task prompt timeout invokes the fatal completion callback with the timeout reason and does not leave the task active. +- [x] The VM incident/error ingestion vertical slice persists matching task/session IDs for an authoritative node → workspace → session → task binding. +- [x] Mismatched node/workspace bindings, task/workspace session mismatches, post-dated tasks, and ambiguous candidates remain uncorrelated. +- [x] A stable incident retry may enrich missing task/session IDs but can never replace a conflicting non-null ID. +- [x] Parent `stop_subtask` invokes the runtime stop before writing `cancelled`, records a cancelled event, synchronizes trigger state, and performs terminal cleanup. +- [x] Legacy `stopped_by_parent` messages and human-input expiry classify as lifecycle outcomes instead of unknown failures. +- [x] Failure-card unit and Playwright visual tests prove lifecycle outcomes are neutral and usable at mobile and desktop sizes. +- [x] Focused and full validation pass; the PR's required GitHub checks are green. +- [x] PR remains open and unmerged; no staging workflow is dispatched. + +## Post-Mortem + +### What broke + +The six-hour prompt watchdog killed the agent but never informed the control plane that the task had fatally ended. The task therefore stalled. When its parent later stopped it, the stop was incorrectly stored as a failed task, and that later lifecycle action displaced the genuine timeout in the user-visible story. The earlier VM incident retained useful evidence but lacked the task/session IDs used by the failure-card debugger link. + +### Root cause + +- Commit `c11b8aa76f` (2026-05-09) introduced the force-stop path. It deliberately clears `activePromptID`, so `HandlePrompt()` exits before normal completion handling, but the branch omitted the replacement `notifyPromptComplete(fatal_error, timeout)` call. +- Commit `4542295f81` (2026-04-07) introduced MCP parent stopping with `status='failed'` and a `stopped_by_parent` underscored reason, diverging from the platform's later canonical `cancelled` stop semantics. +- PR #1750 / commit `a857a337e` (2026-08-07) added durable VM incidents and safe artifacts but persisted only the identifiers present in the VM report (node/workspace), while the task failure link added by PR #1765 resolves by task/session. + +### Timeline + +- 2026-04-07: parent-stop path begins writing failed terminal state. +- 2026-05-09: force-stop watchdog begins bypassing task completion callback. +- 2026-08-07–08: incident artifacts and rich failure/debug surfaces ship independently with incompatible correlation dimensions. +- 2026-08-09 06:47 UTC: production prompt is force-stopped; safe evidence uploads, but task remains active. +- 2026-08-09 09:34 UTC: parent stops the stalled task; generic failed card becomes visible. +- 2026-08-09: screenshot-led investigation correlates the three records and identifies the broken handoffs. + +### Why it was not caught + +- Timeout tests exercised the normal deadline-return path, not a provider process that remained stuck beyond cancellation grace and entered the forced-stop branch. +- Parent-stop tests accepted the local `failed` behavior instead of comparing it with the canonical stop/cancel contract used by other orchestration paths. +- Incident tests proved artifact durability and per-row identifiers independently, but no vertical slice began with a workspace-only VM report and asserted that a task/session deep link could retrieve the resulting evidence. + +### Class of bug + +Cross-boundary lifecycle and identity fragmentation: each component behaved plausibly in isolation, but a fatal runtime transition, a later control-plane termination, and a separate observability store did not preserve one causal/correlation contract end to end. + +### Process fix + +Extend `.claude/rules/23-cross-boundary-contract-tests.md` to require: + +1. every fatal runtime-owned exit path to prove its terminal callback reaches authoritative task state; +2. every intentional stop/cancel writer to use the canonical terminal status and cleanup contract; +3. observability correlation tests to start with the least-correlated producer payload and prove safe, non-ambiguous enrichment through the consumer's lookup dimensions. + +## Verification Record + +- Shared failure-classification tests: 23 passed. +- API focused correlation, strict persistence, observability ingestion, parent-stop, and task-callback suites: 79 passed. +- Web focused failure-card and lifecycle presentation suites: 95 passed. +- VM-agent ACP tests, including exact-once fatal completion: passed. +- Playwright lifecycle audit at 375×667 and 1280×800: passed with no horizontal overflow; both screenshots were inspected. +- Full repository build and typecheck passed. The first full test attempt passed 6,787 of 6,788 API tests; one unrelated orchestration-test setup hook exceeded its 10-second timeout under suite load, then the complete 31-test file passed in 2.88 seconds in isolation. The full retry cleared that failure and passed all 6,788 API tests, but one unrelated command-palette assertion failed after 2,938 web tests passed; its complete 23-test file then passed in 3.42 seconds in isolation. Required PR checks are the final full-suite green gate. +- Repository lint, changed-file formatting, quality checks, `git diff --check`, and VM-agent `go vet ./...`: passed. Existing lint warnings and repository-wide formatting drift remain unchanged. +- Full VM-agent `go test ./...` is blocked locally only in three Docker-dependent PTY/server tests because this workspace has no Docker binary; all other packages, including the changed `internal/acp` package and server callback tests, passed. +- Go, Cloudflare, test, security, constitution, environment, documentation-sync, and UI/UX specialist reviews passed after their findings were addressed. +- PR #1779 passed the complete implementation rollup: tests, Playwright visual audits, VM-agent smoke/integration/E2E, Durable Objects, build, typecheck, lint, code quality, UI compliance, Pulumi tests, benchmarks, and SonarCloud. The PR preflight evidence block was corrected before the final synchronization event. +- Staging was not deployed or verified, per the user's explicit request. diff --git a/tasks/active/2026-08-15-scheduler-lifecycle-race-lab.md b/tasks/active/2026-08-15-scheduler-lifecycle-race-lab.md new file mode 100644 index 0000000000..3a33eda4f5 --- /dev/null +++ b/tasks/active/2026-08-15-scheduler-lifecycle-race-lab.md @@ -0,0 +1,181 @@ +# Build a scheduler lifecycle race lab + +**Priority**: High +**Created**: 2026-08-15 +**SAM task**: `01M019MMRSQB5P5K5HPCV3KC20` +**Idea**: `01M01CS8PMWKD7AX7Q88V3WWKN` + +## Problem + +Recent scheduler incidents survived the existing unit and staging checks because the failures +emerged only when independently reasonable control loops observed different lifecycle states. The +production failures included sessions whose first sleep precondition failed and then became +ineligible for retry, provisioning nodes destroyed before their owning task created a workspace, +and activity or ownership signals that were present in one store but absent from another. + +Running hundreds of real sessions for days is too slow and expensive for routine development. We +need a credential-free local test lab that compresses virtual time, deliberately interleaves +scheduler actions, and exercises the real Cloudflare persistence boundaries where atomicity matters. +The same tests must run in pull-request CI, with a larger but still local exploration profile on a +schedule. + +Staging and production-like soak tests are explicitly out of scope for this task. The pull request +must remain unmerged until Raphaël explicitly authorizes a merge. + +## Research Findings + +1. `tasks/active/2026-08-14-fix-stranded-session-sleep-cleanup.md` documents a cross-control-plane + lifecycle failure: completion happened while the ACP prompt was still active, the failed sleep + state was outside the retry selector, and sessions without snapshot rows never entered the + sweep. Existing tests asserted local call order or seeded only the happy snapshot state. +2. `tasks/archive/2026-08-07-fix-provisioning-node-cleanup-race.md` documents cleanup destroying a + newly provisioned task-owned node before its first heartbeat or workspace. The missing states + were an active task claim and the pre-heartbeat provisioning grace window. +3. The runtime recovery work found the same structural testing gap: isolated stores looked + correct, while a stale secondary heartbeat could defeat the authoritative recovering owner when + the three actors were composed. +4. `findNodeWithCapacity()` reads workspace occupancy before `createAndProvisionWorkspace()` + inserts its `creating` row. Concurrent TaskRunner Durable Objects can therefore observe the same + final slot unless placement has a durable claim or a final atomic recheck. +5. General node cleanup performs provider deletion before marking the D1 node deleted. The trial + cleanup path already demonstrates a safer `destroying` claim with a final active-workspace + predicate, which is a useful production pattern for a Workerd race slice. +6. VM-agent activity reporting reads `SessionHost.config.ProjectID`, while the server session + factory supplies workspace and session IDs but can omit the workspace runtime's project ID. + A cross-project contract test should prove that each activity event reaches its owning project. +7. The repository already has `fast-check`, Vitest, real local D1 and Durable Objects through + `@cloudflare/vitest-pool-workers`, and Go boundary injection. No external infrastructure or + credentials are required for these layers. +8. Deterministic concurrency testing works best with a virtual clock/event queue, a simple model, + explicit yield points around persistent/external boundaries, replayable seeds and shrink paths, + safety checks after every event, and a recovery phase after fault injection stops. +9. Small deterministic scenarios should run on every pull request; many more seeds and longer + traces can run in a credential-free nightly workflow. Failures must print enough seed, path, and + trace data to reproduce locally. + +## Implementation Checklist + +- [x] Add a deterministic virtual-time scheduler lifecycle harness with generated tasks, sessions, + workspaces, nodes, transient failures, stale observations, and explicit interleavings. +- [x] Check safety invariants after every simulated transition and liveness/convergence invariants + after faults stop and all due recovery work drains. +- [x] Add historical calibration scenarios proving the oracle rejects the stranded sleep-retry and + provisioning-cleanup behaviors from the recent production incidents. +- [x] Add a bounded pull-request profile with reproducible seed/path diagnostics. +- [x] Add a deeper credential-free nightly profile that explores more seeds, longer traces, and + larger small-world state spaces without calling staging or cloud providers. +- [x] Add Workerd vertical slices using real local D1/Durable Objects for cleanup-versus-placement, + capacity contention, and cross-store session retry/reconciliation races where applicable. +- [x] Fix any scheduler atomicity or ownership defects the discriminating tests expose, preserving + a regression test for each fix. +- [x] Add a VM-agent contract test for project-scoped activity routing and fix omitted project + context if reproduced. +- [x] Wire the fast profile into pull-request CI and the deep profile into a scheduled/manual CI + workflow using pinned actions and no external credentials. +- [x] Run the fast and Workerd suites repeatedly locally, run the deeper profile enough times to + collect useful evidence, and document which recent incident classes they detect. +- [x] Run full affected-package lint, typecheck, unit, Workers, and Go quality gates. +- [x] Complete task, test, Cloudflare, Go, constitution, and documentation review as applicable. +- [x] Open and maintain a draft PR, push meaningful increments frequently, and do not merge without + explicit authorization. + +## Acceptance Criteria + +- Pull-request CI runs a deterministic, credential-free lifecycle simulation in minutes, not hours, + and failures include a replayable seed/path plus a minimized or bounded trace. +- The harness models multiple projects, tasks, sessions, workspaces, and nodes; asynchronous + lifecycle actions can be reordered at named persistence and external-I/O boundaries. +- Safety invariants prevent capacity overcommit, destructive cleanup of task-owned or active + resources, duplicate live ownership, and terminal resources with no bounded cleanup/retry path. +- Once faults stop, every eligible terminal/idle session and unowned resource converges to a safe + sleeping/deleted state or an explicit bounded retry state. +- Calibration tests fail under policies equivalent to the recent stranded-session and premature + provisioning-node deletion bugs, demonstrating that the oracle is discriminating. +- Real local D1/Durable Object tests exercise the production claim/CAS paths for the highest-risk + races instead of relying only on an in-memory imitation. +- A deeper local nightly profile explores materially more schedules than the pull-request profile + and remains runnable on demand in the same workspace. +- VM-agent activity is routed with the owning workspace's project ID, including concurrent + workspaces from different projects on one node. +- Repeated local runs are green after fixes and the PR report clearly states which recent incident + classes were reproduced, which are prevented, and any remaining blind spots. +- No staging or production infrastructure is used, and the PR remains draft/unmerged pending + explicit authorization. + +## Validation Evidence + +- Baseline scheduler lifecycle suites: 4 files and 82 tests passed. +- Pull-request simulation profile: 200 generated runs passed after calibration. +- Nightly simulation profile: 2,000 generated runs passed locally. +- Historical calibration tests reject stranded sleep retry, premature provisioning-node cleanup, + and last-slot capacity TOCTOU policies. +- Real Workerd/D1 race slice: 4 tests passed, with 24 opposite-order repetitions each for atomic + final-slot placement and cleanup-versus-placement ownership, plus active provisioning claims and + TaskRunner reselection. +- Before the fix, the new VM-agent cross-project activity test failed as intended: both + SessionHosts omitted their workspace project and no callback reached the test control plane. +- After binding SessionHosts to `WorkspaceRuntime.ProjectID`, the cross-project activity test passed + 10 consecutive runs. The broader server package reached an unrelated pre-existing Docker-backed + test that cannot run in this workspace because the Docker CLI is absent. +- An expanded local exploration passed 100,000 generated schedules with up to 200 commands, 40 + task slots, and 8 projects in 8.58 seconds. Its first run exposed the default 5-second Vitest + ceiling, so the nightly profile now carries an explicit bounded timeout for larger runs. +- Full API validation passed after the review fixes: ESLint, TypeScript typecheck, 540 + unit/integration files with 7,235 tests, and 49 Workerd files with 629 tests. The two complete + Workerd passes took 831.87 and 829.49 seconds; + the focused real-D1 race slice remains the inexpensive scheduler-change signal. +- VM-agent validation passed `go vet ./...`, `go build ./...`, and 10 race-detector repetitions of + `TestSessionHostActivityUsesOwningWorkspaceProject`. A full `go test ./internal/server` run was + attempted and reached only the existing Docker-dependent `TestBootstrapLifecycle_SessionsUseDetectedUser` + environment failure (`docker` is not installed); the new routing test passed in that run. +- Repository quality gates passed: formatting, file sizes, source-contract tests, AST checks (zero + errors; repository warnings only), quality-script tests (32 files, 302 tests), and `git diff --check`. + The file-size gate initially caught `workspace-steps.ts` at 809 lines, so remote-branch handling + was extracted into `workspace-branch.ts`; the original module is now 667 lines and its 10 focused + branch-provider tests pass. +- Specialist review found one high-risk false-success path after the initial validation: scheduled + cleanup still used the legacy teardown helper, which collects provider/container failures instead + of throwing. That could mark D1 deleted while an external resource survived. Scheduled cleanup + now uses strict teardown, strict teardown covers managed Cloudflare containers, and failed teardown + releases the `destroying` claim to its prior status with `cleanup_backoff_until`. The focused unit + set passes 152 tests and a real Workerd/D1 slice proves a thrown container teardown leaves the node + `running` with backoff rather than falsely deleted. +- With explicit approval, the Sonar follow-up hardened the credential-free nightly workflow with + `pnpm install --ignore-scripts`, decomposed cleanup success/failure handling and simulator safety + assertions below the cognitive-complexity threshold, and applied the two flagged optional-chain + simplifications. Focused API typecheck, 42 simulator/cleanup tests, ESLint, file-size checks, and + all 302 repository quality-script tests pass after the refactor. A third full Workerd run also + passed all 49 files and 629 tests in 944.82 seconds. Dependency-governance tests pass. The local + Gitleaks wrapper could not complete because scanner output is withheld by policy, so the refreshed + PR Secret Scan remains the authoritative verification for this follow-up. + +## Review Evidence + +| Review | Verdict | Evidence / findings | +| --- | --- | --- | +| Task completion | PASS | All nine research findings map to implemented checklist work; every acceptance criterion has automated or recorded verification. No UI or multi-provider selection surface was added. Existing real session-sleep suites complement the model calibration. | +| Test engineering | PASS | Unsafe historical policies fail calibration; production paths use real D1/TaskRunner DO and HTTP-boundary Go tests; external teardown failure now has both unit and Workerd coverage. Remaining provider/network/long-soak behavior is explicitly documented as out of scope. | +| Cloudflare | PASS after fix | Atomic D1 `INSERT ... SELECT` placement and `destroying` cleanup claims serialize the dangerous ownership transitions. Strict external teardown must succeed before the D1 tombstone; failure releases with bounded backoff. No migration or binding changes. | +| Go | PASS | Workspace runtime project context is copied into each SessionHost without new goroutines or lock ordering. `go vet`, `go build`, and 10 race-detector repetitions pass; the only broader local test limitation is the existing Docker-dependent case. | +| Security | PASS | The nightly workflow retains read-only permissions and SHA-pinned actions while disabling dependency lifecycle scripts. The cleanup refactor preserves parameterized D1 writes, atomic ownership claims, strict teardown, and bounded backoff; no credential, authorization, or new logging surface was introduced. | +| Constitution | PASS | No production URL, timeout, limit, or identifier was hardcoded. Placement capacity retains project/env configuration, cleanup backoff retains existing configuration, and simulation scale/timeout are environment-overridable test controls. | +| Documentation | PASS | The simulator README documents CI profiles, replay, invariants, incident calibration, strict teardown/backoff, and blind spots. No public API, environment variable, schema, or deployment contract changed, so public configuration docs require no update. | + +Task-completion validation notes one deliberate limitation rather than a completion gap: the +stranded-session incident is newly proven by a discriminating model calibration and the repository's +existing real session-sleep tests, not by a new end-to-end cloud/container sleep run. This is the +explicit local/CI boundary of the task. + +## References + +- `tasks/active/2026-08-14-fix-stranded-session-sleep-cleanup.md` +- `tasks/archive/2026-08-07-fix-provisioning-node-cleanup-race.md` +- `.claude/rules/35-vertical-slice-testing.md` +- `.claude/rules/47-control-loop-io-budget.md` +- `.claude/rules/53-scheduled-handler-isolation-and-liveness-signals.md` +- `apps/api/src/durable-objects/task-runner/node-selection.ts` +- `apps/api/src/durable-objects/task-runner/workspace-steps.ts` +- `apps/api/src/scheduled/node-cleanup/shared.ts` +- `apps/api/src/scheduled/trial-expire.ts` +- `apps/api/tests/workers/` +- `packages/vm-agent/internal/server/agent_ws.go` diff --git a/tasks/archive/2026-07-26-claude-guided-verification-code.md b/tasks/archive/2026-07-26-claude-guided-verification-code.md new file mode 100644 index 0000000000..38b0f7bdbe --- /dev/null +++ b/tasks/archive/2026-07-26-claude-guided-verification-code.md @@ -0,0 +1,133 @@ +# Fix Claude guided verification-code forwarding + +## Problem + +Claude Code guided login currently launches `claude setup-token` with ignored +stdin. In a sandbox the browser cannot reach the CLI callback, so Claude shows +the user a short-lived verification code that must be typed into the still +running CLI. The shipped process cannot receive it and hangs until TTL. + +An unmerged follow-up (`73289daa9`) implemented the wrong contract: it asks the +browser to provide the final `sk-ant-oat` token and saves that token directly. +The browser never receives that token; the CLI produces it only after exchanging +the short-lived code. + +## Research findings + +- Reviewed SAM idea `01KYEHTF6BA3ZPTN2RBRYBH462`, including the verified + container flow, root cause, source references, and reviewed file-level plan. +- The driver is `apps/api/scripts/claude-setup-token.mjs`; main currently uses + `stdio: ['ignore', 'pipe', 'pipe']`. +- The state machine and sandbox boundary live in + `apps/api/src/durable-objects/credential-setup-session/index.ts`. +- Route/service plumbing lives in + `apps/api/src/routes/agent-credential-setup-sessions.ts` and + `apps/api/src/services/credential-setup-session.ts`. +- Browser state and behavior live in + `apps/web/src/components/CodexConnectModal.tsx` and + `apps/web/src/lib/api/codex-setup.ts`. +- Open PR #1667 changes only Codex helper copy in + `CodexConnectTrigger.tsx`; this PR will document whether it supersedes or + remains independent. +- Relevant project rules require preflight evidence, fail-fast state handling, + a realistic vertical-slice test, mobile/desktop Playwright visual evidence, + specialist review, staging verification, and no tests that preserve a known + degraded contract. + +## Implementation checklist + +- [x] Reuse the prior branch's route/service/UI plumbing but replace final-token + submission with short-lived verification-code submission. +- [x] Pipe driver stdin, constrain the code-file path to setup home, poll it, + delete it, and write the normalized code plus carriage return to the PTY. +- [x] Harden URL/token parsing against PTY wrapping and publish sanitized + failures when the CLI exits without a token. +- [x] Add the `exchanging` state and DO `submitVerificationCode` guards, + normalization, bounds, charset (including `#`), sandbox write, and + non-persistence guarantees. +- [x] Read driver state during waiting/exchanging/capturing so rejection or exit + fails fast instead of waiting for TTL. +- [x] Expose owned-session `POST /:id/verification-code` without treating the + short-lived code as a credential. +- [x] Preserve strict server-side Claude OAuth-token validation and the existing + capture → encrypted save → teardown path. +- [x] Update the modal with accurate code-paste copy, exchanging progress, + visible failure, and restart affordance. +- [x] Add driver, DO, route/DO/sandbox vertical-slice, UI behavioral, and + discriminating regression coverage. +- [x] Run Playwright visual audits at 375px and 1280px. +- [x] Run full validation and all required specialist reviews. +- [x] Deploy the branch to staging and verify provisioning, URL surfacing, + sandbox delivery, rejected-code fast failure, and complete cleanup. +- [x] Open a PR, make every CI check green, and leave it unmerged for Raphaël's + real Claude subscription E2E. + +## Acceptance criteria + +- [x] The browser submits only a bounded short-lived verification code; the + long-lived token never crosses the browser boundary. +- [x] A `code#state` value with copied whitespace artifacts reaches the CLI as + exact normalized bytes followed by `\r`. +- [x] Invalid codes and premature CLI exits become prompt, sanitized failures. +- [x] Wrapped terminal output cannot cause a truncated OAuth token to be saved. +- [x] Automated coverage proves the full route → DO → sandbox → captured token + → credential save path with realistic state and exact sandbox writes. +- [x] Mobile and desktop UI are accessible, legible, and free of horizontal + overflow. +- [x] Staging has no orphan guided-login sandbox or pool lease after success or + failure cleanup. +- [x] PR is open, all checks including SonarCloud and Preflight Evidence pass, + staging remains deployed from the feature branch, and the PR is not + merged. + +## References + +- SAM idea `01KYEHTF6BA3ZPTN2RBRYBH462` +- PR #1671 / main commit `44adc7e5e` +- Wrong-fix commit `73289daa9` +- Open PR #1667 +- +- anthropics/claude-code issues #47773 and #47699 + + +## Completion evidence + +- PR: https://github.com/raphaeltm/simple-agent-manager/pull/1678 (open, unmerged) +- Final staging deploy: https://github.com/raphaeltm/simple-agent-manager/actions/runs/30193502104 (`c3505f311`, success) +- Live no-account verification: session `01KYEQ1PNJVAADQPMXMMG4FX1V` surfaced a trusted Claude URL in 12.5s, accepted a `code#state`-shaped rejected value, and surfaced sanitized `code_rejected` in 5.0s; cleanup returned 200 and D1 had zero active setup rows. +- CI: all applicable checks green, including Test, Playwright Visual Tests, SonarCloud, Preflight Evidence, and Specialist Review Evidence. +- Remaining explicit human gate: Raphaël must complete the successful OAuth exchange on staging with his real Claude subscription before merge. + +## Follow-up 2026-07-27: real-code attempt still failed — error detail was being discarded + +Raphaël's first real-code retest (session `01KYGZBWGAF1Y7Q4HREJS22XPB`, 05:04 UTC) +failed with `code_rejected` — "Claude rejected the verification code" — despite a +correctly copied code. Investigation (session `77db0283-3193-4621-8b1c-069c1a19f108`): + +- **The submit fix works.** Exchanges now reach a terminal outcome in seconds. +- **Sandbox egress is fine.** A Node-fetch token exchange from inside a staging + sandbox got a genuine `400 invalid_grant` verdict from + `platform.claude.com/v1/oauth/token`. (curl-shaped probes get `429 + rate_limit_error` from the same egress — TLS-fingerprint bot-scoring, a red + herring; the Bun-based CLI is not affected.) +- **The real bug: every `OAuth error:` render was flattened into "rejected".** + Live-reproduced against claude v2.1.220, distinct failure wordings exist: + - paste missing the `#state` half → instant LOCAL `OAuth error: Invalid code. + Please make sure the full code was copied` (no network) + - full-format bad code/state/PKCE → server `OAuth error: Request failed with + status code 400` + - network failure → `OAuth error: connect ECONNREFUSED …` + The driver discarded the line, so the true reason of the real-code failure is + unrecoverable; the most likely candidates are an incomplete mobile copy + (missing `#` half) or a code issued against a different PKCE challenge + (e.g. mobile app-link interception of the sign-in URL). +- **Fix (commit `b26de657f`):** driver extracts + classifies the OAuth error + line after a settle window (`CLAUDE_SETUP_REJECTION_SETTLE_MS`) into + `code_incomplete` / `code_rejected` / `exchange_network_error` with a bounded, + sk-ant-redacted `detail`; the DO maps each class to accurate guidance and + appends `[CLI: …]` to `error_message` (driver free-form `error` still never + surfaced); the modal blocks claude-code pastes without `#` before burning the + session. Verified end-to-end against the real CLI for both failure classes. +- **Class of bug:** collapsing a multi-cause external failure surface into one + fixed user message — the discarded upstream detail was the only signal that + could distinguish user error from environment failure. diff --git a/tasks/archive/2026-08-07-frontend-query-cache-and-rotation-resilience.md b/tasks/archive/2026-08-07-frontend-query-cache-and-rotation-resilience.md new file mode 100644 index 0000000000..380c0bb6cf --- /dev/null +++ b/tasks/archive/2026-08-07-frontend-query-cache-and-rotation-resilience.md @@ -0,0 +1,142 @@ +# Frontend Query Cache and Rotation Resilience + +## Problem + +The authenticated control-plane UI feels slow because responsive transitions and route navigation often restart component-local fetches instead of reusing already-loaded data. The clearest production symptom is phone rotation: rotating a 390×844 phone to 844×390 crosses the app's 767px breakpoint and can unmount the routed page subtree, destroying local state and triggering fresh loaders. + +The current production deployment (`71e97323ee499743df099aa5cdca9867e5a87b30`) matches the audited `main` commit, so this is present in the live code rather than only a local hypothesis. + +## Research Findings + +### Dispatched SOL research + +- `01KZF578YJ1JG4APXDA4J29EYX`: confirmed that `apps/web/src/components/AppShell.tsx` swaps structurally different mobile and desktop sibling trees. The routed `
` occupies different reconciliation positions, so a breakpoint transition discards the page/chat subtree while the root QueryClient and AuthProvider remain mounted. +- `01KZF57GTQW3Q6RW3JPP47QRM2`: recommended shared in-memory TanStack Query caching and intent prefetch first. Persisting the entire QueryClient is unsafe; any browser persistence must be a per-user allowlist with logout/account-switch cleanup, version busting, and sensitive-data exclusions. +- `01KZF57MDCMN7KT94MFSDEF5C5`: recommended converging destination reads on shared query keys before prefetching, then using bounded hover/focus/touch intent prefetch. Recommended a delayed decorative top-edge indicator for background refetches only. + +The original Instant dispatches (`01KZF559Y5BF6D5900W4RFP04C`, `01KZF55E37QM2Z8NMPD63PVQF1`, `01KZF55HMGDQKRW5EVB2QW9A4Z`) failed before agent startup because SAM attempted to clone unpushed generated branches. Corrected retries explicitly checked out remote `main`. + +### Baseline code evidence at `71e97323e` + +- `apps/web/src/components/AppShell.tsx` branched on `useIsMobile()`. The mobile routed `
` was the third root child; the desktop routed `
` followed the sidebar. Without stable sibling identity, React unmounted it when crossing the breakpoint. +- `apps/web/src/hooks/useProjectData.ts` used component-local `useState`/`useEffect` loaders. `AppShell`, `Dashboard`, and `Projects` mounted independent `useProjectList({ limit: 50 })` instances, causing duplicate requests and independent polling for the same data. +- `apps/web/src/pages/Project.tsx` hand-loaded project detail and blocked the child outlet on the first request. A project-card prefetch could not help until the destination read the same shared cache key. +- TanStack Query v5.101.2 was already configured in `apps/web/src/lib/query-client.ts`, but only Nodes, Workspaces, and AdminDiagnosis used it. +- `tasks/archive/2026-08-05-namespace-library-cache-by-user.md` documents a real cross-user metadata leak from un-namespaced `localStorage`. Generic persisted query caching must not repeat that failure. +- The service worker caches the app shell and static assets, not authenticated API responses, so it does not provide data reuse across remounts. + +### Official documentation + +- TanStack Query prefetching: https://tanstack.com/query/latest/docs/framework/react/guides/prefetching +- TanStack Query `useQuery` cache lifetime: https://tanstack.com/query/latest/docs/framework/react/reference/useQuery +- TanStack Query persistence and cache busting: https://tanstack.com/query/v5/docs/framework/react/plugins/persistQueryClient + +## UI Variants Considered + +1. **Delayed top-edge activity line** — global, layout-neutral, visible on mobile and desktop, and does not compete with page content. +2. **Compact “Refreshing” chrome pill** — clearer text but consumes scarce mobile-header space and can become noisy during polling. +3. **Per-section spinners only** — precise but inconsistent across pages and cannot cover shared prefetch/background work. + +Selected: variant 1, with a screen-reader status message. Existing local spinners remain where they already add useful section-level context. + +## Selected First PR + +This PR deliberately combines the direct rotation fix with the smallest cache/prefetch slice that has cross-UI leverage: + +- Preserve routed content identity across AppShell mobile/desktop breakpoint transitions. +- Move project list and project detail reads onto shared TanStack Query option/key factories. +- Deduplicate the project list used by AppShell, Dashboard, and Projects. +- Prefetch project detail on hover, keyboard focus, and touch intent from project cards and sidebar entries. +- Keep stale project data visible during background revalidation. +- Show a delayed, unobtrusive global indicator only when cached query data is being refreshed. +- Keep initial project-list failures truthful in both the page and desktop sidebar instead of presenting failed data as an empty list. +- Make list limits, polling cadence, hover dwell, and background-indicator delay build-time configurable with shared validated defaults. +- Namespace every authenticated query key migrated in this PR by user identity, gate protected + rendering during identity transitions, and clear the previous in-memory + namespace. +- Capture broader query migration and safe persistence as explicit follow-up work. + +## Implementation Checklist + +- [x] Add a failing AppShell regression test proving breakpoint changes preserve child mount/state. +- [x] Give the shared routed `
` stable identity across the mobile and desktop shell branches. +- [x] Add shared project list/detail/GitHub-installation query keys and query options. +- [x] Migrate `useProjectList` and `useProjectDetail` to TanStack Query while preserving their return-value shapes and updating callers for required identity scope. +- [x] Migrate the `Project` parent to cached detail/installation data and keep the outlet visible on background errors/refetches. +- [x] Add bounded project-detail intent prefetch from project cards and sidebar project buttons. +- [x] Add a delayed global background-fetch indicator above AppShell. +- [x] Centralize and document configuration-backed project list/poll/prefetch/indicator defaults and pass their overrides through canonical deployments. +- [x] Identity-scope the migrated project/installations query keys, gate clean signout/session-expiry/account-switch transitions, and preserve the active namespace through transient same-user auth refetch errors. +- [x] Add unit tests for deduplication, cache reuse, stale-data preservation, auth cleanup, indicator behavior, and intent prefetch. +- [x] Add Playwright coverage for portrait→landscape rotation, request counts, indicator rendering, overflow, and mobile/desktop screenshots. +- [x] Update Rule 48 with responsive-shell identity and authenticated-query isolation requirements. +- [x] Run full validation, specialist reviews, and staging verification. +- [x] Create draft PR #1769 without merging. + +## Acceptance Criteria + +- Rotating across 767px does not remount the routed page/chat subtree or discard its local state. +- AppShell plus Dashboard/Projects issue one initial project-list request for the shared key, not duplicate requests. +- Re-entering a recently loaded project/list surface renders cached data immediately; stale data remains visible while revalidation runs. +- Hover/focus/touch intent on a project destination populates the exact query key consumed by `Project`. +- Background revalidation shows a subtle top-edge activity cue without replacing visible content or changing layout. +- Initial project-list failures never render a contradictory empty state in the page or sidebar. +- Project and GitHub-installation queries migrated here are identity-scoped; clean auth identity changes cannot + render the previous account's data even for one frame, while transient + same-user auth refetch errors preserve the active cache. +- No generic QueryClient data is written to `localStorage` or `sessionStorage` in this PR. +- Mobile and desktop visual/behavioral checks pass with no horizontal overflow. + +## Out of Scope + +- Migrating every remaining hand-rolled loader in one PR. +- Persisting authenticated query data across full document reloads. +- Prefetching chat histories, messages, logs, diagnostics, credentials, secrets, environment values, or large file/library payloads. + +## Bug Post-Mortem + +### What broke + +Rotating a phone from portrait to landscape across the 767px breakpoint remounted the routed page subtree, discarded its local state, and restarted project fetches. Shared project surfaces also issued independent component-local requests instead of converging on one cache entry. + +### Root cause + +Commit `5ca21242d` (`feat(web): UI/UX overhaul — design system, navigation, and polish (spec 019) (#149)`) introduced the responsive `AppShell` with structurally different mobile and desktop child lists. The routed `
` occupied different unkeyed reconciliation positions across those branches. Existing component-local `useState`/`useEffect` project loaders then had no shared server-state cache to reuse after the remount. + +### Timeline + +- **2026-02-22:** `5ca21242d` introduced the separate responsive AppShell branches and positional routed subtree; later shell changes retained the identity problem. +- **2026-08-07:** The phone-rotation reload and broader frontend slowness were reported. Baseline inspection at `71e97323e` reproduced the code path, and dispatched research isolated the reconciliation/cache causes. +- **2026-08-07 to 2026-08-08:** The fix moved project server state to shared user-scoped queries, stabilized responsive child identity, added bounded prefetch/background activity UI, and passed local specialist/behavioral review. +- **2026-08-08:** Exact SHA `9e2d86565` deployed to staging; authenticated iPhone and mouse/keyboard Playwright checks verified the rotation, prefetch, and stale-revalidation paths. + +### Class of bug + +Responsive reconciliation identity loss combined with duplicated component-local server state. + +### Why it was not caught + +The suite exercised mobile and desktop layouts independently, but did not resize one mounted authenticated tree across the breakpoint while counting requests. It also lacked a shared-query contract test spanning AppShell, Dashboard, and Projects. + +### Process fix included in this PR + +`.claude/rules/48-stale-while-revalidate-ui.md` now requires stable responsive subtree identity, authenticated query-key isolation, cleanup/gating during identity transitions, and exact-key convergence before prefetching. The Playwright audit adds a portrait-to-landscape request-count regression. + +### Post-mortem file + +This task is the durable post-mortem record and will be archived at `tasks/archive/2026-08-07-frontend-query-cache-and-rotation-resilience.md` when the draft PR is opened. + +## Validation Evidence + +- `pnpm typecheck` — 16/16 tasks passed. +- `pnpm lint` — 7/7 tasks passed with zero errors; existing warning baseline remains. +- `pnpm --filter @simple-agent-manager/web test` — 240 files and 2,903 tests passed. +- `pnpm build` — 9/9 tasks passed. +- `pnpm exec vitest run scripts/quality/deploy-reusable-workflow.test.ts` — 19/19 deployment mapping tests passed. +- Playwright cache audit — 15 passed with 12 intentional device-specific skips across iPhone SE, iPhone 14, and desktop; rotation request-count, stale-refresh, delayed-indicator, overflow, error, single-character, and hostile-looking text cases passed. +- Specialist review — UI/UX, test engineering, security, constitution, documentation sync, and environment consistency all passed after their findings were addressed. +- Final staging deployment — GitHub Actions run `31230115937` passed for exact implementation SHA `9e2d865651a400395cbf51730cb8967117e7316d`, including the repository's built-in live smoke suite. +- Authenticated feature-specific staging Playwright — passed in both an iPhone 14 touch context and a mouse/keyboard-capable context at 390×844 → 844×390. The test observed one project-list request through rotation, retained the first loaded project card, confirmed intent-prefetch detail reuse during navigation, delayed a real background list response to observe `data-refreshing=true` while content stayed mounted, checked horizontal overflow, navigated dashboard/projects/settings, and captured zero console errors. +- Staging screenshots — `.codex/tmp/playwright-screenshots/staging-frontend-cache-landscape.png` and `staging-frontend-cache-refresh.png` were visually inspected; the latter shows the top-edge activity line with the loaded project grid unchanged beneath it. +- `pnpm quality:observability-noise` — no significant log noise detected; D1 and Workers telemetry checks were unavailable in this local environment because their optional credentials/API access were not present. +- Task completion validator — final re-validation PASS after adding the complete Rule 02 post-mortem and removing the backlog task's trailing EOF blank line; checks A–F found no implementation or acceptance-criterion gap, and its package-scoped focused rerun passed 106/106 tests. diff --git a/tasks/archive/2026-08-08-list-triggers-mcp-tool.md b/tasks/archive/2026-08-08-list-triggers-mcp-tool.md new file mode 100644 index 0000000000..d7f6f5f091 --- /dev/null +++ b/tasks/archive/2026-08-08-list-triggers-mcp-tool.md @@ -0,0 +1,80 @@ +# Add MCP `list_triggers` Tool + +## Problem + +SAM agents can create, update, and delete automation triggers through MCP, but they cannot discover the current project's triggers or their IDs. Editing an existing trigger therefore requires bypassing the product API with a raw D1 query. Add one bounded, project-scoped `list_triggers` MCP tool that exposes operational trigger metadata without execution history or secret-bearing webhook configuration. + +## Research Findings + +- `apps/api/src/routes/mcp/trigger-tools.ts` is 523 lines and exceeds the 500-line ceiling. Split the existing create/update/delete handlers mechanically in a standalone commit before adding the feature. +- `apps/api/src/routes/triggers/crud.ts` owns the REST list query and `TriggerRow` to `TriggerResponse` mapper. Extract the base query/mapper into a shared trigger-read service so REST and MCP use one query path. +- The common `triggers` table contains the requested scheduling and execution summary fields, plus prompt/user/project metadata that the MCP list does not need. +- `webhook_trigger_configs` stores `token_hash`, a token suffix/timestamps, filters, and `included_headers_json`; raw one-time webhook tokens are returned only during create/rotation. The MCP list must not query or serialize this side table. A strict output allowlist also excludes prompt templates and any source-specific configuration. +- `github_trigger_configs` stores event filters but no signing secret. The deployment-level GitHub webhook secret is a Worker secret, not a trigger row. It is nevertheless outside the MCP list contract and must not be serialized. +- Project isolation must be a SQL predicate on `triggers.project_id`, tested against a real SQL engine using the repository's SQLite-backed D1 helpers or Worker integration environment. +- MCP list tools clamp caller limits with configurable default/max values. Add `MCP_TRIGGER_LIST_LIMIT` and `MCP_TRIGGER_LIST_MAX` with `DEFAULT_*` fallbacks, Env declarations, `.env.example`, and public configuration reference entries. +- `apps/www/src/content/docs/docs/guides/agents.md` lists project-aware MCP tools and must include `list_triggers`. +- Existing trigger worker tests exercise the actual `/mcp` dispatcher and D1. They are the preferred vertical-slice location for tool registration/dispatch behavior; focused SQLite-backed handler tests can cover the full filter, isolation, redaction, and bounding matrix quickly and deterministically. + +## Implementation Checklist + +- [x] Split `apps/api/src/routes/mcp/trigger-tools.ts` into focused create/update/delete modules in a standalone refactor commit with unchanged behavior. +- [x] Extract the REST trigger base list query and row mapper into a shared service; keep existing REST response behavior intact. +- [x] Add configurable MCP trigger list default/max limits with `DEFAULT_*` constants and environment documentation. +- [x] Add `handleListTriggers` with optional `status`, `sourceType`, and `limit`; validate enum filters and clamp the limit. +- [x] Return an explicit safe field allowlist: `id`, `name`, `description`, `status`, `sourceType`, `cronExpression`, `cronTimezone`, `cronHumanReadable`, `nextFireAt`, `lastTriggeredAt`, `triggerCount`, `taskMode`, `agentProfileId`, `skillId`, `maxConcurrent`, and `skipIfRunning`. +- [x] Add the `list_triggers` MCP definition and dispatcher wiring. +- [x] Add behavioral real-SQL coverage for an empty project, multiple rows/field mapping, status filtering, source-type filtering, cross-project isolation, webhook secret/config redaction, and default/requested/max bounding. +- [x] Add dispatcher/tool-list coverage and update the public MCP tools reference. +- [x] Run focused tests, lint, typecheck, full tests, build, and file-size checks. +- [x] Run task completion, Cloudflare, security, constitution, environment, documentation, and test specialist reviews; address all correctness findings. +- [ ] Run local PR evidence checks against the final PR body, open a PR stating that staging was intentionally skipped by explicit instruction, wait for required CI evidence, and leave it open and unmerged for Raphaël. + +## Validation Evidence + +- Focused API coverage: 274 tests passed, including the 7-test real-SQL `list_triggers` suite. +- Worker MCP vertical slice: 6 tests passed through the actual `/mcp` dispatcher and D1 binding. +- Repository gates: `pnpm lint`, `pnpm typecheck`, `pnpm test` (20/20 tasks; API 6,756/6,756), `pnpm build`, and `pnpm quality:file-sizes` passed. +- Staging was intentionally not deployed or verified, per explicit user instruction. + +## Specialist Review Evidence + +| Reviewer | Status | Outcome | +| --- | --- | --- | +| task-completion-validator | PASS | All planned work and pre-PR acceptance criteria are covered; archival authorized. | +| security-auditor | PASS | SQL project isolation and secret-safe output allowlisting verified. | +| cloudflare-specialist | PASS | D1 query, Worker dispatch, bounds, and real-engine coverage verified. | +| constitution-validator | PASS | Configurable `DEFAULT_*` default/max limits satisfy Principle XI. | +| env-validator | PASS | Optional runtime variables and documentation are synchronized. | +| doc-sync-validator | ADDRESSED | MCP limit grouping labels corrected in `7e2f6eaea`. | +| test-engineer | PASS | Combined filters are discriminating and schema/real-SQL coverage is complete. | + +## Acceptance Criteria + +- `tools/list` advertises `list_triggers` with no required parameters and optional validated `status`, `sourceType`, and `limit` inputs. +- Calling `list_triggers` returns only triggers whose `project_id` matches the current MCP token's project, ordered consistently with the REST list. +- Empty projects return `{ "triggers": [] }`; populated projects return all requested operational fields with a human-readable cron schedule. +- Both filters work independently and together, and the configurable default/max bounds are enforced by the SQL query. +- The payload contains no webhook token/hash/suffix, included-header configuration, source filters, credentials, signing secrets, or execution history. A webhook canary test proves secret material is absent. +- REST and MCP share the base trigger list query/mapper rather than maintaining duplicate SQL paths. +- The split MCP trigger handler modules and REST trigger CRUD module are within the 500-line ceiling; the pre-existing centralized MCP dispatcher remains below the mandatory 800-line limit. +- Local lint, typecheck, tests, build, and applicable quality checks pass; CI reaches the allowed green state. +- No staging deployment or verification is performed, by explicit user instruction. +- The PR remains open and unmerged for Raphaël's review. + +## References + +- `apps/api/src/routes/mcp/trigger-tools.ts` +- `apps/api/src/routes/mcp/tool-definitions-trigger-tools.ts` +- `apps/api/src/routes/mcp/index.ts` +- `apps/api/src/routes/triggers/crud.ts` +- `apps/api/src/services/cron-utils.ts` +- `apps/api/tests/helpers/sqlite-d1.ts` +- `apps/api/tests/workers/mcp-trigger-tools.test.ts` +- `.claude/rules/02-quality-gates.md` +- `.claude/rules/18-file-size-limits.md` +- `.claude/rules/25-review-merge-gate.md` +- `.claude/rules/28-credential-resolution-fallback-tests.md` +- `.claude/rules/35-vertical-slice-testing.md` +- `.claude/rules/50-list-read-row-fault-isolation.md` +- `.specify/memory/constitution.md` (Principle XI) diff --git a/tasks/backlog/2026-08-07-expand-frontend-query-cache-and-persistence.md b/tasks/backlog/2026-08-07-expand-frontend-query-cache-and-persistence.md new file mode 100644 index 0000000000..3f59d1290c --- /dev/null +++ b/tasks/backlog/2026-08-07-expand-frontend-query-cache-and-persistence.md @@ -0,0 +1,41 @@ +# Expand Frontend Query Caching and Safe Persistence + +## Problem + +The first frontend performance PR covers responsive route preservation plus the highest-leverage project list/detail cache. Many other pages still use isolated `useState`/`useEffect` loaders, and a true full document reload still loses the in-memory QueryClient. + +## Research Basis + +- SOL research tasks `01KZF578YJ1JG4APXDA4J29EYX`, `01KZF57GTQW3Q6RW3JPP47QRM2`, and `01KZF57MDCMN7KT94MFSDEF5C5`. +- `tasks/active/2026-08-07-frontend-query-cache-and-rotation-resilience.md`. +- Prior cross-user browser-cache incident: `tasks/archive/2026-08-05-namespace-library-cache-by-user.md`. +- Official TanStack persistence guidance: https://tanstack.com/query/v5/docs/framework/react/plugins/persistQueryClient + +## Proposed Follow-Up + +- Inventory and rank remaining hand-rolled loaders by route frequency, payload cost, volatility, and sensitivity. +- Migrate active-task and cross-project chat summaries, then common project subpages, onto centralized query option factories. +- Add route/parent-load prefetch only after destination pages consume the exact same keys. +- Design an opt-in, authenticated-user-scoped `sessionStorage` persistence layer using `PersistQueryClientProvider`. +- Use an explicit dehydration allowlist. Start with bounded summary/reference data only. +- Version persisted data with a build/schema buster and configure `maxAge`/`gcTime` together. +- Clear persisted state before signout completes and on clean session expiry/account switch. +- Treat quota, parse, and private-mode failures as cache misses without breaking the app. + +## Never Persist Without Separate Security Review + +- Chat messages, prompt content, attachments, or agent output. +- Credentials, tokens, secrets, environment values, or connection configuration. +- Admin errors, diagnoses, logs, incident evidence, or usage/cost details. +- Node/workspace runtime details that can contain environment or infrastructure metadata. +- File/library contents or signed URLs. +- Mutation state. + +## Acceptance Criteria + +- Persisted query keys are deterministically namespaced by authenticated user and schema/build version. +- Logout, session expiry, and account switch cannot render the prior user's data, including colliding resource IDs. +- Only approved allowlisted queries are dehydrated. +- Persistence failures degrade to the normal in-memory cache. +- Tests seed foreign-user/sensitive canaries and prove they never render or remain in storage after auth transitions. +- Staging validation covers reload, offline/online, account switch, quota failure, and cache-buster behavior.