diff --git a/apps/web/e2e/channel.spec.ts b/apps/web/e2e/channel.spec.ts index 597db7fc9..baba3e07a 100644 --- a/apps/web/e2e/channel.spec.ts +++ b/apps/web/e2e/channel.spec.ts @@ -1319,7 +1319,8 @@ test("channel environment surface shows read-only context and opens review", asy }; }); - await page.getByLabel("Workspace task toolbar").getByRole("button", { name: "Summary" }).click(); + const summaryButton = page.getByLabel("Workspace task toolbar").getByRole("button", { name: "Summary" }); + await summaryButton.click(); const popover = page.locator(".channel-progress-popover"); await expect(popover).toBeVisible(); await expect.poll(async () => Math.round((await popover.boundingBox())?.width ?? 0)).toBeGreaterThanOrEqual(270); @@ -1341,7 +1342,7 @@ test("channel environment surface shows read-only context and opens review", asy await expect(quickEnvironmentPage).toContainText("ling/channel-environment"); await page.getByRole("button", { name: "Back to channel" }).click(); await expect(quickEnvironmentPage).toHaveCount(0); - await page.getByLabel("Workspace task toolbar").getByRole("button", { name: "Summary" }).click(); + await summaryButton.click(); await expect(popover).toBeVisible(); const dock = page.locator(".channel-side-panel"); @@ -1498,11 +1499,14 @@ test("channel environment surface shows read-only context and opens review", asy await page.mouse.move(860, expandedResizeHandleBox!.y + 40, { steps: 12 }); await page.mouse.up(); await expect.poll(async () => Math.round((await dock.boundingBox())?.width ?? 0)).toBeGreaterThanOrEqual(840); - await expect(popover).toBeVisible(); + await expect(popover).toBeHidden(); + await expect(summaryButton).toHaveAttribute("aria-pressed", "true"); + await expect(summaryButton).toHaveAttribute("aria-expanded", "false"); await expect.poll(async () => Math.round((await page.locator(".activity-shell .channel-composer").boundingBox())?.width ?? 0)).toBeGreaterThanOrEqual(560); await page.locator(".task-toolbar-panel").click(); await expect(dock).toHaveCount(0); await expect(popover).toBeVisible(); + await expect(summaryButton).toHaveAttribute("aria-expanded", "true"); await page.getByRole("button", { name: "More channel views" }).click(); await page.getByRole("menuitem", { name: "Environment" }).click(); @@ -2009,9 +2013,10 @@ test("wide channel progress popover does not resize the conversation", async ({ expect(spacing.gap).toBeGreaterThanOrEqual(48); expect(spacing.overlapX).toBe(0); - await page.locator(".task-toolbar-progress").click(); - await expect(page.locator(".channel-progress-popover")).toHaveClass(/is-popover-collapsed/); + const summaryButton = page.locator(".task-toolbar-progress"); + await summaryButton.click(); await expect(page.locator(".channel-progress-popover")).toHaveCount(0); + await expect(summaryButton).not.toHaveAttribute("aria-pressed", "true"); }); test("channel runtime policy is visible while preferred specialist controls stay hidden", async ({ page }) => { @@ -7344,7 +7349,8 @@ test("progress popover owns workflow status while the right drawer avoids duplic await expect(qaSpan).toContainText("Waiting for approval"); await expect(qaSpan.locator(".conversation-work-cell-agent-span-detail")).toContainText("Waiting for approval: Validate progress panel"); - await page.locator(".task-toolbar-progress").click(); + const summaryButton = page.locator(".task-toolbar-progress"); + await summaryButton.click(); const progressPopover = page.locator(".channel-progress-popover"); await expect(progressPopover).toHaveCount(1); await expect(progressPopover).toContainText("Ship workflow UI"); @@ -7357,7 +7363,9 @@ test("progress popover owns workflow status while the right drawer avoids duplic await expect(progressPopover).not.toContainText("branch_engineer"); await page.locator(".task-toolbar-panel").click(); - await expect(progressPopover).toBeVisible(); + await expect(progressPopover).toBeHidden(); + await expect(summaryButton).toHaveAttribute("aria-pressed", "true"); + await expect(summaryButton).toHaveAttribute("aria-expanded", "false"); const panel = page.locator(".channel-side-panel.right-dock"); await expect(panel).toHaveAttribute("aria-label", "Channel workspace dock"); await expect(panel).toContainText("Workspace tools"); @@ -11749,7 +11757,10 @@ test("channel activity derives task run approval transition and workflow events" await page.locator(".task-toolbar-progress").click(); await expect(page.locator(".channel-progress-popover")).toHaveCount(1); await page.getByRole("button", { name: "Show details" }).click(); - await expect(page.locator(".channel-progress-popover")).toBeVisible(); + const summaryButton = page.locator(".task-toolbar-progress"); + await expect(page.locator(".channel-progress-popover")).toBeHidden(); + await expect(summaryButton).toHaveAttribute("aria-pressed", "true"); + await expect(summaryButton).toHaveAttribute("aria-expanded", "false"); const contextPanel = page.locator(".channel-side-panel.right-dock"); await expect(contextPanel).toHaveAttribute("aria-label", "Channel workspace dock"); await expect(contextPanel.getByLabel("Attention counts")).toHaveCount(0); diff --git a/apps/web/e2e/ui2-sidebar-rail-sections.spec.ts b/apps/web/e2e/ui2-sidebar-rail-sections.spec.ts index ac48fb867..ef4777b2f 100644 --- a/apps/web/e2e/ui2-sidebar-rail-sections.spec.ts +++ b/apps/web/e2e/ui2-sidebar-rail-sections.spec.ts @@ -85,6 +85,15 @@ test("Sidebar dark compact launchers expose one portal tooltip without object ac await expect(identity.locator).toBeFocused(); } await expectPortalTooltip(page, identity.tooltip); + const tooltip = page.getByRole("tooltip"); + await expect(tooltip).toHaveAttribute("data-side", "right"); + const [launcherBox, tooltipBox] = await Promise.all([ + identity.locator.boundingBox(), + tooltip.boundingBox(), + ]); + expect(launcherBox).not.toBeNull(); + expect(tooltipBox).not.toBeNull(); + expect(tooltipBox!.x).toBeGreaterThan(launcherBox!.x + launcherBox!.width); await captureEvidence(page, `1280x800-ling-dark-launcher-${index + 1}-tooltip.png`); } diff --git a/apps/web/src/features/navigation/SidebarSection.tsx b/apps/web/src/features/navigation/SidebarSection.tsx index 71ddabe6f..15b501202 100644 --- a/apps/web/src/features/navigation/SidebarSection.tsx +++ b/apps/web/src/features/navigation/SidebarSection.tsx @@ -10,6 +10,7 @@ import { StatusIndicator, } from "../../shared/ui/index.js"; import { SidebarSectionMenu, type SidebarSectionMenuItem } from "./SidebarSectionMenu.js"; +import { SidebarTooltip } from "./SidebarTooltip.js"; export type SidebarSectionId = "channels" | "teammates" | "recent"; @@ -61,14 +62,17 @@ export function SidebarSection({ return (
- onRailActivate(sectionId)} - pressed={railActive} - /> + + onRailActivate(sectionId)} + pressed={railActive} + tooltip="" + /> + {stale ? ( ; + export interface SqliteChannelWorkflowStorePorts { readonly assertChannelExists: (channelId: string) => void; readonly assertHandoffInChannel: (channelId: string, handoffId: string) => void; @@ -826,6 +832,10 @@ export class SqliteChannelWorkflowStore { if (!step || step.status !== input.expectedStatus || step.updatedAt !== input.expectedUpdatedAt || step.runId !== expectedRunId) { return { ok: false, code: "stale_transition", currentStatus: step?.status ?? input.expectedStatus }; } + const workflow = this.getChannelWorkflow(step.workflowId); + if (!workflow) { + return { ok: false, code: "invalid_transition", currentStatus: step.status }; + } if (input.delegatedOutcome.workflowId !== step.workflowId || input.delegatedOutcome.workflowStepId !== step.id || input.delegatedOutcome.participantId !== step.toParticipantId @@ -866,6 +876,8 @@ export class SqliteChannelWorkflowStore { this.context.database.exec("BEGIN IMMEDIATE;"); try { let expectedArtifactIds: readonly string[] = []; + let actualRunEventCount = 0; + let actualRunLatestSeq = 0; if (input.expectedRun.kind === "linked_run") { const run = this.context.database.prepare( "SELECT status, updated_at, work_unit_id FROM run_sessions WHERE id = ?", @@ -886,8 +898,10 @@ export class SqliteChannelWorkflowStore { SELECT COUNT(*) AS count, COALESCE(MAX(seq), 0) AS latest_seq FROM run_events WHERE run_id = ? `).get(input.expectedRun.id) as Record; - if (Number(eventWatermark.count) !== input.expectedRun.eventWatermark.count - || Number(eventWatermark.latest_seq) !== input.expectedRun.eventWatermark.latestSeq) { + actualRunEventCount = Number(eventWatermark.count); + actualRunLatestSeq = Number(eventWatermark.latest_seq); + if (actualRunEventCount !== input.expectedRun.eventWatermark.count + || actualRunLatestSeq !== input.expectedRun.eventWatermark.latestSeq) { this.context.database.exec("ROLLBACK;"); return { ok: false, code: "stale_transition", currentStatus: step.status }; } @@ -903,6 +917,21 @@ export class SqliteChannelWorkflowStore { this.context.database.exec("ROLLBACK;"); return { ok: false, code: "invalid_transition", currentStatus: step.status }; } + if (input.outputArtifactId && !expectedArtifactIds.includes(input.outputArtifactId)) { + this.context.database.exec("ROLLBACK;"); + return { ok: false, code: "invalid_transition", currentStatus: step.status }; + } + if (!this.delegatedOutcomeEvidenceIsCurrent( + input.delegatedOutcome as FinalizableDelegatedOutcomeV1, + workflow, + step, + expectedRunId, + input.delegatedOutcome.lineage.attemptId, + expectedArtifactIds, + )) { + this.context.database.exec("ROLLBACK;"); + return { ok: false, code: "invalid_transition", currentStatus: step.status }; + } if (input.expectedApproval) { const approval = this.context.database.prepare( "SELECT status, resolved_at FROM approval_requests WHERE id = ?", @@ -913,6 +942,27 @@ export class SqliteChannelWorkflowStore { return { ok: false, code: "stale_transition", currentStatus: step.status }; } } + const canonicalBasisFingerprint = delegatedStepFinalizationBasisFingerprintV1({ + workflowId: workflow.id, + workflowStepId: step.id, + stepUpdatedAt: input.expectedUpdatedAt, + ...(input.expectedRun.kind === "linked_run" ? { + runId: input.expectedRun.id, + runStatus: input.expectedRun.status, + runUpdatedAt: input.expectedRun.updatedAt, + } : {}), + runEventCount: actualRunEventCount, + runLatestSeq: actualRunLatestSeq, + requirementFingerprint: step.metadata?.delegatedRequirement?.fingerprint, + approval: input.expectedApproval, + artifactIds: expectedArtifactIds, + outcome: input.delegatedOutcome, + verification: input.executionVerification, + }); + if (canonicalBasisFingerprint !== input.delegatedOutcomeBasisFingerprint) { + this.context.database.exec("ROLLBACK;"); + return { ok: false, code: "invalid_transition", currentStatus: step.status }; + } const latest = this.context.database.prepare( "SELECT status, run_id, updated_at, metadata_json FROM channel_workflow_steps WHERE id = ?", ).get(step.id) as Record | undefined; @@ -1008,6 +1058,126 @@ export class SqliteChannelWorkflowStore { } } + private delegatedOutcomeEvidenceIsCurrent( + outcome: FinalizableDelegatedOutcomeV1, + workflow: ChannelWorkflow, + step: ChannelWorkflowStep, + expectedRunId: string | undefined, + expectedAttemptId: string | null, + expectedArtifactIds: readonly string[], + ): boolean { + if (outcome.kind === "completed") { + const requirement = step.metadata?.delegatedRequirement; + const manifest = workflow.metadata?.requiredParticipantManifest; + if (!isDelegatedStepRequirementV1(requirement) + || !manifest + || requirement.workflowId !== workflow.id + || requirement.workflowStepId !== step.id + || requirement.participantId !== step.toParticipantId + || requirement.participantManifestFingerprint !== manifest.fingerprint + || outcome.evidenceRefs.some((ref) => !requirement.acceptedEvidenceKinds.includes(ref.kind))) { + return false; + } + } + const expectedArtifacts = new Set(expectedArtifactIds); + for (const sourceRef of outcome.lineage.sourceRefs) { + if (sourceRef.type === "workflow" && sourceRef.id !== workflow.id) return false; + if (sourceRef.type === "workflow_step" && sourceRef.id !== step.id) return false; + if (sourceRef.type === "run" && sourceRef.id !== (expectedRunId ?? "")) return false; + if (sourceRef.type === "attempt" && sourceRef.id !== (expectedAttemptId ?? "")) return false; + if (sourceRef.type === "artifact") { + if (!expectedRunId || !expectedArtifacts.has(sourceRef.id)) return false; + const artifact = this.ports.getArtifact(sourceRef.id); + if (!artifact || artifact.runId !== expectedRunId) return false; + } + if (sourceRef.type === "execution_action" + && (!expectedRunId || !this.executionActionEvidenceIsCurrent(expectedRunId, expectedAttemptId, sourceRef.id))) { + return false; + } + if (sourceRef.type === "approval") { + const approval = this.findApprovalForExpectedRun(expectedRunId, sourceRef.id); + if (!approval || (expectedRunId ? approval.runId !== expectedRunId : Boolean(approval.runId))) return false; + } + } + for (const evidenceRef of outcome.evidenceRefs) { + if (evidenceRef.kind === "conversation_item") continue; + if (evidenceRef.kind === "workflow_output_packet" || evidenceRef.kind === "artifact") { + if (!expectedRunId || !expectedArtifacts.has(evidenceRef.id)) return false; + const artifact = this.ports.getArtifact(evidenceRef.id); + if (!artifact || artifact.runId !== expectedRunId) return false; + continue; + } + if (!expectedRunId || !this.executionActionEvidenceIsCurrent(expectedRunId, expectedAttemptId, evidenceRef.id)) { + return false; + } + } + return true; + } + + private executionActionEvidenceIsCurrent( + runId: string, + attemptId: string | null, + actionId: string, + ): boolean { + if (actionId.startsWith("approval:")) { + const approvalId = actionId.slice("approval:".length); + if (!approvalId) return false; + const approval = this.findApprovalForExpectedRun(runId, approvalId); + if (!approval || approval.status !== "approved" || !approval.toolCallId) return false; + return this.toolExecutionEvidenceIsCurrent(runId, attemptId, approval.toolCallId); + } + if (!actionId.startsWith("tool:")) return false; + const remainder = actionId.slice("tool:".length); + const separator = remainder.indexOf(":"); + const actionRunId = separator > 0 ? remainder.slice(0, separator) : ""; + const toolCallId = separator > 0 ? remainder.slice(separator + 1) : ""; + if (actionRunId !== runId || !toolCallId) return false; + return this.toolExecutionEvidenceIsCurrent(runId, attemptId, toolCallId); + } + + private toolExecutionEvidenceIsCurrent( + runId: string, + attemptId: string | null, + toolCallId: string, + ): boolean { + const events = this.listAllRunEvents(runId); + let latestToolEvent: RunEventEnvelope | undefined; + let terminalToolEvent: RunEventEnvelope | undefined; + let explicitAttemptId: string | undefined; + for (const event of events) { + if (event.type !== "tool_start" && event.type !== "tool_update" && event.type !== "tool_end") continue; + if (!isUnknownRecord(event.data) || event.data.toolCallId !== toolCallId) continue; + latestToolEvent = event; + if (event.type === "tool_end") terminalToolEvent = event; + if (typeof event.data.attemptId === "string" && event.data.attemptId.trim()) { + explicitAttemptId = event.data.attemptId; + } + } + if (!latestToolEvent || !terminalToolEvent || !isUnknownRecord(terminalToolEvent.data) + || terminalToolEvent.data.isError === true) return false; + const actionAttemptId = explicitAttemptId ?? latestRunAttemptIdAtOrBefore(events, latestToolEvent.seq); + return Boolean(actionAttemptId && actionAttemptId === attemptId); + } + + private findApprovalForExpectedRun(runId: string | undefined, approvalId: string) { + return this.ports.listApprovalRequests({ + ...(runId ? { runId } : {}), + complete: true, + }).find((approval) => approval.id === approvalId); + } + + private listAllRunEvents(runId: string): readonly RunEventEnvelope[] { + const events: RunEventEnvelope[] = []; + let afterSeq = 0; + while (true) { + const page = this.ports.listEvents(runId, { afterSeq, limit: 1000 }); + events.push(...page); + const latestSeq = page.at(-1)?.seq; + if (page.length < 1000 || latestSeq === undefined || latestSeq <= afterSeq) return events; + afterSeq = latestSeq; + } + } + createChannelWorkflowStep(input: CreateChannelWorkflowStepInput, now: string): ChannelWorkflowStep { const workflow = this.getChannelWorkflow(input.workflowId); if (!workflow) { @@ -1484,6 +1654,22 @@ function sameDelegatedEvidenceRefs( return JSON.stringify([...left].map(key).sort()) === JSON.stringify([...right].map(key).sort()); } +function isUnknownRecord(value: unknown): value is Record { + return Boolean(value && typeof value === "object" && !Array.isArray(value)); +} + +function latestRunAttemptIdAtOrBefore( + events: readonly RunEventEnvelope[], + throughSeq: number, +): string | undefined { + for (const event of [...events].sort((left, right) => right.seq - left.seq)) { + if (event.seq > throughSeq || event.type !== "run_created" || !isUnknownRecord(event.data)) continue; + const attemptId = event.data.attemptId; + if (typeof attemptId === "string" && attemptId.trim()) return attemptId; + } + return undefined; +} + function isWorkflowHandoffIntentStatusValue(value: unknown): value is WorkflowHandoffIntentStatus { return value === "proposed" || value === "validated" || value === "accepted" || value === "declined" || value === "converted" || value === "failed"; } diff --git a/packages/daemon/test/workflow-operation-evidence.test.mjs b/packages/daemon/test/workflow-operation-evidence.test.mjs index ce5bca145..4f35cbb15 100644 --- a/packages/daemon/test/workflow-operation-evidence.test.mjs +++ b/packages/daemon/test/workflow-operation-evidence.test.mjs @@ -9,7 +9,11 @@ import { createApprovalResumeBasisV1, createSqliteRunStore, } from "../../core/dist/index.js"; -import { createRequiredParticipantManifestV1, workflowGraphFingerprintV1 } from "../../contracts/dist/index.js"; +import { + createRequiredParticipantManifestV1, + delegatedStepFinalizationBasisFingerprintV1, + workflowGraphFingerprintV1, +} from "../../contracts/dist/index.js"; import { confirmChannelExecutionProposalWorkflow } from "../dist/features/channels/proposal-workflows.js"; import { materializeCommandIntent } from "../dist/features/channels/command-operation-match.js"; import { @@ -571,6 +575,125 @@ test("multi-agent completion requires both Engineer write and QA command evidenc }); }); +test("step finalization rejects evidence that is not owned by the current Run", async (t) => { + await t.test("same-channel foreign execution action", async () => { + const fixture = await createMultiAgentOperationFixture("foreign-action-evidence"); + try { + appendToolAction(fixture.store, fixture.engineerRun.id, "write_app", "write", { path: "app.js", content: "ok" }, false); + appendToolAction(fixture.store, fixture.qaRun.id, "foreign_action", "write", { path: "foreign.js", content: "foreign" }, false); + fixture.store.markRunSucceeded(fixture.engineerRun.id, "2026-08-12T14:00:10.000Z"); + const originalCommit = fixture.store.commitChannelWorkflowStepFinalization.bind(fixture.store); + let observed; + fixture.store.commitChannelWorkflowStepFinalization = (input) => { + const outcome = { + ...input.delegatedOutcome, + lineage: { + ...input.delegatedOutcome.lineage, + sourceRefs: input.delegatedOutcome.lineage.sourceRefs.map((ref) => ref.type === "execution_action" + ? { ...ref, id: `tool:${fixture.qaRun.id}:foreign_action` } + : ref), + }, + evidenceRefs: [{ kind: "execution_action", id: `tool:${fixture.qaRun.id}:foreign_action` }], + }; + const basisFingerprint = delegatedStepFinalizationBasisFingerprintV1({ + workflowId: fixture.workflow.id, + workflowStepId: fixture.engineerStep.id, + stepUpdatedAt: input.expectedUpdatedAt, + runId: input.expectedRun.id, + runStatus: input.expectedRun.status, + runUpdatedAt: input.expectedRun.updatedAt, + runEventCount: input.expectedRun.eventWatermark.count, + runLatestSeq: input.expectedRun.eventWatermark.latestSeq, + requirementFingerprint: fixture.store.getChannelWorkflowStep(fixture.engineerStep.id).metadata.delegatedRequirement.fingerprint, + approval: input.expectedApproval, + artifactIds: input.expectedRun.artifactIds, + outcome, + verification: input.executionVerification, + }); + observed = originalCommit({ ...input, delegatedOutcome: outcome, delegatedOutcomeBasisFingerprint: basisFingerprint }); + return observed; + }; + const result = finalizeDelegatedWorkflowStepFromCanonicalState({ + store: fixture.store, + workflowStepId: fixture.engineerStep.id, + cause: "run_terminal", + now: "2026-08-12T14:00:11.000Z", + }); + assert.equal(result.status, "stale"); + assert.deepEqual(observed, { ok: false, code: "invalid_transition", currentStatus: "running" }); + assert.equal(fixture.store.getChannelWorkflowStep(fixture.engineerStep.id).status, "running"); + } finally { + await fixture.close(); + } + }); + + await t.test("foreign artifact", async () => { + const fixture = await createSynthesisFinalizationFixture("foreign-artifact-evidence"); + try { + const foreignRun = createFixtureRun( + fixture.store, + fixture.cwd, + fixture.workflow.channelId, + fixture.step.id, + fixture.task, + "run_synthesis_foreign_artifact", + "2026-08-12T14:25:00.000Z", + ); + const foreignArtifact = fixture.store.createArtifact({ + runId: foreignRun.id, + type: "json", + path: "foreign-output.json", + sealedPayload: { schemaVersion: 1, kind: "foreign" }, + createdAt: "2026-08-12T14:25:01.000Z", + }); + fixture.store.appendEvent(fixture.run.id, "assistant_text_delta", { text: "Sourced synthesis." }); + fixture.store.markRunSucceeded(fixture.run.id, "2026-08-12T14:25:10.000Z"); + const originalCommit = fixture.store.commitChannelWorkflowStepFinalization.bind(fixture.store); + let observed; + fixture.store.commitChannelWorkflowStepFinalization = (input) => { + const outcome = { + ...input.delegatedOutcome, + lineage: { + ...input.delegatedOutcome.lineage, + sourceRefs: input.delegatedOutcome.lineage.sourceRefs.map((ref) => ref.type === "artifact" + ? { ...ref, id: foreignArtifact.id } + : ref), + }, + evidenceRefs: [{ kind: "workflow_output_packet", id: foreignArtifact.id }], + }; + const basisFingerprint = delegatedStepFinalizationBasisFingerprintV1({ + workflowId: fixture.workflow.id, + workflowStepId: fixture.step.id, + stepUpdatedAt: input.expectedUpdatedAt, + runId: input.expectedRun.id, + runStatus: input.expectedRun.status, + runUpdatedAt: input.expectedRun.updatedAt, + runEventCount: input.expectedRun.eventWatermark.count, + runLatestSeq: input.expectedRun.eventWatermark.latestSeq, + requirementFingerprint: fixture.store.getChannelWorkflowStep(fixture.step.id).metadata.delegatedRequirement.fingerprint, + approval: input.expectedApproval, + artifactIds: input.expectedRun.artifactIds, + outcome, + verification: input.executionVerification, + }); + observed = originalCommit({ ...input, delegatedOutcome: outcome, delegatedOutcomeBasisFingerprint: basisFingerprint }); + return observed; + }; + const result = finalizeDelegatedWorkflowStepFromCanonicalState({ + store: fixture.store, + workflowStepId: fixture.step.id, + cause: "run_terminal", + now: "2026-08-12T14:25:11.000Z", + }); + assert.equal(result.status, "stale"); + assert.deepEqual(observed, { ok: false, code: "invalid_transition", currentStatus: "running" }); + assert.equal(fixture.store.getChannelWorkflowStep(fixture.step.id).status, "running"); + } finally { + await fixture.close(); + } + }); +}); + test("downstream command evidence cannot bypass missing dependency evidence", async () => { const fixture = await createMultiAgentOperationFixture("missing-dependency-evidence"); try { @@ -2217,8 +2340,10 @@ async function createSynthesisFinalizationFixture(suffix) { step = store.updateChannelWorkflowStep(step.id, { taskId: task.id, runId: run.id }, now); return { store, + cwd, workflow, step, + task, run, close: async () => { store.close();