From 3a440d28c4e78ee56ecd52b19f14a38373b3516a Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 14 Oct 2026 20:01:34 -0700 Subject: [PATCH 01/34] fix(server): read existing grants before seeding role defaults (#16365) `insertMissingPrincipalGrants` now reads the existing permission keys first and inserts only missing grants. In the steady state no INSERT runs, so cloud-tenant auth resync no longer waits on row locks held by concurrent grant edits until the statement timeout (Sentry PAPERCLIP-BACKEND-3S). Co-Authored-By: Paperclip Co-Authored-By: Claude Opus 5.5 --- .../principal-access-compatibility.test.ts | 158 ++++++++++++++++++ .../src/middleware/cloud-tenant-actor.test.ts | 15 +- .../principal-access-compatibility.ts | 27 ++- 3 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 server/src/__tests__/principal-access-compatibility.test.ts diff --git a/server/src/__tests__/principal-access-compatibility.test.ts b/server/src/__tests__/principal-access-compatibility.test.ts new file mode 100644 index 00000000000..88336433550 --- /dev/null +++ b/server/src/__tests__/principal-access-compatibility.test.ts @@ -0,0 +1,158 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { and, eq } from "drizzle-orm"; +import { companies, createDb, principalPermissionGrants } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { grantsForHumanRole } from "../services/company-member-roles.js"; +import { + ensureHumanRoleDefaultGrants, + insertMissingPrincipalGrants, +} from "../services/principal-access-compatibility.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +if (!embeddedPostgresSupport.supported) { + console.warn( + `Skipping embedded Postgres principal grant tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, + ); +} + +describeEmbeddedPostgres("insertMissingPrincipalGrants", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-principal-grants-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(principalPermissionGrants); + await db.delete(companies); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + async function createCompany() { + return db + .insert(companies) + .values({ + name: `Grant Seeding ${randomUUID()}`, + issuePrefix: `GS${randomUUID().slice(0, 6).toUpperCase()}`, + }) + .returning() + .then((rows) => rows[0]!); + } + + async function grantKeys(companyId: string, principalId: string) { + const rows = await db + .select({ permissionKey: principalPermissionGrants.permissionKey }) + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, principalId), + ), + ); + return rows.map((row) => row.permissionKey).sort(); + } + + it("inserts only the grants that are missing", async () => { + const company = await createCompany(); + const userId = `user-${randomUUID()}`; + const ownerGrants = grantsForHumanRole("owner"); + await insertMissingPrincipalGrants(db, { + companyId: company.id, + principalType: "user", + principalId: userId, + grants: ownerGrants.slice(0, 2), + grantedByUserId: null, + }); + + const inserted = await ensureHumanRoleDefaultGrants(db, { + companyId: company.id, + principalId: userId, + membershipRole: "owner", + grantedByUserId: null, + }); + + expect(inserted).toBe(ownerGrants.length - 2); + expect(await grantKeys(company.id, userId)).toEqual( + ownerGrants.map((grant) => grant.permissionKey).sort(), + ); + await expect( + ensureHumanRoleDefaultGrants(db, { + companyId: company.id, + principalId: userId, + membershipRole: "owner", + grantedByUserId: null, + }), + ).resolves.toBe(0); + }); + + it("does not wait on a transaction that is deleting grants that already exist", async () => { + const company = await createCompany(); + const userId = `user-${randomUUID()}`; + await ensureHumanRoleDefaultGrants(db, { + companyId: company.id, + principalId: userId, + membershipRole: "owner", + grantedByUserId: null, + }); + + let releaseLock!: () => void; + const lockReleased = new Promise((resolve) => { + releaseLock = resolve; + }); + let signalLockHeld!: () => void; + const lockHeld = new Promise((resolve) => { + signalLockHeld = resolve; + }); + const rollback = new Error("rollback"); + // A permission edit deletes and re-inserts a member's grants inside one + // transaction. Until it commits, an INSERT ... ON CONFLICT on the same keys + // must wait for it. + const holder = db + .transaction(async (tx) => { + await tx + .delete(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, company.id), + eq(principalPermissionGrants.principalId, userId), + ), + ); + signalLockHeld(); + await lockReleased; + throw rollback; + }) + .catch((error) => { + if (error !== rollback) throw error; + }); + + try { + await lockHeld; + const seeding = ensureHumanRoleDefaultGrants(db, { + companyId: company.id, + principalId: userId, + membershipRole: "owner", + grantedByUserId: null, + }); + const outcome = await Promise.race([ + seeding, + new Promise<"blocked">((resolve) => setTimeout(() => resolve("blocked"), 2_000)), + ]); + expect(outcome).toBe(0); + } finally { + releaseLock(); + await holder; + } + }); +}); diff --git a/server/src/middleware/cloud-tenant-actor.test.ts b/server/src/middleware/cloud-tenant-actor.test.ts index 1e090fc5e33..6bf5f55ccd9 100644 --- a/server/src/middleware/cloud-tenant-actor.test.ts +++ b/server/src/middleware/cloud-tenant-actor.test.ts @@ -3,7 +3,14 @@ import express, { type Request } from "express"; import request from "supertest"; import { and, eq } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; -import { authUsers, companies, companyMemberships, instanceSettings, instanceUserRoles } from "@paperclipai/db"; +import { + authUsers, + companies, + companyMemberships, + instanceSettings, + instanceUserRoles, + principalPermissionGrants, +} from "@paperclipai/db"; import { actorMiddleware, cloudActorHeaderSourceFromHeaders, resolveCloudTenantActor } from "./auth.js"; import { idleAdmissionMiddleware, trackIdleRequestHandlers } from "./idle-admission.js"; import { idleWorkSnapshot, startTaskDrain, stopTaskDrain } from "../services/task-admission.js"; @@ -59,10 +66,14 @@ function createFakeDb(options: { return chain; }, select: () => { - if (options.selectThrows) throw new Error("select unavailable"); return { from: (table: unknown) => ({ where: (condition: unknown) => { + // Role-default grant seeding reads existing grants first; keep that + // read working so selectThrows exercises only the degradable reads. + if (options.selectThrows && table !== principalPermissionGrants) { + throw new Error("select unavailable"); + } selectWheres.push({ table, condition }); const rows = table === instanceSettings && settingsRow diff --git a/server/src/services/principal-access-compatibility.ts b/server/src/services/principal-access-compatibility.ts index cb1e86be82f..ca372d98372 100644 --- a/server/src/services/principal-access-compatibility.ts +++ b/server/src/services/principal-access-compatibility.ts @@ -1,4 +1,4 @@ -import { and, eq, notInArray } from "drizzle-orm"; +import { and, eq, inArray, notInArray } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { agents, companyMemberships, principalPermissionGrants } from "@paperclipai/db"; import type { PermissionKey, PrincipalType } from "@paperclipai/shared"; @@ -26,11 +26,34 @@ export async function insertMissingPrincipalGrants( ): Promise { if (input.grants.length === 0) return 0; + // Read before writing. Cloud tenant authentication re-seeds role defaults + // on every sync, and the grants almost always exist already. An INSERT ... + // ON CONFLICT still waits on another transaction that is deleting or + // re-inserting a conflicting row, which stalled authentication until the + // statement timeout. A plain read never waits on row locks. + const existing = await db + .select({ permissionKey: principalPermissionGrants.permissionKey }) + .from(principalPermissionGrants) + .where( + and( + eq(principalPermissionGrants.companyId, input.companyId), + eq(principalPermissionGrants.principalType, input.principalType), + eq(principalPermissionGrants.principalId, input.principalId), + inArray( + principalPermissionGrants.permissionKey, + input.grants.map((grant) => grant.permissionKey), + ), + ), + ); + const existingKeys = new Set(existing.map((row) => row.permissionKey)); + const missing = input.grants.filter((grant) => !existingKeys.has(grant.permissionKey)); + if (missing.length === 0) return 0; + const now = new Date(); const inserted = await db .insert(principalPermissionGrants) .values( - input.grants.map((grant) => ({ + missing.map((grant) => ({ companyId: input.companyId, principalType: input.principalType, principalId: input.principalId, From 275189d8d6665eef3a14c931f7b9b20fd8ad58b9 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 14 Oct 2026 20:14:10 -0700 Subject: [PATCH 02/34] fix(native): report why an unclassified Codex turn failed (#16354) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Native runs use the Paperclip runner to drive a provider session, for example the Codex app server > - When a Codex turn fails, two classifiers look for known failures: a model rejection and a server capacity failure > - When no classifier matches, the run fails with the generic message `Native session failed` and the error code `adapter_failed` > - The run failure report to Sentry then has no data about the cause, because the server drops the Codex `codexErrorInfo` value > - This pull request records a closed-vocabulary diagnostic for that failed turn and sends it to the Sentry `provider_failure` context > - The benefit is that operators can see which Codex failure kind causes these generic failures, and then write a targeted fix ## Linked Issues or Issue Description **What happened?** Hosted staging reports many run failures with the title `Error: Native session failed`. The events have `agent_adapter=paperclip_runner`, `error_code=adapter_failed`, `driverKind=codex_app_server`, and `exitCode=1`. The events do not show why the Codex turn failed. **Expected behavior** A run failure report for a failed Codex turn shows the failure kind, so that an operator can find the cause. The report must not contain provider response text, prompts, or model names. **Steps to reproduce** 1. Start a native Codex run. 2. Make the Codex app server end the turn with `status: "failed"` and an error that is not a model rejection or `serverOverloaded`. For example, use `codexErrorInfo: "contextWindowExceeded"`. 3. Look at the Sentry event for the run failure. Before this change, the `provider_failure` context is empty. **Agent adapter(s) involved** Codex (native runner, `codex_app_server` driver). ## What Changed - `native-provider-failure.ts`: The provider failure observation now records a diagnostic for each failed Codex turn. The diagnostic has only closed values: `provider: "codex"`, `category: "turn_failed"`, a known `CodexErrorInfo` kind (or `unrecognized` / `absent`), an HTTP status from 100 to 599 for the HTTP variants, and a runner failure code from an explicit allowlist of the codes the runner itself writes on a failed Codex turn (any other string becomes `unrecognized`, because a provider error object can carry its own `code`). It never copies the provider message. - New `unclassifiedTurnFailure()` gives this diagnostic only for a failed terminal turn that `forTerminal()` does not classify. `forTerminal()`, error codes, error messages, retries, and finalizer decisions do not change. - New `readNativeTurnFailureDiagnostic()` rebuilds a persisted value from the closed vocabulary and rejects all other values. - `native-session-executor.ts`: The adapter result keeps the generic message. It adds `resultJson.nativeTurnFailure` when the turn failure is unclassified. - `run-failure-diagnostics.ts`: The run failure report copies the validated diagnostic into the `provider` context. Sentry sends this as `provider_failure`. - Tests: unit tests for the vocabulary, HTTP status bounds, unknown kinds, runner codes, and privacy; a diagnostics test; and an update to the end-to-end executor test for the `unknown-error` scenario. ## Verification - `pnpm exec vitest run src/services/native-runtime/native-provider-failure src/services/__tests__/run-failure-diagnostics.test.ts src/services/__tests__/run-failure-report*.test.ts src/__tests__/sentry*.test.ts` (in `server/`): 6 files, 432 passed, 5 skipped. - `pnpm exec vitest run src/services/native-runtime/native-session-executor.test.ts` (in `server/`): 698 passed. - I could not run the full `tsc --noEmit` in my sandbox: the memory limit stopped the process, and the runner vendor build needs `cargo`. CI runs the full typecheck. - To check by hand: make a Codex turn fail with an unclassified error. Then make sure that the Sentry event `provider_failure` context shows `category: turn_failed` and the `codexErrorInfo` kind, and no provider text. ## Risks - Low risk. The change adds diagnostic data only. Run status, error code, error message, retry, and finalizer logic do not change. - Privacy: the diagnostic uses an allowlist of Codex enum names, a bounded integer, and an allowlist of runner-issued failure codes. Tests make sure that provider text does not get through. - If Codex adds a new `CodexErrorInfo` kind, the diagnostic shows `unrecognized` until the allowlist gets the new kind. - This change does not fix the root cause. It gives the data that is necessary to find the cause. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, used through Claude Code with tool use (shell, file edit, test runs). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Related: #15347 (capacity classifier), #15304 (model rejection diagnostics). πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../__tests__/run-failure-diagnostics.test.ts | 13 +++ .../native-provider-failure-evidence.ts | 28 +++++- ...ative-provider-failure.integration.test.ts | 13 ++- .../native-provider-failure.test.ts | 59 +++++++++++- .../native-runtime/native-provider-failure.ts | 89 +++++++++++++++++++ .../native-runtime/native-run-finalizer.ts | 5 +- .../native-runtime/native-session-executor.ts | 3 + .../src/services/run-failure-diagnostics.ts | 4 +- 8 files changed, 205 insertions(+), 9 deletions(-) diff --git a/server/src/services/__tests__/run-failure-diagnostics.test.ts b/server/src/services/__tests__/run-failure-diagnostics.test.ts index 7f1f790fb4c..f2f269039c2 100644 --- a/server/src/services/__tests__/run-failure-diagnostics.test.ts +++ b/server/src/services/__tests__/run-failure-diagnostics.test.ts @@ -190,6 +190,19 @@ describe("run failure diagnostics", () => { } }); + it("exports only the closed vocabulary of an unclassified native turn failure", () => { + const diagnostic = { provider: "codex", category: "turn_failed", codexErrorInfo: "responseStreamDisconnected", httpStatus: 502 }; + const result = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics(run({ resultJson: { + nativeTurnFailure: { ...diagnostic, runnerFailureCode: "private_prompt_slug", message: "private-provider-response" }, + } }), {})); + expect(result.provider).toEqual({ ...diagnostic, runnerFailureCode: "unrecognized" }); + expect(JSON.stringify(result)).not.toContain("rivate"); + for (const value of [null, [], { ...diagnostic, codexErrorInfo: "private-kind" }, + Object.defineProperty({}, "provider", { get() { throw new Error("private"); } })]) { + expect(collectRunFailureDiagnostics(run({ resultJson: { nativeTurnFailure: value } }), {}).provider).toEqual({}); + } + }); + it("exports only the saved remote recovery stage and reason", () => { const result = sanitizeRunFailureDiagnostics(collectRunFailureDiagnostics(run({ errorCode: "runner_remote_process_identity_unavailable", resultJson: { diff --git a/server/src/services/native-runtime/native-provider-failure-evidence.ts b/server/src/services/native-runtime/native-provider-failure-evidence.ts index f2c53356d80..1aa539fccdc 100644 --- a/server/src/services/native-runtime/native-provider-failure-evidence.ts +++ b/server/src/services/native-runtime/native-provider-failure-evidence.ts @@ -9,8 +9,7 @@ function record(value: unknown): Record { ? value as Record : {}; } -/** Re-derive authority from immutable run-bound evidence, including after restart. */ -export async function readPersistedNativeProviderFailure( +async function observePersistedNativeTurnFailure( db: Db, run: typeof heartbeatRuns.$inferSelect, turnId: string | null, @@ -41,7 +40,30 @@ export async function readPersistedNativeProviderFailure( row.sourcePayloadSha256 !== nativeSha256(parsed.event)) return null; const observed = createNativeProviderFailureObservation(execution.provider); observed.observe(parsed.event); - return observed.forTerminal(turnId, terminal); + return observed; +} + +/** Re-derive authority from immutable run-bound evidence, including after restart. */ +export async function readPersistedNativeProviderFailure( + ...args: Parameters +) { + const [, , turnId, terminal] = args; + return (await observePersistedNativeTurnFailure(...args))?.forTerminal(turnId, terminal) ?? null; +} + +/** + * Recovery can finalize before heartbeat saves the adapter result. Rebuild the + * classified failure and the diagnostics-only turn failure from one event. + */ +export async function readPersistedNativeFailureEvidence( + ...args: Parameters +) { + const [, , turnId, terminal] = args; + const observed = await observePersistedNativeTurnFailure(...args); + return { + providerFailure: observed?.forTerminal(turnId, terminal) ?? null, + turnFailure: observed?.unclassifiedTurnFailure(turnId, terminal) ?? null, + }; } export async function readPersistedNativeModelRejection( diff --git a/server/src/services/native-runtime/native-provider-failure.integration.test.ts b/server/src/services/native-runtime/native-provider-failure.integration.test.ts index 2da7891abbc..8592144f2ce 100644 --- a/server/src/services/native-runtime/native-provider-failure.integration.test.ts +++ b/server/src/services/native-runtime/native-provider-failure.integration.test.ts @@ -19,7 +19,7 @@ import { prepareNativeHeartbeatRun } from "./prepare-native-run.js"; import { NATIVE_MODEL_REJECTION_DIAGNOSTIC, NATIVE_MODEL_REJECTION_MESSAGE } from "./native-provider-failure.js"; import { collectRunFailureDiagnostics } from "../run-failure-diagnostics.js"; import { finalizeNativeRun } from "./native-run-finalizer.js"; -import { readPersistedNativeModelRejection } from "./native-provider-failure-evidence.js"; +import { readPersistedNativeFailureEvidence, readPersistedNativeModelRejection } from "./native-provider-failure-evidence.js"; import { heartbeatService } from "../heartbeat.js"; import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-review-participant.js"; import { claimQueuedNativeReviewRun } from "./native-review-dispatch.js"; @@ -136,6 +136,9 @@ describe("native completed failure diagnostics", () => { expect(adapter.errorCode).toBe(classified ? "native_provider_model_rejected" : undefined); expect(adapter.errorMessage).toBe(classified ? NATIVE_MODEL_REJECTION_MESSAGE : scenario === "succeeded" ? null : `Native session ${terminal.runTerminalState}`); expect(adapter.resultJson?.nativeProviderFailure).toEqual(classified ? NATIVE_MODEL_REJECTION_DIAGNOSTIC : undefined); + // Only a committed, unclassified failure of the terminal turn explains the generic message. + const turnFailure = scenario === "unknown-error" ? { provider: "codex", category: "turn_failed", codexErrorInfo: "other" } : undefined; + expect(adapter.resultJson?.nativeTurnFailure).toEqual(turnFailure); expect(adapter.nativeFinalization).toMatchObject({ result, terminal, turnId, workspaceFinalizeStatus: "pending" }); expect(provider.execute).toHaveBeenCalledTimes(1); expect(execution.provider).toEqual({ kind: "codex", model }); @@ -145,7 +148,7 @@ describe("native completed failure diagnostics", () => { expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId))).toHaveLength(0); expect(await db.select().from(heartbeatRunEvents).where(eq(heartbeatRunEvents.runId, runId))).toHaveLength(scenario.startsWith("untrusted-") ? 0 : 1); const diagnostics = collectRunFailureDiagnostics({ ...run, resultJson: adapter.resultJson ?? null }, {}); - expect(diagnostics.provider).toEqual(classified ? NATIVE_MODEL_REJECTION_DIAGNOSTIC : {}); + expect(diagnostics.provider).toEqual(classified ? NATIVE_MODEL_REJECTION_DIAGNOSTIC : turnFailure ?? {}); expect(JSON.stringify(diagnostics)).not.toContain(model); expect(JSON.stringify(diagnostics)).not.toContain("Private"); const [persistedRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); @@ -153,6 +156,7 @@ describe("native completed failure diagnostics", () => { expect(await readPersistedNativeModelRejection(db, persistedRun, turnId, terminal)).toEqual(classified ? { errorCode: "native_provider_model_rejected", errorMessage: NATIVE_MODEL_REJECTION_MESSAGE, diagnostic: NATIVE_MODEL_REJECTION_DIAGNOSTIC, } : null); + expect((await readPersistedNativeFailureEvidence(db, persistedRun, turnId, terminal)).turnFailure).toEqual(turnFailure ?? null); if (classified) { if (scenario === "committed") { // Neither saved display diagnostics nor a different execution binding @@ -197,6 +201,7 @@ describe("native completed failure diagnostics", () => { expect(await db.select().from(nativeRunResults).where(eq(nativeRunResults.runId, runId))).toEqual(acceptedBefore); const [finalRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); expect(finalRun).toMatchObject({ status: "failed", errorCode: "native_provider_model_rejected", error: NATIVE_MODEL_REJECTION_MESSAGE }); + expect(finalRun.resultJson?.nativeTurnFailure).toBeUndefined(); const [finalIssue] = await db.select().from(issues).where(eq(issues.id, issueId)); expect(finalIssue.status).toBe(scenario.endsWith("-review") ? "in_review" : ownerChanged ? "in_progress" : "blocked"); const decisions = await db.select().from(statusDecisions).where(eq(statusDecisions.runId, runId)); @@ -264,6 +269,10 @@ describe("native completed failure diagnostics", () => { await finalizeNativeRun({ db, runId, workspaceFinalizeStatus: "succeeded" }); const [decision] = await db.select().from(statusDecisions).where(eq(statusDecisions.runId, runId)); expect(decision.reasonCode).toBe("run_failed_partial_evidence_preserved"); + // Recovery finalized before heartbeat saved the adapter result, so the + // turn failure must come from the persisted runner event. + const [finalRun] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, runId)); + expect(finalRun.resultJson?.nativeTurnFailure).toEqual(turnFailure); const wakes = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.companyId, companyId)); expect(wakes.filter(wake => wake.payload?.issueId === issueId)).toHaveLength(1); } diff --git a/server/src/services/native-runtime/native-provider-failure.test.ts b/server/src/services/native-runtime/native-provider-failure.test.ts index bb4804a985d..fc2e349a57c 100644 --- a/server/src/services/native-runtime/native-provider-failure.test.ts +++ b/server/src/services/native-runtime/native-provider-failure.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { PrpEvent, PrpTerminalState } from "../../vendor/paperclip-runner/index.js"; -import { createNativeProviderFailureObservation, NATIVE_MODEL_REJECTION_DIAGNOSTIC, NATIVE_MODEL_REJECTION_MESSAGE, NATIVE_PROVIDER_OVERLOADED_MESSAGE } from "./native-provider-failure.js"; +import { createNativeProviderFailureObservation, NATIVE_MODEL_REJECTION_DIAGNOSTIC, NATIVE_MODEL_REJECTION_MESSAGE, NATIVE_PROVIDER_OVERLOADED_MESSAGE, readNativeTurnFailureDiagnostic } from "./native-provider-failure.js"; const model = "test-model"; const response = { @@ -76,6 +76,63 @@ describe("native provider failure observation", () => { expect(observed.forTerminal("turn", terminal)).toBeNull(); }); + it.each([ + [{ codexErrorInfo: "contextWindowExceeded", message: "Private provider text" }, { codexErrorInfo: "contextWindowExceeded" }], + [{ codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 502 } }, message: "Private" }, { codexErrorInfo: "responseStreamDisconnected", httpStatus: 502 }], + [{ codexErrorInfo: { httpConnectionFailed: { httpStatusCode: "502" } } }, { codexErrorInfo: "httpConnectionFailed" }], + [{ codexErrorInfo: { httpConnectionFailed: { httpStatusCode: 9_999 } } }, { codexErrorInfo: "httpConnectionFailed" }], + [{ codexErrorInfo: "privateUnknownKind" }, { codexErrorInfo: "unrecognized" }], + [{ codexErrorInfo: { privateUnknownKind: {} } }, { codexErrorInfo: "unrecognized" }], + [{ codexErrorInfo: { other: {}, badRequest: {} } }, { codexErrorInfo: "unrecognized" }], + [{ code: "provider_event_type_invalid", message: "Unknown canonical provider event type.", recoverable: false }, { codexErrorInfo: "absent", runnerFailureCode: "provider_event_type_invalid" }], + [{ code: "Private Code With Spaces", message: "Private" }, { codexErrorInfo: "absent", runnerFailureCode: "unrecognized" }], + [{ code: "private_prompt_slug", message: "Private" }, { codexErrorInfo: "absent", runnerFailureCode: "unrecognized" }], + [{ code: 42, message: "Private" }, { codexErrorInfo: "absent" }], + [{ message: NATIVE_PROVIDER_OVERLOADED_MESSAGE }, { codexErrorInfo: "absent" }], + ])("describes an unclassified failed turn with closed vocabulary only %#", (error, expected) => { + const observed = observation(); + observed.observe({ ...event, payload: { status: "failed", error } }); + expect(observed.forTerminal("turn", terminal)).toBeNull(); + const diagnostic = observed.unclassifiedTurnFailure("turn", terminal); + expect(diagnostic).toEqual({ provider: "codex", category: "turn_failed", ...expected }); + expect(JSON.stringify(diagnostic)).not.toMatch(/private/i); + expect(readNativeTurnFailureDiagnostic(diagnostic)).toEqual(diagnostic); + }); + + it("does not describe a classified, unfailed, or unrelated turn as unclassified", () => { + const rejected = observation(); + rejected.observe(event); + expect(rejected.unclassifiedTurnFailure("turn", terminal)).toBeNull(); + const overloaded = observation(); + overloaded.observe({ ...event, payload: { status: "failed", error: { codexErrorInfo: "serverOverloaded" } } }); + expect(overloaded.unclassifiedTurnFailure("turn", terminal)).toBeNull(); + const other = observation(); + other.observe({ ...event, payload: { status: "failed", error: { codexErrorInfo: "sandboxError" } } }); + expect(other.unclassifiedTurnFailure("later-turn", terminal)).toBeNull(); + expect(other.unclassifiedTurnFailure(null, terminal)).toBeNull(); + expect(other.unclassifiedTurnFailure("turn", { ...terminal, runTerminalState: "succeeded" })).toBeNull(); + expect(other.unclassifiedTurnFailure("turn", { ...terminal, turnTerminalState: "cancelled" })).toBeNull(); + const claude = createNativeProviderFailureObservation({ kind: "claude", model }); + claude.observe({ ...event, payload: { status: "failed", error: { codexErrorInfo: "sandboxError" } } }); + expect(claude.unclassifiedTurnFailure("turn", terminal)).toBeNull(); + }); + + it.each([ + null, [], "turn_failed", + { provider: "codex", category: "turn_failed", codexErrorInfo: "private-kind" }, + { provider: "claude", category: "turn_failed", codexErrorInfo: "other" }, + { provider: "codex", category: "server_overloaded", codexErrorInfo: "other" }, + ])("rejects a persisted turn-failure diagnostic outside the vocabulary %#", (value) => { + expect(readNativeTurnFailureDiagnostic(value)).toBeNull(); + }); + + it("drops unbounded fields from a persisted turn-failure diagnostic", () => { + expect(readNativeTurnFailureDiagnostic({ + provider: "codex", category: "turn_failed", codexErrorInfo: "badRequest", httpStatus: 1.5, + runnerFailureCode: "private_prompt_slug", message: "private provider response", + })).toEqual({ provider: "codex", category: "turn_failed", codexErrorInfo: "badRequest", runnerFailureCode: "unrecognized" }); + }); + it.each([{ kind: "claude", model }, { kind: "codex" }, { kind: "codex", model: "other" }])( "does not apply a rejection to another selected provider/model (%j)", (provider) => { const observed = createNativeProviderFailureObservation(provider); diff --git a/server/src/services/native-runtime/native-provider-failure.ts b/server/src/services/native-runtime/native-provider-failure.ts index 3fa2e5bbf74..4f9b29fa4c0 100644 --- a/server/src/services/native-runtime/native-provider-failure.ts +++ b/server/src/services/native-runtime/native-provider-failure.ts @@ -35,16 +35,98 @@ export function readNativeModelRejectionDiagnostic(value: unknown) { } catch { return null; } } +/** + * Codex `CodexErrorInfo` variants. Unit variants arrive as strings; HTTP + * variants arrive as one-key objects that can carry `httpStatusCode`. + */ +const CODEX_ERROR_INFO_KINDS: ReadonlySet = new Set([ + "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", "httpConnectionFailed", + "responseStreamConnectionFailed", "internalServerError", "unauthorized", "badRequest", + "threadRollbackFailed", "sandboxError", "responseStreamDisconnected", + "responseTooManyFailedAttempts", "other", +]); +/** + * `error.code` values the runner itself writes on a failed Codex turn: the + * Codex driver's `failProtocol` callers and runnerd's Codex backend. A provider + * error object can also carry `code`, so any other value is `unrecognized`. + */ +const NATIVE_TURN_FAILURE_RUNNER_CODES: ReadonlySet = new Set([ + "thread_binding_mismatch", "turn_binding_mismatch", "turn_start_mismatch", "tool_binding_mismatch", + "runtime_request_binding_mismatch", "runtime_request_duplicate", "runtime_request_recovery_failed", + "notification_transport_failed", "provider_event_type_invalid", "invalid_semantic_result", + "conflicting_semantic_result", "conflicting_turn_terminal", "invalid_spawn_lineage", + "provider_descendant_capacity_exhausted", "provider_terminal_error_missing", "provider_turn_lost_on_restore", +]); + +/** + * Closed vocabulary for a failed Codex turn that no classifier above claims. + * It explains generic `Native session failed` reports without provider text. + */ +export interface NativeTurnFailureDiagnostic { + provider: "codex"; + category: "turn_failed"; + /** A known `CodexErrorInfo` kind, `unrecognized`, or `absent`. */ + codexErrorInfo: string; + httpStatus?: number; + /** A known runner-issued failure code, or `unrecognized` for any other code. */ + runnerFailureCode?: string; +} + +function httpStatusField(value: unknown) { + return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599 + ? { httpStatus: value } : {}; +} + +function runnerFailureCodeField(value: unknown) { + if (typeof value !== "string") return {}; + return { runnerFailureCode: NATIVE_TURN_FAILURE_RUNNER_CODES.has(value) ? value : "unrecognized" }; +} + +function codexErrorInfoFields(value: unknown): Pick { + if (value === undefined || value === null) return { codexErrorInfo: "absent" }; + if (typeof value === "string") return { codexErrorInfo: CODEX_ERROR_INFO_KINDS.has(value) ? value : "unrecognized" }; + const entry = record(value); + const keys = Object.keys(entry); + if (keys.length !== 1 || !CODEX_ERROR_INFO_KINDS.has(keys[0]!)) return { codexErrorInfo: "unrecognized" }; + return { codexErrorInfo: keys[0]!, ...httpStatusField(record(entry[keys[0]!]).httpStatusCode) }; +} + +/** Rebuild a persisted turn-failure diagnostic from its closed vocabulary only. */ +export function readNativeTurnFailureDiagnostic(value: unknown): NativeTurnFailureDiagnostic | null { + try { + const entry = record(value); + const kind = entry.codexErrorInfo; + if (entry.provider !== "codex" || entry.category !== "turn_failed" || typeof kind !== "string" || + (kind !== "absent" && kind !== "unrecognized" && !CODEX_ERROR_INFO_KINDS.has(kind))) return null; + return { + provider: "codex", + category: "turn_failed", + codexErrorInfo: kind, + ...httpStatusField(entry.httpStatus), + ...runnerFailureCodeField(entry.runnerFailureCode), + }; + } catch { return null; } +} + /** Observe only events accepted by the bound control-plane persistence port. */ export function createNativeProviderFailureObservation(provider: { kind: string; model?: string | null }) { let rejectedTurnId: string | null = null; let overloadedTurnId: string | null = null; + const failedTurns = new Map(); return { observe(event: PrpEvent) { if (provider.kind !== "codex" || event.sourceKind !== "runner" || event.eventType !== "turn.failed" || !event.turnId) return; const payload = record(event.payload); const failure = record(payload.error); + if (payload.status === "failed") { + failedTurns.set(event.turnId, { + provider: "codex", + category: "turn_failed", + ...codexErrorInfoFields(failure.codexErrorInfo), + ...runnerFailureCodeField(failure.code), + }); + } if (payload.status === "failed" && failure.codexErrorInfo === "serverOverloaded") { overloadedTurnId = event.turnId; return; @@ -75,5 +157,12 @@ export function createNativeProviderFailureObservation(provider: { kind: string; diagnostic: { ...NATIVE_MODEL_REJECTION_DIAGNOSTIC }, }; }, + /** Diagnostics only: the failed turn that `forTerminal` did not classify. */ + unclassifiedTurnFailure(turnId: string | null | undefined, terminal: PrpTerminalState) { + if (!turnId || terminal.turnTerminalState !== "failed" || terminal.runTerminalState !== "failed" || + turnId === overloadedTurnId || turnId === rejectedTurnId) return null; + const diagnostic = failedTurns.get(turnId); + return diagnostic ? { ...diagnostic } : null; + }, }; } diff --git a/server/src/services/native-runtime/native-run-finalizer.ts b/server/src/services/native-runtime/native-run-finalizer.ts index 065e120e8f9..f37b9f4e969 100644 --- a/server/src/services/native-runtime/native-run-finalizer.ts +++ b/server/src/services/native-runtime/native-run-finalizer.ts @@ -4,7 +4,7 @@ import { activeIssueInteractionCondition } from "../issue-question-context.js"; import { hasPendingNativeChildCompletion } from "./native-child-completion-delivery.js"; import { settleExternalConversation } from "../slack-conversation-lifecycle.js"; import { executionFailureRetryCount } from "../execution-recovery-attempt.js"; -import { readPersistedNativeProviderFailure } from "./native-provider-failure-evidence.js"; +import { readPersistedNativeFailureEvidence } from "./native-provider-failure-evidence.js"; import { dismissAutomaticCompletionReviews } from "./automatic-completion-reviews.js"; import { getNativeReviewAssignment, readNativeReviewAssignmentContext } from "./native-review-participant.js"; import { conversationNativeDecision, isConversation } from "../agent-conversations.js"; @@ -1344,7 +1344,7 @@ export async function finalizeNativeRun(input: { (!planWait || nativeSha256(planWait.result) !== nativeSha256(result))) { throw new Error("native_plan_wait_authority_lost"); } - const providerFailure = await readPersistedNativeProviderFailure( + const { providerFailure, turnFailure } = await readPersistedNativeFailureEvidence( input.db, run, resultRow.turnId, envelope.terminal as PrpTerminalState, ); const ownsProviderFailureDecision = @@ -1504,6 +1504,7 @@ export async function finalizeNativeRun(input: { const clearExecutionFailure = input.projectRunStatus && finalizationPhase === "committed" && terminalState === "succeeded"; const finalizationMetadata = { ...(providerFailure ? { nativeProviderFailure: providerFailure.diagnostic } : {}), + ...(turnFailure ? { nativeTurnFailure: turnFailure } : {}), finalizationPhase, ...(finalizationPhase === "committed" ? { failureCode: null, originalFailureCode: null, nextAttemptAt: null } : {}), assessmentId: assessmentRow.id, diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index a31043d23f6..dedc7ad65ca 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -9593,6 +9593,8 @@ async function executePaperclipNativeSessionWithinScope( if (collectInstructions && !collectedByOwner) await timeController("instruction_collection", () => instructionCopy!.collectStopped()); } const providerFailure = providerFailureObservation.forTerminal(native.turnId, native.terminal); + // Keep the generic failure behavior, but record why the turn failed. + const unclassifiedTurnFailure = providerFailureObservation.unclassifiedTurnFailure(native.turnId, native.terminal); const adapterResult: AdapterExecutionResult = { exitCode: native.terminal.runTerminalState === "succeeded" ? 0 : 1, signal: null, @@ -9607,6 +9609,7 @@ async function executePaperclipNativeSessionWithinScope( nativeTerminal: native.terminal as unknown as Record, ...(input.execution.schema === "paperclip.native-execution-input.v6" ? { providerAccounting: { usage: null, cost: null, externallyBilled: true }, externalStopConfirmed: false } : {}), ...(providerFailure ? { nativeProviderFailure: providerFailure.diagnostic } : {}), + ...(unclassifiedTurnFailure ? { nativeTurnFailure: unclassifiedTurnFailure } : {}), ...(native.goalRolloverRequired ? { goalRolloverRequired: true } : {}), planSynchronizations, }, diff --git a/server/src/services/run-failure-diagnostics.ts b/server/src/services/run-failure-diagnostics.ts index f03bf4c2043..47422a63c7f 100644 --- a/server/src/services/run-failure-diagnostics.ts +++ b/server/src/services/run-failure-diagnostics.ts @@ -9,7 +9,7 @@ import { sanitizeWorkspaceRestoreDiagnostic } from "@paperclipai/adapter-utils/w import { ACP_TOOL_KINDS } from "@paperclipai/adapter-utils/acpx-engine/constants"; import { redactCurrentUserText } from "../log-redaction.js"; import { redactSensitiveText, REDACTED_EVENT_VALUE } from "../redaction.js"; -import { readNativeModelRejectionDiagnostic } from "./native-runtime/native-provider-failure.js"; +import { readNativeModelRejectionDiagnostic, readNativeTurnFailureDiagnostic } from "./native-runtime/native-provider-failure.js"; import { readRemoteRunnerRecoveryDiagnostic } from "./native-runtime/remote-runner-recovery-diagnostics.js"; import { MANAGED_GIT_WORKTREE_REASON_CODES, PERSISTED_WORKSPACE_SOURCE_REASON_CODES, readManagedGitInspectionDiagnostic } from "./workspace-validation-diagnostics.js"; @@ -340,6 +340,8 @@ export function collectRunFailureDiagnostics(run: Run, options: RunFailureReport const provider = scalars(read(result, "terminalSessionFailure"), ["category", "title", "details"]); const nativeProviderFailure = readNativeModelRejectionDiagnostic(read(result, "nativeProviderFailure")); if (nativeProviderFailure) Object.assign(provider, nativeProviderFailure); + const nativeTurnFailure = readNativeTurnFailureDiagnostic(read(result, "nativeTurnFailure")); + if (nativeTurnFailure) Object.assign(provider, nativeTurnFailure); const truncatedFields: string[] = []; const providerTruncation = read(read(result, "terminalSessionFailure"), "truncatedFields"); if (Array.isArray(providerTruncation)) { From 32ede08918d970bf7c4e98107c78ecd3466e209a Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:22:54 -0500 Subject: [PATCH 03/34] fix(grok): copy pairing codes during agent reconnect (#16373) Grok Bot reconnect showed a masked one-use code without a copy action. Add the shared Copy code control, preserve masking and copy-error feedback, and keep the code out of Bot instructions. Cover the reconnect flow and readiness wait in tests and Storybook, and update the user guide. Verified head 1346f7a9090850236c9c5e774e64175d9f98f4d4: 55 checks passed, two optional skips, full tests/build/typecheck/E2E, and Greptile 5/5 with no unresolved threads. Local typecheck, build, token gates, Storybook build and focused regressions pass. Scoped Chrome copying and a fresh built story were verified with synthetic data. Actual Grok runtime qualification remains a release gate. Co-Authored-By: Paperclip --- doc/grok-bot-agent.md | 4 +- ui/src/components/GrokBotConnection.tsx | 15 +++++-- .../new-agent/GrokBotSetup.render.test.tsx | 43 ++++++++++++++++++- .../agents/grok-bot/configuration.stories.tsx | 12 +++++- 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/doc/grok-bot-agent.md b/doc/grok-bot-agent.md index dc842362b26..ee04b8a6f3c 100644 --- a/doc/grok-bot-agent.md +++ b/doc/grok-bot-agent.md @@ -22,7 +22,9 @@ Use **Save & exit** to preserve unfinished setup. See the official Manage the Bot from its agent's **Runtime** settings. This shows connection health, routine configuration, and verification. Model selection, local paths, and runtime tests are managed outside Paperclip; use **Verify connection** to -check both directions. +check both directions. Use **Reconnect agent access** to restore the same agent's +connection. Select **Copy code** beside the masked one-use pairing code, then +paste it into Paperclip's authorization page. Keep it out of Bot chat. Assign ordinary tasks to the agent. Read its progress, answer its question cards, and review its documents and files in the task thread. The Bot can initiate a diff --git a/ui/src/components/GrokBotConnection.tsx b/ui/src/components/GrokBotConnection.tsx index 0aa3fd32060..0e7e7bc2c7c 100644 --- a/ui/src/components/GrokBotConnection.tsx +++ b/ui/src/components/GrokBotConnection.tsx @@ -1,15 +1,17 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { DotPairing } from "@paperclipai/shared"; import { grokBotApi } from "@/api/grokBot"; import { buildGrokAccessPrompt, buildGrokRoutinePrompt } from "@/lib/grok-bot-setup-prompt"; import { AgentSetupPrompt } from "./AgentSetupPrompt"; -import { Button } from "./ui/button"; +import { CopyText } from "./CopyText"; +import { Button, buttonVariants } from "./ui/button"; import { Input } from "./ui/input"; import { queryKeys } from "@/lib/queryKeys"; /** Configuration writes update the authoritative binding, never an unsaved agent form. */ export function GrokBotConnection({ companyId, agentId }: { companyId?: string; agentId?: string }) { + const pairingCodeId = useId(); const cache = useQueryClient(); const key = ["grok-binding", companyId, agentId]; const state = useQuery({ queryKey: key, enabled: !!companyId && !!agentId, @@ -75,7 +77,14 @@ export function GrokBotConnection({ companyId, agentId }: { companyId?: string; {pairing && state.data?.resourceUrl && <> - +
+ +
+ e.target.select()} /> + Copy code +
+

Copy this code into Paperclip’s authorization page. Keep it out of Bot chat.

+
} } {binding?.assignment &&

{binding.assignment.status === "offered" ? "Awaiting acknowledgement" : "Working in a Paperclip work session"}

Revokes this session’s Paperclip authority and stops future dispatch. Remote execution may continue. Open Grok Bot to inspect it.

} diff --git a/ui/src/components/new-agent/GrokBotSetup.render.test.tsx b/ui/src/components/new-agent/GrokBotSetup.render.test.tsx index 88228bf05ca..3a2c873eb89 100644 --- a/ui/src/components/new-agent/GrokBotSetup.render.test.tsx +++ b/ui/src/components/new-agent/GrokBotSetup.render.test.tsx @@ -35,7 +35,7 @@ it("restores the same saved agent when a failed initial resume succeeds on retry expect(retry).toBeTruthy(); flushSync(() => retry!.click()); await vi.waitFor(() => expect([...document.querySelectorAll("label")].some(label => label.textContent === "Webhook key")).toBe(true)); - expect(api.connection).toHaveBeenCalledWith(companyId, agentId, expect.any(AbortSignal)); + await vi.waitFor(() => expect(api.connection).toHaveBeenCalledWith(companyId, agentId, expect.any(AbortSignal))); expect(api.create).not.toHaveBeenCalled(); expect(api.pair).not.toHaveBeenCalled(); }); @@ -82,3 +82,44 @@ it("copies the masked pairing code and offers a retry after a clipboard failure" expect(copyTextToClipboard).toHaveBeenNthCalledWith(2, pairingCode); expect(document.body.textContent).not.toContain(pairingCode); }); + +it("copies a masked reconnect code without adding it to Bot instructions and retries clipboard failure", async () => { + const pairingCode = "synthetic_reconnect_code_12345"; + api.connection.mockResolvedValue({ enabled: true, canConfigureConnection: true, + resourceUrl: "https://paperclip.example/mcp/runner", binding: null }); + api.pair.mockResolvedValue({ bindingId: "binding-fixture", pairingCode, + expiresAt: new Date(Date.now() + 60_000).toISOString() }); + vi.mocked(copyTextToClipboard).mockRejectedValueOnce(new Error("Clipboard unavailable")).mockResolvedValue(); + cache = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } }); + host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host); + flushSync(() => root.render()); + const reconnect = await vi.waitFor(() => { + const button = [...host.querySelectorAll("button")].find(element => element.textContent === "Reconnect agent access")!; + expect(button.disabled).toBe(false); + return button; + }); + flushSync(() => reconnect.click()); + const copy = await vi.waitFor(() => { + const button = host.querySelector('button[aria-label="Copy pairing code"]'); + expect(button).toBeTruthy(); + return button!; + }); + const label = [...host.querySelectorAll("label")].find(element => element.textContent === "One-use pairing code")!; + const input = document.getElementById(label.htmlFor) as HTMLInputElement; + expect(input.type).toBe("password"); + expect(input.readOnly).toBe(true); + expect(input.value).toBe(pairingCode); + expect(host.textContent).not.toContain(pairingCode); + flushSync(() => copy.click()); + await vi.waitFor(() => expect(host.textContent).toContain("Copy failed")); + flushSync(() => copy.click()); + await vi.waitFor(() => expect(host.textContent).toContain("Copied!")); + expect(copyTextToClipboard).toHaveBeenNthCalledWith(1, pairingCode); + expect(copyTextToClipboard).toHaveBeenNthCalledWith(2, pairingCode); + const instructions = host.querySelector('button[aria-label="Copy connection instructions"]')!; + flushSync(() => instructions.click()); + await vi.waitFor(() => expect(copyTextToClipboard).toHaveBeenCalledTimes(3)); + expect(vi.mocked(copyTextToClipboard).mock.calls[2]![0]).toContain("https://paperclip.example/mcp/runner"); + expect(vi.mocked(copyTextToClipboard).mock.calls[2]![0]).not.toContain(pairingCode); + expect(api.pair).toHaveBeenCalledExactlyOnceWith("company-fixture", "agent-fixture", undefined); +}); diff --git a/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx b/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx index 70370e74e60..886c24cfe55 100644 --- a/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx +++ b/ui/storybook/stories/agents/grok-bot/configuration.stories.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { expect, within } from "storybook/test"; +import { expect, userEvent, waitFor, within } from "storybook/test"; import type { AgentDetail, GrokConnection } from "@paperclipai/shared"; import { ConfigurationTab } from "@/pages/AgentDetail"; import { storybookAgents } from "../../../fixtures/paperclipData"; @@ -36,6 +36,8 @@ function Configuration({ disconnected = false }: { disconnected?: boolean }) { const fixture: typeof fetch = async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); if (url.pathname === `/api/companies/${agent.companyId}/agents/${agent.id}/grok-binding`) { + if (disconnected && init?.method === "POST") return Response.json({ bindingId, + pairingCode: "synthetic-reconnect-code", expiresAt: new Date(Date.now() + 900000).toISOString() }); if ((init?.method ?? "GET") !== "GET") return Response.json({ error: "This preview does not authorize connections." }, { status: 405 }); return Response.json(disconnected ? { ...connection, binding: null } : connection); } @@ -71,6 +73,12 @@ export const ConnectedBot: Story = { play: async ({ canvasElement }) => { await expect(page.queryByText("AI connection", { exact: true })).not.toBeInTheDocument(); } }; export const DisconnectedBot: Story = { args: { disconnected: true }, play: async ({ canvasElement }) => { - await expect(await within(canvasElement).findByRole("button", { name: "Reconnect agent access" })).toBeVisible(); + const page = within(canvasElement); + const reconnect = await page.findByRole("button", { name: "Reconnect agent access" }); + await waitFor(() => expect(reconnect).toBeEnabled()); + await userEvent.click(reconnect); + await expect(await page.findByRole("button", { name: "Copy pairing code" })).toBeVisible(); + await expect(page.getByLabelText("One-use pairing code")).toHaveAttribute("type", "password"); + await expect(page.queryByText("synthetic-reconnect-code")).not.toBeInTheDocument(); } }; export const Mobile: Story = { ...ConnectedBot, globals: { viewport: { value: "mobile1", isRotated: false } } }; From d73d7a8651db0897cb50adbca42731bcb8aed550 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:31:38 -0500 Subject: [PATCH 04/34] perf(skills): batch repository dependency reads (#16377) Batch dependency Markdown reads through the bounded Git snapshot reader and queue newly discovered dependencies once. Preserve package contents, limits, authorization, audits, and reference order. Validation: 63 affected cases; full typecheck/build; exact-head full CI, E2E, Superagent and Apex 5/5. Local Git comparison: 502 individual reads reduced to three batches with identical entries. Co-Authored-By: Paperclip --- .../2026-10-09-repository-skill-packages.md | 8 ++++ .../src/__tests__/github-skill-source.test.ts | 22 ++++++++++ .../skill-repository-dependencies.test.ts | 22 ++++++++++ .../skill-source-git-snapshot.test.ts | 41 ++++++++++++++++++- server/src/services/github-skill-source.ts | 6 ++- .../services/skill-repository-dependencies.ts | 39 ++++++++++-------- 6 files changed, 120 insertions(+), 18 deletions(-) create mode 100644 server/src/__tests__/skill-repository-dependencies.test.ts diff --git a/doc/plans/2026-10-09-repository-skill-packages.md b/doc/plans/2026-10-09-repository-skill-packages.md index 77ef830edeb..918bcbdd56c 100644 --- a/doc/plans/2026-10-09-repository-skill-packages.md +++ b/doc/plans/2026-10-09-repository-skill-packages.md @@ -27,3 +27,11 @@ The importer saves available repository files without asking for repair decision The production dialog shows every skill by default, selects all discovered skills, and collapses individual package files. It uses one collapsed warnings disclosure and leaves Import repository available. The importer has no packaging option: new imports keep repository files together. Storybook covers omitted files, warning details, missing references, setup problems, warnings after import, and mobile review. Focused scanner/delivery/PostgreSQL tests and UI tests verify saved warnings and available bytes; browser fixtures exercise the production UI. This revision has not rerun the paid local/Daytona campaigns above. Final acceptance also found and fixed duplicate saved warnings and an installed-file viewer that did not expose repository dependencies. The viewer now lists repository files and reads their saved bytes without GitHub access. Content errors for unsafe execution or secret disclosure omit the affected files with warnings; the remaining repository can still import. Database and scanner regressions verify these exclusions and file-reader company isolation. + +## October 14: dependency read batches + +Oversized repository imports read each Markdown dependency frontier through the existing bounded Git batch reader. The next frontier contains newly found references. The scanner retains its file and byte limits, content audits, reference order, and fallback for snapshot readers without batch support. This avoids one Git child process for each Markdown file during a source refresh. + +The regression checks a real oversized Git repository with 64 skills, transitive references, and a reference cycle. It compares every retained file's bytes and requires four batch reads with no individual reads. A local comparison of the old and final dependency walkers on 500 Markdown files and two shared references returned identical entries: three old samples took 4.9–6.2 seconds with 502 child processes, while the new samples took 62–85 milliseconds with three batches. These measurements exclude provider execution and do not establish agent response latency. + +The frontier uses a queue of newly added Markdown files. A regression at the 9,990-file limit retains every file, terminates a reference cycle, and bounds path visits. The earlier frontier rescan made 50,014,936 path visits in a local comparison. The queue makes 89,911 visits and retains the same output order. diff --git a/server/src/__tests__/github-skill-source.test.ts b/server/src/__tests__/github-skill-source.test.ts index 91f0b20470e..c82bfdea2f2 100644 --- a/server/src/__tests__/github-skill-source.test.ts +++ b/server/src/__tests__/github-skill-source.test.ts @@ -13,6 +13,28 @@ import { skillFileBytes } from '../services/skill-snapshot.js'; const sha = 'a'.repeat(40); const md = (name: string) => `---\nname: ${name}\ndescription: A useful skill\n---\nFollow these instructions.\n`; describe('GitHub skill repository discovery', () => { + it('batches each dependency frontier instead of spawning a Git read for every Markdown file', async () => { + const files = { + ...Object.fromEntries(Array.from({ length: 64 }, (_, i) => [`skills/skill-${i}/SKILL.md`, md(`skill-${i}`) + '\n[Guide](../../shared/guide.md)'])), + 'shared/guide.md': '[Details](details.md)', + 'shared/details.md': '[Guide](guide.md) [Data](data.json)', + 'shared/data.json': '{"example":true}', + ...Object.fromEntries(Array.from({ length: 10_000 }, (_, i) => [`unrelated/${i}.txt`, ''])), + }; + const fixture = githubFixture(files); + const snapshot = await fixture.openSnapshot({ repositoryUrl: '', ref: 'main' }); + const read = snapshot.readBlob; + const singleReads = vi.spyOn(snapshot, 'readBlob'); + const batches = vi.fn(async (entries: typeof snapshot.entries) => new Map(await Promise.all(entries.map(async entry => [entry.sha, await read(entry)] as const)))); + snapshot.readBlobs = batches; + fixture.openSnapshot = async () => snapshot; + const scan = await scanGitHubSkillsWithPackageFallback({ repositoryUrl: 'https://github.com/acme/skills', packageMode: 'repository' }, fixture); + expect(scan.repositoryFiles?.map(file => file.path).sort()).toEqual(Object.keys(files).filter(name => !name.startsWith('unrelated/')).sort()); + expect(scan.candidates.every(skill => skill.error === null)).toBe(true); + expect(singleReads).not.toHaveBeenCalled(); + expect(batches.mock.calls.map(([entries]) => entries.length)).toEqual([64, 1, 1, 1]); + }); + it('keeps skill folders and transitive shared files when unrelated repository files exceed the package limit', async () => { const fixture = githubFixture({ 'skills/main/SKILL.md': md('main') + '\nRead [shared](../../shared/guide.md) and [helper](../helper/SKILL.md).', diff --git a/server/src/__tests__/skill-repository-dependencies.test.ts b/server/src/__tests__/skill-repository-dependencies.test.ts new file mode 100644 index 00000000000..89b7ced8069 --- /dev/null +++ b/server/src/__tests__/skill-repository-dependencies.test.ts @@ -0,0 +1,22 @@ +import { expect, it, vi } from 'vitest'; +import { skillRepositoryDependencies } from '../services/skill-repository-dependencies.js'; +import { MAX_REPOSITORY_FILES } from '../services/skill-repository-package.js'; +import type { GitSkillTreeEntry } from '../services/skill-source-git-snapshot.js'; + +it('keeps path visits bounded for a reference chain at the repository file limit', async () => { + let pathVisits = 0; + const paths = ['skills/main/SKILL.md', ...Array.from({ length: MAX_REPOSITORY_FILES - 1 }, (_, i) => `shared/${i}.md`)]; + const entries: GitSkillTreeEntry[] = paths.map((name, i) => ({ + get path() { pathVisits++; return name; }, + sha: i.toString(16).padStart(40, '0'), mode: '100644', type: 'blob', size: 100, + })); + const content = new Map(paths.map((name, i) => [name, Buffer.from(i === 0 ? '[Next](../../shared/0.md)' + : `[Next](${i === paths.length - 1 ? '0' : String(i)}.md)`)])); + const read = vi.fn(async (entry: GitSkillTreeEntry) => content.get(entry.path)!); + const prefetch = vi.fn(async () => {}); + const selected = await skillRepositoryDependencies(entries, [entries[0]!], new Set(['skills/main']), read, prefetch); + expect(selected).toEqual(entries); + expect(read).toHaveBeenCalledTimes(MAX_REPOSITORY_FILES); + expect(prefetch).toHaveBeenCalledTimes(MAX_REPOSITORY_FILES); + expect(pathVisits).toBeLessThan(MAX_REPOSITORY_FILES * 100); +}); diff --git a/server/src/__tests__/skill-source-git-snapshot.test.ts b/server/src/__tests__/skill-source-git-snapshot.test.ts index 0ec3599cc6e..8fd01f570a0 100644 --- a/server/src/__tests__/skill-source-git-snapshot.test.ts +++ b/server/src/__tests__/skill-source-git-snapshot.test.ts @@ -25,7 +25,7 @@ vi.mock('node:child_process', async importOriginal => { } }; }); import { clearGitSkillSnapshotCache, openGitSkillSnapshot, parseGitDownloadProgress } from '../services/skill-source-git-snapshot.js'; -import { scanGitHubSkills, type GitHubRead } from '../services/github-skill-source.js'; +import { scanGitHubSkills, scanGitHubSkillsWithPackageFallback, type GitHubRead } from '../services/github-skill-source.js'; const exec = promisify(execFile); let directory: string, commit: string, laterCommit: string; const git = (...args: string[]) => exec('git', ['-C', directory, '-c', 'user.name=Test', '-c', 'user.email=test@example.test', ...args]); @@ -51,6 +51,45 @@ afterEach(async () => { vi.useRealTimers(); interception.hang = false; intercept afterAll(async () => { await rm(directory, { recursive: true, force: true }); }); describe('Git skill snapshots', () => { + it('uses real Git batches for transitive dependencies in an oversized repository', async () => { + const remote = await mkdtemp(path.join(os.tmpdir(), 'paperclip-dependency-blobs-')); + interception.remote = remote; + try { + await exec('git', ['-C', remote, 'init', '-b', 'main']); + await mkdir(path.join(remote, 'unrelated')); + await mkdir(path.join(remote, 'shared')); + const files: Record = { + 'shared/guide.md': '[Details](details.md)', + 'shared/details.md': '[Guide](guide.md) [Data](data.json)', + 'shared/data.json': '{"value":true}', + }; + for (let i = 0; i < 64; i++) { + await mkdir(path.join(remote, `skills/skill-${i}`), { recursive: true }); + files[`skills/skill-${i}/SKILL.md`] = `---\nname: skill-${i}\ndescription: Dependency fixture\n---\n[Guide](../../shared/guide.md)`; + } + for (const [file, content] of Object.entries(files)) await writeFile(path.join(remote, file), content); + const unrelated = Buffer.alloc(1024 * 1024, 'x'); + for (let i = 0; i < 62; i++) await writeFile(path.join(remote, `unrelated/${i}.bin`), unrelated); + await exec('git', ['-C', remote, 'add', '.']); + await exec('git', ['-C', remote, '-c', 'user.name=Test', '-c', 'user.email=test@example.test', 'commit', '-m', 'Dependency fixture']); + const commitSha = (await exec('git', ['-C', remote, 'rev-parse', 'HEAD'])).stdout.trim(); + const read = (async (url: string) => url.includes('/commits/') ? { sha: commitSha } : { id: 42, full_name: 'acme/skills', default_branch: 'main' }) as GitHubRead; + read.openSnapshot = (input, options) => openGitSkillSnapshot({ ...request(), ref: input.ref, commitSha: input.commitSha }, options); + const scan = await scanGitHubSkillsWithPackageFallback({ repositoryUrl: 'https://github.com/acme/skills', packageMode: 'repository' }, read); + expect(scan.repositorySubset).toBe(true); + expect(scan.repositoryFiles?.map(file => file.path).sort()).toEqual(Object.keys(files).sort()); + for (const file of scan.repositoryFiles!) { + const content = file.encoding === 'base64' ? Buffer.from(file.content, 'base64') : Buffer.from(file.content); + expect(content.toString()).toBe(files[file.path]); + } + const reads = interception.commands.filter(command => command.args.includes('cat-file')); + expect(reads).toHaveLength(4); + expect(reads.every(command => command.args.includes('--batch'))).toBe(true); + } finally { + await rm(remote, { recursive: true, force: true }); + } + }, 30_000); + it('reads many unrelated support files with one Git batch per scan, including cache hits', async () => { const remote = await mkdtemp(path.join(os.tmpdir(), 'paperclip-many-blobs-')); interception.remote = remote; diff --git a/server/src/services/github-skill-source.ts b/server/src/services/github-skill-source.ts index 41644dd1ba7..f348affd27e 100644 --- a/server/src/services/github-skill-source.ts +++ b/server/src/services/github-skill-source.ts @@ -160,7 +160,11 @@ async function scanRepository(input: SkillScanInput, providerRead: GitHubRead, o if (dependencyBytes > MAX_REPOSITORY_BYTES) throw unprocessable('Repository package exceeds the 61 MB runtime limit.'); dependencyBlobs.set(entry.sha, bytes); return bytes; - }) : entries.filter(entry => entry.type !== 'tree'); + }, snapshot.readBlobs ? async pending => { + options.signal?.throwIfAborted(); + const unread = pending.filter(entry => !dependencyBlobs.has(entry.sha) && (entry.size ?? 0) <= MAX_FILE_BYTES); + if (unread.length) for (const [sha, bytes] of await snapshot.readBlobs!(unread, options.signal)) dependencyBlobs.set(sha, bytes); + } : undefined) : entries.filter(entry => entry.type !== 'tree'); if (repositoryInventory && repositoryInventory.length > MAX_REPOSITORY_FILES) throw unprocessable('Repository package exceeds the 9,990 file limit.'); const readable = repositoryInventory?.filter(entry => entry.type === 'blob' && ['100644', '100755'].includes(entry.mode) && (entry.size ?? 0) <= MAX_FILE_BYTES); if (readable && readable.reduce((sum, entry) => sum + (entry.size ?? 0), 0) > MAX_REPOSITORY_BYTES) throw unprocessable('Repository package exceeds the 61 MB runtime limit.'); diff --git a/server/src/services/skill-repository-dependencies.ts b/server/src/services/skill-repository-dependencies.ts index f77d296ae04..a4a58592ef0 100644 --- a/server/src/services/skill-repository-dependencies.ts +++ b/server/src/services/skill-repository-dependencies.ts @@ -10,10 +10,12 @@ export async function skillRepositoryDependencies( initial: GitSkillTreeEntry[], skillDirectories: ReadonlySet, readBlob: (entry: GitSkillTreeEntry) => Promise, + prefetch?: (entries: GitSkillTreeEntry[]) => Promise, ): Promise { const paths = indexSkillPackagePaths(entries.map(entry => entry.path)); const byPath = new Map(entries.map(entry => [entry.path, entry])); const selected = new Map(); + let pending: GitSkillTreeEntry[] = []; let bytes = 0; const add = (entry: GitSkillTreeEntry) => { if (entry.type === 'tree' || selected.has(entry.path)) return; @@ -21,26 +23,31 @@ export async function skillRepositoryDependencies( if (selected.size > MAX_REPOSITORY_FILES) throw unprocessable('Repository package exceeds the 9,990 file limit.'); if (entry.type === 'blob' && ['100644', '100755'].includes(entry.mode) && (entry.size ?? 0) <= 1024 * 1024) bytes += entry.size ?? 0; if (bytes > MAX_REPOSITORY_BYTES) throw unprocessable('Repository package exceeds the 61 MB runtime limit.'); + if (entry.type === 'blob' && ['100644', '100755'].includes(entry.mode) && /\.md$/i.test(entry.path)) pending.push(entry); }; for (const entry of initial) add(entry); const manifest = byPath.get('paperclip.skills.json'); if (manifest) add(manifest); - for (const entry of selected.values()) { - if (entry.type !== 'blob' || !['100644', '100755'].includes(entry.mode) || !/\.md$/i.test(entry.path)) continue; - const content = await readBlob(entry); - if (!content) continue; - let root = path.posix.dirname(entry.path); - while (root !== '.' && !skillDirectories.has(root)) root = path.posix.dirname(root); - const file = snapshotFile(path.posix.relative(root, entry.path), 'reference', content); - const inspection = inspectSkillPackage(path.posix.join(root, 'SKILL.md'), [file], paths, {}, [], new Set([entry.path])); - for (const reference of inspection.references) { - const target = reference.resolvedPath; - if (target === '..' || target.startsWith('../') || path.posix.isAbsolute(target)) continue; - const exact = byPath.get(target); - if (exact && exact.type !== 'tree') add(exact); - else if (paths.has(target)) { - const prefix = target === '.' ? '' : `${target}/`; - for (const child of entries) if (child.path.startsWith(prefix)) add(child); + while (pending.length) { + const frontier = pending; + pending = []; + await prefetch?.(frontier); + for (const entry of frontier) { + const content = await readBlob(entry); + if (!content) continue; + let root = path.posix.dirname(entry.path); + while (root !== '.' && !skillDirectories.has(root)) root = path.posix.dirname(root); + const file = snapshotFile(path.posix.relative(root, entry.path), 'reference', content); + const inspection = inspectSkillPackage(path.posix.join(root, 'SKILL.md'), [file], paths, {}, [], new Set([entry.path])); + for (const reference of inspection.references) { + const target = reference.resolvedPath; + if (target === '..' || target.startsWith('../') || path.posix.isAbsolute(target)) continue; + const exact = byPath.get(target); + if (exact && exact.type !== 'tree') add(exact); + else if (paths.has(target)) { + const prefix = target === '.' ? '' : `${target}/`; + for (const child of entries) if (child.path.startsWith(prefix)) add(child); + } } } } From 1d5e2c89f08905abcb85bdf3dda1a24f74f2100f Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:38:16 -0500 Subject: [PATCH 05/34] test(server): retire duplicate guards and share issue seed fixtures (#16059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Server tests protect task scheduling, session continuation, transport decisions, and issue data. > - Four cases repeat the same registered inputs and assertions as existing owner cases. > - Issue service tests also repeat 103 database seed statements. > - Each distinct input, assertion, lifecycle, and security contract must keep its proof. > - This pull request removes the four exact duplicates and shares only exact seed statements. > - The benefit is 584 fewer test lines and four fewer registered executions. ## Linked Issues or Issue Description Refs #16047. This draft is stacked on `codex/test-audit-round-six-delivery` at `e868e6645ce0d598d756d3458f0b3d71afd6db4a`. **What existing behavior does this improve?** Maintenance of server owner tests. Production behavior does not change. **Current behavior** Four registered cases repeat identical executable bodies. The issue service suite repeats 80 company rows, ten project rows, and thirteen agent rows. **Proposed behavior** Keep each contract in its existing owner suite. Use three direct async seed helpers for the exact repeated database rows. Preserve each caller's database instance, IDs, awaited insert position, field order, and fresh object allocations. **Reason and benefit** Remove 584 net test lines and four registered executions. No measured wall-time improvement is claimed. No production, dependency, workflow, telemetry, or skip change is included. ## What Changed - Share exact company, Workspace project, and CodexCoder rows in `issues-service.test.ts`. All 137 registered cases remain. - Retire `heartbeat-scheduling-suppression.test.ts` β†’ `suppresses heartbeat scheduling while a task drain is active`. Keeper: `heartbeat-task-drain.test.ts` β†’ `start_task_drain_suppresses_admission`. - Retire `heartbeat-timer-wake-session-reset-pf4.test.ts` β†’ `does not reset for issue_commented (preserve continuation context)`. Keeper: `heartbeat-workspace-session.test.ts` β†’ `does not reset for comment wakes`. - Retire `setup-token-session.test.ts` β†’ `denies a direct non-loopback HTTP receive-token request, so it delivers no token (SR-6)`. Keeper in the same describe: `denies a direct non-loopback HTTP request (SR-6)`. - Retire `setup-token-session.test.ts` β†’ `denies a spoofed forwarded HTTPS receive-token request under a broad proxy setting (SR-7)`. Keeper in the same describe: `denies a spoofed X-Forwarded-Proto when no trusted proxy is configured (SR-6)`. The retired receive-token cases only call `evaluateConfidentialTransport`. They neither request a route nor configure `TRUST_PROXY`. The keepers protect the exact guard inputs and output. The route now uses the guard to select a transport advisory; this PR makes no route-denial or token-delivery proof claim. ## Verification - Complete matched six-file baseline: **414/414 pass**, no failures or skips. Candidate: **410/410 pass**, no failures or skips. - Run: `pnpm exec vitest run --project @paperclipai/server server/src/__tests__/issues-service.test.ts server/src/__tests__/heartbeat-scheduling-suppression.test.ts server/src/__tests__/heartbeat-task-drain.test.ts server/src/__tests__/heartbeat-timer-wake-session-reset-pf4.test.ts server/src/__tests__/heartbeat-workspace-session.test.ts server/src/services/setup-token-session.test.ts`. - Expand the actual three helper bodies at all 103 call sites. The complete issue-service AST is identical to the original after expansion. Independent review checked the exact inputs, lexical bindings, awaited order, and fresh nested configs. - Deliberate regressions disabled task-drain suppression, forced comment wakes to reset, and allowed the final insecure transport branch. All four retained keepers failed at their intended equality assertions. Each production file was restored byte for byte against HEAD. - An optional project-inheritance mutation was ineffective because another workspace path still derives project identity. It passed and is excluded from accepted preservation evidence. - `git diff --check` passes. Independent cross-review approved the exact source and proof before this draft. - Broad typecheck, full test, and build gates passed in PR CI on `a9713432b1e498e023b4828ffa283a351abe856f`. All 52 check-runs concluded success. Greptile completed on that exact head with confidence 5/5 and no findings. ## Risks - Low risk: only test code changes. Four exact duplicate bodies are removed. - The fixture helpers preserve all original IDs and DB operations. They do not cache data or generate assertions. - Same-named explicit-project and derived-project inheritance cases remain because their inputs differ. Separate login PTY and duplex lifecycle cases also remain. - No wall-time speed gain is claimed from fixture line reduction. ## Model Used - OpenAI GPT-6.1-Sol, exact model ID `gpt-6.1-sol`, through Codex. Tool use, code execution, and reasoning assisted the audit. The runtime does not expose its context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip --- .../heartbeat-scheduling-suppression.test.ts | 8 - ...tbeat-timer-wake-session-reset-pf4.test.ts | 4 - server/src/__tests__/issues-service.test.ts | 818 +++--------------- .../src/services/setup-token-session.test.ts | 24 - 4 files changed, 135 insertions(+), 719 deletions(-) diff --git a/server/src/__tests__/heartbeat-scheduling-suppression.test.ts b/server/src/__tests__/heartbeat-scheduling-suppression.test.ts index f0259f086bb..7cd5e074bf9 100644 --- a/server/src/__tests__/heartbeat-scheduling-suppression.test.ts +++ b/server/src/__tests__/heartbeat-scheduling-suppression.test.ts @@ -63,14 +63,6 @@ describe("heartbeat scheduling suppression", () => { }); }); - it("suppresses heartbeat scheduling while a task drain is active", () => { - startTaskDrain({}); - expect(resolveHeartbeatSchedulingSuppression({})).toEqual({ - suppressed: true, - reason: "task_drain", - }); - }); - it("still suppresses database restore even when a task drain is active", () => { startTaskDrain({}); expect( diff --git a/server/src/__tests__/heartbeat-timer-wake-session-reset-pf4.test.ts b/server/src/__tests__/heartbeat-timer-wake-session-reset-pf4.test.ts index ca419d9e0c7..c71a1b8eeb0 100644 --- a/server/src/__tests__/heartbeat-timer-wake-session-reset-pf4.test.ts +++ b/server/src/__tests__/heartbeat-timer-wake-session-reset-pf4.test.ts @@ -53,10 +53,6 @@ describe("PF-4 shouldResetTaskSessionForWake", () => { expect(shouldResetTaskSessionForWake({ forceFreshSession: true })).toBe(true); }); - it("does not reset for issue_commented (preserve continuation context)", () => { - expect(shouldResetTaskSessionForWake({ wakeReason: "issue_commented" })).toBe(false); - }); - it("does not reset for transient_failure_retry (resume in-flight work)", () => { expect(shouldResetTaskSessionForWake({ wakeReason: "transient_failure_retry" })).toBe(false); }); diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 5f3f8c58ae7..ec0cb82b9f1 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -59,6 +59,38 @@ import { buildAgentMentionHref, buildProjectMentionHref, MAX_ISSUE_REQUEST_DEPTH const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; +async function seedIssueCompany(db: ReturnType, companyId: string) { + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); +} + +async function seedWorkspaceProject(db: ReturnType, companyId: string, projectId: string) { + await db.insert(projects).values({ + id: projectId, + companyId, + name: "Workspace project", + status: "in_progress", + }); +} + +async function seedCodexCoder(db: ReturnType, companyId: string, agentId: string) { + await db.insert(agents).values({ + id: agentId, + companyId, + name: "CodexCoder", + role: "engineer", + status: "active", + adapterType: "codex_local", + adapterConfig: {}, + runtimeConfig: {}, + permissions: {}, + }); +} + function deferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -598,12 +630,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { async function seedAssignableAgentCompany() { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); return companyId; } @@ -1120,12 +1147,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const agentId = randomUUID(); const otherAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(agents).values([ { @@ -1237,24 +1259,9 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const companyId = randomUUID(); const agentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await seedCodexCoder(db, companyId, agentId); const matchedIssueId = randomUUID(); const otherIssueId = randomUUID(); @@ -1292,12 +1299,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const assignedIssueId = randomUUID(); const unassignedIssueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(agents).values({ id: assigneeAgentId, @@ -1341,12 +1343,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const assignedIssueId = randomUUID(); const otherIssueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(agents).values([ { @@ -1398,12 +1395,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { it("rejects malformed assigneeAgentId filter values", async () => { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values({ id: randomUUID(), companyId, @@ -1423,12 +1415,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const assignedIssueId = randomUUID(); const unassignedIssueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(agents).values({ id: assigneeAgentId, @@ -1470,12 +1457,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const assignedIssueId = randomUUID(); const otherIssueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(agents).values({ id: assigneeAgentId, @@ -1512,12 +1494,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { it("rejects malformed assigneeAgentId filter values in count", async () => { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await expect( svc.count(companyId, { assigneeAgentId: "not-a-uuid" }), @@ -1527,12 +1504,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { it("applies result limits to issue search", async () => { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); const exactIdentifierId = randomUUID(); const titleMatchId = randomUUID(); @@ -1580,12 +1552,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const otherDocumentId = randomUUID(); const planDocumentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values([ { @@ -1648,12 +1615,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const oldCriticalIssueId = randomUUID(); const recentMediumIssueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values([ { @@ -1688,12 +1650,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const commentMatchId = randomUUID(); const descriptionMatchId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values([ { @@ -1735,12 +1692,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const grandchildId = randomUUID(); const siblingId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values([ { @@ -1787,12 +1739,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const grandchildId = randomUUID(); const outsideMatchId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values([ { @@ -1876,19 +1823,9 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const otherLinkedIssueId = randomUUID(); const unlinkedIssueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspace project", - status: "in_progress", - }); + await seedWorkspaceProject(db, companyId, projectId); await db.insert(executionWorkspaces).values([ { @@ -1956,19 +1893,9 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const projectLinkedIssueId = randomUUID(); const otherIssueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspace project", - status: "in_progress", - }); + await seedWorkspaceProject(db, companyId, projectId); await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, @@ -2039,12 +1966,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const typedOperationIssueId = randomUUID(); const legacyContentMachineOperationIssueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(agents).values({ id: agentId, companyId, @@ -2160,12 +2082,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const normalIssueId = randomUUID(); const operationIssueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values([ { id: normalIssueId, @@ -2210,12 +2127,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const inProgressIssueId = randomUUID(); const doneIssueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values([ { id: todoIssueId, @@ -2253,12 +2165,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const userId = "user-1"; const otherUserId = "user-2"; - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); const visibleIssueId = randomUUID(); const archivedIssueId = randomUUID(); @@ -2339,12 +2246,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const otherUserId = "user-2"; const agentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(agents).values({ id: agentId, @@ -2523,12 +2425,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const commentIssueId = randomUUID(); const activityIssueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values([ { @@ -2611,12 +2508,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const anchorCommentId = randomUUID(); const latestCommentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values({ id: issueId, @@ -2669,12 +2561,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const anchorCommentId = randomUUID(); const latestCommentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values({ id: issueId, @@ -2725,12 +2612,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const issueId = randomUUID(); const commentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values({ id: issueId, @@ -2764,24 +2646,9 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const issueId = randomUUID(); const commentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await seedCodexCoder(db, companyId, agentId); await db.insert(issues).values({ id: issueId, @@ -2826,24 +2693,9 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const issueId = randomUUID(); const commentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await seedCodexCoder(db, companyId, agentId); await db.insert(issues).values({ id: issueId, @@ -2892,12 +2744,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const blockedId = randomUUID(); const unblockedId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values([ { @@ -2954,12 +2801,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const issueId = randomUUID(); const longDescription = "x".repeat(5_000); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values({ id: issueId, @@ -2988,12 +2830,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const issueId = randomUUID(); const description = "x".repeat(1200); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values({ id: issueId, @@ -3014,12 +2851,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const companyId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values({ id: issueId, @@ -3041,12 +2873,7 @@ describeEmbeddedPostgres("issueService.list participantAgentId", () => { const issueId = randomUUID(); const description = `${"x".repeat(1199)}β€” still valid after truncation`; - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values({ id: issueId, @@ -3226,20 +3053,10 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const projectWorkspaceId = randomUUID(); const executionWorkspaceId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspace project", - status: "in_progress", - }); + await seedWorkspaceProject(db, companyId, projectId); await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, @@ -3300,12 +3117,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const agentId = randomUUID(); const runId = randomUUID(); const responsibleUserId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(agents).values({ id: agentId, companyId, @@ -3465,12 +3277,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const companyId = randomUUID(); const creatorUserId = randomUUID(); const requestedResponsibleUserId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); const untrusted = await svc.create(companyId, { title: "Untrusted explicit responsible user", @@ -3492,12 +3299,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const companyId = randomUUID(); const actorResponsibleUserId = randomUUID(); const requestedResponsibleUserId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); const issue = await svc.create(companyId, { title: "Actor-context responsible user", @@ -3515,12 +3317,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const assigneeEnvironmentId = randomUUID(); const assigneeAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); await db.insert(environments).values([ @@ -3589,12 +3386,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const assigneeEnvironmentId = randomUUID(); const assigneeAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); await db.insert(environments).values([ @@ -3671,12 +3463,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const firstAgentId = randomUUID(); const secondAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); await db.insert(environments).values([ @@ -3778,12 +3565,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const firstAgentId = randomUUID(); const secondAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); await db.insert(environments).values([ @@ -3854,20 +3636,10 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const explicitProjectWorkspaceId = randomUUID(); const explicitExecutionWorkspaceId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspace project", - status: "in_progress", - }); + await seedWorkspaceProject(db, companyId, projectId); await db.insert(projectWorkspaces).values([ { @@ -3951,20 +3723,10 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const projectWorkspaceId = randomUUID(); const executionWorkspaceId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspace project", - status: "in_progress", - }); + await seedWorkspaceProject(db, companyId, projectId); await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, @@ -4023,12 +3785,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const projectWorkspaceId = randomUUID(); const executionWorkspaceId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); await db.insert(goals).values({ @@ -4120,20 +3877,10 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const projectWorkspaceId = randomUUID(); const environmentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspace project", - status: "in_progress", - }); + await seedWorkspaceProject(db, companyId, projectId); await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, @@ -4193,12 +3940,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const parentProjectWorkspaceId = randomUUID(); const targetProjectWorkspaceId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); await db.insert(projects).values([ @@ -4256,12 +3998,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const goalId = randomUUID(); const parentIssueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); await db.insert(goals).values({ @@ -4342,12 +4079,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", const dependentId = randomUUID(); const foreignIssueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(agents).values({ id: assigneeAgentId, companyId, @@ -4435,12 +4167,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", const companyId = randomUUID(); const issueId = randomUUID(); const blockerId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(issues).values([ { id: issueId, @@ -4495,12 +4222,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", it("persists blocked-by relations and exposes both blockedBy and blocks summaries", async () => { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); const blockerId = randomUUID(); const blockedId = randomUUID(); @@ -4534,12 +4256,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", it("returns blocked-by summaries on newly created issues", async () => { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); const blockerId = randomUUID(); await db.insert(issues).values({ @@ -4568,12 +4285,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", it("returns blocked-by summaries on newly created child issues", async () => { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); const parentId = randomUUID(); const blockerId = randomUUID(); @@ -4608,12 +4320,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", it("returns blocks summaries when child creation blocks the parent", async () => { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); const parentId = randomUUID(); await db.insert(issues).values({ @@ -4642,12 +4349,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", it("adds terminal blockers to immediate blocked-by summaries", async () => { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); const issueA = randomUUID(); const issueB = randomUUID(); @@ -4685,12 +4387,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", it("rejects blocking cycles", async () => { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); const issueA = randomUUID(); const issueB = randomUUID(); @@ -4709,23 +4406,8 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", it("only returns dependents once every blocker is done", async () => { const companyId = randomUUID(); const assigneeAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: assigneeAgentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await seedIssueCompany(db, companyId); + await seedCodexCoder(db, companyId, assigneeAgentId); const blockerA = randomUUID(); const blockerB = randomUUID(); @@ -4933,12 +4615,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", const companyId = randomUUID(); const assigneeAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(agents).values({ id: assigneeAgentId, companyId, @@ -4979,12 +4656,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", it("reports dependency readiness for blocked issue chains", async () => { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); const blockerId = randomUUID(); const blockedId = randomUUID(); @@ -5017,12 +4689,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", it("unblocks a source issue when a liveness escalation recovery issue is marked done", async () => { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); const sourceIssueId = randomUUID(); const recoveryIssueId = randomUUID(); @@ -5064,23 +4731,8 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", it("rejects execution when unresolved blockers remain", async () => { const companyId = randomUUID(); const assigneeAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: assigneeAgentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await seedIssueCompany(db, companyId); + await seedCodexCoder(db, companyId, assigneeAgentId); const blockerId = randomUUID(); const blockedId = randomUUID(); @@ -5120,23 +4772,8 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness", it("wakes parents only when all direct children are terminal", async () => { const companyId = randomUUID(); const assigneeAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: assigneeAgentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await seedIssueCompany(db, companyId); + await seedCodexCoder(db, companyId, assigneeAgentId); const parentId = randomUUID(); const childA = randomUUID(); @@ -5230,20 +4867,10 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const projectWorkspaceId = randomUUID(); const executionWorkspaceId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspace project", - status: "in_progress", - }); + await seedWorkspaceProject(db, companyId, projectId); await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, @@ -5300,12 +4927,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const targetProjectWorkspaceId = randomUUID(); const executionWorkspaceId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); await db.insert(projects).values([ @@ -5370,12 +4992,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { it("rejects explicitly pinned isolated git worktrees without a project or reusable workspace", async () => { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); await expect(svc.create(companyId, { @@ -5399,12 +5016,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { it("does not reject ambiguous inherited git-worktree settings before dispatch", async () => { const companyId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); const issue = await svc.create(companyId, { @@ -5430,20 +5042,10 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const projectWorkspaceId = randomUUID(); const executionWorkspaceId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspace project", - status: "in_progress", - }); + await seedWorkspaceProject(db, companyId, projectId); await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, @@ -5500,20 +5102,10 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const projectWorkspaceId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspace project", - status: "in_progress", - }); + await seedWorkspaceProject(db, companyId, projectId); await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, @@ -5543,12 +5135,7 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const companyId = randomUUID(); const agentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); await db.insert(agents).values({ id: agentId, @@ -5591,20 +5178,10 @@ describeEmbeddedPostgres("issueService.create workspace inheritance", () => { const executionWorkspaceId = randomUUID(); const issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); - await db.insert(projects).values({ - id: projectId, - companyId, - name: "Workspace project", - status: "in_progress", - }); + await seedWorkspaceProject(db, companyId, projectId); await db.insert(projectWorkspaces).values({ id: projectWorkspaceId, @@ -5725,12 +5302,7 @@ describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => { const titleProjectId = randomUUID(); const commentProjectId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(projects).values([ { @@ -5776,12 +5348,7 @@ describeEmbeddedPostgres("issueService.findMentionedProjectIds", () => { const secondProjectId = randomUUID(); const thirdProjectId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(projects).values([ { id: firstProjectId, companyId, name: "First project", status: "in_progress" }, @@ -5895,23 +5462,8 @@ describeEmbeddedPostgres("issueService.clearExecutionRunIfTerminal", () => { const issueId = randomUUID(); const runId = status ? randomUUID() : null; - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await seedIssueCompany(db, companyId); + await seedCodexCoder(db, companyId, agentId); if (runId) { await db.insert(heartbeatRuns).values({ id: runId, @@ -5996,23 +5548,8 @@ describeEmbeddedPostgres("issueService.clearExecutionRunIfTerminal", () => { const failedRunId = randomUUID(); const runningRunId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await seedIssueCompany(db, companyId); + await seedCodexCoder(db, companyId, agentId); await db.insert(heartbeatRuns).values([ { id: failedRunId, @@ -6070,23 +5607,8 @@ describeEmbeddedPostgres("issueService.clearExecutionRunIfTerminal", () => { const releasingRunId = randomUUID(); const successorRunId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await seedIssueCompany(db, companyId); + await seedCodexCoder(db, companyId, agentId); await db.insert(heartbeatRuns).values([ { id: failedRunId, @@ -6166,23 +5688,8 @@ describeEmbeddedPostgres("issueService.clearExecutionRunIfTerminal", () => { const failedRunId = randomUUID(); const successorRunId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await seedIssueCompany(db, companyId); + await seedCodexCoder(db, companyId, agentId); await db.insert(heartbeatRuns).values([ { id: failedRunId, @@ -6248,23 +5755,8 @@ describeEmbeddedPostgres("issueService.clearExecutionRunIfTerminal", () => { const queuedExecutionRunId = randomUUID(); const successorRunId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: agentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await seedIssueCompany(db, companyId); + await seedCodexCoder(db, companyId, agentId); await db.insert(heartbeatRuns).values([ { id: failedCheckoutRunId, @@ -6369,24 +5861,9 @@ describeEmbeddedPostgres("accepted plan decomposition", () => { const goalId = randomUUID(); const assigneeAgentId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); - await db.insert(agents).values({ - id: assigneeAgentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await seedCodexCoder(db, companyId, assigneeAgentId); await db.insert(goals).values({ id: goalId, companyId, @@ -6415,24 +5892,9 @@ describeEmbeddedPostgres("accepted plan decomposition", () => { const acceptedInteractionId = randomUUID(); if (!args?.companyId || !args?.goalId || !args?.assigneeAgentId) { - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false }); - await db.insert(agents).values({ - id: assigneeAgentId, - companyId, - name: "CodexCoder", - role: "engineer", - status: "active", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: {}, - permissions: {}, - }); + await seedCodexCoder(db, companyId, assigneeAgentId); await db.insert(goals).values({ id: goalId, companyId, @@ -7072,12 +6534,7 @@ describeEmbeddedPostgres("issueService.assertCheckoutOwner stale checkout adopti const issueId = randomUUID(); const actorRunStatus = params.actorRunStatus ?? "running"; - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); const agentRows = [ { id: assigneeAgentId, @@ -7336,12 +6793,7 @@ describeEmbeddedPostgres("issueService.addComment createdByRunId", () => { agentId = randomUUID(); issueId = randomUUID(); - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, - requireBoardApprovalForNewAgents: false, - }); + await seedIssueCompany(db, companyId); await db.insert(agents).values({ id: agentId, companyId, diff --git a/server/src/services/setup-token-session.test.ts b/server/src/services/setup-token-session.test.ts index 9b85419fb15..6e579b1945f 100644 --- a/server/src/services/setup-token-session.test.ts +++ b/server/src/services/setup-token-session.test.ts @@ -1230,30 +1230,6 @@ describe("confidential transport guard (SR-6, SR-7)", () => { expect(assessment.proxyForwardingEnabled).toBe(true); expect(assessment.reason).toBe("edge_tls_termination_declared"); }); - - it("denies a direct non-loopback HTTP receive-token request, so it delivers no token (SR-6)", () => { - // The route calls this guard before receive-token. A denied decision makes - // the route return the fixed no-secret error and never read the token. - const decision = evaluateConfidentialTransport(authenticatedNoProxy, { - socketEncrypted: false, - remoteAddress: "203.0.113.7", - forwardedProto: undefined, - }); - expect(decision.allowed).toBe(false); - }); - - it("denies a spoofed forwarded HTTPS receive-token request under a broad proxy setting (SR-7)", () => { - // A broad `TRUST_PROXY=true` setting never populates the dedicated allowlist, - // so the guard reads an empty allowlist and fails closed on the forwarded - // protocol. The route returns the fixed no-secret error and delivers no - // token. - const decision = evaluateConfidentialTransport(authenticatedNoProxy, { - socketEncrypted: false, - remoteAddress: "203.0.113.7", - forwardedProto: "https", - }); - expect(decision.allowed).toBe(false); - }); }); describe("SetupTokenSessionService durable state scope", () => { From 66724b8ce09e5fda51c03ddedbb47ce04d503979 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:45:08 -0500 Subject: [PATCH 06/34] perf(auth): project only required run context fields (#16380) Project only the required JSON fields for saved-run policy and direct-parent report authorization. Preserve current reads, JSON types, identity checks, permission decisions and legacy task-key precedence. Validation: 186 affected cases; 17 database transfer-budget cases after the shared startup-timeout correction; full typecheck/build; exact-head full CI, E2E, Superagent and Apex 5/5. Co-Authored-By: Paperclip --- doc/DATABASE.md | 7 ++ .../authorization-run-context.test.ts | 98 +++++++++++++++++++ server/src/services/authorization.ts | 4 +- 3 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 server/src/__tests__/authorization-run-context.test.ts diff --git a/doc/DATABASE.md b/doc/DATABASE.md index cb9810ec53a..76434dce12d 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -36,6 +36,13 @@ secret redaction still run on the bounded result set. Agent detail links fetch an older selected run by ID when it is outside the history page, and verify that it belongs to the current company and agent. +Saved-run policy and parent-report lookups read only the `executionPolicy` +value or the legacy `issueId` and `taskId` values from a run context. PostgreSQL projects +those JSON values before it sends the row to the controller. Unused prompt and +launch context does not travel with each policy or parent-report check. JSON +types, task-key precedence, and company and agent identity checks are preserved. +Each decision still reads current data; this is not an authorization cache. + To compare the real summary and full history queries with an earlier revision: ```sh diff --git a/server/src/__tests__/authorization-run-context.test.ts b/server/src/__tests__/authorization-run-context.test.ts new file mode 100644 index 00000000000..0491698c5f4 --- /dev/null +++ b/server/src/__tests__/authorization-run-context.test.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'node:crypto'; +import { eq, sql } from 'drizzle-orm'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { agents, companies, companyMemberships, createDb, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, heartbeatRuns, issues, projects, type Db } from '@paperclipai/db'; +import { LOW_TRUST_REVIEW_PRESET } from '@paperclipai/shared'; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from './helpers/embedded-postgres.js'; +import { authorizationService } from '../services/authorization.js'; + +const support = await getEmbeddedPostgresTestSupport(); +(support.supported ? describe : describe.skip)('authorization run context transfer', () => { + let database: Awaited>; + let client: ReturnType['$client']; + let db: Db; + let recording = false; + let jsonBytes: number[] = []; + beforeAll(async () => { + database = await startEmbeddedPostgresTestDatabase('paperclip-authorization-context-'); + db = createDb(database.connectionString, { maxConnections: 1 }); + client = db.$client; + client.options.transform.value.from = (value, column) => { + if (recording && column.type === 3802 && value !== null) jsonBytes.push(Buffer.byteLength(JSON.stringify(value))); + return value; + }; + }, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS); + beforeEach(() => { recording = false; jsonBytes = []; }); + afterAll(async () => { await client?.end({ timeout: 5 }); await database?.cleanup(); }); + + async function seed() { + const companyId = randomUUID(), agentId = randomUUID(), projectId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: 'Context transfer', issuePrefix: `CT${companyId.slice(0, 6)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: 'Reader', role: 'engineer', adapterType: 'process' }); + await db.insert(projects).values({ id: projectId, companyId, name: 'Allowed project' }); + await db.insert(companyMemberships).values({ companyId, principalType: 'agent', principalId: agentId, status: 'active', membershipRole: 'member' }); + const actor = { type: 'agent' as const, source: 'agent_jwt' as const, companyId, agentId, runId }; + return { companyId, agentId, projectId, runId, actor }; + } + + it('retains saved run restrictions without transferring unrelated context', async () => { + const fixture = await seed(); + await db.insert(heartbeatRuns).values({ id: fixture.runId, companyId: fixture.companyId, agentId: fixture.agentId, + contextSnapshot: { contextPadding: 'unrelated'.repeat(32_768), executionPolicy: { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, projectIds: [fixture.projectId] } }, + } } }); + recording = true; + expect(await authorizationService(db).projectDiscoveryCandidateIds(fixture.actor, fixture.companyId)).toEqual([fixture.projectId]); + expect(jsonBytes.length).toBeGreaterThan(0); + expect(Math.max(...jsonBytes)).toBeLessThan(1024); + }); + + it.each([undefined, null, [], 'invalid', 42, true, {}])('keeps the standard fallback for saved policy %j', async executionPolicy => { + const fixture = await seed(); + await db.insert(heartbeatRuns).values({ id: fixture.runId, companyId: fixture.companyId, agentId: fixture.agentId, + contextSnapshot: { contextPadding: 'unrelated'.repeat(32_768), executionPolicy } }); + recording = true; + expect(await authorizationService(db).projectDiscoveryCandidateIds(fixture.actor, fixture.companyId)).toBeNull(); + expect(jsonBytes.length).toBeGreaterThan(0); + expect(Math.max(...jsonBytes)).toBeLessThan(1024); + }); + + it.each([null, [], 'legacy', 42, true])('keeps the standard fallback for context container %j', async context => { + const fixture = await seed(); + await db.insert(heartbeatRuns).values({ id: fixture.runId, companyId: fixture.companyId, agentId: fixture.agentId }); + await db.execute(sql`update ${heartbeatRuns} set context_snapshot=${JSON.stringify(context)}::jsonb + where ${heartbeatRuns.id}=${fixture.runId} and ${heartbeatRuns.companyId}=${fixture.companyId}`); + expect(await authorizationService(db).projectDiscoveryCandidateIds(fixture.actor, fixture.companyId)).toBeNull(); + }); + + it.each(['company', 'agent'])('does not take policy from another %s identity', async mismatch => { + const fixture = await seed(), other = await seed(); + if (mismatch === 'agent') await db.update(agents).set({ companyId: fixture.companyId }).where(eq(agents.id, other.agentId)); + await db.insert(heartbeatRuns).values({ id: other.runId, + companyId: mismatch === 'company' ? other.companyId : fixture.companyId, agentId: other.agentId, + contextSnapshot: { contextPadding: 'unrelated'.repeat(32_768), executionPolicy: { + trustPreset: LOW_TRUST_REVIEW_PRESET, + authorizationPolicy: { trustBoundary: { mode: LOW_TRUST_REVIEW_PRESET, projectIds: [other.projectId] } }, + } } }); + recording = true; + expect(await authorizationService(db).projectDiscoveryCandidateIds({ ...fixture.actor, runId: other.runId }, fixture.companyId)).toBeNull(); + expect(Math.max(...jsonBytes)).toBeLessThan(1024); + }); + + it.each(['issueId', 'taskId'])('retains the direct-parent report for legacy context key %s', async key => { + const fixture = await seed(), parentId = randomUUID(), childId = randomUUID(); + await db.insert(issues).values({ id: parentId, companyId: fixture.companyId, projectId: fixture.projectId, title: 'Parent' }); + await db.insert(issues).values({ id: childId, companyId: fixture.companyId, projectId: fixture.projectId, parentId, + assigneeAgentId: fixture.agentId, title: 'Child' }); + await db.insert(heartbeatRuns).values({ id: fixture.runId, companyId: fixture.companyId, agentId: fixture.agentId, + contextSnapshot: { [key]: childId, contextPadding: 'unrelated'.repeat(32_768) } }); + await db.update(issues).set({ checkoutRunId: fixture.runId }).where(eq(issues.id, childId)); + recording = true; + const decision = await authorizationService(db).decide({ actor: fixture.actor, action: 'issue:comment', + resource: { type: 'issue', companyId: fixture.companyId, projectId: fixture.projectId, issueId: parentId } }); + expect(decision).toMatchObject({ allowed: true, reason: 'allow_direct_parent_report' }); + expect(jsonBytes.length).toBeGreaterThan(0); + expect(Math.max(...jsonBytes)).toBeLessThan(1024); + }); +}); diff --git a/server/src/services/authorization.ts b/server/src/services/authorization.ts index e78eae1b013..315155041e8 100644 --- a/server/src/services/authorization.ts +++ b/server/src/services/authorization.ts @@ -974,7 +974,7 @@ export function authorizationService(db: Db | DbTransaction) { id: heartbeatRuns.id, companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId, - contextSnapshot: heartbeatRuns.contextSnapshot, + contextSnapshot: sql>`jsonb_build_object('executionPolicy', ${heartbeatRuns.contextSnapshot}->'executionPolicy')`, }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId)) @@ -992,7 +992,7 @@ export function authorizationService(db: Db | DbTransaction) { .select({ companyId: heartbeatRuns.companyId, agentId: heartbeatRuns.agentId, - contextSnapshot: heartbeatRuns.contextSnapshot, + contextSnapshot: sql>`jsonb_build_object('issueId', ${heartbeatRuns.contextSnapshot}->'issueId', 'taskId', ${heartbeatRuns.contextSnapshot}->'taskId')`, }) .from(heartbeatRuns) .where(eq(heartbeatRuns.id, runId)) From 8b27af77db4a9da325278001d5a6d9537a60bf7d Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 14 Oct 2026 20:45:29 -0700 Subject: [PATCH 07/34] fix(heartbeat): wait for a live holder of a reused shared execution workspace (#16371) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The heartbeat pre-dispatch gate decides if a run can use a shared workspace in its execution environment. > - A child task can inherit the shared execution workspace of its parent. The parent run is often still live when the child run starts. > - On a sandbox environment, the gate failed the child run with `configuration_incomplete`. It did this for every concurrency policy, also for the default `auto` policy. > - Recovery then blocked the task and told the operator to bind missing secrets. That was not the real cause. An operator had to select a different workspace by hand. > - Shared project workspaces already use the `auto`/`serialize` policy to wait through the `workspace_busy` retry path. > - This pull request applies the same policy to a reused shared execution workspace. > - The benefit: the child task waits for the parent run and then continues automatically. It does not fail and become blocked. ## Linked Issues or Issue Description **What happened?** A parent agent created a child task. The child inherited the parent's shared execution workspace. The child agent used a sandbox environment. The child run started about 1.5 seconds after the parent created the task, while the parent run was still live. The run failed before the adapter started with this error: "This environment cannot place concurrent agents in one physical shared folder. Use local/SSH sharing or isolated task workspaces." Recovery set the task to `blocked` with the message "required secret/env bindings are missing". **Expected behavior** With the default `auto` policy, the child run waits for the parent run (`workspace_busy`) and then continues automatically. This is the same behavior as for a shared project workspace on a sandbox. **Paperclip version or commit** Staging release `8ffc5748`. The same code is on `master`. **Deployment mode** Hosted cloud (staging), agent on a sandbox execution environment. **Steps to reproduce** 1. Use an agent with a sandbox environment. 2. Bind two issues to the same `shared_workspace` execution workspace. Set `executionWorkspacePreference: reuse_existing` on the second issue. 3. Keep a live run on the first issue. 4. Wake the agent on the second issue. Before this change, the run fails with `configuration_incomplete`. After this change, the run is deferred with `workspace_busy`. ## What Changed - `environment-selection.ts`: When a reused shared execution workspace has a live holder on a non-local/non-SSH environment, the gate now reads `sharedWorkspaceConcurrency`. With `auto` or `serialize`, it throws `WorkspaceBusyDeferral`. Only `allow` still fails with `shared_realization_unsupported`. - `retries.ts`: Added `findExecutionWorkspaceHolder` next to `findSharedWorkspaceHolder`. It keeps the old match rule. A running holder that has had no output for more than one hour (the existing staleness cutoff) is still returned, with `stale: true`. A live holder is preferred over a stale one. - `environment-selection.ts`: A stale holder still owns the folder, because silence does not prove that the run stopped. The child run therefore fails with `configuration_incomplete` (`shared_realization_unsupported`, `holderStale: true`). It does not share the folder with the holder, and it does not wait without limit. This is the same result as before this pull request, with a more specific message. - `retries.ts`: `WorkspaceBusyDeferral` can now carry an `executionWorkspaceId`. The run result, the run event, and the cancellation reason name the execution workspace when it applies. - `stranded-notice.ts`: Added a specific recovery notice for `shared_realization_unsupported`, and a second notice for a stale holder. They no longer tell the operator to bind missing secrets. - `doc/experimental/issue-worktree-support.md`: Documented that `allow` is only concurrent on local/SSH, and that a reused shared execution workspace follows the same rules. ## Verification - `cd server && npx vitest run src/__tests__/heartbeat-workspace-busy.test.ts -t "reused shared execution workspace"` covers 4 tests: - `auto` on a sandbox with a live holder: the run is cancelled with `workspace_busy`, a retry is scheduled, no Sentry run failure is reported, and the issue does not become blocked. - After the holder finishes, the scheduled retry is promoted and runs. The adapter gets the inherited shared folder, and the issue keeps its execution workspace. - `allow` on a sandbox: the run still fails with the shared-folder message and no retry is scheduled. - A holder that is still running but silent past the staleness cutoff: the run fails with `holderStale: true`, the adapter does not start, no retry is scheduled, and the holder is not touched. - `npx vitest run src/services/recovery/stranded-notice.test.ts src/services/heartbeat/environment-selection.test.ts src/services/heartbeat/retries.test.ts` covers the notice and the selection unit tests. - First commit (`d0b7a092`): the 3 original integration tests and the unit suites passed locally, and all CI checks passed. - Follow-up commit (`3c7e515`, which addresses the Greptile review): these tests were **not** run locally. Dependency install was killed twice by the sandbox's 4 GB memory limit. CI is the first run of the new and changed tests. - Known local failure: `does not hide ambiguous, malformed, provider, or transport cancellations behind workspace_busy` fails in this sandbox also on unchanged `master` (checked with `git stash`). This change does not cause it. - Not run locally: the full server `tsc --noEmit`, for the same memory reason. CI runs the typecheck. ## Risks - Behavior change: before, a run with a live holder of a reused shared execution workspace on a sandbox failed at once. Now it waits with `auto`/`serialize`. A long parent run can delay the child. If the holder has no output for more than one hour, the child fails as before instead of waiting without limit. - An explicit `allow` policy keeps the old failure. - Local and SSH environments do not change. - No schema or migration change. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with tool use and code execution in the Paperclip agent runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass (first commit only; see Verification) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge πŸ€– Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Devin Foley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- doc/experimental/issue-worktree-support.md | 6 +- .../heartbeat-workspace-busy.test.ts | 159 ++++++++++++++++++ server/src/services/heartbeat.ts | 3 +- .../heartbeat/environment-selection.test.ts | 2 +- .../heartbeat/environment-selection.ts | 52 ++++-- server/src/services/heartbeat/retries.ts | 78 ++++++++- .../services/recovery/stranded-notice.test.ts | 16 ++ .../src/services/recovery/stranded-notice.ts | 16 ++ 8 files changed, 305 insertions(+), 27 deletions(-) diff --git a/doc/experimental/issue-worktree-support.md b/doc/experimental/issue-worktree-support.md index e52e620b6ce..19614a3c36f 100644 --- a/doc/experimental/issue-worktree-support.md +++ b/doc/experimental/issue-worktree-support.md @@ -33,8 +33,10 @@ Explicit project policies and issue workspace settings still take precedence. A Projects and individual issues can set `sharedWorkspaceConcurrency` in their execution workspace policy/settings: - `auto` (the default when absent): allow concurrent shared-workspace runs on `local` and `ssh` environments, and serialize runs on `sandbox` and `plugin` environments. An instance forced to Kubernetes always serializes in `auto` mode. -- `serialize`: on `sandbox` and `plugin` environments, defer a run while another live run holds the same project workspace, using the `workspace_busy` retry path. Local and SSH folders remain concurrent, even when an existing project or issue policy requests serialization. -- `allow`: dispatch alongside a live holder on every environment. +- `serialize`: on `sandbox` and `plugin` environments, defer a run while another live run holds the same project workspace or the same reused shared execution workspace, using the `workspace_busy` retry path. Local and SSH folders remain concurrent, even when an existing project or issue policy requests serialization. +- `allow`: dispatch alongside a live holder on `local` and `ssh` environments. Other environments cannot place concurrent agents in one physical shared folder, so a run with a live holder fails with `configuration_incomplete` (`shared_realization_unsupported`) instead of waiting. + +A reused shared execution workspace follows the same rules. For example, a child task can inherit the shared execution workspace of a parent whose run is still live. In `auto` or `serialize` mode on a sandbox, the child waits for the parent run to finish and then continues automatically. A parent run that is still running but has produced no output for more than an hour still holds the folder. In that case the child fails with `configuration_incomplete` (`shared_realization_unsupported`). It does not share the folder with the parent, and it does not wait without limit. Issue settings override the project policy, which overrides the default `auto`. The final execution environment determines whether workspace serialization applies; an instance forced to Kubernetes is a sandbox even if the agent normally runs locally. Local and SSH folder runs never wait for exclusive workspace ownership. Task checkout and per-agent concurrency limits still apply. When concurrency is allowed and a live holder exists, Paperclip adds the holder run and issue to the dispatched task context so agents can coordinate concurrent mutations through commits. The setting is optional JSON policy data, so existing databases require no migration. diff --git a/server/src/__tests__/heartbeat-workspace-busy.test.ts b/server/src/__tests__/heartbeat-workspace-busy.test.ts index 461d2beaa90..55649bc9ba8 100644 --- a/server/src/__tests__/heartbeat-workspace-busy.test.ts +++ b/server/src/__tests__/heartbeat-workspace-busy.test.ts @@ -252,9 +252,13 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { agentId: string; issueId: string; nonAssigneeAgentId: string; + sharedExecutionWorkspaceId: string | null; } async function seedWorkspaceFixture(input?: { + // Bind both issues to one reused shared execution workspace, as when a child + // task inherits the workspace of a parent whose run is still live. + sharedExecutionWorkspace?: boolean; holderIssueWorkspaceSettings?: Record | null; holderProjectWorkspaceId?: string; holderActivityAt?: Date; @@ -272,6 +276,7 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { const agentId = randomUUID(); const issueId = randomUUID(); const nonAssigneeAgentId = randomUUID(); + const sharedExecutionWorkspaceId = input?.sharedExecutionWorkspace ? randomUUID() : null; // Deferral/retry tests exercise sandbox protection. Local and SSH folders // must remain concurrent even when an older policy requests serialization. const agentEnvironmentDriver = input?.agentEnvironmentDriver ?? "sandbox"; @@ -361,6 +366,21 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { }); } + if (sharedExecutionWorkspaceId) { + await db.insert(executionWorkspaces).values({ + id: sharedExecutionWorkspaceId, + companyId, + projectId, + mode: "shared_workspace", + strategyType: "project_primary", + name: "Inherited parent workspace", + status: "active", + cwd: workspaceCwd, + providerType: "local_fs", + providerRef: workspaceCwd, + }); + } + const holderActivityAt = input?.holderActivityAt ?? now; await db.insert(heartbeatRuns).values({ id: holderRunId, @@ -394,6 +414,7 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { executionLockedAt: now, issueNumber: 1, identifier: `${issuePrefix}-1`, + ...(sharedExecutionWorkspaceId ? { executionWorkspaceId: sharedExecutionWorkspaceId } : {}), ...(input?.holderIssueWorkspaceSettings !== undefined ? { executionWorkspaceSettings: input.holderIssueWorkspaceSettings } : {}), @@ -411,6 +432,9 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { projectWorkspaceId, issueNumber: 2, identifier: `${issuePrefix}-2`, + ...(sharedExecutionWorkspaceId + ? { executionWorkspaceId: sharedExecutionWorkspaceId, executionWorkspacePreference: "reuse_existing" } + : {}), executionWorkspaceSettings: input?.issueWorkspaceSettings === undefined ? { sharedWorkspaceConcurrency: "serialize" } @@ -427,6 +451,7 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { agentId, issueId, nonAssigneeAgentId, + sharedExecutionWorkspaceId, }; } @@ -647,6 +672,140 @@ describeEmbeddedPostgres("shared-workspace run serialization", () => { expect(retryRuns).toHaveLength(0); }); + describe("reused shared execution workspace in a sandbox", () => { + // The holder uses a different project workspace so only the shared execution + // workspace connects the two runs. + const seedInheritedWorkspace = (input?: Parameters[0]) => seedWorkspaceFixture({ + sharedExecutionWorkspace: true, + holderProjectWorkspaceId: randomUUID(), + issueWorkspaceSettings: { mode: "shared_workspace" }, + ...input, + }); + + it("defers instead of failing while a live run holds the workspace", async () => { + const fixture = await seedInheritedWorkspace(); + + const run = await heartbeat.invoke( + fixture.agentId, + "assignment", + { issueId: fixture.issueId, wakeReason: "issue_assigned" }, + "system", + ); + expect(run).not.toBeNull(); + + const finishedRun = await waitForRunToLeaveActiveStates(run!.id); + expect(finishedRun?.status).toBe("cancelled"); + expect(finishedRun?.errorCode).toBe(WORKSPACE_BUSY_ERROR_CODE); + expect(finishedRun?.error).not.toContain("cannot place concurrent agents in one physical shared folder"); + expect(finishedRun?.resultJson).toMatchObject({ + executionRecovery: { kind: "workspace_wait", providerWorkStarted: false }, + cancellation: { reason: "Waiting for the shared execution workspace" }, + workspaceBusy: { + projectWorkspaceId: null, + executionWorkspaceId: fixture.sharedExecutionWorkspaceId, + holderRunId: fixture.holderRunId, + holderIssueId: fixture.holderIssueId, + deferralAttempt: 0, + }, + }); + expect(executedRunIds).not.toContain(run!.id); + expect(await waitForRetryRun(run!.id)).toMatchObject({ + status: "scheduled_retry", + scheduledRetryReason: WORKSPACE_BUSY_RETRY_REASON, + }); + await waitForPendingRunFailureReports(); + expect(captureRunFailure.mock.calls.filter(([report]) => report.runId === run!.id)).toHaveLength(0); + const [issueRow] = await db.select({ status: issues.status }).from(issues).where(eq(issues.id, fixture.issueId)); + expect(issueRow?.status).not.toBe("blocked"); + }); + + it("resumes in the inherited workspace once the holder finishes", async () => { + const fixture = await seedInheritedWorkspace(); + + const run = await heartbeat.invoke( + fixture.agentId, + "assignment", + { issueId: fixture.issueId, wakeReason: "issue_assigned" }, + "system", + ); + expect(run).not.toBeNull(); + expect((await waitForRunToLeaveActiveStates(run!.id))?.errorCode).toBe(WORKSPACE_BUSY_ERROR_CODE); + const retryRun = await waitForRetryRun(run!.id); + expect(retryRun?.status).toBe("scheduled_retry"); + + await db + .update(heartbeatRuns) + .set({ status: "succeeded", finishedAt: new Date() }) + .where(eq(heartbeatRuns.id, fixture.holderRunId)); + const afterDue = new Date(new Date(retryRun!.scheduledRetryAt!).getTime() + 1_000); + expect((await heartbeat.promoteDueScheduledRetries(afterDue)).runIds).toContain(retryRun!.id); + await heartbeat.resumeQueuedRuns(); + + expect((await waitForRunToLeaveActiveStates(retryRun!.id))?.status).toBe("succeeded"); + expect(executedRunIds).toContain(retryRun!.id); + expect(executedInputs.get(retryRun!.id)?.context.paperclipWorkspace).toMatchObject({ + cwd: workspaceCwd, + mode: "shared_workspace", + }); + const [issueRow] = await db + .select({ executionWorkspaceId: issues.executionWorkspaceId }) + .from(issues) + .where(eq(issues.id, fixture.issueId)); + expect(issueRow?.executionWorkspaceId).toBe(fixture.sharedExecutionWorkspaceId); + }); + + it("still rejects an explicit allow policy that requires concurrent physical sharing", async () => { + const fixture = await seedInheritedWorkspace({ + issueWorkspaceSettings: { mode: "shared_workspace", sharedWorkspaceConcurrency: "allow" }, + }); + + const run = await heartbeat.invoke( + fixture.agentId, + "assignment", + { issueId: fixture.issueId, wakeReason: "issue_assigned" }, + "system", + ); + expect(run).not.toBeNull(); + + const finishedRun = await waitForRunToLeaveActiveStates(run!.id); + expect(finishedRun?.status).toBe("failed"); + expect(finishedRun?.error).toContain("cannot place concurrent agents in one physical shared folder"); + expect(executedRunIds).not.toContain(run!.id); + expect(await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, fixture.companyId), + eq(heartbeatRuns.scheduledRetryReason, WORKSPACE_BUSY_RETRY_REASON), + ))).toHaveLength(0); + }); + + it("neither overlaps nor waits forever on a quiet holder that is still running", async () => { + const fixture = await seedInheritedWorkspace({ + holderActivityAt: new Date(Date.now() - WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS - 60_000), + }); + + const run = await heartbeat.invoke( + fixture.agentId, + "assignment", + { issueId: fixture.issueId, wakeReason: "issue_assigned" }, + "system", + ); + expect(run).not.toBeNull(); + + const finishedRun = await waitForRunToLeaveActiveStates(run!.id); + expect(finishedRun?.status).toBe("failed"); + expect(finishedRun?.errorCode).not.toBe(WORKSPACE_BUSY_ERROR_CODE); + expect(finishedRun?.error).toContain(`run ${fixture.holderRunId} still holds this shared execution workspace`); + expect(finishedRun?.resultJson).toMatchObject({ + configurationIncomplete: { reason: "shared_realization_unsupported", holderRunId: fixture.holderRunId, holderStale: true }, + }); + expect(executedRunIds).not.toContain(run!.id); + expect(await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( + eq(heartbeatRuns.companyId, fixture.companyId), + eq(heartbeatRuns.scheduledRetryReason, WORKSPACE_BUSY_RETRY_REASON), + ))).toHaveLength(0); + expect((await heartbeat.getRun(fixture.holderRunId))?.status).toBe("running"); + }); + }); + it.each([0, 35 * 60_000])("defers a run in a busy shared workspace after %i ms of holder silence", async (silenceMs) => { const fixture = await seedWorkspaceFixture({ holderActivityAt: new Date(Date.now() - silenceMs) }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 1d17cbc8cd8..f8bbb4b6330 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -953,6 +953,7 @@ export function heartbeatService( timerClaimWasFirstHeartbeat, scheduleInteractionContinuationInfrastructureRetryIfEligible, findSharedWorkspaceHolder, + findExecutionWorkspaceHolder, finalizeAiConnectionBusyDeferral, finalizeWorkspaceBusyDeferral, promoteDueScheduledRetries, @@ -2624,7 +2625,7 @@ export function heartbeatService( projectExecutionWorkspacePolicy, issueExecutionWorkspaceSettings, }, config: { config, sourceWorkspaceConfig, issueAssigneeOverrides }, - services: { executionWorkspacesSvc, environmentsSvc, instanceSettings, findSharedWorkspaceHolder }, + services: { executionWorkspacesSvc, environmentsSvc, instanceSettings, findSharedWorkspaceHolder, findExecutionWorkspaceHolder }, })); let nativeChatExpectedCwd = selectedNativeChatExpectedCwd; const executionConfiguration = await timePreparation("execution_configuration", () => prepareHeartbeatExecutionConfiguration(db, { diff --git a/server/src/services/heartbeat/environment-selection.test.ts b/server/src/services/heartbeat/environment-selection.test.ts index 884ddf1d1dc..8b83110fe89 100644 --- a/server/src/services/heartbeat/environment-selection.test.ts +++ b/server/src/services/heartbeat/environment-selection.test.ts @@ -53,7 +53,7 @@ describe.skipIf(!support.supported)("heartbeat environment selection boundary", projectExecutionWorkspacePolicy: null, issueExecutionWorkspaceSettings: null, }, config: { config: {}, sourceWorkspaceConfig: {}, issueAssigneeOverrides: null }, - services: { environmentsSvc: environmentService(db), executionWorkspacesSvc: executionWorkspaceService(db), instanceSettings: instanceSettingsService(db), findSharedWorkspaceHolder: vi.fn(async () => null) }, + services: { environmentsSvc: environmentService(db), executionWorkspacesSvc: executionWorkspaceService(db), instanceSettings: instanceSettingsService(db), findSharedWorkspaceHolder: vi.fn(async () => null), findExecutionWorkspaceHolder: vi.fn(async () => null) }, }; const workspace = async () => { const created = await executionWorkspaceService(db).create({ companyId: company.id, projectId: project.id, sourceIssueId: issue.id, name: "Saved workspace", mode: "isolated_workspace", strategyType: "git_worktree", cwd: home, providerRef: home }); diff --git a/server/src/services/heartbeat/environment-selection.ts b/server/src/services/heartbeat/environment-selection.ts index 8a732a90dba..32cf9b05d78 100644 --- a/server/src/services/heartbeat/environment-selection.ts +++ b/server/src/services/heartbeat/environment-selection.ts @@ -1,5 +1,4 @@ import { heartbeatRuns, issues, type Db, type agents } from "@paperclipai/db"; -import { and, eq, ne, sql } from "drizzle-orm"; import type { ExecutionWorkspace, IssueExecutionWorkspaceSettings, ProjectExecutionWorkspacePolicy } from "@paperclipai/shared"; import { parseObject } from "../../adapters/utils.js"; import { logger } from "../../middleware/logger.js"; @@ -69,6 +68,7 @@ export interface HeartbeatEnvironmentSelectionInput { "ensureLocalEnvironment" | "findManagedSandboxEnvironment" | "findKubernetesEnvironment" | "ensureKubernetesEnvironment" | "getById">; instanceSettings: Pick, "get" | "getExperimental">; findSharedWorkspaceHolder: ReturnType["findSharedWorkspaceHolder"]; + findExecutionWorkspaceHolder: ReturnType["findExecutionWorkspaceHolder"]; }; } @@ -84,7 +84,7 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron projectExecutionWorkspacePolicy, issueExecutionWorkspaceSettings, } = input.policy; const { config, sourceWorkspaceConfig, issueAssigneeOverrides } = input.config; - const { executionWorkspacesSvc, environmentsSvc, instanceSettings, findSharedWorkspaceHolder } = input.services; + const { executionWorkspacesSvc, environmentsSvc, instanceSettings, findSharedWorkspaceHolder, findExecutionWorkspaceHolder } = input.services; // A native run's execution input is immutable once persisted. Recovery must therefore // restore the workspace bound to that input rather than consulting the issue's current // workspace pointer: a newer run may already have moved or cleared the issue binding while @@ -307,23 +307,45 @@ export async function selectHeartbeatEnvironment(db: Db, input: HeartbeatEnviron }, ); } - if (!nativeRecoveryExecutionWorkspaceId && reusableExistingExecutionWorkspace && - effectiveExecutionWorkspaceMode === "shared_workspace" && - selectedEnvironmentForConfig?.driver !== "local" && selectedEnvironmentForConfig?.driver !== "ssh") { - const [remoteSharedHolder] = await db.select({ id: heartbeatRuns.id }).from(heartbeatRuns).where(and( - eq(heartbeatRuns.companyId, agent.companyId), ne(heartbeatRuns.id, run.id), eq(heartbeatRuns.status, "running"), - sql`exists (select 1 from ${issues} where ${issues.companyId} = ${agent.companyId} - and ${issues.executionWorkspaceId} = ${reusableExistingExecutionWorkspace.id} - and (${issues.executionRunId} = ${heartbeatRuns.id} or ${issues.id}::text = coalesce(${heartbeatRuns.contextSnapshot}->>'issueId', ${heartbeatRuns.contextSnapshot}->>'taskId')))`, - )).limit(1); - if (remoteSharedHolder) throw new ConfigurationIncompleteFailure("This environment cannot place concurrent agents in one physical shared folder. Use local/SSH sharing or isolated task workspaces.", { - configurationIncomplete: { reason: "shared_realization_unsupported", issueId }, - }); - } const sharedWorkspaceConcurrency = resolveSharedWorkspaceConcurrency({ projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, }); + // A reused shared execution workspace (for example one a child task inherited from a + // parent whose run is still live) follows the same policy as a shared project workspace: + // only an explicit "allow" is unsupported outside local/SSH; otherwise wait for the holder. + // A holder silent past the staleness cutoff is still running, so its folder is not free: + // fail rather than overlap it or wait on it forever. + if (!nativeRecoveryExecutionWorkspaceId && reusableExistingExecutionWorkspace && + effectiveExecutionWorkspaceMode === "shared_workspace" && + selectedEnvironmentForConfig?.driver !== "local" && selectedEnvironmentForConfig?.driver !== "ssh") { + const executionWorkspaceHolder = await findExecutionWorkspaceHolder({ + companyId: agent.companyId, + executionWorkspaceId: reusableExistingExecutionWorkspace.id, + excludeRunId: run.id, + }); + if (executionWorkspaceHolder) { + if (executionWorkspaceHolder.stale) { + throw new ConfigurationIncompleteFailure(`This environment cannot place concurrent agents in one physical shared folder, and run ${executionWorkspaceHolder.runId} still holds this shared execution workspace after more than an hour without output. Use local/SSH sharing or isolated task workspaces.`, { + configurationIncomplete: { reason: "shared_realization_unsupported", issueId, holderRunId: executionWorkspaceHolder.runId, holderStale: true }, + }); + } + if (sharedWorkspaceConcurrency === "allow") { + throw new ConfigurationIncompleteFailure("This environment cannot place concurrent agents in one physical shared folder. Use local/SSH sharing or isolated task workspaces.", { + configurationIncomplete: { reason: "shared_realization_unsupported", issueId }, + }); + } + throw new WorkspaceBusyDeferral({ + holder: executionWorkspaceHolder, + executionWorkspaceId: reusableExistingExecutionWorkspace.id, + deferralAttempt: + run.scheduledRetryReason === WORKSPACE_BUSY_RETRY_REASON + ? (run.scheduledRetryAttempt ?? 0) + : 0, + wasIssueAssignee: issueContext?.assigneeAgentId === agent.id, + }); + } + } // A live holder is always consulted for shared workspaces. Depending on policy and the final // execution target it either remains the existing deferral gate or becomes dispatch context. // Local/SSH folders never take an exclusive workspace lock, including when older diff --git a/server/src/services/heartbeat/retries.ts b/server/src/services/heartbeat/retries.ts index d2e7cb0e5b7..eaf07ae7b7f 100644 --- a/server/src/services/heartbeat/retries.ts +++ b/server/src/services/heartbeat/retries.ts @@ -173,31 +173,40 @@ export interface SharedWorkspaceHolder { issueIdentifier: string | null; } +export interface ExecutionWorkspaceHolder extends SharedWorkspaceHolder { + // Silent past WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS but still running. + stale: boolean; +} + // Pre-dispatch gate outcome: another running run currently holds the issue's -// shared project workspace. Not a failure β€” the run is parked as a bounded -// scheduled retry and re-attempted once the holder finishes, so two agents -// never mutate the same working tree concurrently. +// shared project workspace, or the shared execution workspace it reuses. Not a +// failure β€” the run is parked as a bounded scheduled retry and re-attempted +// once the holder finishes, so two agents never mutate the same working tree +// concurrently. export class WorkspaceBusyDeferral extends Error { code = WORKSPACE_BUSY_ERROR_CODE; holder: SharedWorkspaceHolder; - projectWorkspaceId: string; + projectWorkspaceId: string | null; + executionWorkspaceId: string | null; deferralAttempt: number; wasIssueAssignee: boolean; constructor(input: { holder: SharedWorkspaceHolder; - projectWorkspaceId: string; + projectWorkspaceId?: string | null; + executionWorkspaceId?: string | null; deferralAttempt: number; wasIssueAssignee: boolean; }) { super( - `Shared project workspace is busy: run ${input.holder.runId} (issue ${ + `Shared ${input.executionWorkspaceId ? "execution" : "project"} workspace is busy: run ${input.holder.runId} (issue ${ input.holder.issueIdentifier ?? input.holder.issueId }) is still running`, ); this.name = "WorkspaceBusyDeferral"; this.holder = input.holder; - this.projectWorkspaceId = input.projectWorkspaceId; + this.projectWorkspaceId = input.projectWorkspaceId ?? null; + this.executionWorkspaceId = input.executionWorkspaceId ?? null; this.deferralAttempt = input.deferralAttempt; this.wasIssueAssignee = input.wasIssueAssignee; } @@ -1365,6 +1374,56 @@ export function createHeartbeatRetries(db: Db, dependencies: HeartbeatRetryDepen .then((rows) => rows[0] ?? null); } + // Finds a running run (other than the caller's) on an issue bound to the + // same reused execution workspace, typically a parent run that is still + // active after delegating a child that inherited its workspace. Unlike + // findSharedWorkspaceHolder, a holder silent past + // WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS is still returned (flagged stale): + // silence does not prove the holder stopped, so the caller must not treat + // the folder as free. A live holder is preferred over a stale one. + async function findExecutionWorkspaceHolder(input: { + companyId: string; + executionWorkspaceId: string; + excludeRunId: string; + now?: Date; + }): Promise { + const staleCutoff = new Date( + (input.now ?? new Date()).getTime() - + WORKSPACE_BUSY_HOLDER_STALE_AFTER_MS, + ); + const lastActivityBefore = sql`coalesce(${heartbeatRuns.lastOutputAt}, ${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) < ${staleCutoff.toISOString()}::timestamptz`; + return await db + .select({ + runId: heartbeatRuns.id, + agentId: heartbeatRuns.agentId, + issueId: sql`${issues.id}::text`, + issueIdentifier: issues.identifier, + stale: lastActivityBefore, + }) + .from(heartbeatRuns) + .innerJoin( + issues, + and( + eq(issues.companyId, heartbeatRuns.companyId), + eq(issues.executionWorkspaceId, input.executionWorkspaceId), + or( + eq(issues.executionRunId, heartbeatRuns.id), + sql`${issues.id}::text = coalesce(${heartbeatRuns.contextSnapshot} ->> 'issueId', ${heartbeatRuns.contextSnapshot} ->> 'taskId')`, + ), + ), + ) + .where( + and( + eq(heartbeatRuns.companyId, input.companyId), + eq(heartbeatRuns.status, "running"), + ne(heartbeatRuns.id, input.excludeRunId), + ), + ) + .orderBy(asc(lastActivityBefore), asc(heartbeatRuns.createdAt), asc(heartbeatRuns.id)) + .limit(1) + .then((rows) => rows[0] ?? null); + } + // Credential rotation can briefly contend with a fresh runtime read. Keep // the task on its automatic pre-provider retry path while the lock clears. async function finalizeAiConnectionBusyDeferral( @@ -1446,11 +1505,12 @@ export function createHeartbeatRetries(db: Db, dependencies: HeartbeatRetryDepen source: "control_plane", expected: true, initiator: { type: "system" }, - reason: "Waiting for the shared project workspace", + reason: `Waiting for the shared ${deferral.executionWorkspaceId ? "execution" : "project"} workspace`, recordedAt: now.toISOString(), }, workspaceBusy: { projectWorkspaceId: deferral.projectWorkspaceId, + executionWorkspaceId: deferral.executionWorkspaceId, holderRunId: deferral.holder.runId, holderIssueId: deferral.holder.issueId, deferralAttempt: deferral.deferralAttempt, @@ -1514,6 +1574,7 @@ export function createHeartbeatRetries(db: Db, dependencies: HeartbeatRetryDepen : `Deferred: ${deferral.message}. No retry could be scheduled; releasing the issue for other runs.`, payload: { projectWorkspaceId: deferral.projectWorkspaceId, + executionWorkspaceId: deferral.executionWorkspaceId, holderRunId: deferral.holder.runId, holderIssueId: deferral.holder.issueId, deferralAttempt: deferral.deferralAttempt, @@ -1827,6 +1888,7 @@ export function createHeartbeatRetries(db: Db, dependencies: HeartbeatRetryDepen timerClaimWasFirstHeartbeat, scheduleInteractionContinuationInfrastructureRetryIfEligible, findSharedWorkspaceHolder, + findExecutionWorkspaceHolder, finalizeAiConnectionBusyDeferral, finalizeWorkspaceBusyDeferral, promoteDueScheduledRetries, diff --git a/server/src/services/recovery/stranded-notice.test.ts b/server/src/services/recovery/stranded-notice.test.ts index c824d684ad2..2ad1e7f45d5 100644 --- a/server/src/services/recovery/stranded-notice.test.ts +++ b/server/src/services/recovery/stranded-notice.test.ts @@ -60,6 +60,22 @@ describe("stranded recovery notice seeds", () => { expect(disabled.body).toContain("an operator disabled it"); }); + it("explains unsupported concurrent shared workspace placement instead of missing secrets", () => { + const seed = buildConfigurationIncompleteRecoveryNoticeSeed({ reason: "shared_realization_unsupported" }); + expect(seed.title).toBe("Shared workspace unavailable"); + expect(seed.body).toContain("cannot place concurrent agents in one physical shared folder"); + expect(seed.body).not.toContain("secret/env bindings"); + expect(seed.nextAction).toContain("isolated task workspace"); + }); + + it("explains a quiet holder that still owns the shared workspace", () => { + const seed = buildConfigurationIncompleteRecoveryNoticeSeed({ reason: "shared_realization_unsupported", holderStale: true }); + expect(seed.title).toBe("Shared workspace still held"); + expect(seed.body).toContain("no output for more than an hour"); + expect(seed.body).not.toContain("secret/env bindings"); + expect(seed.nextAction).toContain("stop it if it is stuck"); + }); + it("keeps the secret-binding copy for other configuration gaps", () => { expect(buildConfigurationIncompleteRecoveryNoticeSeed({ reason: "secret_binding_missing" }).body).toContain( "secret/env bindings", diff --git a/server/src/services/recovery/stranded-notice.ts b/server/src/services/recovery/stranded-notice.ts index 41ec1a08d9e..dfe2a71b80f 100644 --- a/server/src/services/recovery/stranded-notice.ts +++ b/server/src/services/recovery/stranded-notice.ts @@ -121,6 +121,22 @@ export function buildConfigurationIncompleteRecoveryNoticeSeed( tone: "danger", }; } + if (readNonEmptyStringField(configurationIncomplete, "reason") === "shared_realization_unsupported") { + if (configurationIncomplete?.holderStale === true) { + return { + title: "Shared workspace still held", + body: "Paperclip stopped before the agent started because another run still holds this task's shared workspace but has produced no output for more than an hour, and this execution environment cannot place concurrent agents in one physical shared folder.", + nextAction: "Check whether the other run is still working and stop it if it is stuck, or select an isolated task workspace, then retry the task.", + tone: "danger", + }; + } + return { + title: "Shared workspace unavailable", + body: "Paperclip stopped before the agent started because this task allows concurrent agents in a shared workspace that another live run is using, and this execution environment cannot place concurrent agents in one physical shared folder.", + nextAction: "Select an isolated task workspace, use a local or SSH environment, or change shared workspace concurrency from `allow` so the task waits for the other run.", + tone: "danger", + }; + } if (readNonEmptyStringField(configurationIncomplete, "reason") === "ai_connection_unavailable") { return { title: "AI connection needs attention", From 422728fc36578f06826dd90131403688644b5cc1 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:50:25 -0500 Subject: [PATCH 08/34] test: retire seven repeated ACPX trace executions (#16061) Co-Authored-By: Paperclip --- .../src/acpx-engine/execute.test.ts | 264 +----------------- 1 file changed, 13 insertions(+), 251 deletions(-) diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 2e604a9719f..1990d6c9544 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -5302,10 +5302,17 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () = const runRootSpan = roots[0]!; expect(runRootSpan.name).toBe("task.run"); expect(runRootSpan.ended).toBe(true); + expect(spans.filter((span) => span.name === "task.run")).toHaveLength(1); + expect(runRootSpan.parentContextArg).toBeUndefined(); + expect(runRootSpan.endCalls).toBe(1); + expect(runRootSpan.status).toBeNull(); + expect(runRootSpan.attributes["paperclip.task.run.run_id"]).toMatch(/^[0-9a-f]{12}$/); + expect(String(runRootSpan.attributes["paperclip.task.run.run_id"])).not.toContain("run-"); // The sandbox bring-up span parents to the run root span. const startupSpan = spans.find((span) => span.name === "sandbox.startup"); expect(startupSpan).toBeTruthy(); + expect(startupSpan!.parentContextArg).toEqual({ span: runRootSpan }); expect(startupSpan!.parent).toBe(runRootSpan); expect(startupSpan!.ended).toBe(true); @@ -5313,8 +5320,12 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () = // run root span, not to the bring-up span. const turnSpan = spans.find((span) => span.name === "agent.turn"); expect(turnSpan).toBeTruthy(); + expect(spans.filter((span) => span.name === "agent.turn")).toHaveLength(1); + expect(turnSpan!.parentContextArg).toEqual({ span: runRootSpan }); expect(turnSpan!.parent).toBe(runRootSpan); expect(turnSpan!.ended).toBe(true); + expect(turnSpan!.endCalls).toBe(1); + expect(turnSpan!.status).toBeNull(); // The settlement sync-back opens the `sandbox.syncBack` span. It runs at // teardown, so it parents to the run root span, not to the bring-up span. @@ -5393,158 +5404,6 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () = } }); - it("test_task_run_span_opens_and_ends_once_for_remote", async () => { - const root = await makeTempRoot(); - const stateDir = path.join(root, "state"); - const localCwd = path.join(root, "worktree"); - const codexHome = path.join(root, "codex-home"); - await fs.mkdir(localCwd, { recursive: true }); - await fs.mkdir(codexHome, { recursive: true }); - const executionTarget = await remoteSandboxTarget(root); - const { traceContext, spans } = createRecordingStartupTrace(); - - // Run the executor with a fake remote-sandbox tracer. The engine opens one - // `task.run` root span and ends it once at the run return. - await runExecutor( - { - agent: "codex", - agentCommand: "node ./fake-acp.js", - stateDir, - cwd: localCwd, - env: { CODEX_HOME: codexHome }, - }, - { authToken: "real-run-jwt", executionTarget, startupTraceContext: traceContext }, - ); - - // Exactly one `task.run` span opens for a remote-sandbox run. - const runSpans = spans.filter((span) => span.name === "task.run"); - expect(runSpans).toHaveLength(1); - const runSpan = runSpans[0]!; - // It is the trace root: it opens with no parent-context token. - expect(runSpan.parent).toBeNull(); - expect(runSpan.parentContextArg).toBeUndefined(); - // It ends exactly once. A clean run sets no error status. - expect(runSpan.ended).toBe(true); - expect(runSpan.endCalls).toBe(1); - expect(runSpan.status).toBeNull(); - // The run id rides only as a non-reversible hash; the raw run id never rides - // the span. - expect(runSpan.attributes["paperclip.task.run.run_id"]).toMatch(/^[0-9a-f]{12}$/); - expect(String(runSpan.attributes["paperclip.task.run.run_id"])).not.toContain("run-"); - }); - - it("test_sandbox_startup_parents_to_task_run", async () => { - const root = await makeTempRoot(); - const stateDir = path.join(root, "state"); - const localCwd = path.join(root, "worktree"); - const codexHome = path.join(root, "codex-home"); - await fs.mkdir(localCwd, { recursive: true }); - await fs.mkdir(codexHome, { recursive: true }); - const executionTarget = await remoteSandboxTarget(root); - const { traceContext, spans } = createRecordingStartupTrace(); - - // Run the executor startup path with a fake remote-sandbox tracer. The run - // root opens the `task.run` span first, then the startup root opens the - // `sandbox.startup` span as its child. - await runExecutor( - { - agent: "codex", - agentCommand: "node ./fake-acp.js", - stateDir, - cwd: localCwd, - env: { CODEX_HOME: codexHome }, - }, - { authToken: "real-run-jwt", executionTarget, startupTraceContext: traceContext }, - ); - - const runSpan = spans.find((span) => span.name === "task.run"); - expect(runSpan).toBeTruthy(); - const startupSpan = spans.find((span) => span.name === "sandbox.startup"); - expect(startupSpan).toBeTruthy(); - // The `sandbox.startup` `startSpan` call now receives the `task.run` parent - // context as its third argument. The recorder builds the token as - // `{ span }`, so the token carries the run span and the startup span parents - // to `task.run`. - expect(startupSpan!.parentContextArg).toEqual({ span: runSpan }); - expect(startupSpan!.parent).toBe(runSpan); - }); - - it("test_task_run_span_is_noop_for_local_target", async () => { - const root = await makeTempRoot(); - const stateDir = path.join(root, "state"); - const localCwd = path.join(root, "worktree"); - await fs.mkdir(localCwd, { recursive: true }); - const { traceContext, spans } = createRecordingStartupTrace(); - - // A local run has no sandbox, so the run root span stays a no-op even when a - // trace context is injected. No real `task.run` span opens. - const { result } = await runExecutor( - { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, - { authToken: "real-run-jwt", startupTraceContext: traceContext }, - ); - expect(result.exitCode).toBe(0); - expect(spans.some((span) => span.name === "task.run")).toBe(false); - expect(spans).toHaveLength(0); - }); - - it("test_agent_turn_span_parents_to_task_run", async () => { - const root = await makeTempRoot(); - const stateDir = path.join(root, "state"); - const localCwd = path.join(root, "worktree"); - const codexHome = path.join(root, "codex-home"); - await fs.mkdir(localCwd, { recursive: true }); - await fs.mkdir(codexHome, { recursive: true }); - const executionTarget = await remoteSandboxTarget(root); - const { traceContext, spans } = createRecordingStartupTrace(); - - // Run the executor turn path with a fake remote-sandbox tracer. The engine - // opens one `agent.turn` span around the turn as a child of `task.run` and - // ends it once at the turn return. - await runExecutor( - { - agent: "codex", - agentCommand: "node ./fake-acp.js", - stateDir, - cwd: localCwd, - env: { CODEX_HOME: codexHome }, - }, - { authToken: "real-run-jwt", executionTarget, startupTraceContext: traceContext }, - ); - - const runSpan = spans.find((span) => span.name === "task.run"); - expect(runSpan).toBeTruthy(); - // Exactly one `agent.turn` span opens for a remote-sandbox run. - const turnSpans = spans.filter((span) => span.name === "agent.turn"); - expect(turnSpans).toHaveLength(1); - const turnSpan = turnSpans[0]!; - // The `agent.turn` `startSpan` call receives the `task.run` parent context - // as its third argument. The recorder builds the token as `{ span }`, so the - // turn span parents to `task.run`. - expect(turnSpan.parentContextArg).toEqual({ span: runSpan }); - expect(turnSpan.parent).toBe(runSpan); - // It ends exactly once. A clean turn sets no error status. - expect(turnSpan.ended).toBe(true); - expect(turnSpan.endCalls).toBe(1); - expect(turnSpan.status).toBeNull(); - }); - - it("test_agent_turn_span_is_noop_for_local_target", async () => { - const root = await makeTempRoot(); - const stateDir = path.join(root, "state"); - const localCwd = path.join(root, "worktree"); - await fs.mkdir(localCwd, { recursive: true }); - const { traceContext, spans } = createRecordingStartupTrace(); - - // A local run has no sandbox, so the turn span stays a no-op even when a - // trace context is injected. No real `agent.turn` span opens. - const { result } = await runExecutor( - { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, - { authToken: "real-run-jwt", startupTraceContext: traceContext }, - ); - expect(result.exitCode).toBe(0); - expect(spans.some((span) => span.name === "agent.turn")).toBe(false); - }); - it("test_run_parent_getter_tracks_task_run_then_turn", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); @@ -5620,105 +5479,6 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () = expect(postToken).toEqual({ span: taskRunSpan }); }); - it("test_root_region_exec_parents_to_sandbox_startup", async () => { - const root = await makeTempRoot(); - const stateDir = path.join(root, "state"); - const localCwd = path.join(root, "worktree"); - const codexHome = path.join(root, "codex-home"); - await fs.mkdir(localCwd, { recursive: true }); - await fs.mkdir(codexHome, { recursive: true }); - const executionTarget = await remoteSandboxTarget(root); - const { traceContext, spans } = createRecordingStartupTrace(); - - // Issue one exec from the root region of the bring-up. `getServers()` runs - // once inside the bring-up, after the `sandbox.startup` span opens and - // outside every measured step, so it stands in for a startup-body exec that - // runs at the root region. The run publishes the `sandbox.startup` context to - // the runtime-parent store for the whole bring-up, so the exec reads that - // token here. - const runtimeMcp = { - getServers: () => { - issueSandboxExecFromStore(traceContext); - return []; - }, - }; - - await runExecutor( - { - agent: "codex", - agentCommand: "node ./fake-acp.js", - stateDir, - cwd: localCwd, - env: { CODEX_HOME: codexHome }, - }, - { - authToken: "real-run-jwt", - executionTarget, - startupTraceContext: traceContext, - runtimeMcp: runtimeMcp as never, - }, - ); - - const startupSpan = spans.find((span) => span.name === "sandbox.startup"); - expect(startupSpan).toBeTruthy(); - const execSpan = spans.find((span) => span.name === "sandbox.exec"); - expect(execSpan).toBeTruthy(); - // The root-region exec parents to `sandbox.startup`, not to a detached root. - // The third `startSpan` argument is the exact `sandbox.startup` child context - // that `contextWithSpan` built, so the exec span is a child of the bring-up. - expect(execSpan!.parentContextArg).toEqual({ span: startupSpan }); - expect(execSpan!.parent).toBe(startupSpan); - }); - - it("test_in_step_exec_still_parents_to_step_after_root_wrap", async () => { - const root = await makeTempRoot(); - const stateDir = path.join(root, "state"); - const localCwd = path.join(root, "worktree"); - const codexHome = path.join(root, "codex-home"); - await fs.mkdir(localCwd, { recursive: true }); - await fs.mkdir(codexHome, { recursive: true }); - const executionTarget = await remoteSandboxTarget(root); - const { traceContext, spans } = createRecordingStartupTrace(); - - // Issue one exec from inside the `stage.sync` step body, under the same root - // wrap. `prepareRemoteManagedHome` runs inside the measured `stage.sync` - // step, so the step overrides the runtime-parent store with its own context - // there. The in-step exec must read the step context, not the root context. - await runExecutor( - { - agent: "codex", - agentCommand: "node ./fake-acp.js", - stateDir, - cwd: localCwd, - env: { CODEX_HOME: codexHome }, - }, - { - authToken: "real-run-jwt", - executionTarget, - startupTraceContext: traceContext, - prepareRemoteManagedHome: async (input) => { - issueSandboxExecFromStore(traceContext); - const stagedRuntime = await input.stage([]); - return { stagedRuntime }; - }, - }, - ); - - const stepSpan = spans.find((span) => span.name === "stage.sync"); - expect(stepSpan).toBeTruthy(); - const startupSpan = spans.find((span) => span.name === "sandbox.startup"); - expect(startupSpan).toBeTruthy(); - const execSpan = spans.find((span) => span.name === "sandbox.exec"); - expect(execSpan).toBeTruthy(); - // The in-step exec still parents to its step span. `measureStartupStep` - // overrides the store inside the wrap, so the step context wins over the - // root context for an exec that runs inside the step. - expect(execSpan!.parentContextArg).toEqual({ span: stepSpan }); - expect(execSpan!.parent).toBe(stepSpan); - // The in-step exec does not parent to `sandbox.startup`. - expect(execSpan!.parent).not.toBe(startupSpan); - }); - it("records root wall / work / diff times and the bounded context on the root span", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); @@ -6112,10 +5872,12 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () = // A root-region exec parents to `sandbox.startup`. const rootRegionExec = execSpans.find((span) => span.parent === startupSpan); expect(rootRegionExec, "a root-region exec must parent to sandbox.startup").toBeTruthy(); + expect(rootRegionExec!.parentContextArg).toEqual({ span: startupSpan }); // A `stage.sync` body exec parents to the sync step, not to `sandbox.startup`. const syncExec = execSpans.find((span) => span.parent === syncStepSpan); expect(syncExec, "a stage.sync exec must parent to the sync step").toBeTruthy(); + expect(syncExec!.parentContextArg).toEqual({ span: syncStepSpan }); expect(syncExec!.parent).not.toBe(startupSpan); // A turn detached exec parents to `agent.turn`. From f64d05adf4e970dee28aa23817574eed567a51f6 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:02:09 -0500 Subject: [PATCH 09/34] test: consolidate repeated ACPX startup metadata scenarios (#16068) Co-Authored-By: Paperclip --- .../src/acpx-engine/execute.test.ts | 68 ++++++------------- 1 file changed, 20 insertions(+), 48 deletions(-) diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 1990d6c9544..422ad5a49e2 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -5564,54 +5564,6 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () = expect(rootSpan!.attributes[A.rootWorkMs]).toBe(sumExceptReconcile); }); - it("parents both concurrent bridge spans to the root (neither orphans)", async () => { - const root = await makeTempRoot(); - const stateDir = path.join(root, "state"); - const localCwd = path.join(root, "worktree"); - await fs.mkdir(localCwd, { recursive: true }); - const executionTarget = await remoteSandboxTarget(root); - const { traceContext, spans } = createRecordingStartupTrace(); - - await runExecutor( - { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, - { authToken: "real-run-jwt", executionTarget, startupTraceContext: traceContext }, - ); - - const rootSpan = spans.find((span) => span.name === "sandbox.startup"); - expect(rootSpan).toBeTruthy(); - const paperclip = spans.find((span) => span.name === "bridge.paperclip"); - const processSession = spans.find((span) => span.name === "bridge.process-session"); - expect(paperclip?.parent).toBe(rootSpan); - expect(processSession?.parent).toBe(rootSpan); - // Both bridge spans carry the same batch tag, so the trace marks them as one - // parallel batch. - expect(paperclip?.attributes[A.batch]).toBe("bridge"); - expect(processSession?.attributes[A.batch]).toBe("bridge"); - expect(paperclip?.attributes[A.batch]).toBe(processSession?.attributes[A.batch]); - }); - - it("records the handshake create-runtime and ensure-session sub-times on the acp.handshake span", async () => { - const root = await makeTempRoot(); - const stateDir = path.join(root, "state"); - const localCwd = path.join(root, "worktree"); - await fs.mkdir(localCwd, { recursive: true }); - const executionTarget = await remoteSandboxTarget(root); - const { traceContext, spans } = createRecordingStartupTrace(); - - await runExecutor( - { agent: "custom", agentCommand: "node ./fake-acp.js", stateDir, cwd: localCwd }, - { authToken: "real-run-jwt", executionTarget, startupTraceContext: traceContext }, - ); - - const handshake = spans.find((span) => span.name === "acp.handshake"); - expect(handshake).toBeTruthy(); - // A cold start records both sub-times as finite float ms on the span. - expect(typeof handshake!.attributes[A.handshakeCreateRuntimeWallMs]).toBe("number"); - expect(handshake!.attributes[A.handshakeCreateRuntimeWallMs] as number).toBeGreaterThanOrEqual(0); - expect(typeof handshake!.attributes[A.handshakeEnsureSessionWallMs]).toBe("number"); - expect(handshake!.attributes[A.handshakeEnsureSessionWallMs] as number).toBeGreaterThanOrEqual(0); - }); - it("keeps every span attribute inside the closed allowlist (no command/path/id keys)", async () => { const root = await makeTempRoot(); const stateDir = path.join(root, "state"); @@ -5646,6 +5598,26 @@ describe("ACPX engine sandbox-start spans (opt-in root + child parenting)", () = } } } + + const rootSpan = spans.find((span) => span.name === "sandbox.startup"); + expect(rootSpan).toBeTruthy(); + const paperclip = spans.find((span) => span.name === "bridge.paperclip"); + const processSession = spans.find((span) => span.name === "bridge.process-session"); + expect(paperclip?.parent).toBe(rootSpan); + expect(processSession?.parent).toBe(rootSpan); + // Both bridge spans carry the same batch tag, so the trace marks them as one + // parallel batch. + expect(paperclip?.attributes[A.batch]).toBe("bridge"); + expect(processSession?.attributes[A.batch]).toBe("bridge"); + expect(paperclip?.attributes[A.batch]).toBe(processSession?.attributes[A.batch]); + + const handshake = spans.find((span) => span.name === "acp.handshake"); + expect(handshake).toBeTruthy(); + // A cold start records both sub-times as finite float ms on the span. + expect(typeof handshake!.attributes[A.handshakeCreateRuntimeWallMs]).toBe("number"); + expect(handshake!.attributes[A.handshakeCreateRuntimeWallMs] as number).toBeGreaterThanOrEqual(0); + expect(typeof handshake!.attributes[A.handshakeEnsureSessionWallMs]).toBe("number"); + expect(handshake!.attributes[A.handshakeEnsureSessionWallMs] as number).toBeGreaterThanOrEqual(0); }); it("closes the root span with error status when the bring-up handshake fails", async () => { From ccbfefef5f23d312bc11130d14eab05aec97d900 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:12:34 -0500 Subject: [PATCH 10/34] test(ui): retire 33 duplicate executions and share render setup (#16072) Co-Authored-By: Paperclip --- ...test-audit-r7-ui-agentconfig-controls.json | 530 ++++++++++++++ ...2026-10-12-test-audit-r7-ui-agentconfig.md | 47 ++ ...test-audit-r7-ui-appboundary-controls.json | 137 ++++ ...2026-10-12-test-audit-r7-ui-appboundary.md | 33 + ...2-test-audit-r7-ui-appdetail-controls.json | 103 +++ .../2026-10-12-test-audit-r7-ui-appdetail.md | 47 ++ ...test-audit-r7-ui-attachments-controls.json | 168 +++++ ...2026-10-12-test-audit-r7-ui-attachments.md | 34 + ...est-audit-r7-ui-company-menu-controls.json | 401 ++++++++++ ...026-10-12-test-audit-r7-ui-company-menu.md | 41 ++ .../2026-10-12-test-audit-r7-ui-controls.json | 692 ++++++++++++++++++ ...est-audit-r7-ui-experimental-controls.json | 306 ++++++++ ...026-10-12-test-audit-r7-ui-experimental.md | 38 + ...-12-test-audit-r7-ui-filters-controls.json | 221 ++++++ .../2026-10-12-test-audit-r7-ui-filters.md | 35 + ...0-12-test-audit-r7-ui-inline-controls.json | 180 +++++ .../2026-10-12-test-audit-r7-ui-inline.md | 33 + ...2-test-audit-r7-ui-issuechat-controls.json | 145 ++++ .../2026-10-12-test-audit-r7-ui-issuechat.md | 32 + ...test-audit-r7-ui-issuedetail-controls.json | 149 ++++ ...2026-10-12-test-audit-r7-ui-issuedetail.md | 32 + ...-test-audit-r7-ui-onboarding-controls.json | 549 ++++++++++++++ ...-audit-r7-ui-onboarding-step-controls.json | 196 +++++ ...-10-12-test-audit-r7-ui-onboarding-step.md | 34 + .../2026-10-12-test-audit-r7-ui-onboarding.md | 48 ++ ...10-12-test-audit-r7-ui-route-controls.json | 303 ++++++++ .../2026-10-12-test-audit-r7-ui-route.md | 41 ++ ...t-audit-r7-ui-secret-popover-controls.json | 192 +++++ ...6-10-12-test-audit-r7-ui-secret-popover.md | 34 + ...t-audit-r7-ui-service-worker-controls.json | 197 +++++ ...6-10-12-test-audit-r7-ui-service-worker.md | 34 + ...-12-test-audit-r7-ui-sidebar-controls.json | 244 ++++++ .../2026-10-12-test-audit-r7-ui-sidebar.md | 39 + ...12-test-audit-r7-ui-timeline-controls.json | 271 +++++++ .../2026-10-12-test-audit-r7-ui-timeline.md | 40 + ...-test-audit-r7-ui-transcript-controls.json | 139 ++++ .../2026-10-12-test-audit-r7-ui-transcript.md | 40 + doc/plans/2026-10-12-test-audit-r7-ui.md | 74 ++ .../AgentConfigForm.render.test.tsx | 92 +-- ui/src/components/AppErrorBoundary.test.tsx | 26 +- ui/src/components/InlineEditor.test.tsx | 21 +- .../IssueAttachmentsSection.test.tsx | 32 +- ui/src/components/IssueChatThread.test.tsx | 50 -- .../components/IssueFiltersPopover.test.tsx | 28 +- ui/src/components/Layout.test.tsx | 164 +---- .../components/OnboardingWizard.step.test.tsx | 9 - ui/src/components/OnboardingWizard.test.tsx | 112 +-- ui/src/components/RouteErrorBoundary.test.tsx | 57 +- ui/src/components/Sidebar.test.tsx | 50 +- ui/src/components/SidebarCompanyMenu.test.tsx | 54 +- .../EnvironmentVariablesEditor.test.tsx | 35 +- .../task-chat/transcript-adapter.test.ts | 15 - ui/src/lib/service-worker-updates.test.ts | 10 - ui/src/pages/Agents.test.tsx | 214 +----- .../InstanceExperimentalSettings.test.tsx | 30 +- ui/src/pages/InviteLanding.test.tsx | 192 +---- ui/src/pages/IssueDetail.test.tsx | 17 +- ui/src/pages/Timeline.test.tsx | 50 +- ui/src/pages/apps/AppDetail.test.tsx | 13 +- 59 files changed, 6112 insertions(+), 1038 deletions(-) create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-agentconfig-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-agentconfig.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-appboundary-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-appboundary.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-appdetail-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-appdetail.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-attachments-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-attachments.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-company-menu-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-company-menu.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-experimental-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-experimental.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-filters-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-filters.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-inline-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-inline.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-issuechat-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-issuechat.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-issuedetail-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-issuedetail.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-onboarding-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-onboarding-step-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-onboarding-step.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-onboarding.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-route-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-route.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-secret-popover-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-secret-popover.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-service-worker-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-service-worker.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-sidebar-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-sidebar.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-timeline-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-timeline.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-transcript-controls.json create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui-transcript.md create mode 100644 doc/plans/2026-10-12-test-audit-r7-ui.md diff --git a/doc/plans/2026-10-12-test-audit-r7-ui-agentconfig-controls.json b/doc/plans/2026-10-12-test-audit-r7-ui-agentconfig-controls.json new file mode 100644 index 00000000000..c8b438ce094 --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-r7-ui-agentconfig-controls.json @@ -0,0 +1,530 @@ +{ + "audit": { + "file": "ui/src/components/AgentConfigForm.render.test.tsx", + "base": "e3effbb9e5c66b838f67fd487987c8bf1007e413", + "originalSha256": "57282ae3afe8c55ac118f3a658152d8df500a87b15383c8f6b0530a7fdcfb8a0", + "candidateSha256": "132112f3d2bb74d0844673a39ce31ffca1306e0b82d9846f3e0cafaf83e81708", + "allOtherProgramAstUnchanged": true, + "allOtherSourceBytesUnchanged": true, + "newHelpers": 0, + "registeredInvocationsRemoved": 4, + "rootRendersRemoved": 4, + "netTestLinesRemoved": 44, + "migrations": [ + { + "retired": "keeps secret access out of the main Configuration content", + "keeper": "hides the environment override when Local is the only configured environment", + "exactSetupStatements": 2, + "sameEnclosingHooks": true, + "exactCleanupStatements": 0, + "migratedGuards": [ + "expect(result.container.textContent).not.toContain(\"Secret access\");" + ], + "exactDuplicateGuardAlreadyKept": [], + "registeredInvocationsRemoved": 1, + "rootRendersRemoved": 1 + }, + { + "retired": "starts a login session for the effective sandbox and shows the code and the authentication URL", + "keeper": "shows a Cancel affordance while a login is active and cancels the session", + "exactSetupStatements": 5, + "sameEnclosingHooks": true, + "exactCleanupStatements": 0, + "migratedGuards": [ + "expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith(\"company-1\", \"codex_local\", {\n environmentId: \"sandbox-1\",\n });", + "expect(result.container.textContent).toContain(\"WXYZ-1234\");", + "expect(result.container.textContent).toContain(\"https://auth.example.test/device\");" + ], + "exactDuplicateGuardAlreadyKept": [], + "registeredInvocationsRemoved": 1, + "rootRendersRemoved": 1 + }, + { + "retired": "shows the authorization URL and a browser-code input for a Claude sandbox", + "keeper": "cancels an active Claude login and returns to its start state", + "exactSetupStatements": 6, + "sameEnclosingHooks": true, + "exactCleanupStatements": 0, + "migratedGuards": [ + "expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledWith(\"company-1\", {\n environmentId: \"sandbox-1\",\n });", + "expect(result.container.textContent).toContain(\"https://claude.example.test/authorize\");", + "expect(\n result.container.querySelector('input[aria-label=\"Browser code\"]'),\n ).toBeTruthy();", + "expect(result.container.textContent).not.toContain(\"not encrypted\");" + ], + "exactDuplicateGuardAlreadyKept": [], + "registeredInvocationsRemoved": 1, + "rootRendersRemoved": 1 + }, + { + "retired": "keeps the non-path ACP controls visible when the policy hides the engine choice", + "keeper": "hides the host path and execution-engine fields for claude_local when the policy is on", + "exactSetupStatements": 6, + "sameEnclosingHooks": true, + "exactCleanupStatements": 0, + "migratedGuards": [ + "expect(labels).toContain(\"ACP session mode\");", + "expect(labels).toContain(\"ACP non-interactive permissions\");" + ], + "exactDuplicateGuardAlreadyKept": [], + "registeredInvocationsRemoved": 1, + "rootRendersRemoved": 1 + } + ] + }, + "verifierSha256": "084bdbc359e510397ef64daeb24e259efbc62c8b5d2c56d3e09d7f2534f155aa", + "mutationVerifierSha256": "ca8463047109d38a447d93fb94a19bb8efe9b1df440e0abf91ab31a8c0a358a2", + "runs": [ + { + "kind": "baseline", + "total": 99, + "passed": 99, + "failed": 0, + "skipped": 0, + "jsonSha256": "16a733e7e1de263ce88f1fbd89b94d4844aab7b6e7eca1fd670899fd670e263f", + "logSha256": "3f6f04966d8971e70a782681fccde2afd1aee5099fd8f376197109ed2babd2e5" + }, + { + "kind": "candidate", + "total": 95, + "passed": 95, + "failed": 0, + "skipped": 0, + "jsonSha256": "7fdabb69061f6d6f072c17224832171a225ac9a992d53c94d050c277eb467120", + "logSha256": "2717b82c2589f41dad5d9e926a7a37dd4fd79594acc1e9e6d9614a89ae2b5841" + } + ], + "mutations": [ + { + "slug": "secrets", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "title": "hides the environment override when Local is the only configured environment", + "mutation": { + "before": "if (!isCreate && props.content === \"secrets\")", + "after": "if (!isCreate && (props.content === \"secrets\" || props.content === undefined))", + "replacementCount": 1 + }, + "intendedGuard": "expect(result.container.textContent).not.toContain(\"Secret access\")", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "24cf0c3e870ddc964fe327426491a4bbdfde043206191df578686ffb089d2f8f", + "logSha256": "10c8a0e3b2aafe33ab63fce0881fdf2b066d1cee507ccc589db6076701da4161", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "a9af939b306874f9bc7ef93c6954f3a234aafbb252c14672097aa33732c86f90", + "logSha256": "7fa30fef5ccdfa2cb797df92c4b207dff1eac29c99b9dcefa7a977278a4c3593", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected 'Secret accessSecrets this agent can r\u2026' not to contain 'Secret access'\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AgentConfigForm.render.test.tsx:822:46\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + }, + { + "slug": "codex-company", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "title": "shows a Cancel affordance while a login is active and cancels the session", + "mutation": { + "before": "agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId, aiConnection })", + "after": "agentsApi.startAdapterAuthLogin(\"wrong-company\", adapterType, { environmentId, aiConnection })", + "replacementCount": 1 + }, + "intendedGuard": "expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith(", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "fabccb095c86b4151cb80e0446d83e60074a4da76e7f0a36d4fef0246a483696", + "logSha256": "3397bb075079467542110406669cb26177cbbcdee8c6e6c56a5f52ccaaafd30f", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "332cf3fce408da458c33289abaf343fbf76ffedc68d37ee532a5c43ffc09f93e", + "logSha256": "81892e052e638ba655789fcac2b9bf95b243d1bd628c86a72053ebd7c596a432", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected \"vi.fn()\" to be called with arguments: [ Array(3) ]\n\nReceived:\n\n 1st vi.fn() call:\n\n [\n- \"company-1\",\n+ \"wrong-company\",\n \"codex_local\",\n {\n+ \"aiConnection\": undefined,\n \"environmentId\": \"sandbox-1\",\n },\n ]\n\n\nNumber of calls: 1\n\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2233:10)\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:1944:14)\n at Proxy.methodWrapper (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/chai@6.3.0/node_modules/chai/index.js:1737:25)\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AgentConfigForm.render.test.tsx:1593:49\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + }, + { + "slug": "codex-adapter", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "title": "shows a Cancel affordance while a login is active and cancels the session", + "mutation": { + "before": "agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId, aiConnection })", + "after": "agentsApi.startAdapterAuthLogin(companyId, \"wrong-adapter\", { environmentId, aiConnection })", + "replacementCount": 1 + }, + "intendedGuard": "expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith(", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "3acb41404a916f7bc11b1ff698bfbaaa22017c67835907e59283b7b1df5ea07c", + "logSha256": "6530c744b0788868f9123b062307dfd1fac3f40740e193943a81da360fb19f3a", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "b3cbd3bfee156e965bed6924beefc3f0e6009785f3dcade569f03b88fd5cba55", + "logSha256": "90eeb1ecbbce2406915a5123c24d1311016fc1fe3d878af795781da3c352a071", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected \"vi.fn()\" to be called with arguments: [ Array(3) ]\n\nReceived:\n\n 1st vi.fn() call:\n\n [\n \"company-1\",\n- \"codex_local\",\n+ \"wrong-adapter\",\n {\n+ \"aiConnection\": undefined,\n \"environmentId\": \"sandbox-1\",\n },\n ]\n\n\nNumber of calls: 1\n\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2233:10)\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:1944:14)\n at Proxy.methodWrapper (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/chai@6.3.0/node_modules/chai/index.js:1737:25)\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AgentConfigForm.render.test.tsx:1593:49\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + }, + { + "slug": "codex-environment", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "title": "shows a Cancel affordance while a login is active and cancels the session", + "mutation": { + "before": "agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId, aiConnection })", + "after": "agentsApi.startAdapterAuthLogin(companyId, adapterType, { environmentId: \"wrong-sandbox\", aiConnection })", + "replacementCount": 1 + }, + "intendedGuard": "expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith(", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "3afd47d7705415ceb4ad8b7ce1415bd61b0b202d65732881660d75e75910c536", + "logSha256": "874b11b544fc038fba5e9ff57b156229acbbefb1db8410bf1bebc6794d1e5f20", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "2750457c4f575d786dade8a250f50520e08b1e351c95d2562900a06ffc9ab769", + "logSha256": "845ff3e01a9b6640496b2f895ca31e169639a65e3bf8e94bb3a383026e232b8b", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected \"vi.fn()\" to be called with arguments: [ Array(3) ]\n\nReceived:\n\n 1st vi.fn() call:\n\n [\n \"company-1\",\n \"codex_local\",\n {\n- \"environmentId\": \"sandbox-1\",\n+ \"aiConnection\": undefined,\n+ \"environmentId\": \"wrong-sandbox\",\n },\n ]\n\n\nNumber of calls: 1\n\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2233:10)\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:1944:14)\n at Proxy.methodWrapper (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/chai@6.3.0/node_modules/chai/index.js:1737:25)\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AgentConfigForm.render.test.tsx:1593:49\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + }, + { + "slug": "codex-code", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "title": "shows a Cancel affordance while a login is active and cancels the session", + "mutation": { + "before": "{prompt.code}", + "after": "{\"missing code\"}", + "replacementCount": 1 + }, + "intendedGuard": "expect(result.container.textContent).toContain(\"WXYZ-1234\")", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "f985ab64892d06589b60759cb68183f52c9128056b79c93e69880cb5ff8c3ad1", + "logSha256": "dba2f7e6b14ed30859608119e88ac17f20c3c6de3ad93727a357d3860dc5a592", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "3a7ecc1e9c5e40c93c8e92f9e3930a4d0a01a2f84f01ffed53541c3403be4737", + "logSha256": "77c403024242dedc9cfa765484486337c97a35133cea348ed4ec57caf26a760d", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected 'IdentityNameTitleReports toChoose man\u2026' to contain 'WXYZ-1234'\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AgentConfigForm.render.test.tsx:1596:42\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + }, + { + "slug": "codex-url", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "title": "shows a Cancel affordance while a login is active and cancels the session", + "mutation": { + "before": "{prompt.url}", + "after": "{\"missing url\"}", + "replacementCount": 1 + }, + "intendedGuard": "expect(result.container.textContent).toContain(\"https://auth.example.test/device\")", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "0a11ca18e53fda28ea9a2956ee7254ab1b9f40f32f772b530df169adb06569b9", + "logSha256": "1772bbd6667cd1f32383f0671a186bc263054ef434de07933bac4feda1e3d4b6", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "3cca5f95b518a31622ebf3f17758c4617fa40476623210da56eee539c5a1efb6", + "logSha256": "1bac45eb7f9b150cdc6a99067d62e588baeca3af41d148fe897d0a0fa5949332", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected 'IdentityNameTitleReports toChoose man\u2026' to contain 'https://auth.example.test/device'\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AgentConfigForm.render.test.tsx:1597:42\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + }, + { + "slug": "claude-company", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "title": "cancels an active Claude login and returns to its start state", + "mutation": { + "before": "agentsApi.startClaudeSetupTokenLogin(companyId, {", + "after": "agentsApi.startClaudeSetupTokenLogin(\"wrong-company\", {", + "replacementCount": 1 + }, + "intendedGuard": "expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledWith(", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "40ec576f564d239009569ca0033715f58f5b255f76ef22da20b134c2e490218f", + "logSha256": "075d5fe2d62f1b80f60d63e0ce37e540fffdf2e666b01a21740264e6d4075478", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "0bf3dff32509866e86a80d67029ee81a1437d335045a91cb6cdf7f6ce59b5982", + "logSha256": "7ae4dcd73616fa424d373204986a05e798f573b5659daef4ac58bc6f7b9d9fdf", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected \"vi.fn()\" to be called with arguments: [ 'company-1', \u2026(1) ]\n\nReceived:\n\n 1st vi.fn() call:\n\n [\n- \"company-1\",\n+ \"wrong-company\",\n {\n+ \"aiConnection\": undefined,\n \"environmentId\": \"sandbox-1\",\n },\n ]\n\n\nNumber of calls: 1\n\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2233:10)\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:1944:14)\n at Proxy.methodWrapper (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/chai@6.3.0/node_modules/chai/index.js:1737:25)\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AgentConfigForm.render.test.tsx:2782:54\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + }, + { + "slug": "claude-environment", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "title": "cancels an active Claude login and returns to its start state", + "mutation": { + "before": "agentsApi.startClaudeSetupTokenLogin(companyId, {\n environmentId,", + "after": "agentsApi.startClaudeSetupTokenLogin(companyId, {\n environmentId: \"wrong-sandbox\",", + "replacementCount": 1 + }, + "intendedGuard": "expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledWith(", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "4250b6cc6d66dc4df5753f403ef6894ceacdbc94bbf7812c5788ae143201d617", + "logSha256": "b76c385cbee9d1e26130b8e0c8c5f61fb2b31523e2ca65e2fbbf7303037ff179", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "7dfc3e5efa6eeb0c719d717b901b31fca6ca4a5cff1eb817382fa932a90f983b", + "logSha256": "56c3a19a6f86d4cc69918c47afd81bc4b4d887f788c135c7b956ea4fc3c0903c", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected \"vi.fn()\" to be called with arguments: [ 'company-1', \u2026(1) ]\n\nReceived:\n\n 1st vi.fn() call:\n\n [\n \"company-1\",\n {\n- \"environmentId\": \"sandbox-1\",\n+ \"aiConnection\": undefined,\n+ \"environmentId\": \"wrong-sandbox\",\n },\n ]\n\n\nNumber of calls: 1\n\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2233:10)\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:1944:14)\n at Proxy.methodWrapper (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/chai@6.3.0/node_modules/chai/index.js:1737:25)\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AgentConfigForm.render.test.tsx:2782:54\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + }, + { + "slug": "claude-url", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "title": "cancels an active Claude login and returns to its start state", + "mutation": { + "before": "{authorizationUrl}", + "after": "{\"missing authorization url\"}", + "replacementCount": 1 + }, + "intendedGuard": "expect(result.container.textContent).toContain(\"https://claude.example.test/authorize\")", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "8ee00e28a8630374d9bfe8c85d96a44121121377b89b7f845ee4b32d659833f5", + "logSha256": "35255166875e6184b0a10b1abc4c6f42a281d4217eeac6ba2d1bbda83620aaf9", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "b2aca78cbef836af30a95188def37939d9ea2d0af743392abe804ae1bf118605", + "logSha256": "bb383cfabd0dc16036f31324b47ad392159a9eb7db75c622e2c2e132c2d1ee63", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected 'IdentityNameTitleReports toChoose man\u2026' to contain 'https://claude.example.test/authorize'\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AgentConfigForm.render.test.tsx:2787:42\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + }, + { + "slug": "claude-input", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "title": "cancels an active Claude login and returns to its start state", + "mutation": { + "before": "aria-label=\"Browser code\"", + "after": "aria-label=\"Missing browser input\"", + "replacementCount": 1 + }, + "intendedGuard": "result.container.querySelector('input[aria-label=\"Browser code\"]')", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "cf8cb1ded0e9999bb966c94c1c38aa7a147d217a06ff4ebe647c4f03ed3e550f", + "logSha256": "dc8c2389ba9b411989fc0d60a27e489829b21de1001c94b908555c337dfc685e", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "1bffa0d67d82af2e69b851eea8a1d4b2c319f85cad4b9b9fdf36dc95c143bde5", + "logSha256": "13f2b561842f8803f084be9723c7042abf5c0be74b625c99effb180f0c57dc4d", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected null to be truthy\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AgentConfigForm.render.test.tsx:2790:7\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + }, + { + "slug": "claude-advisory", + "owner": "ui/src/components/AgentConfigForm.tsx", + "sourceSha256": "bf999102f39bfc762747ce5d598e1e6ec2f926ac5fb28de55fa1baf5493f28b1", + "title": "cancels an active Claude login and returns to its start state", + "mutation": { + "before": "{transportInsecure && (", + "after": "{true && (", + "replacementCount": 2 + }, + "intendedGuard": "expect(result.container.textContent).not.toContain(\"not encrypted\")", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "ce03b65cc51ee03992fec6dfa438798f57053b914aca0cb56a791a43817a04b7", + "logSha256": "1e6753c737dd67dafe5f8cc4f7ec40be3be0875a4b97f0668e8d457a31462d4f", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "7f3ed2737dd31b0caf900567545c59d50c2cbf9aca876fd35080f13c87fb8757", + "logSha256": "aca8606f2a3f8176814074b81c913ec3f702e44141cd2131e28c14de2ce695c7", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected 'IdentityNameTitleReports toChoose man\u2026' not to contain 'not encrypted'\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AgentConfigForm.render.test.tsx:2793:46\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + }, + { + "slug": "acp-mode", + "owner": "ui/src/adapters/claude-local/config-fields.tsx", + "sourceSha256": "c7e5aa4093bf30d31d5d3b1de092abe767965b7c79143965f92f8c250bf916f6", + "title": "hides the host path and execution-engine fields for claude_local when the policy is on", + "mutation": { + "before": "label=\"ACP session mode\"", + "after": "label=\"Missing ACP mode\"", + "replacementCount": 1 + }, + "intendedGuard": "expect(labels).toContain(\"ACP session mode\")", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "f6011ba4b3d6f34b47bf64dd76424cfb674b81a6663a58df2dd3994b9f87fca7", + "logSha256": "0174f7e7bcb488d93a15306214bb579be7049bfba08e3a048c3183f79360b6b0", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "294e714633d60e3121afe528b1c461d32bf13e4407cbfbd0da2581fc6d22f1ad", + "logSha256": "90a2ade05e125113eabeb530edec54a30e21dabd097407bb285139f4d87a35af", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected [ Array(17) ] to include 'ACP session mode'\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2077:15)\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:1944:14)\n at Proxy.methodWrapper (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/chai@6.3.0/node_modules/chai/index.js:1737:25)\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AgentConfigForm.render.test.tsx:3599:20\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + }, + { + "slug": "acp-permissions", + "owner": "ui/src/adapters/claude-local/config-fields.tsx", + "sourceSha256": "c7e5aa4093bf30d31d5d3b1de092abe767965b7c79143965f92f8c250bf916f6", + "title": "hides the host path and execution-engine fields for claude_local when the policy is on", + "mutation": { + "before": "label=\"ACP non-interactive permissions\"", + "after": "label=\"Missing ACP permissions\"", + "replacementCount": 1 + }, + "intendedGuard": "expect(labels).toContain(\"ACP non-interactive permissions\")", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "f859cc875b6f5c83be73f63cf9122d2fe928ad225c6d815a9ae29f6fed365766", + "logSha256": "06269344f536044578526368812f6c89c02f8208b9258beb773d14043859720b", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "b8b76b1c053101b532aec230f5356cbea28900d0c40fb012e3a8cc9b93a1ff2d", + "logSha256": "590f6908806038ca072a41f61516323dcba661886afbab66c0e1cf280c596060", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected [ Array(17) ] to include 'ACP non-interactive permissions'\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:2077:15)\n at Proxy. (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/index.C4tKZ0yW.js:1944:14)\n at Proxy.methodWrapper (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/chai@6.3.0/node_modules/chai/index.js:1737:25)\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AgentConfigForm.render.test.tsx:3600:20\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + } + ], + "priorEvidenceUnchanged": [ + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-appboundary-controls.json", + "sha256": "bad49e7bc92dea919df6b980bfa45e7452984cf31a6fd4655b355ba0444ff9a6" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-appboundary.md", + "sha256": "cd6f173584db0d485004af6f85b1ce78fa65b19d8c6ef582b061f0d232c6c1ed" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-appdetail-controls.json", + "sha256": "7906fde0d62e14a7511b5cf45f864c9386a4c377922333eefc7f7e703c832f6a" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-appdetail.md", + "sha256": "715f23cd27502c92d0f5d1b83b91bcac03f254c73ab63b44181ae76939805229" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-attachments-controls.json", + "sha256": "c4ff66a3226e1edafd286cad0ca45c9445795b46944cf70d606c59a754913f3a" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-attachments.md", + "sha256": "d38715393c95306d10e871a5a2c9a32f105fa1452871cd7ad030d86dbbdb7416" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-controls.json", + "sha256": "0d445f6f066032f2d9cd724525a8d7d2290246cbfeee1f6ab4b44818467af2a7" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-route-controls.json", + "sha256": "0a72a7770ec749f2c0507a74d395e65d70b722d0b9d2a767a2c066a67ed46382" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-route.md", + "sha256": "9b5e1543163d6c9d0958aa3e377a593ec458f6674ce1ddb2bf3ca158786f09c4" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-sidebar-controls.json", + "sha256": "ae9f24746e67432cf6247c5da130b9c1bba46fc85112c0610d3f18545dfce326" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-sidebar.md", + "sha256": "5e267441c43aa8375fa5c54edcbd2879428d657708be9a2ab372aa66e4792886" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-timeline-controls.json", + "sha256": "6a86b07770f1cd8d758334fb61c46254197a7f533d24c056fc9a52c317488291" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-timeline.md", + "sha256": "612dc3ec4c3d435888055ba20a7f28230ce89ea8b071b8c38d1ea2c147074a96" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-transcript-controls.json", + "sha256": "9e4dbdc33806d6fcc912dc3aacc8691a77939e2ce2403032b2a8127c0e407e94" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-transcript.md", + "sha256": "9e4ac8c92a3a9eca727c29a77ae428702d19ecdee0cab0e3dfcda0a36bf004db" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui.md", + "sha256": "896b9928af85a4c7d588dfca93484980aadb238bd79d8142e671c62f0b191fa8" + } + ], + "ownerRestoredToBaseGitBytes": true, + "productionLinesChanged": 0 +} diff --git a/doc/plans/2026-10-12-test-audit-r7-ui-agentconfig.md b/doc/plans/2026-10-12-test-audit-r7-ui-agentconfig.md new file mode 100644 index 00000000000..84eaf0b9da3 --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-r7-ui-agentconfig.md @@ -0,0 +1,47 @@ +# Test audit, Round 7 UI β€” Agent configuration follow-up + +This separate packet removes **4 registered executions, 4 root renders and 44 net test lines** from `ui/src/components/AgentConfigForm.render.test.tsx`. It adds no helper and changes no production lines. Audit base: `e3effbb9e5c66b838f67fd487987c8bf1007e413`. Prior committed evidence remains byte-identical. Render/execution savings are per complete-suite invocation; no measured runtime or CI-speed claim is made. + +## Exact retirement map + +| Retired registration | Keeper | Observations retained | +|---|---|---| +| `keeps secret access out of the main Configuration content` | `hides the environment override when Local is the only configured environment` | expect(result.container.textContent).not.toContain("Secret access"); | +| `starts a login session for the effective sandbox and shows the code and the authentication URL` | `shows a Cancel affordance while a login is active and cancels the session` | expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith("company-1", "codex_local", { environmentId: "sandbox-1", });; expect(result.container.textContent).toContain("WXYZ-1234");; expect(result.container.textContent).toContain("https://auth.example.test/device"); | +| `shows the authorization URL and a browser-code input for a Claude sandbox` | `cancels an active Claude login and returns to its start state` | expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledWith("company-1", { environmentId: "sandbox-1", });; expect(result.container.textContent).toContain("https://claude.example.test/authorize");; expect( result.container.querySelector('input[aria-label="Browser code"]'), ).toBeTruthy();; expect(result.container.textContent).not.toContain("not encrypted"); | +| `keeps the non-path ACP controls visible when the policy hides the engine choice` | `hides the host path and execution-engine fields for claude_local when the policy is on` | expect(labels).toContain("ACP session mode");; expect(labels).toContain("ACP non-interactive permissions"); | + +Each of four pairs uses identical beforeEach/afterEach hooks and render/helper input expressions. The Local-only configuration keeper absorbs the Secret access exclusion. The Codex and Claude cancellation keepers retain their respective original provider-specific start API and displayed-code/submitted-browser-code observations before any Cancel click; their providers and lifecycle remain separate. Real runTest/startLogin helpers click Test/Sign in under the original QueryClient/Toast/Tooltip tree with fresh clients and roots. The managed-sandbox-only keeper absorbs both visible non-path ACP labels after the same Advanced/Advanced Run Policy toggles. AgentConfigForm.tsx passes the policy into real ClaudeLocalAdvancedFields from adapters/claude-local/config-fields.tsx, which retains non-path mode/permission fields while host paths are hidden. Existing enabled/disabled/loading, stored-token, terminal-state, submit, cancel-error and timeout cases remain unchanged. Two retired login cases avoid repeated environment-test/start-login API paths; polling request counts and time savings are not claimed. + +The AST verifier checks exact setup, shared enclosing hooks and matched cleanup where present, reconstructs candidate text solely from the declared registrations/assertion migrations, and requires exact byte equality plus full-program AST equality outside those edits. No fixture, helper, provider, action order, wait, assertion strength, timer or teardown changes. The IssueChatThread keeper is an existing complete subset proof; InlineEditor’s identical not-null guard is retained once. Every other case stays byte-identical. Default CI includes this file in `@paperclipai/ui` under `general-workspaces-a`, once across the two file shards; workflow/config unchanged. + +## Complete owner-file results + +| Run | Registered | Passed | Failed | Skipped | +|---|---:|---:|---:|---:| +| Baseline | 99 | 99 | 0 | 0 | +| Candidate | 95 | 95 | 0 | 0 | + +From `ui/`: `pnpm exec vitest run src/components/AgentConfigForm.render.test.tsx`. + +All 13 isolated positive controls passed. All 13 deliberate behavior regressions failed at their intended retained/migrated assertion, rather than setup, timeout or a different guard: + +| Regression | Intended guard | +|---|---| +| secrets | `expect(result.container.textContent).not.toContain("Secret access")` | +| codex-company | `expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith(` | +| codex-adapter | `expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith(` | +| codex-environment | `expect(mockAgentsApi.startAdapterAuthLogin).toHaveBeenCalledWith(` | +| codex-code | `expect(result.container.textContent).toContain("WXYZ-1234")` | +| codex-url | `expect(result.container.textContent).toContain("https://auth.example.test/device")` | +| claude-company | `expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledWith(` | +| claude-environment | `expect(mockAgentsApi.startClaudeSetupTokenLogin).toHaveBeenCalledWith(` | +| claude-url | `expect(result.container.textContent).toContain("https://claude.example.test/authorize")` | +| claude-input | `result.container.querySelector('input[aria-label="Browser code"]')` | +| claude-advisory | `expect(result.container.textContent).not.toContain("not encrypted")` | +| acp-mode | `expect(labels).toContain("ACP session mode")` | +| acp-permissions | `expect(labels).toContain("ACP non-interactive permissions")` | + +Exactly one production owner was temporarily changed per control. Every owner was restored byte for byte after each mutation and matched pinned Git bytes. Filtered controls select one keeper; unselected tests do not count as full-suite evidence. [Controls JSON](2026-10-12-test-audit-r7-ui-agentconfig-controls.json) records exact owner/source hashes, migration/input proof, complete-file run/log digests, mutation bodies, intended assertion failures and restoration receipts. Temporary raw verifiers and logs are identified by digests rather than committed in full. + +The earlier broad `pnpm test:run` was intentionally stopped without completion and is not claimed as passed. Separate packet results are focused proof, not a completed combined delivery-head replay or pending full CI. No push, rebase, PR or merge is included here. diff --git a/doc/plans/2026-10-12-test-audit-r7-ui-appboundary-controls.json b/doc/plans/2026-10-12-test-audit-r7-ui-appboundary-controls.json new file mode 100644 index 00000000000..cafb0f6a273 --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-r7-ui-appboundary-controls.json @@ -0,0 +1,137 @@ +{ + "audit": { + "file": "ui/src/components/AppErrorBoundary.test.tsx", + "base": "c39262305b6b48a5decd564af8369083cd972b33", + "originalSha256": "1cc53b4a6978c3c5e98761467208d8e2e9a165163956a64a33bb84cea2778327", + "candidateSha256": "51fce35eb3b3b2dd0e1aacdd09f14dcd208e2e6b557ab0e4de9a16606fa3b778", + "allOtherProgramAstUnchanged": true, + "allOtherSourceBytesUnchanged": true, + "newHelpers": 0, + "registeredInvocationsRemoved": 1, + "rootRendersRemoved": 1, + "netTestLinesRemoved": 20, + "migrations": [ + { + "retired": "renders a reload prompt instead of a blank page when the shell throws in render", + "keeper": "reports one captured error and keeps the reload prompt", + "setupAstEqual": true, + "setupBindingNormalization": null, + "guards": [ + "expect(container.textContent).toContain(\"Paperclip hit an error\");", + "expect(container.textContent).toContain(\"Maximum update depth exceeded\");" + ], + "registeredInvocationsRemoved": 1 + } + ] + }, + "verifierSha256": "0f1b173c8a399bb8c3e831edac6805634304a749faffcfff7b8b1900da3d34a3", + "mutationVerifierSha256": "a05f5ba9071c0ce7ce8eac1bc0f6c7dd83401c4e720816389d779f8919312064", + "runs": [ + { + "kind": "baseline", + "jsonSha256": "c1a2a5f2da21b5a730f174b628b23c1b01e682b2b8cc10ab8f332dbcede339bd", + "logSha256": "475145ce3b58595c9e76cbed628598ecc97a3d5cc835b1e374a10507e72ba9f7", + "total": 6, + "passed": 6, + "failed": 0, + "skipped": 0 + }, + { + "kind": "candidate", + "jsonSha256": "b27bec00f8765cc28254633e2752f96f15d6f0734cd59b4e0c10267ba138ff46", + "logSha256": "bba83ddd4f7aae20381ef90bfd2f17f6b44818e71994c5f716ea17df8c10c057", + "total": 5, + "passed": 5, + "failed": 0, + "skipped": 0 + } + ], + "mutations": [ + { + "slug": "title", + "owner": "ui/src/components/AppErrorBoundary.tsx", + "sourceSha256": "72bfc237fd0760e8432a9948e3423b6cab6881507f28815240f585f46e3ceb7c", + "title": "reports one captured error and keeps the reload prompt", + "mutation": { + "before": "Paperclip hit an error", + "after": "Unexpected title", + "replacementCount": 1, + "additionalPreClosingTagReplacement": false + }, + "intendedGuard": "expect(container.textContent).toContain(\"Paperclip hit an error\")", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "85d36ca8369f85ceb5e9173db5670b2d5ccf3c843cf26b3b1fb0ddb73a59a029", + "logSha256": "088a6c3c7dafb9546d8c32db0829797a2917cba4ce2e78c0215529c99d9096f2", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "b82c5bc57625cae251acafe46aa64021129848550e43cb66c19996b10f7ced0f", + "logSha256": "5f8f8ad95575b5920ae4574c4c04d7d7a6b3db3262c293973953b4f56f2f6604", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected 'Unexpected titleSomething went wrong \u2026' to contain 'Paperclip hit an error'\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AppErrorBoundary.test.tsx:53:35\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:1628:35\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:26\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:3321:20\n at new Promise ()\n at runWithCancel (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:3316:10)\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:3301:20\n at new Promise ()\n at runWithTimeout (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:3259:10)\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:3882:64" + ] + } + }, + { + "slug": "detail", + "owner": "ui/src/components/AppErrorBoundary.tsx", + "sourceSha256": "72bfc237fd0760e8432a9948e3423b6cab6881507f28815240f585f46e3ceb7c", + "title": "reports one captured error and keeps the reload prompt", + "mutation": { + "before": "{error.message}", + "after": "{\"hidden detail\"}", + "replacementCount": 1, + "additionalPreClosingTagReplacement": false + }, + "intendedGuard": "expect(container.textContent).toContain(\"Maximum update depth exceeded\")", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "a11dcafca2602e4baed9feff4925dcca4c09ceb46a36b5baf782742274fe467d", + "logSha256": "23301c06f7a3c46599867040f2fe947f36fe6bf9c33c66e6c0d465c9b7a97746", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "3d0fdd9f0f6fa788decca2bdf816bee3bcbe4f2d5836ba6582e8e2860369d974", + "logSha256": "31c8775a1049760ef218a4d1bb14df2dde621652cd58f1b53c24651a4dfa0d3a", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected 'Paperclip hit an errorSomething went \u2026' to contain 'Maximum update depth exceeded'\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/AppErrorBoundary.test.tsx:54:35\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:1628:35\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:26\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:3321:20\n at new Promise ()\n at runWithCancel (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:3316:10)\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:3301:20\n at new Promise ()\n at runWithTimeout (file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:3259:10)\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:3882:64" + ] + } + } + ], + "priorEvidenceUnchanged": [ + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-appdetail-controls.json", + "sha256": "7906fde0d62e14a7511b5cf45f864c9386a4c377922333eefc7f7e703c832f6a" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-appdetail.md", + "sha256": "715f23cd27502c92d0f5d1b83b91bcac03f254c73ab63b44181ae76939805229" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-controls.json", + "sha256": "0d445f6f066032f2d9cd724525a8d7d2290246cbfeee1f6ab4b44818467af2a7" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-transcript-controls.json", + "sha256": "9e4dbdc33806d6fcc912dc3aacc8691a77939e2ce2403032b2a8127c0e407e94" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui-transcript.md", + "sha256": "9e4ac8c92a3a9eca727c29a77ae428702d19ecdee0cab0e3dfcda0a36bf004db" + }, + { + "file": "doc/plans/2026-10-12-test-audit-r7-ui.md", + "sha256": "896b9928af85a4c7d588dfca93484980aadb238bd79d8142e671c62f0b191fa8" + } + ], + "ownerRestoredToBaseGitBytes": true, + "productionLinesChanged": 0 +} diff --git a/doc/plans/2026-10-12-test-audit-r7-ui-appboundary.md b/doc/plans/2026-10-12-test-audit-r7-ui-appboundary.md new file mode 100644 index 00000000000..a6780b611f0 --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-r7-ui-appboundary.md @@ -0,0 +1,33 @@ +# Test audit, Round 7 UI β€” App error boundary follow-up + +This isolated follow-up removes **1 registered executions, 1 root renders and 20 net test lines** from `ui/src/components/AppErrorBoundary.test.tsx`. It changes no production lines and adds no helper. The pinned audit base is `c39262305b6b48a5decd564af8369083cd972b33`. Prior UI proof remains unchanged. Registration/render reductions are per complete-suite invocation; no measured runtime or CI-speed claim is made. + +## Exact migrations + +| Retired registration | Keeper | Original observations retained | +|---|---|---| +| `renders a reload prompt instead of a blank page when the shell throws in render` | `reports one captured error and keeps the reload prompt` | expect(container.textContent).toContain("Paperclip hit an error");; expect(container.textContent).toContain("Maximum update depth exceeded"); | + +main.tsx wraps the app in AppErrorBoundary. The paired tests create the same root and render the same BoomRender throw under the real boundary, with identical hooks/unmount. Move title and original error detail before retained capture/count/metadata/reload-button checks. The retired reload-button expression already matches the retained expression exactly. Effect errors, healthy children and DOM insertion/translation errors remain distinct and unchanged. + +The verifier reconstructs the exact candidate text from only the declared removals and insertions, requires byte equality, and compares the entire program AST after the same migrations. Every unrelated source byte, hook, mock, test body and binding remains unchanged, outside the audit receipt’s explicitly declared duplicate-title or blank-line cleanup where applicable. Setup ASTs are equal except the explicitly normalized attachment callback binding described above where applicable. No tests are skipped, softened, suppressed or added. Each file remains included in `@paperclipai/ui` under CI `general-workspaces-a`; Vitest file sharding selects it once across the two shards, with no workflow/config change. + +## Complete-file evidence + +| Run | Registered | Passed | Failed | Skipped | +|---|---:|---:|---:|---:| +| Baseline | 6 | 6 | 0 | 0 | +| Candidate | 5 | 5 | 0 | 0 | + +From `ui/`: `pnpm exec vitest run src/components/AppErrorBoundary.test.tsx`. + +All 2 separately selected positive keeper controls passed. Every deliberate behavior mutation failed at the corresponding intended keeper assertion, not setup, timeout or another guard: + +| Mutation | Intended assertion | +|---|---| +| title | `expect(container.textContent).toContain("Paperclip hit an error")` | +| detail | `expect(container.textContent).toContain("Maximum update depth exceeded")` | + +Every mutated production owner was restored byte for byte after each mutation and checked against pinned Git bytes. Name-filtered controls select exactly one keeper; their unselected tests are not full-suite evidence. [Compact controls](2026-10-12-test-audit-r7-ui-appboundary-controls.json) preserve source/owner hashes, exact migration map, body verifier digests, complete run/log digests, intended failure receipts and restoration checks. Temporary AST/receipt scripts initially needed corrections for chained assertions or statement position; those verifier attempts produced no accepted test evidence. + +The earlier broad local `pnpm test:run` was intentionally stopped before completion; it is not claimed as a pass. These focused runs do not substitute for pending delivery-head CI. No push, rebase, pull request or merge is included in this packet. diff --git a/doc/plans/2026-10-12-test-audit-r7-ui-appdetail-controls.json b/doc/plans/2026-10-12-test-audit-r7-ui-appdetail-controls.json new file mode 100644 index 00000000000..86e3b3a6fac --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-r7-ui-appdetail-controls.json @@ -0,0 +1,103 @@ +{ + "audit": { + "file": "ui/src/pages/apps/AppDetail.test.tsx", + "base": "81cd7ab0ca0bdc55ea0927bc44fcf6e764a530ed", + "originalSha256": "35c4d4828daa80d37b8319fa1dfd4c049c03e26a780c6ce3b1e64990f32be9c6", + "candidateSha256": "f41c56cc4bae6e496067c93b58419ed3acfc6938f7a1ea7169ac2b1fa9ae3e61", + "retired": "removes the separate always-installed controls from Permissions", + "keeper": "renders searchable action groups with three-way permission toggles", + "identicalInitialSetup": true, + "migratedExactAssertions": [ + "expect(container.textContent).toContain(\"Which agents can use this connection?\");", + "expect(container.textContent).not.toContain(\"Always installed\");", + "expect(putConnectionInstallsMock).not.toHaveBeenCalled();" + ], + "allOtherSourceBytesUnchanged": true, + "allOtherProgramAstUnchanged": true, + "newHelpers": 0, + "registeredInvocationsRemoved": 1, + "completeRenderCallsRemoved": 1, + "netTestLinesRemoved": 7 + }, + "verifierSha256": "8252a8caeedbbbc3b6b630376b8207b4f78021b719ac601e061d394535ecea0e", + "runs": [ + { + "kind": "baseline", + "jsonSha256": "74eb47f37400e2409108c4a88bbbd1f0c25d12d0e7f9c8576aad598758472ea3", + "total": 78, + "passed": 78, + "failed": 0, + "skipped": 0 + }, + { + "kind": "candidate", + "jsonSha256": "50a8a2e03f8521f125011f82c0ee816b55e1bd1d751e9d3fd2fe3fd71fad2314", + "total": 77, + "passed": 77, + "failed": 0, + "skipped": 0 + } + ], + "mutations": [ + { + "slug": "appdetail-old-install-copy", + "owner": "ui/src/pages/apps/app-detail/PermissionsPanel.tsx", + "title": "renders searchable action groups with three-way permission toggles", + "sourceSha": "b3b96c2e2fbafee17e0b01c9127c30bd4a0a787f2481a264001fd6c2bce8e379", + "replacements": 1, + "mutation": { + "before": ">Which agents can use this connection?", + "after": ">Which agents can use this connection?Always installed" + }, + "restoredByteForByte": true, + "positive": { + "exit": 0, + "caseStatus": "passed", + "jsonSha256": "a97f5dd88c5febcf4fe906f34ea7efd0672781b95b2757aeb9cbcb0bd9247edf", + "logSha256": "26c7c2d6e1c6edca6e56e0f21ecbe3897c28c3f7ad1300dd07886fe905d0c025" + }, + "negative": { + "exit": 1, + "caseStatus": "failed", + "intendedAssertionFailure": "AssertionError: expected 'GitHubConnected2 actions availableUnv\u2026' not to contain 'Always installed'", + "jsonSha256": "a444c1fbc181dabecbcf67ce1ea7da5659d2bf3cc85a5983fc02fd49418ea66d", + "logSha256": "feef1c4c056f2bba80e2bda438edc27e099da834f11d3c5b1ccaf1e13da73b0d" + } + }, + { + "slug": "appdetail-install-on-mount", + "owner": "ui/src/pages/apps/AppDetail.tsx", + "title": "renders searchable action groups with three-way permission toggles", + "sourceSha": "ee8ab6e844ce11bf5d13954f0a73de3e42de61a933deb01ad9e40cb76e3b93d1", + "replacements": 1, + "mutation": { + "before": " const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;", + "after": " useEffect(() => { void toolsApi.putConnectionInstalls(connectionId, []); }, [connectionId]);\n const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null;" + }, + "restoredByteForByte": true, + "positive": { + "exit": 0, + "caseStatus": "passed", + "jsonSha256": "b87e88f3d6a624d21ce9795889d002fb0bb8a08ad7b27bd5cad6420b03ec6f04", + "logSha256": "0d0d9d88fd6da2c652fb4119fb2ed3cef7c624514e8b0501f3daf62578ec99ac" + }, + "negative": { + "exit": 1, + "caseStatus": "failed", + "intendedAssertionFailure": "AssertionError: expected \"vi.fn()\" to not be called at all, but actually been called 1 times", + "jsonSha256": "7d04f5db02f84793fe53914e4f8cfc98f7644766146861857afcf586809b6d16", + "logSha256": "cc98a64e983162d6330263ea4433cc6b17c4925b3b9806794996dafb9b7acd0c" + } + } + ], + "allProductionOwnersRestored": true, + "productionLinesChanged": 0, + "priorUiProofUnchanged": { + "commit": "81cd7ab0ca0bdc55ea0927bc44fcf6e764a530ed", + "reportSha256": "896b9928af85a4c7d588dfca93484980aadb238bd79d8142e671c62f0b191fa8", + "controlsSha256": "0d445f6f066032f2d9cd724525a8d7d2290246cbfeee1f6ab4b44818467af2a7" + }, + "ciInclusion": "@paperclipai/ui project in general-workspaces-a; complete file remains included; no routing changes", + "netCombinedTestLinesRemoved": 371, + "combinedRegisteredInvocationsRemoved": 2 +} diff --git a/doc/plans/2026-10-12-test-audit-r7-ui-appdetail.md b/doc/plans/2026-10-12-test-audit-r7-ui-appdetail.md new file mode 100644 index 00000000000..91ceee0a2af --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-r7-ui-appdetail.md @@ -0,0 +1,47 @@ +# Test audit, Round 7 UI β€” AppDetail follow-up + +This follow-up removes **one duplicate registered invocation, one complete render, and seven net test lines** from `ui/src/pages/apps/AppDetail.test.tsx`. It adds no helper and changes zero production lines. Combined with the [approved first UI batch](2026-10-12-test-audit-r7-ui.md), the branch removes **371 net test lines and two registered invocations**. The original UI report, controls and test bytes remain unchanged. + +The follow-up audit base is local UI commit `81cd7ab0ca0bdc55ea0927bc44fcf6e764a530ed`. This file has the same original bytes at Round 6 parent `e868e6645ce0d598d756d3458f0b3d71afd6db4a`. + +## Exact retirement and keeper + +Retired: `removes the separate always-installed controls from Permissions`. + +Keeper: `renders searchable action groups with three-way permission toggles`. + +Both cases use the same enclosing beforeEach, connection, catalog, profiles, grants, install state and spies. Their two pre-assertion statements are byte-identical: select the permissions tab, then await the same complete AppDetail render. Neither case changes a fixture or takes a user action before its assertions. Both permission observations were updated by [PR #12802](https://github.com/paperclipai/paperclip/pull/12802). + +All three retired observations move unchanged into the keeper, immediately after render and before its existing assertions: + +- The agent-access question is visible. +- The old Always installed copy is absent. +- The connection-install mutation API has not been called. + +The keeper also retains its original fixed action-group counts, action names, review prompt, search input, selected Allowed/Ask first radio states, two Test actions, old-copy exclusions and card-layout exclusion. Viewer gates, picked-agent mode, required installed agents, access mutations, AI credential routing, task-grant removal and failure paths remain separate cases. + +App.tsx mounts AppDetail at `apps/:connectionId/:tab`. AppDetail's actual permissions path renders AgentAccessSection and ActionsSection in PermissionsPanel. AgentAccessSection supplies the question and access controls. The install API is used by the explicit AI-connection save path, so a no-write-on-read check is a separate observation from absent UI copy. Both fixed guards remain in the keeper. The grouped action-control sibling suite remains unchanged. + +The test stays in the `@paperclipai/ui` project and the `general-workspaces-a` lane. This cut removes a collected test and its full query-client/root render; it changes no suite inclusion or CI routing. No wall-time improvement is claimed. + +## Proof + +| Complete AppDetail file | Registered cases | Passed | Failed | Skipped | +|---|---:|---:|---:|---:| +| Baseline | 78 | 78 | 0 | 0 | +| Candidate | 77 | 77 | 0 | 0 | + +Run from `ui/`: `pnpm exec vitest run src/pages/apps/AppDetail.test.tsx`. + +The body audit checks the two identical setup statements, copies the three exact assertion ASTs, and compares full original/candidate programs outside the declared removal and insertion. It also reconstructs the exact candidate text from only those source edits and requires byte equality. Every other source byte remains unchanged, including all mock factories, beforeEach, render helper, spies, waits, cleanup and original keeper assertions. No withPermissions helper exists in these pinned file bytes; no such setup was added or changed. + +Both positive keeper controls passed. Two deliberately broken production owners failed at the intended migrated guard: + +| Production regression | Direct keeper failure | +|---|---| +| Reintroduce Always installed copy beside the access question in PermissionsPanel | Fixed negative old-copy assertion. | +| Write connection installs from an AppDetail mount effect | Fixed no-install-API-call assertion. | + +Each negative control selected only the keeper. Its unselected cases do not count as complete-file proof. Neither mutation failed on setup, timeout or an unrelated action assertion. Both production owners were restored byte for byte and checked against pinned Git source. + +[Follow-up controls](2026-10-12-test-audit-r7-ui-appdetail-controls.json) retain exact original/candidate hashes, the unchanged prior proof hashes, body-audit digest, complete-file results, mutation descriptions, intended failures, run/log digests and source-restoration receipts. Raw verifiers and logs remain temporary audit packets; their digests do not retain complete payloads. Latest delivery-head replay, typecheck, token gates, build and CI status belong in the PR. The broad local default run was stopped before completion as recorded by the coordinator; it is not a full-suite pass. diff --git a/doc/plans/2026-10-12-test-audit-r7-ui-attachments-controls.json b/doc/plans/2026-10-12-test-audit-r7-ui-attachments-controls.json new file mode 100644 index 00000000000..31b88e75810 --- /dev/null +++ b/doc/plans/2026-10-12-test-audit-r7-ui-attachments-controls.json @@ -0,0 +1,168 @@ +{ + "audit": { + "file": "ui/src/components/IssueAttachmentsSection.test.tsx", + "base": "c39262305b6b48a5decd564af8369083cd972b33", + "originalSha256": "938aead11f9ccd2246049fe7ebe82800c3bdc8ff7c17fc20017d03ecc659d73a", + "candidateSha256": "e7cc76d20708169e8eadba692d6f9bc9e7ba32cb18075e72c8aaceb420705a94", + "allOtherProgramAstUnchanged": true, + "allOtherSourceBytesUnchanged": true, + "newHelpers": 0, + "registeredInvocationsRemoved": 1, + "rootRendersRemoved": 1, + "netTestLinesRemoved": 22, + "migrations": [ + { + "retired": "renders video attachments through the same player used for artifact outputs", + "keeper": "lets video attachments open the shared media gallery", + "setupAstEqual": false, + "setupBindingNormalization": "Inline fresh vi.fn becomes existing keeper fresh named vi.fn; provider/attachment/delete callback/flush unchanged", + "guards": [ + "const video = container.querySelector(\"video\");", + "expect(video?.getAttribute(\"src\")).toBe(\"/api/attachments/video-attachment/content\");", + "expect(video?.getAttribute(\"controls\")).not.toBeNull();", + "expect(fetchSpy).not.toHaveBeenCalled();" + ], + "registeredInvocationsRemoved": 1 + } + ] + }, + "verifierSha256": "0f1b173c8a399bb8c3e831edac6805634304a749faffcfff7b8b1900da3d34a3", + "mutationVerifierSha256": "a05f5ba9071c0ce7ce8eac1bc0f6c7dd83401c4e720816389d779f8919312064", + "runs": [ + { + "kind": "baseline", + "jsonSha256": "41996a845a818368c4fe0a7a4b31474ddc1b35c56c9aff524dcfae3218a1112c", + "logSha256": "cf1b9e033afd854462df31d4d65f4e604f317f6473c1f75c25b91ccf46e3e92c", + "total": 8, + "passed": 8, + "failed": 0, + "skipped": 0 + }, + { + "kind": "candidate", + "jsonSha256": "e9db5edd68d2e89898848aa90dabf75957fb703813c429472f80fbae92d194b4", + "logSha256": "67fe4596626962ac86f547b251f992158d5a28063a2c62962816433c3e837ed2", + "total": 7, + "passed": 7, + "failed": 0, + "skipped": 0 + } + ], + "mutations": [ + { + "slug": "source", + "owner": "ui/src/components/IssueAttachmentsSection.tsx", + "sourceSha256": "2ba7827325ed63d57fad24f31144e54c48c0618584c0cd013b26acf93e7f525b", + "title": "lets video attachments open the shared media gallery", + "mutation": { + "before": "", + "after": "", + "replacementCount": 1, + "additionalPreClosingTagReplacement": false + }, + "intendedGuard": "expect(video?.getAttribute(\"src\")).toBe(", + "restoredByteForByte": true, + "positive": { + "exit": 0, + "jsonSha256": "26bb673ede3119522a715a31928f8066c41c6538e78139e5a1dde1e879964fd5", + "logSha256": "5f31a93058981f8a4a0b9c109b851aad78153f734eb723cc04998fb3ffca555b", + "caseStatus": "passed" + }, + "negative": { + "exit": 1, + "jsonSha256": "41c490f0613aaf4958ad6ccc613dcf31053f2f528b1c7b2a252230470bc7d8ae", + "logSha256": "984bfe91fe92dcb4518504ae6fe21205466e3c775c2dde6806934471d73ab7d5", + "caseStatus": "failed", + "intendedFailureMessages": [ + "AssertionError: expected '/wrong-video.webm' to be '/api/attachments/video-attachment/con\u2026' // Object.is equality\n at /Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/ui/src/components/IssueAttachmentsSection.test.tsx:206:40\n at file:///Users/dotta/.codex/worktrees/test-audit-r7-ui/paperclip/node_modules/.pnpm/vitest@5.0.3_@opentelemetry+api@1.9.1_@types+node@24.13.3_jsdom@30.0.1_@noble+hashes@2.4.0__v_wr5mxfuu6kgge6st623q4v5egm/node_modules/vitest/dist/chunks/run.BTlFXlnv.js:2783:20" + ] + } + }, + { + "slug": "controls", + "owner": "ui/src/components/IssueAttachmentsSection.tsx", + "sourceSha256": "2ba7827325ed63d57fad24f31144e54c48c0618584c0cd013b26acf93e7f525b", + "title": "lets video attachments open the shared media gallery", + "mutation": { + "before": "", + "after": "