diff --git a/packages/paperclip-runner/src/drivers/cursor-cloud/cursor-cloud-driver.test.ts b/packages/paperclip-runner/src/drivers/cursor-cloud/cursor-cloud-driver.test.ts index 532faaf17b6..219f45cd95d 100644 --- a/packages/paperclip-runner/src/drivers/cursor-cloud/cursor-cloud-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/cursor-cloud/cursor-cloud-driver.test.ts @@ -994,21 +994,32 @@ describe("Cursor Cloud native driver", () => { try { const turn = await f.session.startTurn({ message: { role: "user", text: rejection === "unsent_payload" ? "\u0001".repeat(400_000) : "Rejected task" } }); await entered.promise; + const observeHeldCheckpoint = async () => { + const checkpointPath = join(f.root, (await readdir(f.root)).find(name => /^checkpoint-.*\.json$/.test(name))!); + expect(JSON.parse(await readFile(checkpointPath, "utf8")).phase).not.toBe("idle"); + return checkpointPath; + }; + let checkpointPath: string; if (overlap === "stop_write") { stop(); await stopHeld.promise; rejectCreate.resolve(); await new Promise(accept => setImmediate(accept)); + // The Stop checkpoint gate still holds its cancellation clock here. + checkpointPath = await observeHeldCheckpoint(); releaseStop.resolve(); - await waitFor(() => pausedRejection || stopDone); + await Promise.race([rejectionHeld.promise, stopped!]); expect(pausedRejection, "the definite rejection must reach durable settlement even while stopping").toBe(true); } else { rejectCreate.resolve(); await rejectionHeld.promise; + // File observations must not consume Stop's 300ms settlement budget. + checkpointPath = await observeHeldCheckpoint(); stop(); await new Promise(accept => setImmediate(accept)); } expect(stopDone, "Stop must wait for the rejected-create checkpoint").toBe(false); expect(await f.session.read()).toMatchObject({ phase: "stopping" }); await expect(f.session.startTurn({ message: { role: "user", text: "Too early" } })).rejects.toThrow("unresolved remote work"); - const checkpointPath = join(f.root, (await readdir(f.root)).find(name => /^checkpoint-.*\.json$/.test(name))!); - expect(JSON.parse(await readFile(checkpointPath, "utf8")).phase).not.toBe("idle"); + expect(peer.getAgent).toHaveBeenCalledTimes(before.getAgent); + expect(peer.getRun).toHaveBeenCalledTimes(before.getRun); + expect(peer.cancelRun).toHaveBeenCalledTimes(before.cancel); releaseRejection.resolve(); expect(await stopped).toEqual({ error: null }); await waitFor(() => f.events.some(event => event.turnId === turn.turnId && event.eventType === "turn.failed" && event.payload.remoteWorkAccepted === false)); diff --git a/tests/runner-e2e/PRODUCT-JOURNEYS.md b/tests/runner-e2e/PRODUCT-JOURNEYS.md index 42d8c6cde70..9bc8ca779ab 100644 --- a/tests/runner-e2e/PRODUCT-JOURNEYS.md +++ b/tests/runner-e2e/PRODUCT-JOURNEYS.md @@ -33,6 +33,7 @@ never start a server or depend on a tenant filesystem. | `ask-question` | Company, configured agent capability, positive model allowance and exactly one permitted work run | Reuse the existing cross-model Ask question prompt; require one successful run with matching company/task/agent/model/environment, one saved exact answer, and visible readback after reload | | `question-answer-resume` | Native agent; up to three work runs | Reuse the classic two-question story: answer a choice and a free-text question through the browser, verify each native continuation, then read the exact saved answers in an agent-authored task document after reload | | `task-properties` | Company, signed-in board user, task-write capability, prepared run-owned backlog task | Open its properties, assign it to the signed-in user, reload, check both UI and saved assignment; verify no agent run | +| `task-edit-lifecycle` | Company, signed-in board user, task-write capability; target support for run-owned labels and exact predeclared `taskEdit.revisedTitle` cleanup; zero model runs | Create a self-assigned task, edit its inline header title and label, reload, complete and reopen it; independently verify exact saved state, unchanged context and zero runs. Open a closed Properties panel through its shipped toggle | | `task-document-persistence` | Company, signed-in board user, task-write capability, prepared run-owned backlog task; no model allowance or runs | Create a general document through Artifacts, edit its title and body, reopen and reload it; independently verify exact canonical content, preserved original and latest revisions, board-user authorship, and no agent work | | `task-search-filter` | Company, signed-in board user, task-write capability; zero model runs | Search three owned target/control tasks through the task list, require exact matching identities, filter Backlog to exclude the completed control, reload and return from the exact saved task with search/filter context intact | | `plan-revise-accept` | Native agent; three work runs | Reuse the classic Plan story: canonical draft, browser revision request, new revision-bound confirmation, approval and exact saved final response | @@ -488,3 +489,38 @@ within the original journey deadline. Binary attachment and browser downloads remain single attempts; transport failures and digest mismatches fail visibly. Git inspection consumes the exact digest-verified download buffer; browser downloads remain an independent result-access check. The repository journey shares the observation reader and browser helpers above. + + +## Task editing and lifecycle + +`task-edit-lifecycle` creates a self-assigned task through the browser with an +explicit title. It edits the title inline in the task header and adds one +run-owned label through the Properties panel. If the panel starts closed, the +journey opens it through the ordinary Toggle side panel or Show properties +control. It changes the status to In Progress, reloads, marks +the task Done, reloads, reopens it to Todo and reloads again. Independent API +reads bind every observed state to the same company, task and human owner. +The exact description, assignee, project and priority remain unchanged. +Priority editing is not part of this journey because the production UI hides +that control. No agent or model work is allowed. + +Labels use `Journey QA label ${campaignId}`, retaining the complete campaign +identity within the production 48-character limit for ordinary generated +campaigns. The production schema rejects an overlong name before any browser +mutation or ownership intent; names are never truncated or hashed. +The target must journal `label` resources by campaign name before browser label +creation and by exact ID after its 201 response. Before title editing, the owned +issue receipt retains its original name and ID and declares +`taskEdit: { revisedTitle }` for this case. Cleanup may match only that one +predeclared title or the original title on the same company and issue ID. +The private target must reconcile a lost label-creation response, remove only +its proven run-owned label, and verify absence. Targets without these ownership +and cleanup semantics must not select this journey. + +The case uses no private fixture writes. Its limits require zero model calls and +zero task runs. `private-stories/task-edit-lifecycle/task-lifecycle-observations.json` +retains the five allowlisted task observations. The adjacent +`observation-read-failures.json` retains bounded read-transport failures. +Valid, wrong and missing evidence +controls calibrate the independent oracle. Registration and local checks do not +claim a Cloud staging pass or admission to a daily plan. diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index f5c240c0df4..5f08f93b832 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -126,7 +126,29 @@ describe("runner E2E catalog", () => { }); it("exports deployment journeys separately from the model matrix and default selection", () => { - expect(productJourneys.map(journey => journey.id)).toEqual(["company-access-isolation", "repository-continuation", "routine-scheduled-run", "inbox-triage", "file-input-answer", "task-create", "project-task-context", "ask-question", "question-answer-resume", "task-properties", "task-document-persistence", "task-search-filter", "plan-revise-accept", "build-revise", "protected-actions", "create-skill-studio", "stop-redirect", "followup-while-running", "hire-delegate-reuse", "cloud-onboarding"]); + expect(productJourneys.map(journey => journey.id)).toEqual([ + "task-edit-lifecycle", + "company-access-isolation", + "repository-continuation", + "routine-scheduled-run", + "inbox-triage", + "file-input-answer", + "task-create", + "project-task-context", + "ask-question", + "question-answer-resume", + "task-properties", + "task-document-persistence", + "task-search-filter", + "plan-revise-accept", + "build-revise", + "protected-actions", + "create-skill-studio", + "stop-redirect", + "followup-while-running", + "hire-delegate-reuse", + "cloud-onboarding" + ]); expect(runnerSuites.some(suite => suite.id === "product-journeys")).toBe(false); expect(runnerMatrix.some(execution => execution.suite.id === "product-journeys")).toBe(false); expect(selectRunnerExecutions(parseRunnerSelectors(["--all"])).some(execution => execution.suite.id === "product-journeys")).toBe(false); diff --git a/tests/runner-e2e/journey-features.ts b/tests/runner-e2e/journey-features.ts index 962117f2ab1..18cf44b1f33 100644 --- a/tests/runner-e2e/journey-features.ts +++ b/tests/runner-e2e/journey-features.ts @@ -28,6 +28,7 @@ associate(["product-journeys"], { "followup-while-running": ["steering/queue"], "hire-delegate-reuse": ["agents/hire", "documents-artifacts/documents"], "project-task-context": ["projects/lifecycle", "projects/task-context", "tasks/create"], + "task-edit-lifecycle": ["tasks/properties", "tasks/completion"], "task-properties": ["tasks/properties"], "task-document-persistence": ["documents-artifacts/documents"], "task-search-filter": ["tasks/organize"], "cloud-onboarding": ["onboarding/first-task"] }); associate(["antigravity-agent-journey"], { "saved-subscription-two-turn": ["agents/hire", "agents/instructions", "runs-adapters/validation", "runs-adapters/session", diff --git a/tests/runner-e2e/product-journeys/agent-flows.ts b/tests/runner-e2e/product-journeys/agent-flows.ts index 96d0fd8d713..5986c9ca143 100644 --- a/tests/runner-e2e/product-journeys/agent-flows.ts +++ b/tests/runner-e2e/product-journeys/agent-flows.ts @@ -20,7 +20,13 @@ import { agentJourneyRuns } from "./catalog.js"; import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js"; type Row = Record; -type AgentJourney = Exclude; +type AgentJourney = Exclude; interface TaskState { currentIssue: Row & { id: string; status: string }; taskRuns: Array; comments: Row[]; interactions: Array; attachments: StoryDelivery[] } interface Options { deadlineAt: number; directory: string; capture(id: string, label: string, file: string): Promise } const active = (run: Row) => ["queued", "running"].includes(run.status); diff --git a/tests/runner-e2e/product-journeys/catalog.ts b/tests/runner-e2e/product-journeys/catalog.ts index 1e1f2cabf36..7de9ae41786 100644 --- a/tests/runner-e2e/product-journeys/catalog.ts +++ b/tests/runner-e2e/product-journeys/catalog.ts @@ -2,6 +2,7 @@ import { agentJourneyRuns } from "./success-contract.js"; export { agentJourneyRuns, agentJourneyRunCountValid } from "./success-contract.js"; import type { JourneyId, TargetConfig } from "./types.js"; export const productJourneys = [ + { id: "task-edit-lifecycle", label: "Edit task properties, complete and reopen with saved context", requires: ["company", "board-user", "task-write"], capabilities: ["tasks/properties", "tasks/completion"], paid: false }, { id: "company-access-isolation", label: "Switch QA workspaces and verify restricted agent isolation", requires: ["company", "board-user", "task-write", "company-access"], capabilities: ["companies/selection", "access/resources"], paid: false }, { id: "repository-continuation", label: "Continue repository work with the same saved workspace", requires: ["company", "board-user", "task-write", "agent"], capabilities: ["projects/task-context", "documents-artifacts/attachments"], paid: true }, { id: "routine-scheduled-run", label: "Run a scheduled routine once and verify pause and saved history", requires: ["company", "board-user", "task-write", "agent"], capabilities: ["routines/definition", "routines/triggers", "routines/history", "routines/management"], paid: true }, diff --git a/tests/runner-e2e/product-journeys/launch.ts b/tests/runner-e2e/product-journeys/launch.ts index 6482fc9580a..705946b8503 100644 --- a/tests/runner-e2e/product-journeys/launch.ts +++ b/tests/runner-e2e/product-journeys/launch.ts @@ -9,6 +9,7 @@ import { runnerSuites } from "../catalog.js"; import { productJourneys, parseTargetConfig, preparationPreview, isAgentJourney } from "./catalog.js"; import { agentJourneyRunCountValid } from "./success-contract.js"; import { journeyFixtures } from "./fixtures.js"; +import { taskEditLifecycle } from "./task-lifecycle.js"; import { taskProperties } from "./task-properties.js"; import { taskSearchFilter } from "./task-search-filter.js"; import { askQuestion, taskCreate } from "./basic-flows.js"; @@ -113,7 +114,73 @@ export async function launchProductJourneys(args: string[], repositoryRoot: stri try { sha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repositoryRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { /* Packaged harnesses may not include Git metadata. */ } const source = resolveRunnerE2ESource({ sha, ref: null, workflowRunUrl: null }); const definitionHash = createHash("sha256").update(JSON.stringify(productJourneys)); - for (const name of ["observation-read.ts", "../connection-config.ts", "catalog.ts", "company-access-flow.ts", "company-access-contract.ts", "repository-continuation-flow.ts", "repository-continuation-contract.ts", "repository-continuation-evidence.ts", "../registered-artifact.ts", "../user-actions.ts", "../connection-target.ts", "../../../packages/shared/src/project-mentions.ts", "routine-schedule-flow.ts", "routine-schedule-evidence.ts", "input-file-flow.ts", "input-file-evidence.ts", "inbox-triage-flow.ts", "inbox-triage-evidence.ts", "inbox-triage-fixture.ts", "inbox-author-contract.ts", "task-properties.ts", "task-search-filter.ts", "task-search-evidence.ts", "basic-flows.ts", "basic-evidence.ts", "task-document-flow.ts", "task-document-evidence.ts", "project-context-flow.ts", "project-context-readback.ts", "project-context-evidence.ts", "agent-flows.ts", "hire-delegate-flow.ts", "hire-delegate-composer.ts", "hire-delegate-evidence.ts", "active-followup-flow.ts", "active-followup-evidence.ts", "stop-redirect-evidence.ts", "clarification-flow.ts", "clarification-evidence.ts", "../question-resume-ui.ts", "../question-resume-scoring.ts", "../runtime-question-readiness.ts", "../chat-flow.ts", "../continuation-readiness.ts", "../continuation-cases.ts", "success-contract.ts", "product-screenshot.ts", "cloud-onboarding.ts", "onboarding-connect.ts", "onboarding-first-task.ts", "workspace-deferral.ts", "onboarding-pre-acceptance.ts", "artifact-evidence.ts", "fixtures.ts", "launch.ts", "types.ts", "collection.ts", "local-target.ts", "../../../ui/src/lib/markdown-reply-content.ts", "../../../ui/src/lib/issue-reference.ts", "../../../ui/src/lib/company-routes.ts", "../../../ui/package.json"]) definitionHash.update(await readFile(path.join(import.meta.dirname, name))); + for (const name of [ + "task-lifecycle.ts", + "task-lifecycle-evidence.ts", + "../../../packages/shared/src/validators/issue.ts", + "observation-read.ts", + "../connection-config.ts", + "catalog.ts", + "company-access-flow.ts", + "company-access-contract.ts", + "repository-continuation-flow.ts", + "repository-continuation-contract.ts", + "repository-continuation-evidence.ts", + "../registered-artifact.ts", + "../user-actions.ts", + "../connection-target.ts", + "../../../packages/shared/src/project-mentions.ts", + "routine-schedule-flow.ts", + "routine-schedule-evidence.ts", + "input-file-flow.ts", + "input-file-evidence.ts", + "inbox-triage-flow.ts", + "inbox-triage-evidence.ts", + "inbox-triage-fixture.ts", + "inbox-author-contract.ts", + "task-properties.ts", + "task-search-filter.ts", + "task-search-evidence.ts", + "basic-flows.ts", + "basic-evidence.ts", + "task-document-flow.ts", + "task-document-evidence.ts", + "project-context-flow.ts", + "project-context-readback.ts", + "project-context-evidence.ts", + "agent-flows.ts", + "hire-delegate-flow.ts", + "hire-delegate-composer.ts", + "hire-delegate-evidence.ts", + "active-followup-flow.ts", + "active-followup-evidence.ts", + "stop-redirect-evidence.ts", + "clarification-flow.ts", + "clarification-evidence.ts", + "../question-resume-ui.ts", + "../question-resume-scoring.ts", + "../runtime-question-readiness.ts", + "../chat-flow.ts", + "../continuation-readiness.ts", + "../continuation-cases.ts", + "success-contract.ts", + "product-screenshot.ts", + "cloud-onboarding.ts", + "onboarding-connect.ts", + "onboarding-first-task.ts", + "workspace-deferral.ts", + "onboarding-pre-acceptance.ts", + "artifact-evidence.ts", + "fixtures.ts", + "launch.ts", + "types.ts", + "collection.ts", + "local-target.ts", + "../../../ui/src/lib/markdown-reply-content.ts", + "../../../ui/src/lib/issue-reference.ts", + "../../../ui/src/lib/company-routes.ts", + "../../../ui/package.json" + ]) definitionHash.update(await readFile(path.join(import.meta.dirname, name))); for (const file of ["catalog.ts", "api.ts", "run-observations.ts", "native-task-reply.ts", "native-event-envelope.ts", "user-actions.ts", "plan-revision-flow.ts", "skill-studio-flow.ts", "protected-action-flow.ts", "story-artifact.ts", "stop-redirect-flow.ts", "everyday-cases.ts", "everyday-delivery.ts", "chat-hardening.ts", "everyday-decisions.ts", "connection-guidance-evidence.ts", "everyday-artifact.py"]) definitionHash.update(await readFile(path.join(import.meta.dirname, "..", file))); definitionHash.update(runnerSuites.flatMap(suite => suite.tasks).find(task => task.id === "ask-question")!.buildPrompt("definition")); const suiteDefinitionHash = definitionHash.digest("hex"); @@ -234,6 +301,8 @@ export async function launchProductJourneys(args: string[], repositoryRoot: stri } }); } else if (id === "company-access-isolation") await companyAccessIsolation(ready, session!, campaignId, evidence); else if (id === "inbox-triage") await inboxTriage(ready, session!, campaignId, evidence); + else if (id === "task-edit-lifecycle") await taskEditLifecycle(ready, session!, campaignId, evidence, + { deadlineAt, directory: path.join(outputDir, "private-stories", id) }); else if (id === "task-create") await taskCreate(ready, session!, campaignId, evidence); else if (id === "task-document-persistence") await taskDocumentPersistence(ready, session!, campaignId, evidence); else if (id === "project-task-context") await projectTaskContext(ready, session!, campaignId, evidence); diff --git a/tests/runner-e2e/product-journeys/success-contract.ts b/tests/runner-e2e/product-journeys/success-contract.ts index 32c2eca9b47..91b14865b2e 100644 --- a/tests/runner-e2e/product-journeys/success-contract.ts +++ b/tests/runner-e2e/product-journeys/success-contract.ts @@ -1,6 +1,18 @@ /** Closed, target-independent success requirements used by dispatch and retained evidence. */ -export const agentJourneyRuns = { "repository-continuation": 2, "routine-scheduled-run": 1, "file-input-answer": 1, "ask-question": 1, "plan-revise-accept": 3, "build-revise": 2, - "followup-while-running": 2, "protected-actions": 4, "create-skill-studio": 1, "stop-redirect": 3, "question-answer-resume": 3, "hire-delegate-reuse": 7 } as const; +export const agentJourneyRuns = { + "repository-continuation": 2, + "routine-scheduled-run": 1, + "file-input-answer": 1, + "ask-question": 1, + "plan-revise-accept": 3, + "build-revise": 2, + "followup-while-running": 2, + "protected-actions": 4, + "create-skill-studio": 1, + "stop-redirect": 3, + "question-answer-resume": 3, + "hire-delegate-reuse": 7 +} as const; export function agentJourneyRunCountValid(id: string, count: unknown): boolean { if (!Object.hasOwn(agentJourneyRuns, id) || !Number.isSafeInteger(count)) return false; @@ -9,6 +21,7 @@ export function agentJourneyRunCountValid(id: string, count: unknown): boolean { return (count as number) >= minimum && (count as number) <= maximum; } export const journeyRequiredChecks: Record = { + "task-edit-lifecycle": ["task-lifecycle-browser-created", "task-lifecycle-visible-edits-persisted", "task-lifecycle-edits-reloaded", "task-lifecycle-completed-and-reopened", "task-lifecycle-unedited-context-preserved", "no-agent-work"], "company-access-isolation": ["isolation-owner-resource-controls", "cloud-workspace-switch-and-return", "workspace-data-scoped", "restricted-agent-positive-controls", "cross-workspace-agent-read-write-denied", "isolation-credential-revoked", "no-agent-work"], "repository-continuation": ["repository-configured-source-bound", "repository-first-commit-verified", "repository-original-files-saved", "repository-followup-stored-once", "repository-same-workspace-retained", "repository-second-commit-verified", "repository-original-and-result-reopened", "configured-model-and-environment"], "routine-scheduled-run": ["routine-browser-created", "routine-scheduler-created-execution", "routine-result-attributed", "routine-history-preserved", "routine-pause-prevented-next-run", "routine-result-reopened", "configured-model-and-environment"], diff --git a/tests/runner-e2e/product-journeys/task-lifecycle-evidence.test.ts b/tests/runner-e2e/product-journeys/task-lifecycle-evidence.test.ts new file mode 100644 index 00000000000..ab1cfd3176f --- /dev/null +++ b/tests/runner-e2e/product-journeys/task-lifecycle-evidence.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; +import { createIssueLabelSchema } from "../../../packages/shared/src/validators/issue.js"; +import { taskLifecycleEvidence, taskLifecycleLabelName, type LifecycleObservation, type LifecycleTask } from "./task-lifecycle-evidence.js"; +import { parseTargetConfig, preparationPreview } from "./catalog.js"; +import { journeyRequiredChecks } from "./success-contract.js"; +import { validateRetainedRunnerResult } from "../result-validation.js"; +import type { RunnerE2EResult } from "../types.js"; + +function fixture(): Parameters[0] { + const expected = { taskId: "task-1", companyId: "company-1", userId: "user-1", originalTitle: "Original task", + revisedTitle: "Revised task", description: "Exact persisted context", labelName: "Owned label" }; + const initial: LifecycleTask = { id: expected.taskId, companyId: expected.companyId, identifier: "QA-1", title: expected.originalTitle, + description: expected.description, status: "todo", priority: "medium", projectId: "project-1", assigneeUserId: expected.userId, + assigneeAgentId: null, createdByUserId: expected.userId, createdByAgentId: null, labelIds: [], completedAt: null }; + const observe = (task: LifecycleTask, labelVisible: boolean): LifecycleObservation => ({ task: structuredClone(task), runCount: 0, + titleVisible: true, descriptionVisible: true, statusVisible: true, labelVisible }); + const edited = { ...initial, title: expected.revisedTitle, status: "in_progress", labelIds: ["label-1"] }; + return { expected, label: { id: "label-1", name: expected.labelName, companyId: expected.companyId, color: "#0088ff" }, + created: observe(initial, false), edited: observe(edited, true), reloaded: observe(edited, true), + completed: observe({ ...edited, status: "done", completedAt: "2026-01-01T00:00:00.000Z" }, true), + reopened: observe({ ...edited, status: "todo" }, true) }; +} +const stages = ["created", "edited", "reloaded", "completed", "reopened"] as const; + +describe("task lifecycle saved-state oracle", () => { + it("keeps a full generated campaign identity within the production label-name limit", () => { + const campaigns = ["journey-1793720364049-f3055878", "journey-9999999999999-ffffffff"]; + const labels = campaigns.map(taskLifecycleLabelName); + for (const [index, name] of labels.entries()) { + expect(name).toContain(campaigns[index]); + expect(createIssueLabelSchema.shape.name.safeParse(name).success).toBe(true); + } + expect(new Set(labels).size).toBe(campaigns.length); + }); + it("rejects an overlong campaign before it can produce a browser label", () => { + expect(() => taskLifecycleLabelName("c".repeat(32))).toThrow("task_lifecycle_campaign_label_name_invalid"); + }); + it("accepts the complete browser-edit/reload/complete/reopen sequence", () => { + expect(taskLifecycleEvidence(fixture())).toEqual({ created: true, edited: true, reloaded: true, + completedAndReopened: true, uneditedFieldsPreserved: true, noAgentWork: true }); + }); + it("accepts a fresh backlog task and an absent project without changing either contract", () => { + const input = fixture(); input.created.task!.status = "backlog"; + for (const stage of stages) input[stage].task!.projectId = null; + expect(Object.values(taskLifecycleEvidence(input)).every(Boolean)).toBe(true); + }); + it.each(stages)("rejects a missing saved task at %s", stage => { + const input = fixture(); input[stage].task = null; + const result = taskLifecycleEvidence(input); + expect(result.uneditedFieldsPreserved).toBe(false); + expect(Object.values(result).every(Boolean)).toBe(false); + }); + it.each(["id", "companyId", "assigneeUserId", "assigneeAgentId", "createdByUserId", "createdByAgentId", "description"] as const)( + "rejects a wrong owner or saved context field: %s", key => { + const input = fixture(); Object.assign(input.reopened.task!, { [key]: "foreign" }); + expect(taskLifecycleEvidence(input).completedAndReopened).toBe(false); + expect(taskLifecycleEvidence(input).uneditedFieldsPreserved).toBe(false); + }); + it.each(["identifier", "priority", "projectId"] as const)("requires %s to survive every edit", key => { + for (const stage of stages.slice(1)) { + const input = fixture(); Object.assign(input[stage].task!, { [key]: "changed" }); + expect(taskLifecycleEvidence(input).uneditedFieldsPreserved).toBe(false); + } + }); + it.each(["edited", "reloaded", "completed", "reopened"] as const)("rejects a lost title or label at %s", stage => { + for (const patch of [{ title: "Original task" }, { labelIds: [] }, { labelIds: ["foreign"] }, { labelIds: ["label-1", "label-1"] }]) { + const input = fixture(); Object.assign(input[stage].task!, patch); + expect(Object.values(taskLifecycleEvidence(input)).every(Boolean)).toBe(false); + } + }); + it.each(["titleVisible", "descriptionVisible", "statusVisible", "labelVisible"] as const)("requires visible reloaded %s", key => { + for (const stage of ["reloaded", "completed", "reopened"] as const) { + const input = fixture(); input[stage][key] = false; + expect(Object.values(taskLifecycleEvidence(input)).every(Boolean)).toBe(false); + } + }); + it("does not accept a completed-looking UI without saved Done or completion time", () => { + const wrongStatus = fixture(); wrongStatus.completed.task!.status = "in_review"; + expect(taskLifecycleEvidence(wrongStatus).completedAndReopened).toBe(false); + for (const completedAt of [null, "", "not a timestamp"]) { + const input = fixture(); input.completed.task!.completedAt = completedAt; + expect(taskLifecycleEvidence(input).completedAndReopened).toBe(false); + } + }); + it("requires reopened Todo with its completion time cleared", () => { + for (const patch of [{ status: "done" }, { completedAt: "2026-01-01T00:00:00.000Z" }]) { + const input = fixture(); Object.assign(input.reopened.task!, patch); + expect(taskLifecycleEvidence(input).completedAndReopened).toBe(false); + } + }); + it.each(stages)("requires an explicit zero-run observation at %s", stage => { + for (const runCount of [null, 1, -1]) { + const input = fixture(); input[stage].runCount = runCount; + expect(taskLifecycleEvidence(input).noAgentWork).toBe(false); + } + }); + it("rejects stale starting data, a wrong label and an unchanged expected title", () => { + for (const change of [ + (input: ReturnType) => { input.created.task!.labelIds = ["existing"]; }, + (input: ReturnType) => { input.created.task!.status = "done"; }, + (input: ReturnType) => { input.label = null; }, + (input: ReturnType) => { input.label!.companyId = "foreign"; }, + (input: ReturnType) => { input.label!.name = "Other label"; }, + (input: ReturnType) => { input.label!.color = "invalid"; }, + (input: ReturnType) => { input.expected.revisedTitle = input.expected.originalTitle; }, + ]) { + const input = fixture(); change(input); + expect(Object.values(taskLifecycleEvidence(input)).every(Boolean)).toBe(false); + } + }); +}); + +function result(): RunnerE2EResult { + return { schema: "paperclip.runner-e2e.result/v2", suiteId: "product-journeys", suiteDefinitionHash: "test", + executionId: "product-journeys.qa.browser.daytona.task-edit-lifecycle", profileId: "browser", environmentId: "daytona", + caseId: "task-edit-lifecycle", provider: null, model: null, runtimeMode: "none", status: "passed", cleanup: "passed", + attempt: 1, startedAt: "2026-01-01T00:00:00Z", finishedAt: "2026-01-01T00:00:01Z", durationMs: 1000, + productJourney: { outcome: "passed", startingState: "recipe", phases: [{ phase: "journey", outcome: "passed" }], + checks: journeyRequiredChecks["task-edit-lifecycle"]!.map(id => ({ id, passed: true })), privacy: "private", resourceDisposition: "released", + deployment: { kind: "cloud", targetId: "qa", origin: "https://qa.example.test", commit: "a".repeat(40), + cloudRelease: "test", runtimeImage: null, configurationHash: "test" } } }; +} + +describe("task lifecycle discovery and report contract", () => { + it("previews a browser-only existing target with no provider allowance", () => { + const config = parseTargetConfig({ version: 1, id: "qa", mode: "existing-cloud", adapterModule: "/private/adapter.mjs", + journeys: ["task-edit-lifecycle"], executionEnvironment: "daytona", baseline: { features: {}, connections: [] }, + limits: { durationMs: 180000, maxRuns: 0, modelBudgetCents: 0 } }); + expect(preparationPreview(config).journeys).toMatchObject([{ id: "task-edit-lifecycle", paid: false }]); + expect(config.limits.maxRuns).toBe(0); + }); + it("rejects every missing required check and unexpected native work", () => { + expect(() => validateRetainedRunnerResult(result())).not.toThrow(); + for (const id of journeyRequiredChecks["task-edit-lifecycle"]!) { + const missing = result(); missing.productJourney!.checks = missing.productJourney!.checks.filter(check => check.id !== id); + expect(() => validateRetainedRunnerResult(missing)).toThrow(); + } + for (const patch of [{ provider: "openai" }, { model: "wrong" }, { runtimeMode: "native" }, { runIds: ["unexpected"] }]) { + expect(() => validateRetainedRunnerResult({ ...result(), ...patch })).toThrow(); + } + const usage = result(); usage.usage = { runs: [{ runId: "unexpected", usage: { inputTokens: 1 } }] }; + expect(() => validateRetainedRunnerResult(usage)).toThrow("result.browserOnly"); + const emptyUsage = result(); emptyUsage.usage = { runs: [] }; + expect(() => validateRetainedRunnerResult(emptyUsage)).not.toThrow(); + }); +}); diff --git a/tests/runner-e2e/product-journeys/task-lifecycle-evidence.ts b/tests/runner-e2e/product-journeys/task-lifecycle-evidence.ts new file mode 100644 index 00000000000..a20b1bab0d4 --- /dev/null +++ b/tests/runner-e2e/product-journeys/task-lifecycle-evidence.ts @@ -0,0 +1,98 @@ +import { createIssueLabelSchema } from "../../../packages/shared/src/validators/issue.js"; +import { JourneyError } from "./types.js"; + +export function taskLifecycleLabelName(campaignId: string): string { + const name = `Journey QA label ${campaignId}`; + if (!createIssueLabelSchema.shape.name.safeParse(name).success) { + throw new JourneyError("unsupported", "task_lifecycle_campaign_label_name_invalid"); + } + return name; +} + +export interface LifecycleTask { + id: string; + companyId: string; + identifier: string; + title: string; + description: string; + status: string; + priority: string; + projectId: string | null; + assigneeUserId: string; + assigneeAgentId: null; + createdByUserId: string; + createdByAgentId: null; + labelIds: string[]; + completedAt: string | null; +} +export interface LifecycleLabel { id: string; companyId: string; name: string; color: string } +export interface LifecycleObservation { + task: LifecycleTask | null; + runCount: number | null; + titleVisible: boolean; + descriptionVisible: boolean; + statusVisible: boolean; + labelVisible: boolean; +} +export interface LifecycleExpectation { + taskId: string; + companyId: string; + userId: string; + originalTitle: string; + revisedTitle: string; + description: string; + labelName: string; +} + +function ownedTask(task: LifecycleTask | null, expected: LifecycleExpectation) { + return Boolean(task && task.id === expected.taskId && task.companyId === expected.companyId && task.identifier + && task.description === expected.description && task.assigneeUserId === expected.userId && task.assigneeAgentId === null + && task.createdByUserId === expected.userId && task.createdByAgentId === null); +} +function singleton(ids: unknown, id: string) { return Array.isArray(ids) && ids.length === 1 && ids[0] === id; } +function sameUneditedFields(task: LifecycleTask | null, initial: LifecycleTask | null) { + return Boolean(task && initial && task.identifier === initial.identifier && task.priority === initial.priority && task.projectId === initial.projectId); +} + +/** Independent saved states accompany visible UI readback. Empty or missing observations cannot pass. */ +export function taskLifecycleEvidence(input: { + expected: LifecycleExpectation; + label: LifecycleLabel | null; + created: LifecycleObservation; + edited: LifecycleObservation; + reloaded: LifecycleObservation; + completed: LifecycleObservation; + reopened: LifecycleObservation; +}) { + const { expected, label, created, edited, reloaded, completed, reopened } = input; + const validExpected = Boolean(expected.taskId && expected.companyId && expected.userId && expected.originalTitle + && expected.revisedTitle && expected.originalTitle !== expected.revisedTitle && expected.description && expected.labelName); + const fresh = validExpected && ownedTask(created.task, expected) && created.task!.title === expected.originalTitle + && ["backlog", "todo"].includes(created.task!.status) && created.task!.completedAt === null + && ["critical", "high", "medium", "low"].includes(created.task!.priority) + && (created.task!.projectId === null || typeof created.task!.projectId === "string" && created.task!.projectId.length > 0) + && Array.isArray(created.task!.labelIds) && created.task!.labelIds.length === 0 + && created.titleVisible && created.descriptionVisible && created.statusVisible; + const labelValid = Boolean(label && label.id && label.companyId === expected.companyId && label.name === expected.labelName + && /^#[a-fA-F0-9]{6}$/.test(label.color)); + const saved = (observation: LifecycleObservation, status: string) => fresh && labelValid && ownedTask(observation.task, expected) + && sameUneditedFields(observation.task, created.task) && observation.task!.title === expected.revisedTitle + && observation.task!.status === status && singleton(observation.task!.labelIds, label!.id); + const shown = (observation: LifecycleObservation) => observation.titleVisible && observation.descriptionVisible + && observation.statusVisible && observation.labelVisible; + const editedMatches = saved(edited, "in_progress") && shown(edited) && edited.task!.completedAt === null; + const reloadedMatches = saved(reloaded, "in_progress") && shown(reloaded) && reloaded.task!.completedAt === null; + const completedMatches = saved(completed, "done") && shown(completed) + && typeof completed.task!.completedAt === "string" && Number.isFinite(Date.parse(completed.task!.completedAt)); + const reopenedMatches = saved(reopened, "todo") && shown(reopened) && reopened.task!.completedAt === null; + const observations = [created, edited, reloaded, completed, reopened]; + return { + created: fresh, + edited: editedMatches, + reloaded: reloadedMatches, + completedAndReopened: completedMatches && reopenedMatches, + uneditedFieldsPreserved: fresh && observations.every(observation => ownedTask(observation.task, expected) + && sameUneditedFields(observation.task, created.task)), + noAgentWork: fresh && observations.every(observation => observation.runCount === 0), + }; +} diff --git a/tests/runner-e2e/product-journeys/task-lifecycle.ts b/tests/runner-e2e/product-journeys/task-lifecycle.ts new file mode 100644 index 00000000000..b2086f890cb --- /dev/null +++ b/tests/runner-e2e/product-journeys/task-lifecycle.ts @@ -0,0 +1,173 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { expect } from "@playwright/test"; +import { createTaskThroughUi, withBrowserEvent } from "../user-actions.js"; +import { createObservedReader } from "./observation-read.js"; +import { taskLifecycleEvidence, taskLifecycleLabelName, type LifecycleExpectation, type LifecycleLabel, type LifecycleObservation, type LifecycleTask } from "./task-lifecycle-evidence.js"; +import { JourneyError, type JourneyContext, type JourneyEvidence, type TargetSession } from "./types.js"; + +type Stage = "create" | "initial_read" | "rename" | "label" | "in_progress" | "reload" | "complete" | "reopen"; +function taskSnapshot(task: LifecycleTask): LifecycleTask { + return { id: task.id, companyId: task.companyId, identifier: task.identifier, title: task.title, + description: task.description, status: task.status, priority: task.priority, projectId: task.projectId, + assigneeUserId: task.assigneeUserId, assigneeAgentId: task.assigneeAgentId, createdByUserId: task.createdByUserId, + createdByAgentId: task.createdByAgentId, labelIds: task.labelIds, completedAt: task.completedAt }; +} +const emptyObservation = (): LifecycleObservation => ({ task: null, runCount: null, + titleVisible: false, descriptionVisible: false, statusVisible: false, labelVisible: false }); + +async function properties(context: JourneyContext) { + const { page } = context; + // Closed panes keep their rows mounted. Their geometry alone does not make + // an inert or accessibility-hidden panel available for interaction. + const row = page.locator('[data-property-row]:not([inert] *):not([aria-hidden="true"] *)') + .filter({ has: page.locator('[data-property-label="Labels"]') }).filter({ visible: true }); + const toggle = page.getByRole("button", { name: "Toggle side panel", exact: true, pressed: false }).filter({ visible: true }); + const propertiesButton = page.getByRole("button", { name: /^(Show properties|Properties)$/ }).filter({ visible: true }); + await expect.poll(async () => await row.isVisible() || await toggle.isVisible() || await propertiesButton.isVisible()).toBe(true); + if (!await row.isVisible()) { + if (await toggle.isVisible()) await toggle.click(); + else await propertiesButton.click(); + } + await expect(row).toBeVisible(); + await row.scrollIntoViewIfNeeded(); + await expect(row).toBeInViewport(); + return row; +} + +async function setStatus(context: JourneyContext, label: string) { + const { page } = context; + await page.getByTestId("issue-detail-header").filter({ visible: true }) + .getByRole("button", { name: /^Change status \(current:/ }).click(); + // The shipped picker includes both a labeled status glyph and visible text. + // Bind the choice to its exact accessible glyph inside the open popover. + await page.locator('[data-slot="popover-content"]').filter({ visible: true }) + .getByRole("button").filter({ has: page.getByRole("img", { name: label, exact: true }) }).click(); +} + +/** All measured edits use the shipped browser. Independent GETs verify saved state. */ +export async function taskEditLifecycle(context: JourneyContext, session: TargetSession, campaignId: string, evidence: JourneyEvidence, + options: { deadlineAt: number; directory: string }) { + const { page, api, company, user } = context; + const read = createObservedReader(api, options.deadlineAt); + const journeyId = "task-edit-lifecycle" as const; + const originalTitle = `Journey QA task lifecycle ${campaignId}`; + const revisedTitle = `Journey QA task lifecycle revised ${campaignId}`; + const description = `Keep this task assigned to me. Saved task context ${campaignId} must survive every edit.`; + const labelName = taskLifecycleLabelName(campaignId); + const observed = { created: emptyObservation(), edited: emptyObservation(), reloaded: emptyObservation(), + completed: emptyObservation(), reopened: emptyObservation() }; + let expected: LifecycleExpectation | undefined; + let label: LifecycleLabel | null = null; + let stage: Stage = "create"; + const check = (id: string, passed: boolean) => { + evidence.checks.push({ id, passed }); + if (!passed) throw new JourneyError("failed", id.replaceAll("-", "_")); + }; + const preserve = async () => { + await mkdir(options.directory, { recursive: true, mode: 0o700 }); + await writeFile(path.join(options.directory, "task-lifecycle-observations.json"), + JSON.stringify({ schema: "paperclip.task-lifecycle-observations/v1", stage, expected, label, ...observed }, null, 2), { mode: 0o600 }); + }; + const grade = () => taskLifecycleEvidence({ expected: expected!, label, ...observed }); + try { + for (const capability of ["company", "board-user", "task-write"]) { + if (!context.capabilities.includes(capability)) throw new JourneyError("unsupported", `missing_${capability}`); + } + if (!campaignId) throw new JourneyError("blocked", "task_lifecycle_campaign_required"); + const created = await createTaskThroughUi({ page, origin: api.origin, issuePrefix: company.issuePrefix, + agentName: "Me", title: originalTitle, prompt: description, workMode: "standard", requireExplicitTitle: true, + beforeDispatch: () => session.own({ kind: "issue", name: originalTitle, journeyId }) }); + await session.own({ kind: "issue", name: originalTitle, id: created.issueId, journeyId }); + const route = `/api/issues/${created.issueId}`; + expected = { taskId: created.issueId, companyId: company.id, userId: user.id, originalTitle, revisedTitle, description, labelName }; + const task = await read.get(route); + await page.goto(`${api.origin}/${company.issuePrefix}/issues/${task.identifier}`); + const capture = async (title: string, statusLabel: string, withLabel: boolean): Promise => { + await expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible(); + await expect(page.getByText(description, { exact: true }).first()).toBeVisible(); + const status = page.getByRole("button", { name: `Change status (current: ${statusLabel})`, exact: true }).filter({ visible: true }).first(); + await expect(status).toBeVisible(); + const labelRow = await properties(context); + if (withLabel) await expect(labelRow.getByText(labelName, { exact: true })).toBeVisible(); + const saved = await read.get(route); + const runs = await read.get(`${route}/runs`); + return { task: structuredClone(taskSnapshot(saved)), runCount: Array.isArray(runs) ? runs.length : null, + titleVisible: await page.getByRole("heading", { name: title, exact: true }).isVisible(), + descriptionVisible: await page.getByText(description, { exact: true }).first().isVisible(), + statusVisible: await status.isVisible(), labelVisible: withLabel && await labelRow.getByText(labelName, { exact: true }).isVisible() }; + }; + stage = "initial_read"; + const initialStatus = task.status === "backlog" ? "Backlog" : "Todo"; + observed.created = await capture(originalTitle, initialStatus, false); + await preserve(); + check("task-lifecycle-browser-created", grade().created); + stage = "rename"; + // Keep the creation receipt stable. Cleanup may accept this one exact, + // predeclared title if the browser save commits but its response is lost. + await session.own({ kind: "issue", name: originalTitle, id: created.issueId, journeyId, taskEdit: { revisedTitle } }); + await page.getByRole("heading", { name: originalTitle, exact: true }).click(); + const titleEditor = page.getByTestId("issue-detail-header").locator("textarea").first(); + await titleEditor.fill(revisedTitle); + await titleEditor.press("Enter"); + await expect.poll(async () => (await read.get(route)).title).toBe(revisedTitle); + stage = "label"; + const labelsRoute = `/api/companies/${company.id}/labels`; + const beforeLabels = await read.get(labelsRoute); + if (beforeLabels.some(row => row.name === labelName)) throw new JourneyError("failed", "task_lifecycle_label_not_fresh"); + const labels = await properties(context); + await labels.locator("[data-property-value]").getByRole("button").first().click(); + await page.getByPlaceholder("New label", { exact: true }).fill(labelName); + await session.own({ kind: "label", name: labelName, journeyId }); + const response = await withBrowserEvent(() => page.waitForResponse(response => response.request().method() === "POST" + && new URL(response.url()).pathname === labelsRoute), + () => page.getByRole("button", { name: "Create label", exact: true }).click()); + if (response.status() !== 201) throw new JourneyError("failed", "task_lifecycle_label_create_failed"); + const createdLabel = await response.json() as LifecycleLabel; + if (typeof createdLabel.id !== "string" || !createdLabel.id || createdLabel.name !== labelName || createdLabel.companyId !== company.id) { + throw new JourneyError("failed", "task_lifecycle_label_receipt_mismatch"); + } + await session.own({ kind: "label", name: labelName, id: createdLabel.id, journeyId }); + label = { id: createdLabel.id, companyId: createdLabel.companyId, name: createdLabel.name, color: createdLabel.color }; + await page.keyboard.press("Escape"); + const savedLabels = await read.get(labelsRoute); + if (savedLabels.filter(row => row.name === labelName).length !== 1 + || !savedLabels.some(row => row.id === label!.id && row.name === labelName && row.companyId === company.id && row.color === label!.color)) { + throw new JourneyError("failed", "task_lifecycle_label_saved_mismatch"); + } + stage = "in_progress"; + await setStatus(context, "In Progress"); + await expect.poll(async () => (await read.get(route)).status).toBe("in_progress"); + await expect.poll(async () => { + const task = await read.get(route); + return Array.isArray(task.labelIds) && task.labelIds.length === 1 && task.labelIds[0] === label!.id; + }).toBe(true); + observed.edited = await capture(revisedTitle, "In Progress", true); + await preserve(); + check("task-lifecycle-visible-edits-persisted", grade().edited); + stage = "reload"; + await page.reload(); + observed.reloaded = await capture(revisedTitle, "In Progress", true); + await preserve(); + check("task-lifecycle-edits-reloaded", grade().reloaded); + stage = "complete"; + await setStatus(context, "Done"); + await expect.poll(async () => (await read.get(route)).status).toBe("done"); + await page.reload(); + observed.completed = await capture(revisedTitle, "Done", true); + await preserve(); + stage = "reopen"; + await setStatus(context, "Todo"); + await expect.poll(async () => (await read.get(route)).status).toBe("todo"); + await page.reload(); + observed.reopened = await capture(revisedTitle, "Todo", true); + await preserve(); + const final = grade(); + check("task-lifecycle-completed-and-reopened", final.completedAndReopened); + check("task-lifecycle-unedited-context-preserved", final.uneditedFieldsPreserved); + check("no-agent-work", final.noAgentWork); + } catch (error) { + if (error instanceof JourneyError) throw error; + throw new JourneyError("failed", `task_lifecycle_${stage}_failed`); + } finally { await preserve(); await read.preserve(options.directory); } +} diff --git a/tests/runner-e2e/product-journeys/types.ts b/tests/runner-e2e/product-journeys/types.ts index 4c235990f6c..34dbf8ff913 100644 --- a/tests/runner-e2e/product-journeys/types.ts +++ b/tests/runner-e2e/product-journeys/types.ts @@ -2,8 +2,28 @@ import type { BrowserContext, Page } from "@playwright/test"; import type { ConnectionApi } from "../connection-target.js"; export type JourneyOutcome = "passed" | "failed" | "blocked" | "unsupported"; -export type JourneyId = "task-properties" | "task-create" | "project-task-context" | "task-document-persistence" | "ask-question" | "cloud-onboarding" - | "question-answer-resume" | "task-search-filter" | "followup-while-running" | "plan-revise-accept" | "build-revise" | "protected-actions" | "create-skill-studio" | "stop-redirect" | "hire-delegate-reuse" | "file-input-answer" | "inbox-triage" | "routine-scheduled-run" | "repository-continuation" | "company-access-isolation"; +export type JourneyId = + "task-edit-lifecycle" + | "task-properties" + | "task-create" + | "project-task-context" + | "task-document-persistence" + | "ask-question" + | "cloud-onboarding" + | "question-answer-resume" + | "task-search-filter" + | "followup-while-running" + | "plan-revise-accept" + | "build-revise" + | "protected-actions" + | "create-skill-studio" + | "stop-redirect" + | "hire-delegate-reuse" + | "file-input-answer" + | "inbox-triage" + | "routine-scheduled-run" + | "repository-continuation" + | "company-access-isolation"; export type TargetMode = "managed-self-hosted" | "existing-cloud" | "new-cloud"; export interface DeploymentIdentity { kind: "self-hosted" | "cloud"; @@ -113,7 +133,8 @@ export interface TargetSession { /** Publish the observed finalization failure without releasing resources or leases. */ checkpointEvidence?(input?: TargetLifecycleInput): Promise; /** Durable intent before creation; receipt immediately after creation. */ - own(resource: { kind: "issue" | "project" | "agent" | "connection" | "tool-connection" | "skill" | "routine"; name: string; id?: string; journeyId?: JourneyId; + own(resource: { kind: "issue" | "project" | "agent" | "connection" | "tool-connection" | "skill" | "routine" | "label"; name: string; id?: string; journeyId?: JourneyId; + taskEdit?: { revisedTitle: string }; freshChat?: { companyId: string; userId: string; leadAgentId: string; absent: true } }): Promise; } export interface TargetProvider { diff --git a/tests/runner-e2e/result-validation.ts b/tests/runner-e2e/result-validation.ts index 419e37a934d..9906e0d4869 100644 --- a/tests/runner-e2e/result-validation.ts +++ b/tests/runner-e2e/result-validation.ts @@ -434,8 +434,8 @@ export function validateRetainedRunnerResult( if (!evidence) return invalid("result.productJourney"); const required = journeyRequiredChecks[checked.caseId]; if (evidence.outcome !== "passed" || !evidence.deployment || !required || !required.every(id => evidence.checks.some(c => c.id === id && c.passed)) || evidence.checks.some(check => !check.passed) || evidence.phases.some(p => p.outcome !== "passed") || checked.cleanup !== "passed") invalid("result.productJourney"); - if (["inbox-triage", "task-properties", "task-create", "project-task-context", "task-document-persistence", "task-search-filter", "company-access-isolation"].includes(checked.caseId) && (checked.runtimeMode !== "none" || checked.provider !== null || checked.model !== null || checked.runIds?.length)) invalid("result.browserOnly"); - if (checked.caseId === "company-access-isolation" && checked.usage != null && Object.entries(checked.usage).some(([key, value]) => key !== "runs" || !Array.isArray(value) || value.length > 0)) invalid("result.browserOnly"); + if (["task-edit-lifecycle", "inbox-triage", "task-properties", "task-create", "project-task-context", "task-document-persistence", "task-search-filter", "company-access-isolation"].includes(checked.caseId) && (checked.runtimeMode !== "none" || checked.provider !== null || checked.model !== null || checked.runIds?.length)) invalid("result.browserOnly"); + if (["company-access-isolation", "task-edit-lifecycle"].includes(checked.caseId) && checked.usage != null && Object.entries(checked.usage).some(([key, value]) => key !== "runs" || !Array.isArray(value) || value.length > 0)) invalid("result.browserOnly"); if (Object.hasOwn(agentJourneyRuns, checked.caseId) && (!agentJourneyRunCountValid(checked.caseId, checked.runIds?.length) || !checked.model || !checked.provider || checked.runtimeMode !== "native")) invalid("result.agentJourneyRun"); if (checked.caseId === "cloud-onboarding" && (!checked.runIds?.length || !evidence.operations || !evidence.operations.requestCount || !evidence.operations.traceIds.length || evidence.operations.campaignId !== evidence.campaignId || evidence.operations.attemptId !== evidence.attemptId)) invalid("result.paidJourneyReceipt");