diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9f79a90550d..8aae399ba81 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -229,14 +229,70 @@ function resolvePendingUserInputAnswer( return normalizeDraftAnswer(draft?.selectedOptionLabel); } +/** + * Task ids owned by a workflow run. Mirrors the desktop derivation in + * apps/web/src/workflow-logic.ts (collectWorkflowTaskIds) — mobile has no + * workflow card yet, so all rows for those tasks are suppressed rather than + * rendered as per-tick noise. + */ +function collectWorkflowTaskIds( + activities: ReadonlyArray, +): Set { + const taskIds = new Set(); + for (const activity of activities) { + const payload = activity.payload as Record | null | undefined; + const taskId = + payload && typeof payload === "object" && typeof payload["taskId"] === "string" + ? payload["taskId"] + : undefined; + if (!taskId) continue; + const workflowName = + payload && typeof payload === "object" ? payload["workflowName"] : undefined; + const taskType = payload && typeof payload === "object" ? payload["taskType"] : undefined; + if ( + activity.kind === "task.workflow-updated" || + activity.kind === "task.workflow-meta" || + (activity.kind === "task.started" && + (taskType === "local_workflow" || typeof workflowName === "string")) + ) { + taskIds.add(taskId); + } + } + return taskIds; +} + +function activityBelongsToWorkflow( + activity: OrchestrationThreadActivity, + workflowTaskIds: ReadonlySet, +): boolean { + const payload = activity.payload as Record | null | undefined; + const taskId = payload && typeof payload === "object" ? payload["taskId"] : undefined; + return typeof taskId === "string" && workflowTaskIds.has(taskId); +} + function deriveWorkLogEntries( activities: ReadonlyArray, ): DerivedWorkLogEntry[] { const ordered = Arr.sort(activities, activityOrder); + const workflowTaskIds = collectWorkflowTaskIds(activities); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; + // Workflow snapshot/meta activities back the desktop workflow card; on + // mobile they would render as ever-mutating raw rows, so skip them — + // along with the per-tick progress rows the workflow owns. task.completed + // stays: with no workflow card here it is mobile's only signal that a + // workflow finished, failed, or was stopped. + if (activity.kind === "task.workflow-updated") continue; + if (activity.kind === "task.workflow-meta") continue; + if ( + activity.kind === "task.progress" && + workflowTaskIds.size > 0 && + activityBelongsToWorkflow(activity, workflowTaskIds) + ) { + continue; + } if (activity.kind === "context-window.updated") continue; if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 707c87c43c9..80c192b7ce5 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -106,6 +106,7 @@ function createProviderServiceHarness( startSession: () => unsupported(), sendTurn: () => unsupported(), interruptTurn: () => unsupported(), + stopTask: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), stopSession: () => unsupported(), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index ce464565dc5..2f3a0b6a5b5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -239,6 +239,7 @@ describe("ProviderCommandReactor", () => { } }), ); + const stopTask = vi.fn(() => Effect.void); const renameBranch = vi.fn((input: unknown) => Effect.succeed({ branch: @@ -301,6 +302,7 @@ describe("ProviderCommandReactor", () => { respondToRequest: respondToRequest as ProviderServiceShape["respondToRequest"], respondToUserInput: respondToUserInput as ProviderServiceShape["respondToUserInput"], stopSession: stopSession as ProviderServiceShape["stopSession"], + stopTask: stopTask as ProviderServiceShape["stopTask"], listSessions: () => Effect.succeed(runtimeSessions), getCapabilities: (_provider) => Effect.succeed({ @@ -417,6 +419,7 @@ describe("ProviderCommandReactor", () => { respondToRequest, respondToUserInput, stopSession, + stopTask, renameBranch, refreshStatus, generateBranchName, @@ -2094,4 +2097,80 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); expect(thread?.session?.activeTurnId).toBeNull(); }); + + effectIt.effect( + "reacts to thread.task.stop by stopping the background task on the active session", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-for-task-stop"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "claudeAgent", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-1"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + yield* harness.engine.dispatch({ + type: "thread.task.stop", + commandId: CommandId.make("cmd-task-stop"), + threadId: ThreadId.make("thread-1"), + taskId: "task-9", + createdAt: now, + }); + + yield* Effect.promise(() => waitFor(() => harness.stopTask.mock.calls.length === 1)); + expect(harness.stopTask.mock.calls[0]?.[0]).toEqual({ + threadId: "thread-1", + taskId: "task-9", + }); + }), + ); + + effectIt.effect("appends a task-stop failure activity when no active session is bound", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.task.stop", + commandId: CommandId.make("cmd-task-stop-no-session"), + threadId: ThreadId.make("thread-1"), + taskId: "task-9", + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return ( + thread?.activities.some((activity) => activity.kind === "provider.task.stop.failed") ?? + false + ); + }), + ); + + expect(harness.stopTask).not.toHaveBeenCalled(); + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect( + thread?.activities.find((activity) => activity.kind === "provider.task.stop.failed"), + ).toMatchObject({ + payload: { + detail: expect.stringContaining("No active provider session"), + }, + }); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 9c7a7c94bb1..f433080749f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -53,7 +53,8 @@ type ProviderIntentEvent = Extract< | "thread.turn-interrupt-requested" | "thread.approval-response-requested" | "thread.user-input-response-requested" - | "thread.session-stop-requested"; + | "thread.session-stop-requested" + | "thread.task-stop-requested"; } >; @@ -219,6 +220,7 @@ const make = Effect.gen(function* () { readonly kind: | "provider.turn.start.failed" | "provider.turn.interrupt.failed" + | "provider.task.stop.failed" | "provider.approval.respond.failed" | "provider.user-input.respond.failed" | "provider.session.stop.failed"; @@ -1002,6 +1004,44 @@ const make = Effect.gen(function* () { }); }); + const processTaskStopRequested = Effect.fn("processTaskStopRequested")(function* ( + event: Extract, + ) { + const thread = yield* resolveThread(event.payload.threadId); + if (!thread) { + return; + } + const hasSession = thread.session && thread.session.status !== "stopped"; + if (!hasSession) { + return yield* appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.task.stop.failed", + summary: "Background task stop failed", + detail: "No active provider session is bound to this thread.", + turnId: null, + createdAt: event.payload.createdAt, + }); + } + + yield* providerService + .stopTask({ + threadId: event.payload.threadId, + taskId: event.payload.taskId, + }) + .pipe( + Effect.catchCause((cause) => + appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.task.stop.failed", + summary: "Background task stop failed", + detail: Cause.pretty(cause), + turnId: null, + createdAt: event.payload.createdAt, + }), + ), + ); + }); + const processDomainEvent = Effect.fn("processDomainEvent")(function* ( event: ProviderIntentEvent, ) { @@ -1042,6 +1082,9 @@ const make = Effect.gen(function* () { case "thread.session-stop-requested": yield* processSessionStopRequested(event); return; + case "thread.task-stop-requested": + yield* processTaskStopRequested(event); + return; } }); @@ -1068,7 +1111,8 @@ const make = Effect.gen(function* () { event.type === "thread.turn-interrupt-requested" || event.type === "thread.approval-response-requested" || event.type === "thread.user-input-response-requested" || - event.type === "thread.session-stop-requested" + event.type === "thread.session-stop-requested" || + event.type === "thread.task-stop-requested" ) { return yield* worker.enqueue(event); } diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 001ba388949..a997565c155 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -101,6 +101,7 @@ function createProviderServiceHarness() { startSession: () => unsupported(), sendTurn: () => unsupported(), interruptTurn: () => unsupported(), + stopTask: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), stopSession: () => unsupported(), @@ -3060,4 +3061,191 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.status).toBe("error"); expect(thread.session?.lastError).toBe("runtime still processed"); }); + + it("projects a plain task.progress (no workflowProgress) into a single activity", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-plain-progress"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-plain-progress"), + payload: { + taskId: "task-plain-1", + description: "thinking through the patch", + summary: "thinking through the patch", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-plain-progress", + ), + ); + const progress = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-plain-progress", + ); + expect(progress?.kind).toBe("task.progress"); + expect( + thread.activities.filter((activity: ProviderRuntimeTestActivity) => + activity.id.startsWith("workflow:"), + ), + ).toHaveLength(0); + }); + + it("projects a workflow task.progress into a per-tick row plus a stable workflow snapshot", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const workflowProgress = [ + { type: "workflow_phase", index: 0, title: "Plan" }, + { type: "workflow_agent", index: 0, state: "start", phaseIndex: 0 }, + { type: "workflow_log", message: "kicked off" }, + ]; + + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-workflow-progress"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-workflow-progress"), + payload: { + taskId: "task-wf-1", + description: "spec workflow", + workflowProgress, + usage: { total_tokens: 100 }, + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => + activity.id === `workflow:${JSON.stringify(["thread-1", "task-wf-1"])}`, + ), + ); + + const perTick = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-workflow-progress", + ); + expect(perTick?.kind).toBe("task.progress"); + + const snapshot = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => + activity.id === `workflow:${JSON.stringify(["thread-1", "task-wf-1"])}`, + ); + expect(snapshot?.kind).toBe("task.workflow-updated"); + const payload = + snapshot?.payload && typeof snapshot.payload === "object" + ? (snapshot.payload as Record) + : undefined; + expect(payload?.taskId).toBe("task-wf-1"); + expect(payload?.workflowProgress).toEqual(workflowProgress); + }); + + it("projects task.workflowMeta into a stable workflow-meta activity", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "task.workflowMeta", + eventId: asEventId("evt-workflow-meta"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-workflow-meta"), + payload: { + taskId: "task-wf-meta", + runId: "wf_abc", + workflowName: "spec", + scriptPath: "/x/s.js", + transcriptDir: "/x/t", + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => + activity.id === `workflow-meta:${JSON.stringify(["thread-1", "task-wf-meta"])}`, + ), + ); + const meta = thread.activities.find( + (activity: ProviderRuntimeTestActivity) => + activity.id === `workflow-meta:${JSON.stringify(["thread-1", "task-wf-meta"])}`, + ); + expect(meta?.kind).toBe("task.workflow-meta"); + const payload = + meta?.payload && typeof meta.payload === "object" + ? (meta.payload as Record) + : undefined; + expect(payload).toMatchObject({ + taskId: "task-wf-meta", + runId: "wf_abc", + workflowName: "spec", + scriptPath: "/x/s.js", + transcriptDir: "/x/t", + }); + }); + + it("upserts successive workflow snapshots under the same stable activity id", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-workflow-progress-1"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-workflow-upsert"), + payload: { + taskId: "task-wf-2", + description: "spec workflow", + workflowProgress: [{ type: "workflow_agent", index: 0, state: "start" }], + }, + }); + harness.emit({ + type: "task.progress", + eventId: asEventId("evt-workflow-progress-2"), + provider: ProviderDriverKind.make("claudeAgent"), + createdAt: now, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-workflow-upsert"), + payload: { + taskId: "task-wf-2", + description: "spec workflow", + workflowProgress: [{ type: "workflow_agent", index: 0, state: "done" }], + }, + }); + + const thread = await waitForThread(harness.readModel, (entry) => + entry.activities.some( + (activity: ProviderRuntimeTestActivity) => activity.id === "evt-workflow-progress-2", + ), + ); + await harness.drain(); + const drained = await harness.readModel(); + const drainedThread = drained.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + + const snapshots = drainedThread?.activities.filter( + (activity: ProviderRuntimeTestActivity) => + activity.id === `workflow:${JSON.stringify(["thread-1", "task-wf-2"])}`, + ); + expect(snapshots).toHaveLength(1); + const payload = + snapshots?.[0]?.payload && typeof snapshots[0].payload === "object" + ? (snapshots[0].payload as Record) + : undefined; + expect(payload?.workflowProgress).toEqual([ + { type: "workflow_agent", index: 0, state: "done" }, + ]); + // Per-tick rows remain distinct (one per progress event). + expect( + thread.activities.filter( + (activity: ProviderRuntimeTestActivity) => activity.kind === "task.progress", + ).length, + ).toBeGreaterThanOrEqual(2); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 3e5978f4846..5cb6c3561f7 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -8,6 +8,7 @@ import { type OrchestrationProposedPlanId, CheckpointRef, isToolLifecycleItemType, + EventId, ThreadId, type ThreadTokenUsageSnapshot, TurnId, @@ -162,6 +163,20 @@ function maxCheckpointTurnCount( return maxTurnCount; } +/** + * Stable per-task activity ids: workflow snapshot/meta activities are + * upserted (one projection row per run), not appended per progress tick. + */ +// JSON-encode the (threadId, taskId) tuple: both are free-form strings, so a +// bare `:`-joined key would let distinct pairs collide on the upsert id. +function workflowActivityId(threadId: ThreadId, taskId: string): EventId { + return EventId.make(`workflow:${JSON.stringify([threadId, taskId])}`); +} + +function workflowMetaActivityId(threadId: ThreadId, taskId: string): EventId { + return EventId.make(`workflow-meta:${JSON.stringify([threadId, taskId])}`); +} + function truncateDetail(value: string, limit = 180): string { return value.length > limit ? `${value.slice(0, limit - 3)}...` : value; } @@ -456,6 +471,8 @@ function runtimeEventToActivities( payload: { taskId: event.payload.taskId, ...(event.payload.taskType ? { taskType: event.payload.taskType } : {}), + ...(event.payload.toolUseId ? { toolUseId: event.payload.toolUseId } : {}), + ...(event.payload.workflowName ? { workflowName: event.payload.workflowName } : {}), ...(event.payload.description ? { detail: truncateDetail(event.payload.description) } : {}), @@ -467,18 +484,40 @@ function runtimeEventToActivities( } case "task.progress": { + const progressActivity: OrchestrationThreadActivity = { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "task.progress", + summary: "Reasoning update", + payload: { + taskId: event.payload.taskId, + detail: truncateDetail(event.payload.summary ?? event.payload.description), + ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), + ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), + ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), + }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }; + if (event.payload.workflowProgress === undefined) { + return [progressActivity]; + } + // Workflow snapshots are cumulative state, not timeline entries: reuse a + // stable activity id per task so the projection and client upsert one + // row per run instead of accumulating one per progress tick. return [ + progressActivity, { - id: event.eventId, + id: workflowActivityId(event.threadId, event.payload.taskId), createdAt: event.createdAt, tone: "info", - kind: "task.progress", - summary: "Reasoning update", + kind: "task.workflow-updated", + summary: truncateDetail(event.payload.description), payload: { taskId: event.payload.taskId, - detail: truncateDetail(event.payload.summary ?? event.payload.description), - ...(event.payload.summary ? { summary: truncateDetail(event.payload.summary) } : {}), - ...(event.payload.lastToolName ? { lastToolName: event.payload.lastToolName } : {}), + description: truncateDetail(event.payload.description), + workflowProgress: event.payload.workflowProgress, ...(event.payload.usage !== undefined ? { usage: event.payload.usage } : {}), }, turnId: toTurnId(event.turnId) ?? null, @@ -487,6 +526,23 @@ function runtimeEventToActivities( ]; } + case "task.workflowMeta": { + return [ + { + id: workflowMetaActivityId(event.threadId, event.payload.taskId), + createdAt: event.createdAt, + tone: "info", + kind: "task.workflow-meta", + summary: event.payload.workflowName + ? `Workflow "${event.payload.workflowName}" launched` + : "Workflow launched", + payload: { ...event.payload }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } + case "task.completed": { return [ { diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 0d4af771ca8..058456f0995 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -483,6 +483,28 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.task.stop": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.task-stop-requested", + payload: { + threadId: command.threadId, + taskId: command.taskId, + createdAt: command.createdAt, + }, + }; + } + case "thread.approval.respond": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/decider.workflowTaskStop.test.ts b/apps/server/src/orchestration/decider.workflowTaskStop.test.ts new file mode 100644 index 00000000000..f4d816a10e5 --- /dev/null +++ b/apps/server/src/orchestration/decider.workflowTaskStop.test.ts @@ -0,0 +1,121 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + ProjectId, + ThreadId, + ProviderInstanceId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const asCommandId = (value: string): CommandId => CommandId.make(value); +const asEventId = (value: string): EventId => EventId.make(value); +const asProjectId = (value: string): ProjectId => ProjectId.make(value); +const asThreadId = (value: string): ThreadId => ThreadId.make(value); + +const now = "2026-01-01T00:00:00.000Z"; + +const seedReadModel = Effect.gen(function* () { + const initial = createEmptyReadModel(now); + const withProject = yield* projectEvent(initial, { + sequence: 1, + eventId: asEventId("evt-project-create"), + aggregateKind: "project", + aggregateId: asProjectId("project-workflow"), + type: "project.created", + occurredAt: now, + commandId: asCommandId("cmd-project-create"), + causationEventId: null, + correlationId: asCommandId("cmd-project-create"), + metadata: {}, + payload: { + projectId: asProjectId("project-workflow"), + title: "Project Workflow", + workspaceRoot: "/tmp/project-workflow", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + return yield* projectEvent(withProject, { + sequence: 2, + eventId: asEventId("evt-thread-create"), + aggregateKind: "thread", + aggregateId: asThreadId("thread-workflow"), + type: "thread.created", + occurredAt: now, + commandId: asCommandId("cmd-thread-create"), + causationEventId: null, + correlationId: asCommandId("cmd-thread-create"), + metadata: {}, + payload: { + threadId: asThreadId("thread-workflow"), + projectId: asProjectId("project-workflow"), + title: "Thread Workflow", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); +}); + +it.layer(NodeServices.layer)("decider thread.task.stop", (it) => { + it.effect("emits a thread.task-stop-requested event carrying the task id", () => + Effect.gen(function* () { + const readModel = yield* seedReadModel; + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.task.stop", + commandId: asCommandId("cmd-task-stop"), + threadId: asThreadId("thread-workflow"), + taskId: "task-9", + createdAt: now, + }, + readModel, + }); + const events = Array.isArray(result) ? result : [result]; + expect(events).toHaveLength(1); + const event = events[0]!; + expect(event.type).toBe("thread.task-stop-requested"); + expect(event.payload).toMatchObject({ + threadId: asThreadId("thread-workflow"), + taskId: "task-9", + createdAt: now, + }); + }), + ); + + it.effect("rejects a task stop for a thread that does not exist", () => + Effect.gen(function* () { + const readModel = yield* seedReadModel; + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.task.stop", + commandId: asCommandId("cmd-task-stop-missing"), + threadId: asThreadId("thread-missing"), + taskId: "task-9", + createdAt: now, + }, + readModel, + }), + ); + expect(error.message).toContain("thread-missing"); + expect(error.message).toContain("does not exist"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 191bf8e27db..eef9d661d96 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -58,6 +58,7 @@ class FakeClaudeQuery implements AsyncIterable { public readonly setModelCalls: Array = []; public readonly setPermissionModeCalls: Array = []; public readonly setMaxThinkingTokensCalls: Array = []; + public readonly stopTaskCalls: Array = []; public closeCalls = 0; emit(message: SDKMessage): void { @@ -110,6 +111,10 @@ class FakeClaudeQuery implements AsyncIterable { this.setMaxThinkingTokensCalls.push(maxThinkingTokens); }; + readonly stopTask = async (taskId: string): Promise => { + this.stopTaskCalls.push(taskId); + }; + readonly close = (): void => { this.closeCalls += 1; this.finish(); @@ -3786,4 +3791,225 @@ describe("ClaudeAdapterLive", () => { Effect.provide(harness.layer), ); }); + + it.effect( + "normalizes workflow_progress on task progress, clipping previews and dropping malformed entries", + () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "task.progress", + ).pipe(Stream.runCollect, Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const longPreview = "x".repeat(300); + harness.query.emit({ + type: "system", + subtype: "task_progress", + task_id: "task-wf-1", + description: "spec workflow", + workflow_progress: [ + { type: "workflow_phase", index: 0, title: "Plan" }, + { type: "workflow_agent", index: 0, state: "start", promptPreview: longPreview }, + { type: "workflow_log", message: "kicked off" }, + { type: "workflow_agent", state: "start" }, // missing index -> dropped + { type: "workflow_mystery", index: 9 }, // unknown type -> dropped + ], + session_id: "sdk-session-workflow-progress", + uuid: "task-workflow-progress-1", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const progress = runtimeEvents.find((event) => event.type === "task.progress"); + assert.equal(progress?.type, "task.progress"); + if (progress?.type === "task.progress") { + const workflowProgress = progress.payload.workflowProgress; + // Only the three well-formed entries survive; order is phases, agents, logs. + assert.equal(workflowProgress?.length, 3); + assert.equal(workflowProgress?.[0]?.type, "workflow_phase"); + assert.equal(workflowProgress?.[1]?.type, "workflow_agent"); + assert.equal(workflowProgress?.[2]?.type, "workflow_log"); + const agent = workflowProgress?.[1]; + if (agent?.type === "workflow_agent") { + // 240-char clip + a trailing ellipsis. + assert.equal(agent.promptPreview?.length, 241); + assert.equal(agent.promptPreview?.endsWith("…"), true); + } + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + + it.effect("forwards workflow tool_use_id and workflow_name on task started", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "task.started", + ).pipe(Stream.runCollect, Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + harness.query.emit({ + type: "system", + subtype: "task_started", + task_id: "task-wf-1", + description: "spec workflow", + task_type: "local_workflow", + tool_use_id: "tool-wf-1", + workflow_name: "spec", + session_id: "sdk-session-workflow-started", + uuid: "task-workflow-started-1", + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const started = runtimeEvents.find((event) => event.type === "task.started"); + assert.equal(started?.type, "task.started"); + if (started?.type === "task.started") { + assert.equal(started.payload.toolUseId, "tool-wf-1"); + assert.equal(started.payload.workflowName, "spec"); + assert.equal(started.payload.taskType, "local_workflow"); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("emits task.workflowMeta from a Workflow tool result", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const runtimeEventsFiber = yield* Stream.takeUntil( + adapter.streamEvents, + (event) => event.type === "task.workflowMeta", + ).pipe(Stream.runCollect, Effect.forkChild); + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "run the workflow", + attachments: [], + }); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-workflow-tool", + uuid: "stream-workflow-start", + parent_tool_use_id: null, + event: { + type: "content_block_start", + index: 1, + content_block: { + type: "tool_use", + id: "tool-wf-1", + name: "Workflow", + input: {}, + }, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "stream_event", + session_id: "sdk-session-workflow-tool", + uuid: "stream-workflow-stop", + parent_tool_use_id: null, + event: { + type: "content_block_stop", + index: 1, + }, + } as unknown as SDKMessage); + + harness.query.emit({ + type: "user", + session_id: "sdk-session-workflow-tool", + uuid: "user-workflow-result", + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-wf-1", + content: "workflow launched", + }, + ], + }, + tool_use_result: { + taskId: "task-1", + runId: "wf_abc", + workflowName: "spec", + scriptPath: "/x/s.js", + transcriptDir: "/x/t", + taskType: "local_workflow", + }, + } as unknown as SDKMessage); + + const runtimeEvents = Array.from(yield* Fiber.join(runtimeEventsFiber)); + const meta = runtimeEvents.find((event) => event.type === "task.workflowMeta"); + assert.equal(meta?.type, "task.workflowMeta"); + if (meta?.type === "task.workflowMeta") { + assert.deepEqual(meta.payload, { + taskId: "task-1", + runId: "wf_abc", + workflowName: "spec", + taskType: "local_workflow", + scriptPath: "/x/s.js", + transcriptDir: "/x/t", + }); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("forwards stopTask to the Claude query runtime", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + yield* adapter.streamEvents.pipe(Stream.runDrain, Effect.forkChild); + + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + + const stopTask = adapter.stopTask; + if (stopTask === undefined) { + throw new Error("Expected the Claude adapter to expose stopTask."); + } + yield* stopTask(THREAD_ID, "task-9"); + + assert.deepEqual(harness.query.stopTaskCalls, ["task-9"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); }); diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 97a93f85829..93aaae9bce5 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -45,6 +45,11 @@ import { ThreadId, TurnId, type UserInputQuestion, + type WorkflowAgentProgressEntry, + type WorkflowLogProgressEntry, + type WorkflowPhaseProgressEntry, + type WorkflowProgressEntry, + type WorkflowRunHandles, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, @@ -204,6 +209,7 @@ interface ClaudeSessionContext { interface ClaudeQueryRuntime extends AsyncIterable { readonly interrupt: () => Promise; + readonly stopTask?: (taskId: string) => Promise; readonly setModel: (model?: string) => Promise; readonly setPermissionMode: (mode: PermissionMode) => Promise; readonly setMaxThinkingTokens: (maxThinkingTokens: number | null) => Promise; @@ -714,6 +720,242 @@ function readStringArray(value: unknown): Array { : []; } +const WORKFLOW_TOOL_NAME = "Workflow"; +const MAX_WORKFLOW_AGENT_ENTRIES = 300; +const MAX_WORKFLOW_PHASE_ENTRIES = 50; +const MAX_WORKFLOW_LOG_ENTRIES = 40; +const MAX_WORKFLOW_PREVIEW_CHARS = 240; +const MAX_WORKFLOW_RESULT_PREVIEW_CHARS = 400; + +function workflowString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function workflowClippedString(value: unknown, limit: number): string | undefined { + const text = workflowString(value); + if (text === undefined) { + return undefined; + } + return text.length > limit ? `${text.slice(0, limit)}\u2026` : text; +} + +function workflowFiniteNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function normalizeWorkflowAgentEntry( + entry: Record, +): WorkflowAgentProgressEntry | undefined { + const index = workflowFiniteNumber(entry.index); + const state = workflowString(entry.state); + if (index === undefined || state === undefined) { + return undefined; + } + const isolation = + entry.isolation === "worktree" || entry.isolation === "remote" ? entry.isolation : undefined; + return { + type: "workflow_agent", + index, + state, + ...(workflowString(entry.label) !== undefined ? { label: workflowString(entry.label) } : {}), + ...(workflowFiniteNumber(entry.phaseIndex) !== undefined + ? { phaseIndex: workflowFiniteNumber(entry.phaseIndex) } + : {}), + ...(workflowString(entry.phaseTitle) !== undefined + ? { phaseTitle: workflowString(entry.phaseTitle) } + : {}), + ...(workflowString(entry.agentId) !== undefined + ? { agentId: workflowString(entry.agentId) } + : {}), + ...(workflowString(entry.agentType) !== undefined + ? { agentType: workflowString(entry.agentType) } + : {}), + ...(workflowString(entry.model) !== undefined ? { model: workflowString(entry.model) } : {}), + ...(workflowString(entry.fallbackModel) !== undefined + ? { fallbackModel: workflowString(entry.fallbackModel) } + : {}), + ...(isolation !== undefined ? { isolation } : {}), + ...(workflowFiniteNumber(entry.attempt) !== undefined + ? { attempt: workflowFiniteNumber(entry.attempt) } + : {}), + ...(workflowFiniteNumber(entry.queuedAt) !== undefined + ? { queuedAt: workflowFiniteNumber(entry.queuedAt) } + : {}), + ...(workflowFiniteNumber(entry.startedAt) !== undefined + ? { startedAt: workflowFiniteNumber(entry.startedAt) } + : {}), + ...(workflowFiniteNumber(entry.lastProgressAt) !== undefined + ? { lastProgressAt: workflowFiniteNumber(entry.lastProgressAt) } + : {}), + ...(entry.cached === true ? { cached: true } : {}), + ...(workflowString(entry.remoteSessionId) !== undefined + ? { remoteSessionId: workflowString(entry.remoteSessionId) } + : {}), + ...(workflowString(entry.lastToolName) !== undefined + ? { lastToolName: workflowString(entry.lastToolName) } + : {}), + ...(workflowClippedString(entry.lastToolSummary, MAX_WORKFLOW_PREVIEW_CHARS) !== undefined + ? { + lastToolSummary: workflowClippedString(entry.lastToolSummary, MAX_WORKFLOW_PREVIEW_CHARS), + } + : {}), + ...(workflowClippedString(entry.promptPreview, MAX_WORKFLOW_PREVIEW_CHARS) !== undefined + ? { promptPreview: workflowClippedString(entry.promptPreview, MAX_WORKFLOW_PREVIEW_CHARS) } + : {}), + ...(workflowClippedString(entry.resultPreview, MAX_WORKFLOW_RESULT_PREVIEW_CHARS) !== undefined + ? { + resultPreview: workflowClippedString( + entry.resultPreview, + MAX_WORKFLOW_RESULT_PREVIEW_CHARS, + ), + } + : {}), + ...(workflowClippedString(entry.error, MAX_WORKFLOW_PREVIEW_CHARS) !== undefined + ? { error: workflowClippedString(entry.error, MAX_WORKFLOW_PREVIEW_CHARS) } + : {}), + ...(workflowFiniteNumber(entry.tokens) !== undefined + ? { tokens: workflowFiniteNumber(entry.tokens) } + : {}), + ...(workflowFiniteNumber(entry.toolCalls) !== undefined + ? { toolCalls: workflowFiniteNumber(entry.toolCalls) } + : {}), + ...(workflowFiniteNumber(entry.durationMs) !== undefined + ? { durationMs: workflowFiniteNumber(entry.durationMs) } + : {}), + }; +} + +/** + * Normalize the Claude Agent SDK's `workflow_progress` snapshot. + * + * The field is deliberate wire surface (the CLI's own /workflows view renders + * it) but is absent from the published SDK types, so every read is defensive: + * malformed entries and unknown entry types are dropped, previews are + * clipped, and entry counts are capped before the snapshot enters the + * runtime-event contract. + */ +function normalizeWorkflowProgress( + value: unknown, +): ReadonlyArray | undefined { + if (!Array.isArray(value) || value.length === 0) { + return undefined; + } + // Last write wins per index: the snapshot may re-emit an agent/phase slot + // (retries, later progress), and the cap must count unique slots, not raw + // entries, or repeats would freeze later agents in a stale state. + const agentsByIndex = new Map(); + const phasesByIndex = new Map(); + const logs: Array = []; + for (const raw of value) { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + continue; + } + const entry = raw as Record; + switch (entry.type) { + case "workflow_agent": { + const agent = normalizeWorkflowAgentEntry(entry); + if ( + agent && + (agentsByIndex.has(agent.index) || agentsByIndex.size < MAX_WORKFLOW_AGENT_ENTRIES) + ) { + agentsByIndex.set(agent.index, agent); + } + break; + } + case "workflow_phase": { + const index = workflowFiniteNumber(entry.index); + const title = workflowString(entry.title); + if ( + index !== undefined && + title !== undefined && + (phasesByIndex.has(index) || phasesByIndex.size < MAX_WORKFLOW_PHASE_ENTRIES) + ) { + phasesByIndex.set(index, { + type: "workflow_phase", + index, + title, + ...(workflowString(entry.kind) !== undefined + ? { kind: workflowString(entry.kind) } + : {}), + }); + } + break; + } + case "workflow_log": { + const logMessage = workflowClippedString(entry.message, MAX_WORKFLOW_RESULT_PREVIEW_CHARS); + if (logMessage !== undefined) { + logs.push({ type: "workflow_log", message: logMessage }); + } + break; + } + default: + break; + } + } + // Narration is append-only upstream; keep the newest lines when clipping. + const clippedLogs = + logs.length > MAX_WORKFLOW_LOG_ENTRIES ? logs.slice(-MAX_WORKFLOW_LOG_ENTRIES) : logs; + const entries = [...phasesByIndex.values(), ...agentsByIndex.values(), ...clippedLogs]; + return entries.length > 0 ? entries : undefined; +} + +function readClaudeWorkflowProgress( + message: SDKMessage, +): ReadonlyArray | undefined { + // `workflow_progress` is not yet declared on SDKTaskProgressMessage — this + // cast is the single place the undocumented field is read. + const raw = (message as { readonly workflow_progress?: unknown }).workflow_progress; + return normalizeWorkflowProgress(raw); +} + +function workflowHttpUrl(value: unknown): string | undefined { + const text = workflowString(value); + if (text === undefined) { + return undefined; + } + // Clients render this into an anchor href — restrict to web URLs so a + // hostile tool result cannot smuggle a javascript:/file: scheme through. + try { + const parsed = new URL(text); + return parsed.protocol === "https:" || parsed.protocol === "http:" ? text : undefined; + } catch { + return undefined; + } +} + +function normalizeWorkflowRunHandles( + toolUseResult: Record, +): WorkflowRunHandles | undefined { + const taskId = workflowString(toolUseResult.taskId); + if (taskId === undefined) { + return undefined; + } + return { + taskId, + ...(workflowString(toolUseResult.runId) !== undefined + ? { runId: workflowString(toolUseResult.runId) } + : {}), + ...(workflowString(toolUseResult.workflowName) !== undefined + ? { workflowName: workflowString(toolUseResult.workflowName) } + : {}), + ...(workflowString(toolUseResult.taskType) !== undefined + ? { taskType: workflowString(toolUseResult.taskType) } + : {}), + ...(workflowString(toolUseResult.scriptPath) !== undefined + ? { scriptPath: workflowString(toolUseResult.scriptPath) } + : {}), + ...(workflowString(toolUseResult.transcriptDir) !== undefined + ? { transcriptDir: workflowString(toolUseResult.transcriptDir) } + : {}), + ...(workflowHttpUrl(toolUseResult.sessionUrl) !== undefined + ? { sessionUrl: workflowHttpUrl(toolUseResult.sessionUrl) } + : {}), + ...(workflowString(toolUseResult.warning) !== undefined + ? { warning: workflowString(toolUseResult.warning) } + : {}), + }; +} + function readClaudeToolUseResult(message: SDKMessage): Record | undefined { if (message.type !== "user") { return undefined; @@ -2451,6 +2693,30 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }); } + if (!toolResult.isError && tool.toolName === WORKFLOW_TOOL_NAME && toolUseResult) { + const workflowHandles = normalizeWorkflowRunHandles(toolUseResult); + if (workflowHandles) { + const workflowMetaStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + type: "task.workflowMeta", + eventId: workflowMetaStamp.eventId, + provider: PROVIDER, + createdAt: workflowMetaStamp.createdAt, + threadId: context.session.threadId, + ...(context.turnState ? { turnId: asCanonicalTurnId(context.turnState.turnId) } : {}), + payload: workflowHandles, + providerRefs: nativeProviderRefs(context, { + providerItemId: tool.itemId, + }), + raw: { + source: "claude.sdk.message", + method: "claude/user", + payload: message, + }, + }); + } + } + context.inFlightTools.delete(index); } }); @@ -2673,10 +2939,13 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( taskId: RuntimeTaskId.make(message.task_id), description: message.description, ...(message.task_type ? { taskType: message.task_type } : {}), + ...(message.tool_use_id ? { toolUseId: message.tool_use_id } : {}), + ...(message.workflow_name ? { workflowName: message.workflow_name } : {}), }, }); return; - case "task_progress": + case "task_progress": { + const workflowProgress = readClaudeWorkflowProgress(message); yield* emitThreadTokenUsage( context, normalizeClaudeTaskProgressTokenUsage(message.usage, context), @@ -2694,9 +2963,18 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(message.summary ? { summary: message.summary } : {}), ...(message.usage ? { usage: message.usage } : {}), ...(message.last_tool_name ? { lastToolName: message.last_tool_name } : {}), + ...(workflowProgress !== undefined ? { workflowProgress } : {}), }, }); return; + } + case "task_updated": + // Task status patches (pause/background/description edits). The + // canonical lifecycle events above already carry everything the + // runtime-event model consumes; swallow these instead of routing + // them to the unknown-subtype warning path, which would fire on + // every workflow status transition. + return; case "task_notification": yield* emitThreadTokenUsage( context, @@ -3751,6 +4029,24 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( }, ); + const stopTask: NonNullable = Effect.fn("stopTask")( + function* (threadId, taskId) { + const context = yield* requireSession(threadId); + const stop = context.query.stopTask; + if (stop === undefined) { + return yield* toRequestError( + threadId, + "task/stop", + new Error("The Claude SDK runtime for this session does not expose stopTask."), + ); + } + yield* Effect.tryPromise({ + try: () => stop(taskId), + catch: (cause) => toRequestError(threadId, "task/stop", cause), + }); + }, + ); + const readThread: ClaudeAdapterShape["readThread"] = Effect.fn("readThread")( function* (threadId) { const context = yield* requireSession(threadId); @@ -3854,6 +4150,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( startSession, sendTurn, interruptTurn, + stopTask, readThread, rollbackThread, respondToRequest, diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 89e9c56eb8a..6b4e39dfc3a 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -1030,9 +1030,15 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => { entry.result.outcome !== null && "outcome" in entry.result.outcome && entry.result.outcome.outcome === "cancelled"; - yield* waitForJsonLogMatch(requestLogPath, (entry) => entry.method === "session/cancel"); + // Assert each condition against the snapshot its own wait resolved on: + // the two log appends are independent, so a snapshot that satisfies one + // predicate can miss (or tear) the other line under load. + const cancelRequests = yield* waitForJsonLogMatch( + requestLogPath, + (entry) => entry.method === "session/cancel", + ); + assert.isTrue(cancelRequests.some((entry) => entry.method === "session/cancel")); const requests = yield* waitForJsonLogMatch(requestLogPath, isCancelledApprovalResponse); - assert.isTrue(requests.some((entry) => entry.method === "session/cancel")); assert.isTrue(requests.some(isCancelledApprovalResponse)); yield* adapter.stopSession(threadId); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2eaaeb8ce3c..03df91c07d6 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -19,6 +19,7 @@ import { ProviderSendTurnInput, ProviderSessionStartInput, ProviderStopSessionInput, + ProviderStopTaskInput, type ProviderInstanceId, type ProviderDriverKind, type ProviderRuntimeEvent, @@ -754,6 +755,45 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( }, ); + const stopTask: ProviderServiceMethod<"stopTask"> = Effect.fn("stopTask")(function* (rawInput) { + const input = yield* decodeInputOrValidationError({ + operation: "ProviderService.stopTask", + schema: ProviderStopTaskInput, + payload: rawInput, + }); + // No recovery: a background task cannot be running without a live + // in-process session, so resurrecting one just to stop a task would be + // wasted work (and would resume the session as a side effect). + const routed = yield* resolveRoutableSession({ + threadId: input.threadId, + operation: "ProviderService.stopTask", + allowRecovery: false, + }); + yield* Effect.annotateCurrentSpan({ + "provider.operation": "stop-task", + "provider.kind": routed.adapter.provider, + "provider.thread_id": input.threadId, + "provider.task_id": input.taskId, + }); + if (!routed.isActive) { + return yield* toValidationError( + "ProviderService.stopTask", + "No active provider session is running for this thread.", + ); + } + const adapterStopTask = routed.adapter.stopTask; + if (adapterStopTask === undefined) { + return yield* toValidationError( + "ProviderService.stopTask", + `Provider '${routed.adapter.provider}' does not support stopping background tasks.`, + ); + } + yield* adapterStopTask(routed.threadId, input.taskId); + yield* analytics.record("provider.task.stopped", { + provider: routed.adapter.provider, + }); + }); + const respondToRequest: ProviderServiceMethod<"respondToRequest"> = Effect.fn("respondToRequest")( function* (rawInput) { const input = yield* decodeInputOrValidationError({ @@ -1075,6 +1115,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( respondToRequest, respondToUserInput, stopSession, + stopTask, listSessions, getCapabilities, getInstanceInfo, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index e976c183a43..72ad4e39010 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -153,6 +153,7 @@ describe("ProviderSessionReaper", () => { startSession: () => unsupported(), sendTurn: () => unsupported(), interruptTurn: () => unsupported(), + stopTask: () => unsupported(), respondToRequest: () => unsupported(), respondToUserInput: () => unsupported(), stopSession, diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 01eeae7b7bd..6f2e124b84f 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -68,6 +68,13 @@ export interface ProviderAdapterShape { */ readonly interruptTurn: (threadId: ThreadId, turnId?: TurnId) => Effect.Effect; + /** + * Stop one background task (e.g. a running workflow) inside an active + * session. Optional: adapters whose provider has no background-task + * runtime omit it, and callers surface an "unsupported" error. + */ + readonly stopTask?: (threadId: ThreadId, taskId: string) => Effect.Effect; + /** * Respond to an interactive approval request. */ diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index 4d4cb4fa01a..c9474014012 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -13,6 +13,7 @@ */ import type { ProviderInterruptTurnInput, + ProviderStopTaskInput, ProviderInstanceId, ProviderRespondToRequestInput, ProviderRespondToUserInputInput, @@ -72,6 +73,11 @@ export interface ProviderServiceShape { input: ProviderRespondToUserInputInput, ) => Effect.Effect; + /** + * Stop one background task inside an active provider session. + */ + readonly stopTask: (input: ProviderStopTaskInput) => Effect.Effect; + /** * Stop a provider session. */ diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 26528c84d34..2ef3dfd61d4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -103,6 +103,7 @@ import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as WorkflowInspection from "./workflow/WorkflowInspectionService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; @@ -670,6 +671,7 @@ const buildAppUnderTest = (options?: { registerTerminalProcesses: () => Effect.void, unregisterTerminal: () => Effect.void, }), + WorkflowInspection.layer, ), ), Layer.provide( diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0c632d8486c..b473099e8a2 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -65,6 +65,7 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as WorkflowInspection from "./workflow/WorkflowInspectionService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; @@ -291,7 +292,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), Layer.provideMerge(ProviderRuntimeLayerLive), - Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), + Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive, WorkflowInspection.layer)), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(Keybindings.layer), Layer.provideMerge(ProviderRegistryLive), diff --git a/apps/server/src/workflow/WorkflowInspectionService.test.ts b/apps/server/src/workflow/WorkflowInspectionService.test.ts new file mode 100644 index 00000000000..410ac39412c --- /dev/null +++ b/apps/server/src/workflow/WorkflowInspectionService.test.ts @@ -0,0 +1,349 @@ +// @effect-diagnostics nodeBuiltinImport:off - test builds fixtures via Node fs/path directly. +// @effect-diagnostics preferSchemaOverJson:off - fixtures serialize plain JSON journal records. +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; + +import * as WorkflowInspectionService from "./WorkflowInspectionService.ts"; + +interface Layout { + readonly root: string; + readonly transcriptDir: string; + readonly scriptsDir: string; + readonly scriptPath: string; +} + +const makeLayout = (fs: FileSystem.FileSystem, root: string) => + Effect.gen(function* () { + const sessionDir = NodePath.join(root, "proj", "sess"); + const transcriptDir = NodePath.join(sessionDir, "subagents", "workflows", "wf_abc"); + const scriptsDir = NodePath.join(sessionDir, "workflows", "scripts"); + yield* fs.makeDirectory(transcriptDir, { recursive: true }); + yield* fs.makeDirectory(scriptsDir, { recursive: true }); + return { + root, + transcriptDir, + scriptsDir, + scriptPath: NodePath.join(scriptsDir, "spec.js"), + } satisfies Layout; + }); + +/** Build a service instance whose projects root is an isolated temp dir. */ +const setup = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-workflow-inspection-" }); + const layout = yield* makeLayout(fs, root); + const service = yield* WorkflowInspectionService.make({ projectsRoot: root }); + return { fs, service, layout }; +}); + +describe("WorkflowInspectionService", () => { + describe("readScript", () => { + it.effect("reads a contained script and reports it untruncated", () => + Effect.gen(function* () { + const { fs, service, layout } = yield* setup; + yield* fs.writeFileString(layout.scriptPath, "export const run = () => 1;\n"); + + const result = yield* service.readScript({ scriptPath: layout.scriptPath }); + assert.equal(result.source, "export const run = () => 1;\n"); + assert.isFalse(result.truncated); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("clips scripts larger than the cap and marks them truncated", () => + Effect.gen(function* () { + const { fs, service, layout } = yield* setup; + const big = "a".repeat(512 * 1024 + 128); + yield* fs.writeFileString(layout.scriptPath, big); + + const result = yield* service.readScript({ scriptPath: layout.scriptPath }); + assert.isTrue(result.truncated); + assert.equal(result.source.length, 512 * 1024); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects a relative path as invalid-path", () => + Effect.gen(function* () { + const { service } = yield* setup; + const error = yield* service + .readScript({ scriptPath: "relative/spec.js" }) + .pipe(Effect.flip); + assert.equal(error._tag, "WorkflowInspectionError"); + assert.equal(error.reason, "invalid-path"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects a path outside the projects root as invalid-path", () => + Effect.gen(function* () { + const { fs, service } = yield* setup; + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-workflow-outside-" }); + const outsideScript = NodePath.join(outside, "escape.js"); + yield* fs.writeFileString(outsideScript, "export const x = 1;"); + + const error = yield* service.readScript({ scriptPath: outsideScript }).pipe(Effect.flip); + assert.equal(error._tag, "WorkflowInspectionError"); + assert.equal(error.reason, "invalid-path"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects a symlink inside the root that escapes it as invalid-path", () => + Effect.gen(function* () { + const { fs, service, layout } = yield* setup; + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-workflow-outside-" }); + const outsideScript = NodePath.join(outside, "real.js"); + yield* fs.writeFileString(outsideScript, "export const x = 1;"); + + const linkPath = NodePath.join(layout.scriptsDir, "link.js"); + yield* Effect.promise(() => NodeFSP.symlink(outsideScript, linkPath)); + + const error = yield* service.readScript({ scriptPath: linkPath }).pipe(Effect.flip); + assert.equal(error._tag, "WorkflowInspectionError"); + assert.equal(error.reason, "invalid-path"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects a non-script extension as invalid-path", () => + Effect.gen(function* () { + const { fs, service, layout } = yield* setup; + const badPath = NodePath.join(layout.scriptsDir, "spec.txt"); + yield* fs.writeFileString(badPath, "not a script"); + + const error = yield* service.readScript({ scriptPath: badPath }).pipe(Effect.flip); + assert.equal(error._tag, "WorkflowInspectionError"); + assert.equal(error.reason, "invalid-path"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reports a missing script as not-found", () => + Effect.gen(function* () { + const { service, layout } = yield* setup; + const missing = NodePath.join(layout.scriptsDir, "missing.js"); + const error = yield* service.readScript({ scriptPath: missing }).pipe(Effect.flip); + assert.equal(error._tag, "WorkflowInspectionError"); + assert.equal(error.reason, "not-found"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + }); + + describe("readJournal", () => { + it.effect("summarizes started and result records with clipping", () => + Effect.gen(function* () { + const { fs, service, layout } = yield* setup; + const bigResult = "z".repeat(64 * 1024); + const lines = [ + JSON.stringify({ type: "started", key: "k1", agentId: "a1" }), + JSON.stringify({ + type: "result", + key: "k1", + agentId: "a1", + result: { ok: true, value: 42 }, + }), + JSON.stringify({ type: "started", key: "k2", agentId: "a2" }), + "this-is-not-json{", + JSON.stringify({ type: "result", key: "k3", agentId: "a3", result: bigResult }), + ]; + yield* fs.writeFileString( + NodePath.join(layout.transcriptDir, "journal.jsonl"), + `${lines.join("\n")}\n`, + ); + + const result = yield* service.readJournal({ transcriptDir: layout.transcriptDir }); + assert.isFalse(result.truncated); + assert.deepEqual( + result.entries.map((entry) => entry.agentId), + ["a1", "a2", "a3"], + ); + + const a1 = result.entries[0]; + assert.isTrue(a1?.hasResult); + assert.equal(a1?.resultJson, JSON.stringify({ ok: true, value: 42 })); + assert.isUndefined(a1?.resultTruncated); + + const a2 = result.entries[1]; + assert.isFalse(a2?.hasResult); + assert.isUndefined(a2?.resultJson); + + const a3 = result.entries[2]; + assert.isTrue(a3?.hasResult); + assert.isTrue(a3?.resultTruncated); + assert.equal(a3?.resultJson?.length, 32 * 1024); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reports a missing journal as not-found", () => + Effect.gen(function* () { + const { service, layout } = yield* setup; + const error = yield* service + .readJournal({ transcriptDir: layout.transcriptDir }) + .pipe(Effect.flip); + assert.equal(error._tag, "WorkflowInspectionError"); + assert.equal(error.reason, "not-found"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects a journal.jsonl symlink that escapes the root as invalid-path", () => + Effect.gen(function* () { + const { fs, service, layout } = yield* setup; + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-workflow-outside-" }); + const secret = NodePath.join(outside, "secret.jsonl"); + yield* fs.writeFileString(secret, JSON.stringify({ type: "started", agentId: "leak" })); + + const escapeDir = NodePath.join(layout.transcriptDir, "..", "wf_journal_escape"); + yield* fs.makeDirectory(escapeDir, { recursive: true }); + yield* Effect.promise(() => + NodeFSP.symlink(secret, NodePath.join(escapeDir, "journal.jsonl")), + ); + + const error = yield* service.readJournal({ transcriptDir: escapeDir }).pipe(Effect.flip); + assert.equal(error._tag, "WorkflowInspectionError"); + assert.equal(error.reason, "invalid-path"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects a transcript dir outside the root as invalid-path", () => + Effect.gen(function* () { + const { fs, service } = yield* setup; + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-workflow-outside-" }); + yield* fs.writeFileString(NodePath.join(outside, "journal.jsonl"), ""); + + const error = yield* service.readJournal({ transcriptDir: outside }).pipe(Effect.flip); + assert.equal(error._tag, "WorkflowInspectionError"); + assert.equal(error.reason, "invalid-path"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + }); + + describe("readAgentTranscript", () => { + it.effect("reads the full transcript and reports completion", () => + Effect.gen(function* () { + const { fs, service, layout } = yield* setup; + yield* fs.writeFileString( + NodePath.join(layout.transcriptDir, "agent-a1.jsonl"), + "l0\nl1\nl2\n", + ); + + const result = yield* service.readAgentTranscript({ + transcriptDir: layout.transcriptDir, + agentId: "a1", + }); + assert.deepEqual(result.lines, ["l0", "l1", "l2"]); + assert.equal(result.nextLine, 3); + assert.isTrue(result.complete); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("returns the remainder from a mid-file cursor", () => + Effect.gen(function* () { + const { fs, service, layout } = yield* setup; + yield* fs.writeFileString( + NodePath.join(layout.transcriptDir, "agent-a1.jsonl"), + "l0\nl1\nl2\n", + ); + + const result = yield* service.readAgentTranscript({ + transcriptDir: layout.transcriptDir, + agentId: "a1", + afterLine: 1, + }); + assert.deepEqual(result.lines, ["l1", "l2"]); + assert.equal(result.nextLine, 3); + assert.isTrue(result.complete); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("returns empty and complete when the cursor is past end-of-file", () => + Effect.gen(function* () { + const { fs, service, layout } = yield* setup; + yield* fs.writeFileString( + NodePath.join(layout.transcriptDir, "agent-a1.jsonl"), + "l0\nl1\nl2\n", + ); + + const result = yield* service.readAgentTranscript({ + transcriptDir: layout.transcriptDir, + agentId: "a1", + afterLine: 10, + }); + assert.deepEqual(result.lines, []); + assert.equal(result.nextLine, 10); + assert.isTrue(result.complete); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects a traversal agent id as invalid-path", () => + Effect.gen(function* () { + const { service, layout } = yield* setup; + const error = yield* service + .readAgentTranscript({ transcriptDir: layout.transcriptDir, agentId: "../journal" }) + .pipe(Effect.flip); + assert.equal(error._tag, "WorkflowInspectionError"); + assert.equal(error.reason, "invalid-path"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("pages a long transcript at the line cap", () => + Effect.gen(function* () { + const { fs, service, layout } = yield* setup; + const lines = Array.from({ length: 500 }, (_unused, index) => `line-${index}`); + yield* fs.writeFileString( + NodePath.join(layout.transcriptDir, "agent-a1.jsonl"), + lines.join("\n"), + ); + + const first = yield* service.readAgentTranscript({ + transcriptDir: layout.transcriptDir, + agentId: "a1", + }); + assert.equal(first.lines.length, 400); + assert.equal(first.nextLine, 400); + assert.isFalse(first.complete); + + const second = yield* service.readAgentTranscript({ + transcriptDir: layout.transcriptDir, + agentId: "a1", + afterLine: first.nextLine, + }); + assert.equal(second.lines.length, 100); + assert.equal(second.nextLine, 500); + assert.isTrue(second.complete); + assert.equal(second.lines[99], "line-499"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("rejects an agent transcript symlink that escapes the root as invalid-path", () => + Effect.gen(function* () { + const { fs, service, layout } = yield* setup; + const outside = yield* fs.makeTempDirectoryScoped({ prefix: "t3-workflow-outside-" }); + const secret = NodePath.join(outside, "secret.txt"); + yield* fs.writeFileString(secret, "root:x:0:0::/root:/bin/bash"); + + const escapeDir = NodePath.join(layout.transcriptDir, "..", "wf_transcript_escape"); + yield* fs.makeDirectory(escapeDir, { recursive: true }); + yield* Effect.promise(() => + NodeFSP.symlink(secret, NodePath.join(escapeDir, "agent-leak.jsonl")), + ); + + const error = yield* service + .readAgentTranscript({ transcriptDir: escapeDir, agentId: "leak" }) + .pipe(Effect.flip); + assert.equal(error._tag, "WorkflowInspectionError"); + assert.equal(error.reason, "invalid-path"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reports a missing transcript as not-found", () => + Effect.gen(function* () { + const { service, layout } = yield* setup; + const error = yield* service + .readAgentTranscript({ transcriptDir: layout.transcriptDir, agentId: "missing" }) + .pipe(Effect.flip); + assert.equal(error._tag, "WorkflowInspectionError"); + assert.equal(error.reason, "not-found"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + }); +}); diff --git a/apps/server/src/workflow/WorkflowInspectionService.ts b/apps/server/src/workflow/WorkflowInspectionService.ts new file mode 100644 index 00000000000..06ef488523c --- /dev/null +++ b/apps/server/src/workflow/WorkflowInspectionService.ts @@ -0,0 +1,319 @@ +// @effect-diagnostics nodeBuiltinImport:off - realpath containment must use Node's fs/path directly. +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { + WorkflowInspectionError, + type WorkflowReadAgentTranscriptInput, + type WorkflowReadAgentTranscriptResult, + type WorkflowReadJournalInput, + type WorkflowReadJournalResult, + type WorkflowReadScriptInput, + type WorkflowReadScriptResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; + +/** + * Read-only inspection of Claude Agent SDK workflow-run artifacts on local + * disk. Clients echo server-local paths back over RPC, so every path is + * validated structurally (absolute + realpath-contained inside the projects + * root) before any disk access — the service must never become an + * arbitrary-file-read oracle. + */ + +/** `readScript`: clip source text past this many characters. */ +const SCRIPT_MAX_CHARS = 512 * 1024; +/** `readJournal`: clip each serialized result past this many characters. */ +const JOURNAL_RESULT_MAX_CHARS = 32 * 1024; +/** `readJournal`: cap the number of distinct agents reported. */ +const JOURNAL_MAX_ENTRIES = 512; +/** `readAgentTranscript`: cap the number of lines returned per page. */ +const TRANSCRIPT_MAX_LINES = 400; +/** `readAgentTranscript`: stop a page once accumulated chars exceed this. */ +const TRANSCRIPT_MAX_CHARS = 768 * 1024; + +/** Only these agent id shapes are accepted before touching the filesystem. */ +const AGENT_ID_PATTERN = /^[A-Za-z0-9_-]+$/; + +/** Mutable while parsing the journal; frozen into the readonly contract shape. */ +interface MutableJournalEntry { + agentId: string; + hasResult: boolean; + resultJson?: string; + resultTruncated?: boolean; +} + +/** Parse one JSONL line defensively; unparseable lines return `undefined`. */ +const parseJsonLine = (text: string): unknown => { + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +}; + +const isEnoent = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "code" in cause && + (cause as { code?: unknown }).code === "ENOENT"; + +const isNotFoundPlatformError = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "reason" in cause && + typeof (cause as { reason?: unknown }).reason === "object" && + (cause as { reason: { _tag?: unknown } }).reason !== null && + (cause as { reason: { _tag?: unknown } }).reason._tag === "NotFound"; + +export class WorkflowInspectionService extends Context.Service< + WorkflowInspectionService, + { + readonly readScript: ( + input: WorkflowReadScriptInput, + ) => Effect.Effect; + readonly readJournal: ( + input: WorkflowReadJournalInput, + ) => Effect.Effect; + readonly readAgentTranscript: ( + input: WorkflowReadAgentTranscriptInput, + ) => Effect.Effect; + } +>()("t3/workflow/WorkflowInspectionService") {} + +export const make = (options?: { readonly projectsRoot?: string }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const projectsRoot = + options?.projectsRoot ?? NodePath.join(NodeOS.homedir(), ".claude", "projects"); + + /** + * Resolve the real path of `target` and prove it is contained within the + * real projects root using a path-segment-safe prefix comparison. ENOENT + * during either realpath maps to `not-found`; escape maps to + * `invalid-path`. + */ + const resolveContained = Effect.fn("WorkflowInspectionService.resolveContained")(function* ( + operation: string, + target: string, + ) { + if (!NodePath.isAbsolute(target)) { + return yield* new WorkflowInspectionError({ + operation, + reason: "invalid-path", + detail: "Path must be absolute.", + }); + } + + const realRoot = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(projectsRoot), + catch: (cause) => + new WorkflowInspectionError({ + operation, + reason: isEnoent(cause) ? "not-found" : "read-failed", + detail: "Failed to resolve the workflow projects root.", + cause, + }), + }); + + const realTarget = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(target), + catch: (cause) => + new WorkflowInspectionError({ + operation, + reason: isEnoent(cause) ? "not-found" : "read-failed", + detail: "Failed to resolve the requested path.", + cause, + }), + }); + + if (realTarget !== realRoot && !realTarget.startsWith(realRoot + NodePath.sep)) { + return yield* new WorkflowInspectionError({ + operation, + reason: "invalid-path", + detail: "Path escapes the workflow projects root.", + }); + } + + return realTarget; + }); + + const readScript = Effect.fn("WorkflowInspectionService.readScript")(function* ( + input: WorkflowReadScriptInput, + ) { + const operation = "WorkflowInspectionService.readScript"; + if (!input.scriptPath.endsWith(".js") && !input.scriptPath.endsWith(".mjs")) { + return yield* new WorkflowInspectionError({ + operation, + reason: "invalid-path", + detail: "Script path must end with .js or .mjs.", + }); + } + + const realPath = yield* resolveContained(operation, input.scriptPath); + const source = yield* fs.readFileString(realPath).pipe( + Effect.mapError( + (cause) => + new WorkflowInspectionError({ + operation, + reason: isNotFoundPlatformError(cause) ? "not-found" : "read-failed", + detail: "Failed to read the workflow script.", + cause, + }), + ), + ); + + const truncated = source.length > SCRIPT_MAX_CHARS; + return { + source: truncated ? source.slice(0, SCRIPT_MAX_CHARS) : source, + truncated, + } satisfies WorkflowReadScriptResult; + }); + + const readJournal = Effect.fn("WorkflowInspectionService.readJournal")(function* ( + input: WorkflowReadJournalInput, + ) { + const operation = "WorkflowInspectionService.readJournal"; + const realDir = yield* resolveContained(operation, input.transcriptDir); + // Re-contain the joined leaf: a symlink named journal.jsonl inside a + // valid directory must not escape the projects root. + const journalPath = yield* resolveContained( + operation, + NodePath.join(realDir, "journal.jsonl"), + ); + const raw = yield* fs.readFileString(journalPath).pipe( + Effect.mapError( + (cause) => + new WorkflowInspectionError({ + operation, + reason: isNotFoundPlatformError(cause) ? "not-found" : "read-failed", + detail: "Failed to read the workflow journal.", + cause, + }), + ), + ); + + // Preserve first-seen agent order via an insertion-ordered Map. + const entries = new Map(); + let truncated = false; + + const ensureEntry = (agentId: string): MutableJournalEntry | undefined => { + const existing = entries.get(agentId); + if (existing !== undefined) return existing; + if (entries.size >= JOURNAL_MAX_ENTRIES) { + truncated = true; + return undefined; + } + const created: MutableJournalEntry = { agentId, hasResult: false }; + entries.set(agentId, created); + return created; + }; + + for (const line of raw.split("\n")) { + const text = line.trim(); + if (text.length === 0) continue; + const record = parseJsonLine(text); + if (record === undefined) continue; + if (typeof record !== "object" || record === null) continue; + const parsed = record as { + type?: unknown; + agentId?: unknown; + result?: unknown; + }; + if (typeof parsed.agentId !== "string" || parsed.agentId.length === 0) continue; + + if (parsed.type === "started") { + ensureEntry(parsed.agentId); + continue; + } + if (parsed.type === "result") { + const entry = ensureEntry(parsed.agentId); + if (entry === undefined) continue; + entry.hasResult = true; + // @effect-diagnostics-next-line preferSchemaOverJson:off - result is arbitrary JSON re-serialized verbatim. + const serialized = JSON.stringify(parsed.result); + if (serialized !== undefined) { + const resultTruncated = serialized.length > JOURNAL_RESULT_MAX_CHARS; + entry.resultJson = resultTruncated + ? serialized.slice(0, JOURNAL_RESULT_MAX_CHARS) + : serialized; + if (resultTruncated) entry.resultTruncated = true; + } + } + } + + return { + entries: Array.from(entries.values()), + truncated, + } satisfies WorkflowReadJournalResult; + }); + + const readAgentTranscript = Effect.fn("WorkflowInspectionService.readAgentTranscript")( + function* (input: WorkflowReadAgentTranscriptInput) { + const operation = "WorkflowInspectionService.readAgentTranscript"; + if (!AGENT_ID_PATTERN.test(input.agentId)) { + return yield* new WorkflowInspectionError({ + operation, + reason: "invalid-path", + detail: "Agent id contains unsupported characters.", + }); + } + + const realDir = yield* resolveContained(operation, input.transcriptDir); + // Re-contain the joined leaf: a symlink named agent-.jsonl inside + // a valid directory must not escape the projects root. + const transcriptPath = yield* resolveContained( + operation, + NodePath.join(realDir, `agent-${input.agentId}.jsonl`), + ); + // v1 reads the whole file per page; acceptable for current transcript + // sizes. Revisit with a streaming/seek reader if transcripts grow large. + const raw = yield* fs.readFileString(transcriptPath).pipe( + Effect.mapError( + (cause) => + new WorkflowInspectionError({ + operation, + reason: isNotFoundPlatformError(cause) ? "not-found" : "read-failed", + detail: "Failed to read the agent transcript.", + cause, + }), + ), + ); + + const allLines = raw.split("\n"); + if (allLines.length > 0 && allLines[allLines.length - 1] === "") { + allLines.pop(); + } + const total = allLines.length; + const afterLine = Math.max(0, input.afterLine ?? 0); + + const lines: string[] = []; + let accumulated = 0; + for (let index = afterLine; index < total && lines.length < TRANSCRIPT_MAX_LINES; index++) { + const current = allLines[index] ?? ""; + lines.push(current); + accumulated += current.length; + if (accumulated > TRANSCRIPT_MAX_CHARS) break; + } + + const nextLine = afterLine + lines.length; + return { + lines, + nextLine, + complete: nextLine >= total, + } satisfies WorkflowReadAgentTranscriptResult; + }, + ); + + return WorkflowInspectionService.of({ + readScript, + readJournal, + readAgentTranscript, + }); + }); + +export const layer = Layer.effect(WorkflowInspectionService, make()); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9020e99f670..32355654947 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -89,6 +89,7 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; +import * as WorkflowInspection from "./workflow/WorkflowInspectionService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; @@ -313,6 +314,9 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope], [WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope], [WS_METHODS.vcsListRefs, AuthOrchestrationReadScope], + [WS_METHODS.workflowReadScript, AuthOrchestrationReadScope], + [WS_METHODS.workflowReadJournal, AuthOrchestrationReadScope], + [WS_METHODS.workflowReadAgentTranscript, AuthOrchestrationReadScope], [WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope], [WS_METHODS.vcsRemoveWorktree, AuthOrchestrationOperateScope], [WS_METHODS.vcsCreateRef, AuthOrchestrationOperateScope], @@ -399,6 +403,7 @@ const makeWsRpcLayer = ( const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const workflowInspection = yield* WorkflowInspection.WorkflowInspectionService; const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; @@ -1559,6 +1564,20 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.reviewGetDiffPreview, review.getDiffPreview(input), { "rpc.aggregate": "review", }), + [WS_METHODS.workflowReadScript]: (input) => + observeRpcEffect(WS_METHODS.workflowReadScript, workflowInspection.readScript(input), { + "rpc.aggregate": "workflow", + }), + [WS_METHODS.workflowReadJournal]: (input) => + observeRpcEffect(WS_METHODS.workflowReadJournal, workflowInspection.readJournal(input), { + "rpc.aggregate": "workflow", + }), + [WS_METHODS.workflowReadAgentTranscript]: (input) => + observeRpcEffect( + WS_METHODS.workflowReadAgentTranscript, + workflowInspection.readAgentTranscript(input), + { "rpc.aggregate": "workflow" }, + ), [WS_METHODS.terminalOpen]: (input) => observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { "rpc.aggregate": "terminal", diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f5ea5bb1eba..b77ccc3848b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -249,6 +249,8 @@ import { resolveServerConfigVersionMismatch, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; +import { deriveWorkflowRuns } from "../workflow-logic"; +import { WorkflowPanel } from "./workflow/WorkflowPanel"; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; @@ -1012,6 +1014,7 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const stopThreadTask = useAtomCommand(threadEnvironment.stopTask, { reportFailure: true }); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); @@ -1727,6 +1730,44 @@ function ChatViewContent(props: ChatViewProps) { const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo(() => deriveWorkLogEntries(threadActivities), [threadActivities]); + // Mirrors derivePhase: interrupted/stopped/error sessions are all + // disconnected, and a workflow cannot still be running under any of them. + const workflowSessionActive = phase !== "disconnected"; + const workflowRuns = useMemo( + () => deriveWorkflowRuns(threadActivities, { sessionActive: workflowSessionActive }), + [threadActivities, workflowSessionActive], + ); + const activeWorkflowSurface = + activeRightPanelSurface?.kind === "workflow" ? activeRightPanelSurface : null; + const activeWorkflowRun = useMemo( + () => + activeWorkflowSurface + ? (workflowRuns.find((run) => run.taskId === activeWorkflowSurface.taskId) ?? null) + : null, + [activeWorkflowSurface, workflowRuns], + ); + const onOpenWorkflowDetails = useCallback( + (taskId: string) => { + if (!activeThreadRef) { + return; + } + useRightPanelStore.getState().openWorkflow(activeThreadRef, taskId); + }, + [activeThreadRef], + ); + const onStopWorkflowTask = useMemo(() => { + if (!activeThread || activeThread.session?.status === "stopped") { + return null; + } + const threadId = activeThread.id; + const threadEnvironmentId = activeThread.environmentId; + return (taskId: string) => { + void stopThreadTask({ + environmentId: threadEnvironmentId, + input: { threadId, taskId }, + }); + }; + }, [activeThread, stopThreadTask]); const pendingApprovals = useMemo( () => derivePendingApprovals(threadActivities), [threadActivities], @@ -2060,8 +2101,13 @@ function ChatViewContent(props: ChatViewProps) { }, [attachmentPreviewHandoffByMessageId, displayServerMessages, optimisticUserMessages]); const timelineEntries = useMemo( () => - deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), - [activeThread?.proposedPlans, timelineMessages, workLogEntries], + deriveTimelineEntries( + timelineMessages, + activeThread?.proposedPlans ?? [], + workLogEntries, + workflowRuns, + ), + [activeThread?.proposedPlans, timelineMessages, workLogEntries, workflowRuns], ); const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); @@ -4970,6 +5016,16 @@ function ChatViewContent(props: ChatViewProps) { + ) : activeRightPanelSurface?.kind === "workflow" ? ( + onStopWorkflowTask(activeWorkflowRun.taskId) + : undefined + } + /> ) : activeRightPanelSurface?.kind === "plan" ? ( ; case "plan": return ; + case "workflow": + return ; } } diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index c6e277cce08..40adf7f18d3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -6,6 +6,7 @@ import { type TimelineEntry, type WorkLogEntry, } from "../../session-logic"; +import type { WorkflowRun } from "../../workflow-logic"; import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../../types"; import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; @@ -136,6 +137,12 @@ export type MessagesTimelineRow = createdAt: string; proposedPlan: ProposedPlan; } + | { + kind: "workflow"; + id: string; + createdAt: string; + workflowRun: WorkflowRun; + } | { kind: "working"; id: string; createdAt: string | null }; export interface StableMessagesTimelineRowsState { @@ -488,6 +495,16 @@ export function deriveMessagesTimelineRows(input: { continue; } + if (timelineEntry.kind === "workflow") { + nextRows.push({ + kind: "workflow", + id: timelineEntry.id, + createdAt: timelineEntry.createdAt, + workflowRun: timelineEntry.workflowRun, + }); + continue; + } + const assistantTurnStillInProgress = timelineEntry.message.role === "assistant" && unsettledTurnId !== null && @@ -571,6 +588,19 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean case "proposed-plan": return a.proposedPlan === (b as typeof a).proposedPlan; + case "workflow": { + const bw = b as typeof a; + // WorkflowRun view models are rebuilt per derivation; `revision` is a + // deterministic per-run change counter, so equal revisions (and status, + // which sessionActive can flip without a new activity) mean identical + // content. + return ( + a.createdAt === bw.createdAt && + a.workflowRun.status === bw.workflowRun.status && + a.workflowRun.revision === bw.workflowRun.revision + ); + } + case "work": return Equal.equals(a.groupedEntries, (b as typeof a).groupedEntries); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 0957e025311..84006a95106 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -177,6 +177,8 @@ function buildProps() { turnDiffSummaryByAssistantMessageId: new Map(), routeThreadKey: "environment-local:thread-1", onOpenTurnDiff: () => {}, + onOpenWorkflowDetails: () => {}, + onStopWorkflowTask: null, revertTurnCountByUserMessageId: new Map(), onRevertUserMessage: () => {}, isRevertingCheckpoint: false, diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1a4dc6b6895..e07ee8a178c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -112,6 +112,7 @@ import { parseReviewCommentMessageSegments, type ReviewCommentContext, } from "../../reviewCommentContext"; +import { WorkflowRunCard } from "../workflow/WorkflowRunCard"; // --------------------------------------------------------------------------- // Context — shared state consumed by every row component via Context. @@ -134,6 +135,8 @@ interface TimelineRowSharedState { onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; onToggleWorkGroup: (groupId: string, anchorElement?: HTMLElement) => void; + onOpenWorkflowDetails: (taskId: string) => void; + onStopWorkflowTask: ((taskId: string) => void) | null; } interface TimelineRowActivityState { @@ -173,6 +176,8 @@ interface MessagesTimelineProps { timestampFormat: TimestampFormat; workspaceRoot: string | undefined; skills?: ReadonlyArray>; + onOpenWorkflowDetails: (taskId: string) => void; + onStopWorkflowTask: ((taskId: string) => void) | null; anchorMessageId: MessageId | null; onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; onAnchorSizeChanged: (messageId: MessageId, size: number) => void; @@ -206,6 +211,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, + onOpenWorkflowDetails, + onStopWorkflowTask, anchorMessageId, onAnchorReady, onAnchorSizeChanged, @@ -421,6 +428,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + onOpenWorkflowDetails, + onStopWorkflowTask, }), [ timestampFormat, @@ -435,6 +444,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, onToggleTurnFold, onToggleWorkGroup, + onOpenWorkflowDetails, + onStopWorkflowTask, ], ); const activityState = useMemo( @@ -820,11 +831,30 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time ) : null} {row.kind === "proposed-plan" ? : null} + {row.kind === "workflow" ? : null} {row.kind === "working" ? : null} ); }); +function WorkflowTimelineRow({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + const run = row.workflowRun; + const onOpenDetails = useCallback(() => { + ctx.onOpenWorkflowDetails(run.taskId); + }, [ctx, run.taskId]); + const stopHandler = ctx.onStopWorkflowTask; + const onStop = useMemo(() => { + if (run.status !== "running" || stopHandler === null) { + return undefined; + } + return () => { + stopHandler(run.taskId); + }; + }, [run.status, run.taskId, stopHandler]); + return ; +} + function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const userImages = row.message.attachments ?? []; diff --git a/apps/web/src/components/workflow/WorkflowPanel.tsx b/apps/web/src/components/workflow/WorkflowPanel.tsx new file mode 100644 index 00000000000..ad4daac8f4b --- /dev/null +++ b/apps/web/src/components/workflow/WorkflowPanel.tsx @@ -0,0 +1,753 @@ +import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { CheckIcon, ChevronRightIcon, CopyIcon, ExternalLinkIcon, NetworkIcon } from "lucide-react"; +import { + Component, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type ReactElement, + type ReactNode, + Suspense, + use, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { useTheme } from "~/hooks/useTheme"; +import { type DiffThemeName, resolveDiffThemeName } from "~/lib/diffRendering"; +import { cn } from "~/lib/utils"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "~/state/use-atom-command"; +import { workflowEnvironment } from "~/state/workflow"; +import { + isRemoteWorkflowRun, + type WorkflowRun, + type WorkflowRunAgent, + type WorkflowRunStatus, + workflowRunTitle, +} from "~/workflow-logic"; +import { Button } from "../ui/button"; +import { + AgentRowContent, + PhaseHeader, + WorkflowStatusChip, + safeWorkflowSessionUrl, +} from "./workflowUi"; +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; + +type WorkflowTabId = "run" | "script" | "logs"; + +const WORKFLOW_TABS: ReadonlyArray<{ id: WorkflowTabId; label: string }> = [ + { id: "run", label: "Run" }, + { id: "script", label: "Script" }, + { id: "logs", label: "Logs" }, +]; + +// --------------------------------------------------------------------------- +// Root — handles the not-found empty state, then delegates to the inner panel +// so every data hook runs unconditionally. +// --------------------------------------------------------------------------- + +export function WorkflowPanel(props: { + workflowRun: WorkflowRun | null; + environmentId: EnvironmentId; + onStop?: (() => void) | undefined; +}): ReactElement { + if (props.workflowRun === null) { + return ( +
+ Workflow not found +
+ ); + } + return ( + + ); +} + +function WorkflowPanelInner({ + run, + environmentId, + onStop, +}: { + run: WorkflowRun; + environmentId: EnvironmentId; + onStop?: (() => void) | undefined; +}): ReactElement { + const [tab, setTab] = useState("run"); + const { resolvedTheme } = useTheme(); + const themeName = resolveDiffThemeName(resolvedTheme); + + const scriptPath = run.handles?.scriptPath; + const transcriptDir = run.handles?.transcriptDir; + const remote = isRemoteWorkflowRun(run); + const isTerminal = run.status !== "running"; + + const scriptQuery = useEnvironmentQuery( + tab === "script" && scriptPath !== undefined + ? workflowEnvironment.readScript({ environmentId, input: { scriptPath } }) + : null, + ); + const journalQuery = useEnvironmentQuery( + tab === "logs" && transcriptDir !== undefined + ? workflowEnvironment.readJournal({ environmentId, input: { transcriptDir } }) + : null, + ); + + return ( +
+
+ + + {workflowRunTitle(run)} + + +
+ {isTerminal && run.handles?.runId !== undefined && scriptPath !== undefined && ( + + )} + {onStop && ( + + )} +
+
+ +
+ {WORKFLOW_TABS.map((entry) => ( + + ))} +
+ +
+ {tab === "run" && ( + + )} + {tab === "script" && ( + + )} + {tab === "logs" && ( + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Header copy button +// --------------------------------------------------------------------------- + +function CopyResumeButton({ + scriptPath, + runId, +}: { + scriptPath: string; + runId: string; +}): ReactElement { + const { copyToClipboard, isCopied } = useCopyToClipboard({ + timeout: 1200, + target: "workflow resume command", + }); + + const handleCopy = useCallback(() => { + copyToClipboard(`Workflow({ scriptPath: "${scriptPath}", resumeFromRunId: "${runId}" })`); + }, [copyToClipboard, runId, scriptPath]); + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Run tab +// --------------------------------------------------------------------------- + +function RunTab({ + run, + environmentId, + transcriptDir, + remote, +}: { + run: WorkflowRun; + environmentId: EnvironmentId; + transcriptDir: string | undefined; + remote: boolean; +}): ReactElement { + if (remote) { + const sessionUrl = safeWorkflowSessionUrl(run.handles?.sessionUrl); + if (sessionUrl === undefined) { + return ; + } + return ( + + + Running in the cloud — open session + + ); + } + + if (run.phases.length === 0) { + return ; + } + + return ( +
+ {run.phases.map((phase) => ( +
+ + {phase.agents.map((agent) => ( + + ))} +
+ ))} +
+ ); +} + +const stopPropagation = (event: ReactMouseEvent) => event.stopPropagation(); + +function ExpandableAgentRow({ + agent, + environmentId, + transcriptDir, + runStatus, +}: { + agent: WorkflowRunAgent; + environmentId: EnvironmentId; + transcriptDir: string | undefined; + runStatus: WorkflowRunStatus; +}): ReactElement { + const [expanded, setExpanded] = useState(false); + const agentId = agent.agentId; + const canExpand = + agentId !== undefined && transcriptDir !== undefined && agent.isolation !== "remote"; + + const toggle = useCallback(() => setExpanded((value) => !value), []); + const onKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggle(); + } + }, + [toggle], + ); + + const leading = canExpand ? ( + + ) : ( + + ); + + return ( +
+ + {canExpand && expanded && agentId !== undefined && transcriptDir !== undefined && ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Transcript view (cursor-paged, polled while running) +// --------------------------------------------------------------------------- + +interface TranscriptEntry { + kind: "text" | "tool"; + text: string; +} + +const TRANSCRIPT_MAX_RETAINED_LINES = 600; +const TRANSCRIPT_TEXT_MAX_CHARS = 600; +const TRANSCRIPT_TOOL_PREVIEW_CHARS = 120; + +function clipTranscriptText(text: string, limit: number): string { + const trimmed = text.trim(); + return trimmed.length > limit ? `${trimmed.slice(0, limit)}\u2026` : trimmed; +} + +function toolInputPreview(input: unknown): string | undefined { + if (typeof input !== "object" || input === null) { + return undefined; + } + const record = input as Record; + // The most informative single field per common tool, else the first string. + const preferred = record.command ?? record.file_path ?? record.pattern ?? record.prompt; + const value = + typeof preferred === "string" + ? preferred + : Object.values(record).find((entry): entry is string => typeof entry === "string"); + return value !== undefined ? clipTranscriptText(value, TRANSCRIPT_TOOL_PREVIEW_CHARS) : undefined; +} + +/** + * Distill one transcript JSONL line into displayable entries. Only assistant + * text and tool calls render; user turns, tool results, attachments, thinking, + * and harness metadata are skipped — they dominate line counts on long runs + * and read as noise ("user", "attachment", ...) when printed raw. + */ +function parseTranscriptEntries(raw: string): TranscriptEntry[] { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (typeof parsed !== "object" || parsed === null) { + return []; + } + const record = parsed as Record; + if (record.type !== "assistant") { + return []; + } + const message = + typeof record.message === "object" && record.message !== null + ? (record.message as Record) + : record; + const content = message.content; + if (typeof content === "string") { + const text = clipTranscriptText(content, TRANSCRIPT_TEXT_MAX_CHARS); + return text.length > 0 ? [{ kind: "text", text }] : []; + } + if (!Array.isArray(content)) { + return []; + } + const entries: TranscriptEntry[] = []; + for (const block of content) { + if (typeof block !== "object" || block === null) { + continue; + } + const blockRecord = block as Record; + if (blockRecord.type === "text" && typeof blockRecord.text === "string") { + const text = clipTranscriptText(blockRecord.text, TRANSCRIPT_TEXT_MAX_CHARS); + if (text.length > 0) { + entries.push({ kind: "text", text }); + } + } else if (blockRecord.type === "tool_use" && typeof blockRecord.name === "string") { + const preview = toolInputPreview(blockRecord.input); + entries.push({ + kind: "tool", + text: preview !== undefined ? `${blockRecord.name} ${preview}` : blockRecord.name, + }); + } + } + return entries; +} + +function AgentTranscriptView({ + environmentId, + transcriptDir, + agentId, + runStatus, +}: { + environmentId: EnvironmentId; + transcriptDir: string; + agentId: string; + runStatus: WorkflowRunStatus; +}): ReactElement { + const runTranscript = useAtomCommand( + workflowEnvironment.readAgentTranscript, + "workflow read transcript", + ); + const [lines, setLines] = useState([]); + const [trimmed, setTrimmed] = useState(false); + const [failed, setFailed] = useState(false); + const [loading, setLoading] = useState(false); + // Render-visible mirror of completeRef: "have we caught up to EOF at least + // once" — before that, an absence of parsed entries just means we are + // still paging, not that the agent produced no output. + const [caughtUp, setCaughtUp] = useState(false); + const transcriptEntries = useMemo(() => lines.flatMap(parseTranscriptEntries), [lines]); + const nextLineRef = useRef(0); + const completeRef = useRef(false); + const loadingRef = useRef(false); + + const loadMore = useCallback(async () => { + // `complete` only means the last read caught up to end-of-file; a live + // run keeps appending, so polling must keep re-reading past prior EOF. + if (loadingRef.current) { + return; + } + loadingRef.current = true; + setLoading(true); + const result = await runTranscript({ + environmentId, + input: { transcriptDir, agentId, afterLine: nextLineRef.current }, + }); + loadingRef.current = false; + setLoading(false); + if (result._tag !== "Success") { + setFailed(true); + return; + } + setFailed(false); + nextLineRef.current = result.value.nextLine; + completeRef.current = result.value.complete; + if (result.value.complete) { + setCaughtUp(true); + } + if (result.value.lines.length > 0) { + // Long-running agents can produce transcripts far larger than a view + // needs — retain a bounded tail so memory stays flat on million-token + // threads. + setLines((prev) => { + const merged = [...prev, ...result.value.lines]; + if (merged.length > TRANSCRIPT_MAX_RETAINED_LINES) { + setTrimmed(true); + return merged.slice(-TRANSCRIPT_MAX_RETAINED_LINES); + } + return merged; + }); + } + }, [agentId, environmentId, runTranscript, transcriptDir]); + + // Drain all currently-available pages when the row opens. + useEffect(() => { + const control = { cancelled: false }; + const drain = async () => { + while (!control.cancelled && !completeRef.current) { + const before = nextLineRef.current; + await loadMore(); + if (control.cancelled || nextLineRef.current === before) { + break; + } + } + }; + void drain(); + return () => { + control.cancelled = true; + }; + }, [loadMore]); + + // Keep polling for new lines while the run is live; when the run settles + // (or the row opens on an already-terminal run), fetch once more so lines + // appended after the last poll tick are not lost. + useEffect(() => { + if (runStatus !== "running") { + void loadMore(); + return; + } + const id = setInterval(() => { + void loadMore(); + }, 2000); + return () => clearInterval(id); + }, [loadMore, runStatus]); + + return ( +
+ {transcriptEntries.length === 0 ? ( + failed ? ( +

Failed to load transcript.

+ ) : loading || !caughtUp ? ( +

Loading transcript…

+ ) : ( +

No assistant output yet.

+ ) + ) : ( + <> + {trimmed && ( +

+ Earlier activity trimmed — showing the latest entries. +

+ )} + {transcriptEntries.map((entry, index) => ( +
+ {entry.kind === "tool" ? `→ ${entry.text}` : entry.text} +
+ ))} + + )} + {failed && lines.length > 0 && ( +

Failed to load more transcript.

+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Script tab +// --------------------------------------------------------------------------- + +let cachedScriptHighlighter: Promise | undefined; + +function getScriptHighlighter(): Promise { + cachedScriptHighlighter ??= getSharedHighlighter({ + themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], + langs: ["javascript" as SupportedLanguages], + preferredHighlighter: "shiki-js", + }); + return cachedScriptHighlighter; +} + +class WorkflowCodeErrorBoundary extends Component< + { fallback: ReactNode; children: ReactNode }, + { hasError: boolean } +> { + constructor(props: { fallback: ReactNode; children: ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + override render() { + return this.state.hasError ? this.props.fallback : this.props.children; + } +} + +function ScriptHighlight({ + source, + themeName, +}: { + source: string; + themeName: DiffThemeName; +}): ReactElement { + const highlighter = use(getScriptHighlighter()); + const html = useMemo( + () => highlighter.codeToHtml(source, { lang: "javascript", theme: themeName }), + [highlighter, source, themeName], + ); + return ( +
+ ); +} + +function ScriptTab({ + scriptPath, + query, + themeName, +}: { + scriptPath: string | undefined; + query: { + data: { source: string; truncated: boolean } | null; + error: string | null; + isPending: boolean; + }; + themeName: DiffThemeName; +}): ReactElement { + if (scriptPath === undefined) { + return ; + } + if (query.error !== null) { + return

{query.error}

; + } + if (query.data === null) { + return ; + } + const { source, truncated } = query.data; + const fallback = ( +
+      {source}
+    
+ ); + return ( +
+ {truncated && ( +

Script truncated for display.

+ )} + + + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Logs tab +// --------------------------------------------------------------------------- + +interface JournalEntry { + agentId: string; + hasResult: boolean; + resultJson?: string | undefined; + resultTruncated?: boolean | undefined; +} + +function LogsTab({ + logs, + transcriptDir, + query, +}: { + logs: string[]; + transcriptDir: string | undefined; + query: { + data: { entries: readonly JournalEntry[]; truncated: boolean } | null; + error: string | null; + isPending: boolean; + }; +}): ReactElement { + return ( +
+ {logs.length === 0 ? ( + + ) : ( +
+ {logs.map((log, index) => ( + // oxlint-disable-next-line no-array-index-key -- logs are append-only, index is stable +
+ {log} +
+ ))} +
+ )} + + {transcriptDir !== undefined && ( +
+

+ Results +

+ {query.error !== null ? ( +

{query.error}

+ ) : query.data === null ? ( + query.isPending ? ( + + ) : null + ) : query.data.entries.length === 0 ? ( + + ) : ( +
+ {query.data.entries.map((entry) => ( + + ))} +
+ )} + {query.data?.truncated && ( +

Results truncated.

+ )} +
+ )} +
+ ); +} + +function JournalResultRow({ entry }: { entry: JournalEntry }): ReactElement { + const [expanded, setExpanded] = useState(false); + const resultJson = entry.resultJson; + return ( +
+
+ {entry.agentId} + {!entry.hasResult && pending} +
+ {resultJson !== undefined && + (expanded ? ( +
setExpanded(false)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setExpanded(false); + } + }} + > +
+              {resultJson}
+            
+ {entry.resultTruncated && ( +

Result truncated.

+ )} +
+ ) : ( + + ))} +
+ ); +} + +// --------------------------------------------------------------------------- +// Shared muted body +// --------------------------------------------------------------------------- + +function MutedBody({ text }: { text: string }): ReactElement { + return

{text}

; +} diff --git a/apps/web/src/components/workflow/WorkflowRunCard.tsx b/apps/web/src/components/workflow/WorkflowRunCard.tsx new file mode 100644 index 00000000000..1fedad65c87 --- /dev/null +++ b/apps/web/src/components/workflow/WorkflowRunCard.tsx @@ -0,0 +1,152 @@ +import { ExternalLinkIcon, NetworkIcon } from "lucide-react"; +import { type ReactElement } from "react"; + +import { cn } from "~/lib/utils"; +import { + formatWorkflowDuration, + formatWorkflowTokens, + isRemoteWorkflowRun, + type WorkflowRun, + type WorkflowRunAgent, + workflowRunTitle, +} from "~/workflow-logic"; +import { Button } from "../ui/button"; +import { + AgentRowContent, + PhaseHeader, + WorkflowStatusChip, + agentRollupLabel, + safeWorkflowSessionUrl, +} from "./workflowUi"; + +const MAX_CARD_AGENT_ROWS = 8; + +function agentRecency(agent: WorkflowRunAgent): number { + return agent.lastProgressAt ?? agent.startedAt ?? agent.queuedAt ?? 0; +} + +/** Choose which agent indices survive the card cap: running+error first, then most recent. */ +function selectVisibleAgentIndices(agents: WorkflowRunAgent[], cap: number): Set { + const prioritized = [...agents].sort((a, b) => { + const aUrgent = a.status === "running" || a.status === "error" ? 0 : 1; + const bUrgent = b.status === "running" || b.status === "error" ? 0 : 1; + if (aUrgent !== bUrgent) { + return aUrgent - bUrgent; + } + return agentRecency(b) - agentRecency(a); + }); + return new Set(prioritized.slice(0, cap).map((agent) => agent.index)); +} + +export function WorkflowRunCard(props: { + workflowRun: WorkflowRun; + onOpenDetails?: (() => void) | undefined; + onStop?: (() => void) | undefined; +}): ReactElement { + const { workflowRun: run, onOpenDetails, onStop } = props; + const title = workflowRunTitle(run); + const remote = isRemoteWorkflowRun(run); + const tokens = run.usage?.totalTokens; + const durationMs = run.usage?.durationMs; + + const allAgents = run.phases.flatMap((phase) => phase.agents); + const overCap = allAgents.length > MAX_CARD_AGENT_ROWS; + const visibleIndices = overCap ? selectVisibleAgentIndices(allAgents, MAX_CARD_AGENT_ROWS) : null; + const hiddenCount = overCap ? allAgents.length - MAX_CARD_AGENT_ROWS : 0; + + return ( +
+
+ + {title} + +
+
+ {agentRollupLabel(run.agentCounts)} + {tokens !== undefined && {formatWorkflowTokens(tokens)}} + {durationMs !== undefined && {formatWorkflowDuration(durationMs)}} +
+ {(onStop || onOpenDetails) && ( +
+ {onStop && ( + + )} + {onOpenDetails && ( + + )} +
+ )} +
+
+ + {remote ? ( + + ) : allAgents.length > 0 ? ( +
+ {run.phases.map((phase) => { + const rows = phase.agents.filter( + (agent) => visibleIndices === null || visibleIndices.has(agent.index), + ); + if (rows.length === 0) { + return null; + } + return ( +
+ + {rows.map((agent) => ( +
+ +
+ ))} +
+ ); + })} + {hiddenCount > 0 && ( + + )} +
+ ) : null} + + {run.handles?.warning !== undefined && ( +

{run.handles.warning}

+ )} +
+ ); +} + +function RemoteRunBody({ run }: { run: WorkflowRun }): ReactElement { + const sessionUrl = safeWorkflowSessionUrl(run.handles?.sessionUrl); + if (sessionUrl === undefined) { + return ( +

+ Running in the cloud +

+ ); + } + return ( + + + Running in the cloud — open session + + ); +} diff --git a/apps/web/src/components/workflow/workflowUi.tsx b/apps/web/src/components/workflow/workflowUi.tsx new file mode 100644 index 00000000000..f01b1593996 --- /dev/null +++ b/apps/web/src/components/workflow/workflowUi.tsx @@ -0,0 +1,236 @@ +import type { ReactElement, ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import { + formatWorkflowDuration, + formatWorkflowTokens, + type WorkflowAgentStatus, + type WorkflowRun, + type WorkflowRunAgent, + type WorkflowRunPhase, + type WorkflowRunStatus, +} from "~/workflow-logic"; + +// --------------------------------------------------------------------------- +// Run-level status chip +// --------------------------------------------------------------------------- + +interface RunStatusVisual { + label: string; + dotClass: string; + textClass: string; + pulse: boolean; +} + +const RUN_STATUS_VISUALS: Record = { + running: { label: "Running", dotClass: "bg-info", textClass: "text-info", pulse: true }, + completed: { + label: "Completed", + dotClass: "bg-success", + textClass: "text-success", + pulse: false, + }, + failed: { + label: "Failed", + dotClass: "bg-destructive", + textClass: "text-destructive", + pulse: false, + }, + stopped: { + label: "Stopped", + dotClass: "bg-muted-foreground", + textClass: "text-muted-foreground", + pulse: false, + }, +}; + +export function WorkflowStatusChip({ status }: { status: WorkflowRunStatus }): ReactElement { + const visual = RUN_STATUS_VISUALS[status]; + return ( + + + {visual.pulse && ( + + )} + + + {visual.label} + + ); +} + +// --------------------------------------------------------------------------- +// Agent-level presentation +// --------------------------------------------------------------------------- + +const AGENT_STATUS_DOT: Record = { + queued: "bg-muted-foreground/50", + running: "bg-info animate-pulse", + done: "bg-success", + error: "bg-destructive", +}; + +export function AgentStatusDot({ status }: { status: WorkflowAgentStatus }): ReactElement { + return ; +} + +export function agentDisplayLabel(agent: WorkflowRunAgent): string { + return agent.label ?? agent.agentType ?? `agent ${agent.index}`; +} + +function AgentMetaBadges({ agent }: { agent: WorkflowRunAgent }): ReactElement | null { + const badges: string[] = []; + if (agent.cached) { + badges.push("cached"); + } + if (agent.attempt !== undefined && agent.attempt > 1) { + badges.push(`retry ${agent.attempt}`); + } + if (badges.length === 0) { + return null; + } + return ( + <> + {badges.map((badge) => ( + + {badge} + + ))} + + ); +} + +/** "94.2k tok · 47 tools · 7m 03s" — cumulative per-agent stats from the + * SDK snapshot. Tokens and tool counts update on every progress tick; the + * duration is the reported total once the agent settles, and the elapsed + * time between start and the latest tick while it runs (tick-driven, so no + * client timer is needed). */ +export function agentStatsLabel(agent: WorkflowRunAgent): string | undefined { + const parts: string[] = []; + if (agent.tokens !== undefined && agent.tokens > 0) { + parts.push(`${formatWorkflowTokens(agent.tokens)} tok`); + } + if (agent.toolCalls !== undefined && agent.toolCalls > 0) { + parts.push(`${agent.toolCalls} ${agent.toolCalls === 1 ? "tool" : "tools"}`); + } + const settled = agent.status === "done" || agent.status === "error"; + if (settled && agent.durationMs !== undefined && agent.durationMs > 0) { + parts.push(formatWorkflowDuration(agent.durationMs)); + } else if ( + !settled && + agent.startedAt !== undefined && + agent.lastProgressAt !== undefined && + agent.lastProgressAt > agent.startedAt + ) { + parts.push(formatWorkflowDuration(agent.lastProgressAt - agent.startedAt)); + } + return parts.length > 0 ? parts.join(" · ") : undefined; +} + +/** + * The shared inner content of an agent row. Two-line layout: the label owns + * the first line and wraps freely (long workflow labels are common), and + * model / stats / badges / error text sit on a muted meta line beneath — + * nothing competes for horizontal space, so nothing clips. + */ +export function AgentRowContent({ + agent, + leading, +}: { + agent: WorkflowRunAgent; + leading?: ReactNode; +}): ReactElement { + const stats = agentStatsLabel(agent); + // Failures often surface only in resultPreview (e.g. a thrown value the + // runner stringified) — fall back to it so red rows always explain why. + const errorText = agent.status === "error" ? (agent.error ?? agent.resultPreview) : undefined; + const metaLabel = [agent.model, stats].filter((part) => part !== undefined).join(" · "); + const hasBadges = agent.cached === true || (agent.attempt !== undefined && agent.attempt > 1); + return ( +
+ {/* Fixed line-height boxes center the affordances on the first text line. */} + {leading !== undefined && {leading}} + + + +
+
+ {agentDisplayLabel(agent)} +
+ {(metaLabel.length > 0 || hasBadges || errorText !== undefined) && ( +
+ {metaLabel.length > 0 && {metaLabel}} + + {errorText !== undefined && ( + {errorText} + )} +
+ )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Phase header + rollup helpers +// --------------------------------------------------------------------------- + +/** + * Only web URLs may reach an anchor href. The server already filters the + * scheme at ingestion; this guards payloads persisted before that filter + * (and any other producer) as defense in depth. + */ +export function safeWorkflowSessionUrl(sessionUrl: string | undefined): string | undefined { + if (sessionUrl === undefined) { + return undefined; + } + try { + const parsed = new URL(sessionUrl); + return parsed.protocol === "https:" || parsed.protocol === "http:" ? sessionUrl : undefined; + } catch { + return undefined; + } +} + +/** Settled agents (done or error) — the x/y header is a progress counter, + * and an errored agent has no work remaining. */ +export function phaseDoneCount(phase: WorkflowRunPhase): number { + return phase.agents.filter((agent) => agent.status === "done" || agent.status === "error").length; +} + +export function PhaseHeader({ phase }: { phase: WorkflowRunPhase }): ReactElement { + return ( +
+ + {phase.title} + + {phase.agents.length > 0 && ( + + {phaseDoneCount(phase)}/{phase.agents.length} + + )} +
+ ); +} + +export function agentRollupLabel(counts: WorkflowRun["agentCounts"]): string { + return `${counts.done + counts.error}/${counts.total} agents`; +} diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 70d163306cc..e10d2dd26bd 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -14,7 +14,15 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; -export const RIGHT_PANEL_KINDS = ["plan", "diff", "files", "file", "preview", "terminal"] as const; +export const RIGHT_PANEL_KINDS = [ + "plan", + "diff", + "files", + "file", + "preview", + "terminal", + "workflow", +] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export type RightPanelSurface = @@ -37,7 +45,8 @@ export type RightPanelSurface = revealLine: number | null; revealRequestId: number; } - | { id: "plan"; kind: "plan" }; + | { id: "plan"; kind: "plan" } + | { id: `workflow:${string}`; kind: "workflow"; taskId: string }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; const RIGHT_PANEL_STORAGE_VERSION = 7; @@ -50,10 +59,14 @@ export interface ThreadRightPanelState { interface RightPanelStoreState { byThreadKey: Record; - open: (ref: ScopedThreadRef, kind: Exclude) => void; + open: ( + ref: ScopedThreadRef, + kind: Exclude, + ) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; + openWorkflow: (ref: ScopedThreadRef, taskId: string) => void; splitTerminal: ( ref: ScopedThreadRef, surfaceId: string, @@ -72,7 +85,10 @@ interface RightPanelStoreState { show: (ref: ScopedThreadRef) => void; close: (ref: ScopedThreadRef) => void; toggleVisibility: (ref: ScopedThreadRef) => void; - toggle: (ref: ScopedThreadRef, kind: Exclude) => void; + toggle: ( + ref: ScopedThreadRef, + kind: Exclude, + ) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -83,7 +99,7 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = { }; const singletonSurface = ( - kind: Exclude, + kind: Exclude, ): RightPanelSurface => { switch (kind) { case "diff": @@ -112,6 +128,12 @@ const fileSurface = ( revealRequestId, }); +const workflowSurface = (taskId: string): RightPanelSurface => ({ + id: `workflow:${taskId}`, + kind: "workflow", + taskId, +}); + const terminalSurface = (terminalId: string): RightPanelSurface => ({ id: `terminal:${terminalId}`, kind: "terminal", @@ -286,6 +308,12 @@ export const useRightPanelStore = create()( }; }), })), + openWorkflow: (ref, taskId) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + upsertSurface(current, workflowSurface(taskId)), + ), + })), openTerminal: (ref, terminalId) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index 0f12e672f66..c77a830fc48 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -22,6 +22,7 @@ import { workEntryIndicatesToolNeutralStatus, workEntryIndicatesToolSuccess, } from "./session-logic"; +import { deriveWorkflowRuns } from "./workflow-logic.ts"; let nextActivityId = 0; @@ -1490,6 +1491,72 @@ describe("deriveWorkLogEntries", () => { expect(entries).toHaveLength(1); expect(entries[0]?.id).toBe("a-complete-same-timestamp"); }); + + it("suppresses workflow task rows and snapshot activities while keeping plain task rows", () => { + const activities: OrchestrationThreadActivity[] = [ + // Plain task: its progress/completed rows must survive. + makeActivity({ + id: "plain-progress", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "task.progress", + summary: "Reasoning update", + tone: "info", + payload: { taskId: "plain-1", summary: "thinking about it" }, + }), + makeActivity({ + id: "plain-complete", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "task.completed", + summary: "Task completed", + tone: "info", + payload: { taskId: "plain-1", status: "completed", detail: "plain done" }, + }), + // Workflow task: every row below belongs to the dedicated workflow card. + makeActivity({ + id: "wf-start", + createdAt: "2026-02-23T00:00:03.000Z", + kind: "task.started", + summary: "local_workflow task started", + tone: "info", + payload: { taskId: "wf-1", taskType: "local_workflow", workflowName: "spec" }, + }), + makeActivity({ + id: "wf-progress", + createdAt: "2026-02-23T00:00:04.000Z", + kind: "task.progress", + summary: "Reasoning update", + tone: "info", + payload: { taskId: "wf-1", summary: "workflow ticking" }, + }), + makeActivity({ + id: "wf-updated", + createdAt: "2026-02-23T00:00:05.000Z", + kind: "task.workflow-updated", + summary: "spec workflow", + tone: "info", + payload: { taskId: "wf-1", description: "spec", workflowProgress: [] }, + }), + makeActivity({ + id: "wf-meta", + createdAt: "2026-02-23T00:00:06.000Z", + kind: "task.workflow-meta", + summary: "Workflow launched", + tone: "info", + payload: { taskId: "wf-1", runId: "wf_abc" }, + }), + makeActivity({ + id: "wf-complete", + createdAt: "2026-02-23T00:00:07.000Z", + kind: "task.completed", + summary: "Task completed", + tone: "info", + payload: { taskId: "wf-1", status: "completed" }, + }), + ]; + + const entries = deriveWorkLogEntries(activities); + expect(entries.map((entry) => entry.id)).toEqual(["plain-progress", "plain-complete"]); + }); }); describe("deriveTimelineEntries", () => { @@ -1537,6 +1604,54 @@ describe("deriveTimelineEntries", () => { }, }); }); + + it("emits a workflow entry sorted chronologically among messages", () => { + const workflowRuns = deriveWorkflowRuns([ + makeActivity({ + id: "wf-start", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "task.started", + summary: "local_workflow task started", + tone: "info", + payload: { taskId: "task-wf", taskType: "local_workflow", workflowName: "spec" }, + }), + ]); + expect(workflowRuns).toHaveLength(1); + + const entries = deriveTimelineEntries( + [ + { + id: MessageId.make("message-before"), + role: "user", + text: "kick it off", + createdAt: "2026-02-23T00:00:01.000Z", + turnId: null, + updatedAt: "2026-02-23T00:00:01.000Z", + streaming: false, + }, + { + id: MessageId.make("message-after"), + role: "assistant", + text: "done", + createdAt: "2026-02-23T00:00:03.000Z", + turnId: null, + updatedAt: "2026-02-23T00:00:03.000Z", + streaming: false, + }, + ], + [], + [], + workflowRuns, + ); + + expect(entries.map((entry) => entry.kind)).toEqual(["message", "workflow", "message"]); + const workflowEntry = entries[1]; + expect(workflowEntry).toMatchObject({ + kind: "workflow", + id: "workflow:task-wf", + workflowRun: { taskId: "task-wf", name: "spec" }, + }); + }); }); describe("deriveWorkLogEntries context window handling", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5d5051f748e..5c24a7aae96 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -21,6 +21,7 @@ import type { ThreadSession, TurnDiffSummary, } from "./types"; +import { collectWorkflowTaskIds, type WorkflowRun } from "./workflow-logic.ts"; export type ProviderPickerKind = ProviderDriverKind; @@ -137,6 +138,12 @@ export type TimelineEntry = kind: "work"; createdAt: string; entry: WorkLogEntry; + } + | { + id: string; + kind: "workflow"; + createdAt: string; + workflowRun: WorkflowRun; }; export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { @@ -624,15 +631,36 @@ export function hasActionableProposedPlan( return proposedPlan !== null && proposedPlan.implementedAt === null; } +function activityBelongsToWorkflow( + activity: OrchestrationThreadActivity, + workflowTaskIds: ReadonlySet, +): boolean { + const payload = activity.payload as Record | null | undefined; + const taskId = payload && typeof payload === "object" ? payload["taskId"] : undefined; + return typeof taskId === "string" && workflowTaskIds.has(taskId); +} + export function deriveWorkLogEntries( activities: ReadonlyArray, ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); + const workflowTaskIds = collectWorkflowTaskIds(activities); const entries: DerivedWorkLogEntry[] = []; for (const activity of ordered) { if (activity.kind === "tool.started") continue; if (activity.kind === "task.started") continue; if (activity.kind === "context-window.updated") continue; + // Workflow runs render as dedicated timeline cards; their snapshot + // activities and per-tick task rows would duplicate that surface. + if (activity.kind === "task.workflow-updated") continue; + if (activity.kind === "task.workflow-meta") continue; + if ( + (activity.kind === "task.progress" || activity.kind === "task.completed") && + workflowTaskIds.size > 0 && + activityBelongsToWorkflow(activity, workflowTaskIds) + ) { + continue; + } if (activity.summary === "Checkpoint captured") continue; if (isPlanBoundaryToolActivity(activity)) continue; entries.push(toDerivedWorkLogEntry(activity)); @@ -1341,6 +1369,7 @@ export function deriveTimelineEntries( messages: ReadonlyArray, proposedPlans: ReadonlyArray, workEntries: ReadonlyArray, + workflowRuns: ReadonlyArray = [], ): TimelineEntry[] { const messageRows: TimelineEntry[] = messages.map((message) => ({ id: message.id, @@ -1360,7 +1389,13 @@ export function deriveTimelineEntries( createdAt: entry.createdAt, entry, })); - return [...messageRows, ...proposedPlanRows, ...workRows].toSorted((a, b) => + const workflowRows: TimelineEntry[] = workflowRuns.map((workflowRun) => ({ + id: `workflow:${workflowRun.taskId}`, + kind: "workflow", + createdAt: workflowRun.createdAt, + workflowRun, + })); + return [...messageRows, ...proposedPlanRows, ...workRows, ...workflowRows].toSorted((a, b) => a.createdAt.localeCompare(b.createdAt), ); } diff --git a/apps/web/src/state/workflow.ts b/apps/web/src/state/workflow.ts new file mode 100644 index 00000000000..276773c555d --- /dev/null +++ b/apps/web/src/state/workflow.ts @@ -0,0 +1,5 @@ +import { createWorkflowEnvironmentAtoms } from "@t3tools/client-runtime/state/workflow"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +export const workflowEnvironment = createWorkflowEnvironmentAtoms(connectionAtomRuntime); diff --git a/apps/web/src/workflow-logic.test.ts b/apps/web/src/workflow-logic.test.ts new file mode 100644 index 00000000000..df44f91c7ce --- /dev/null +++ b/apps/web/src/workflow-logic.test.ts @@ -0,0 +1,397 @@ +import { + NonNegativeInt, + EventId, + type OrchestrationThreadActivity, + TurnId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + collectWorkflowTaskIds, + deriveWorkflowAgentStatus, + deriveWorkflowRuns, + groupWorkflowAgentsByPhase, + isRemoteWorkflowRun, + type WorkflowRunAgent, +} from "./workflow-logic.ts"; + +let nextActivityId = 0; + +function buildActivity(overrides: { + id?: string; + createdAt?: string; + kind?: string; + summary?: string; + tone?: OrchestrationThreadActivity["tone"]; + payload?: Record; + turnId?: string; + sequence?: number; +}): OrchestrationThreadActivity { + return { + id: EventId.make(overrides.id ?? `activity-${nextActivityId++}`), + createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z", + kind: overrides.kind ?? "task.started", + // summary/kind must be trimmed non-empty branded strings. + summary: overrides.summary ?? "Workflow", + tone: overrides.tone ?? "info", + payload: overrides.payload ?? {}, + turnId: overrides.turnId ? TurnId.make(overrides.turnId) : null, + ...(overrides.sequence !== undefined + ? { sequence: NonNegativeInt.make(overrides.sequence) } + : {}), + }; +} + +describe("deriveWorkflowAgentStatus", () => { + it("maps terminal states directly", () => { + expect(deriveWorkflowAgentStatus({ state: "done" })).toBe("done"); + expect(deriveWorkflowAgentStatus({ state: "error" })).toBe("error"); + }); + + it("treats start without startedAt as queued and with startedAt as running", () => { + expect(deriveWorkflowAgentStatus({ state: "start" })).toBe("queued"); + expect(deriveWorkflowAgentStatus({ state: "start", startedAt: 123 })).toBe("running"); + }); + + it("renders unknown future states as running once startedAt is present", () => { + expect(deriveWorkflowAgentStatus({ state: "reticulating", startedAt: 1 })).toBe("running"); + expect(deriveWorkflowAgentStatus({ state: "reticulating" })).toBe("queued"); + }); +}); + +describe("groupWorkflowAgentsByPhase", () => { + const agent = (over: Partial & { index: number }): WorkflowRunAgent => ({ + state: "start", + status: "queued", + ...over, + }); + + it("groups agents under their phase and synthesizes an Agents phase for unphased agents", () => { + const phases = groupWorkflowAgentsByPhase({ + phases: [{ index: 0, title: "Plan" }], + agents: [ + agent({ index: 0, phaseIndex: 0 }), + agent({ index: 1 }), // no phaseIndex -> synthetic "Agents" phase (index -1) + ], + }); + expect(phases.map((phase) => phase.title)).toEqual(["Agents", "Plan"]); + const synthetic = phases.find((phase) => phase.title === "Agents"); + expect(synthetic?.index).toBe(-1); + expect(synthetic?.agents.map((entry) => entry.index)).toEqual([1]); + }); + + it("falls back to a Phase title when the phase is unknown but an agent references it", () => { + const phases = groupWorkflowAgentsByPhase({ + phases: [], + agents: [agent({ index: 0, phaseIndex: 2 })], + }); + expect(phases).toHaveLength(1); + expect(phases[0]?.title).toBe("Phase 2"); + }); + + it("prefers an agent-supplied phaseTitle for an otherwise-unknown phase", () => { + const phases = groupWorkflowAgentsByPhase({ + phases: [], + agents: [agent({ index: 0, phaseIndex: 5, phaseTitle: "Custom" })], + }); + expect(phases[0]?.title).toBe("Custom"); + }); +}); + +function workflowStartedActivity(taskId: string, extra?: Record) { + return buildActivity({ + id: `start-${taskId}`, + kind: "task.started", + createdAt: "2026-02-23T00:00:01.000Z", + turnId: "turn-1", + payload: { taskId, taskType: "local_workflow", workflowName: "spec", ...extra }, + }); +} + +function workflowUpdatedActivity( + taskId: string, + workflowProgress: unknown[], + extra?: Record, +) { + return buildActivity({ + id: `updated-${taskId}`, + kind: "task.workflow-updated", + createdAt: "2026-02-23T00:00:02.000Z", + payload: { taskId, description: "spec workflow", workflowProgress, ...extra }, + }); +} + +describe("deriveWorkflowRuns", () => { + it("derives a single running->completed lifecycle from started + updated + meta + completed", () => { + const runs = deriveWorkflowRuns([ + workflowStartedActivity("task-1"), + workflowUpdatedActivity("task-1", [ + { type: "workflow_phase", index: 0, title: "Plan" }, + { type: "workflow_agent", index: 0, state: "done", phaseIndex: 0 }, + { type: "workflow_log", message: "kicked off" }, + ]), + buildActivity({ + id: "meta-task-1", + kind: "task.workflow-meta", + createdAt: "2026-02-23T00:00:03.000Z", + payload: { + taskId: "task-1", + runId: "wf_abc", + scriptPath: "/x/s.js", + transcriptDir: "/x/t", + }, + }), + buildActivity({ + id: "complete-task-1", + kind: "task.completed", + createdAt: "2026-02-23T00:00:04.000Z", + payload: { taskId: "task-1", status: "completed", detail: "all done" }, + }), + ]); + + expect(runs).toHaveLength(1); + const run = runs[0]!; + expect(run.taskId).toBe("task-1"); + expect(run.status).toBe("completed"); + expect(run.name).toBe("spec"); + expect(run.completionSummary).toBe("all done"); + expect(run.handles?.runId).toBe("wf_abc"); + expect(run.logs).toEqual(["kicked off"]); + expect(run.agentCounts).toEqual({ total: 1, queued: 0, running: 0, done: 1, error: 0 }); + expect(run.turnId).toBe(TurnId.make("turn-1")); + }); + + it("maps failed and stopped completion statuses", () => { + const failed = deriveWorkflowRuns([ + workflowStartedActivity("task-f"), + buildActivity({ + id: "complete-task-f", + kind: "task.completed", + createdAt: "2026-02-23T00:00:04.000Z", + payload: { taskId: "task-f", status: "failed" }, + }), + ]); + expect(failed[0]?.status).toBe("failed"); + + const stopped = deriveWorkflowRuns([ + workflowStartedActivity("task-s"), + buildActivity({ + id: "complete-task-s", + kind: "task.completed", + createdAt: "2026-02-23T00:00:04.000Z", + payload: { taskId: "task-s", status: "stopped" }, + }), + ]); + expect(stopped[0]?.status).toBe("stopped"); + }); + + it("derives agent status per entry (queued/running/done/error/unknown-with-startedAt)", () => { + const runs = deriveWorkflowRuns([ + workflowStartedActivity("task-1"), + workflowUpdatedActivity("task-1", [ + { type: "workflow_agent", index: 0, state: "start" }, + { type: "workflow_agent", index: 1, state: "start", startedAt: 100 }, + { type: "workflow_agent", index: 2, state: "done" }, + { type: "workflow_agent", index: 3, state: "error" }, + { type: "workflow_agent", index: 4, state: "reticulating", startedAt: 5 }, + ]), + ]); + expect(runs[0]?.agentCounts).toEqual({ + total: 5, + queued: 1, + running: 2, + done: 1, + error: 1, + }); + }); + + it("lets a later agent entry with the same index win", () => { + const runs = deriveWorkflowRuns([ + workflowStartedActivity("task-1"), + workflowUpdatedActivity("task-1", [ + { type: "workflow_agent", index: 0, state: "start" }, + { type: "workflow_agent", index: 0, state: "done" }, + ]), + ]); + expect(runs[0]?.agentCounts.total).toBe(1); + expect(runs[0]?.agentCounts.done).toBe(1); + expect(runs[0]?.agentCounts.queued).toBe(0); + }); + + it("drops malformed progress entries without throwing", () => { + const runs = deriveWorkflowRuns([ + workflowStartedActivity("task-1"), + workflowUpdatedActivity("task-1", [ + { type: "workflow_agent", state: "start" }, // missing index + { type: "workflow_agent", index: 1 }, // missing state + "not an object", + null, + { type: "workflow_mystery", index: 9 }, + { type: "workflow_agent", index: 2, state: "done" }, + ]), + ]); + expect(runs[0]?.agentCounts.total).toBe(1); + expect(runs[0]?.agentCounts.done).toBe(1); + }); + + it("parses per-agent tokens, tool calls, and duration from the snapshot", () => { + const runs = deriveWorkflowRuns([ + workflowStartedActivity("task-1"), + workflowUpdatedActivity("task-1", [ + { + type: "workflow_agent", + index: 0, + state: "done", + tokens: 94_200, + toolCalls: 47, + durationMs: 423_000, + }, + ]), + ]); + const agent = runs[0]?.phases.flatMap((phase) => phase.agents)[0]; + expect(agent?.tokens).toBe(94_200); + expect(agent?.toolCalls).toBe(47); + expect(agent?.durationMs).toBe(423_000); + }); + + it("parses snake_case usage from the updated snapshot", () => { + const runs = deriveWorkflowRuns([ + workflowStartedActivity("task-1"), + workflowUpdatedActivity("task-1", [{ type: "workflow_agent", index: 0, state: "done" }], { + usage: { total_tokens: 1200, tool_uses: 3, duration_ms: 4500 }, + }), + ]); + expect(runs[0]?.usage).toEqual({ totalTokens: 1200, toolUses: 3, durationMs: 4500 }); + }); + + it("ignores plain (non-workflow) tasks entirely", () => { + const runs = deriveWorkflowRuns([ + buildActivity({ + id: "plain-start", + kind: "task.started", + payload: { taskId: "plain-1", taskType: "plan" }, + }), + buildActivity({ + id: "plain-progress", + kind: "task.progress", + payload: { taskId: "plain-1", summary: "thinking" }, + }), + buildActivity({ + id: "plain-complete", + kind: "task.completed", + payload: { taskId: "plain-1", status: "completed" }, + }), + ]); + expect(runs).toEqual([]); + }); + + it("terminalizes a still-running run and settles in-flight agents when the session is gone", () => { + const runs = deriveWorkflowRuns( + [ + workflowStartedActivity("task-1"), + workflowUpdatedActivity("task-1", [ + { type: "workflow_agent", index: 0, state: "done" }, + { type: "workflow_agent", index: 1, state: "start", startedAt: 1000 }, + { type: "workflow_agent", index: 2, state: "start" }, + ]), + ], + { sessionActive: false }, + ); + const run = runs[0]; + expect(run?.status).toBe("stopped"); + const agents = run?.phases.flatMap((phase) => phase.agents) ?? []; + expect(agents.map((agent) => agent.status)).toEqual(["done", "error", "error"]); + expect(agents[1]?.error).toBe("Interrupted before completion"); + expect(run?.agentCounts).toEqual({ total: 3, queued: 0, running: 0, done: 1, error: 2 }); + }); + + it("applies a completion even when it sorts before its task.started", () => { + const completed = buildActivity({ + id: "completed-task-1", + kind: "task.completed", + createdAt: "2026-02-23T00:00:00.500Z", + sequence: 1, + payload: { taskId: "task-1", status: "completed", detail: "done" }, + }); + // Same-timestamp + inverted sequence (adopted runs can reset provider + // sequence): the started activity sorts after the completion. + const started = { ...workflowStartedActivity("task-1"), sequence: NonNegativeInt.make(5) }; + const runs = deriveWorkflowRuns([completed, started]); + expect(runs).toHaveLength(1); + expect(runs[0]?.status).toBe("completed"); + expect(runs[0]?.name).toBe("spec"); + }); + + it("keeps a running run untouched while the session is active", () => { + const runs = deriveWorkflowRuns( + [ + workflowStartedActivity("task-1"), + workflowUpdatedActivity("task-1", [ + { type: "workflow_agent", index: 0, state: "start", startedAt: 1000 }, + ]), + ], + { sessionActive: true }, + ); + expect(runs[0]?.status).toBe("running"); + expect(runs[0]?.phases.flatMap((phase) => phase.agents)[0]?.status).toBe("running"); + }); + + it("detects remote runs from session handles", () => { + const runs = deriveWorkflowRuns([ + workflowStartedActivity("task-remote"), + buildActivity({ + id: "meta-remote", + kind: "task.workflow-meta", + createdAt: "2026-02-23T00:00:03.000Z", + payload: { taskId: "task-remote", sessionUrl: "https://example.com/run" }, + }), + ]); + expect(runs).toHaveLength(1); + expect(isRemoteWorkflowRun(runs[0]!)).toBe(true); + }); +}); + +describe("collectWorkflowTaskIds", () => { + it("collects workflow task ids via workflowName, local_workflow task type, and workflow kinds", () => { + const ids = collectWorkflowTaskIds([ + buildActivity({ + id: "s1", + kind: "task.started", + payload: { taskId: "by-name", workflowName: "spec" }, + }), + buildActivity({ + id: "s2", + kind: "task.started", + payload: { taskId: "by-type", taskType: "local_workflow" }, + }), + buildActivity({ + id: "u1", + kind: "task.workflow-updated", + payload: { taskId: "by-updated", workflowProgress: [] }, + }), + buildActivity({ + id: "m1", + kind: "task.workflow-meta", + payload: { taskId: "by-meta" }, + }), + ]); + expect([...ids].sort()).toEqual(["by-meta", "by-name", "by-type", "by-updated"]); + }); + + it("does not collect plain task ids", () => { + const ids = collectWorkflowTaskIds([ + buildActivity({ + id: "plain", + kind: "task.started", + payload: { taskId: "plain-1", taskType: "plan" }, + }), + buildActivity({ + id: "plain-progress", + kind: "task.progress", + payload: { taskId: "plain-1" }, + }), + ]); + expect(ids.has("plain-1")).toBe(false); + expect(ids.size).toBe(0); + }); +}); diff --git a/apps/web/src/workflow-logic.ts b/apps/web/src/workflow-logic.ts new file mode 100644 index 00000000000..d51561ec38e --- /dev/null +++ b/apps/web/src/workflow-logic.ts @@ -0,0 +1,546 @@ +import type { OrchestrationThreadActivity, TurnId } from "@t3tools/contracts"; + +/** + * Derivation of workflow-run view models from thread activities. + * + * Workflow state arrives as three activity kinds emitted by the server: + * - `task.started` / `task.completed` — lifecycle (shared with plain tasks) + * - `task.workflow-updated` — cumulative snapshot (phases, agents, logs), + * upserted under a stable activity id per task + * - `task.workflow-meta` — run handles (script path, transcript dir, run id) + * + * Everything here parses `activity.payload` defensively: payloads are + * `unknown` end-to-end and originate from an undocumented SDK surface, so a + * malformed field must degrade to less detail, never throw. + */ + +export type WorkflowAgentStatus = "queued" | "running" | "done" | "error"; + +export interface WorkflowRunAgent { + index: number; + status: WorkflowAgentStatus; + state: string; + label?: string | undefined; + phaseIndex?: number | undefined; + phaseTitle?: string | undefined; + agentId?: string | undefined; + agentType?: string | undefined; + model?: string | undefined; + isolation?: "worktree" | "remote" | undefined; + attempt?: number | undefined; + queuedAt?: number | undefined; + startedAt?: number | undefined; + lastProgressAt?: number | undefined; + cached?: boolean | undefined; + remoteSessionId?: string | undefined; + lastToolName?: string | undefined; + lastToolSummary?: string | undefined; + promptPreview?: string | undefined; + resultPreview?: string | undefined; + error?: string | undefined; + tokens?: number | undefined; + toolCalls?: number | undefined; + durationMs?: number | undefined; +} + +export interface WorkflowRunPhase { + index: number; + title: string; + kind?: string | undefined; + agents: WorkflowRunAgent[]; +} + +export interface WorkflowRunUsage { + totalTokens?: number | undefined; + toolUses?: number | undefined; + durationMs?: number | undefined; +} + +export interface WorkflowRunHandlesView { + runId?: string | undefined; + taskType?: string | undefined; + scriptPath?: string | undefined; + transcriptDir?: string | undefined; + sessionUrl?: string | undefined; + warning?: string | undefined; +} + +export type WorkflowRunStatus = "running" | "completed" | "failed" | "stopped"; + +export interface WorkflowRun { + taskId: string; + status: WorkflowRunStatus; + createdAt: string; + updatedAt: string; + /** Monotonic per-derivation change counter — bumped on every applied + * workflow activity so renderers can cheaply detect content changes even + * when timestamps collide at millisecond precision. */ + revision: number; + turnId: TurnId | null; + name?: string | undefined; + description?: string | undefined; + completionSummary?: string | undefined; + phases: WorkflowRunPhase[]; + logs: string[]; + usage?: WorkflowRunUsage | undefined; + handles?: WorkflowRunHandlesView | undefined; + agentCounts: { + total: number; + queued: number; + running: number; + done: number; + error: number; + }; +} + +function asRecord(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +function asNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +export function deriveWorkflowAgentStatus(input: { + state: string; + startedAt?: number; +}): WorkflowAgentStatus { + if (input.state === "done") { + return "done"; + } + if (input.state === "error") { + return "error"; + } + // "start" plus any state a future SDK adds: running once work has begun. + return input.startedAt !== undefined ? "running" : "queued"; +} + +function parseAgentEntry(entry: Record): WorkflowRunAgent | undefined { + const index = asNumber(entry.index); + const state = asString(entry.state); + if (index === undefined || state === undefined) { + return undefined; + } + const startedAt = asNumber(entry.startedAt); + const isolation = + entry.isolation === "worktree" || entry.isolation === "remote" ? entry.isolation : undefined; + return { + index, + state, + status: deriveWorkflowAgentStatus({ state, ...(startedAt !== undefined ? { startedAt } : {}) }), + ...(asString(entry.label) !== undefined ? { label: asString(entry.label) } : {}), + ...(asNumber(entry.phaseIndex) !== undefined ? { phaseIndex: asNumber(entry.phaseIndex) } : {}), + ...(asString(entry.phaseTitle) !== undefined ? { phaseTitle: asString(entry.phaseTitle) } : {}), + ...(asString(entry.agentId) !== undefined ? { agentId: asString(entry.agentId) } : {}), + ...(asString(entry.agentType) !== undefined ? { agentType: asString(entry.agentType) } : {}), + ...(asString(entry.model) !== undefined ? { model: asString(entry.model) } : {}), + ...(isolation !== undefined ? { isolation } : {}), + ...(asNumber(entry.attempt) !== undefined ? { attempt: asNumber(entry.attempt) } : {}), + ...(asNumber(entry.queuedAt) !== undefined ? { queuedAt: asNumber(entry.queuedAt) } : {}), + ...(startedAt !== undefined ? { startedAt } : {}), + ...(asNumber(entry.lastProgressAt) !== undefined + ? { lastProgressAt: asNumber(entry.lastProgressAt) } + : {}), + ...(entry.cached === true ? { cached: true } : {}), + ...(asString(entry.remoteSessionId) !== undefined + ? { remoteSessionId: asString(entry.remoteSessionId) } + : {}), + ...(asString(entry.lastToolName) !== undefined + ? { lastToolName: asString(entry.lastToolName) } + : {}), + ...(asString(entry.lastToolSummary) !== undefined + ? { lastToolSummary: asString(entry.lastToolSummary) } + : {}), + ...(asString(entry.promptPreview) !== undefined + ? { promptPreview: asString(entry.promptPreview) } + : {}), + ...(asString(entry.resultPreview) !== undefined + ? { resultPreview: asString(entry.resultPreview) } + : {}), + ...(asString(entry.error) !== undefined ? { error: asString(entry.error) } : {}), + ...(asNumber(entry.tokens) !== undefined ? { tokens: asNumber(entry.tokens) } : {}), + ...(asNumber(entry.toolCalls) !== undefined ? { toolCalls: asNumber(entry.toolCalls) } : {}), + ...(asNumber(entry.durationMs) !== undefined ? { durationMs: asNumber(entry.durationMs) } : {}), + }; +} + +interface ParsedWorkflowProgress { + phases: Array<{ index: number; title: string; kind?: string | undefined }>; + agents: WorkflowRunAgent[]; + logs: string[]; +} + +function parseWorkflowProgress(value: unknown): ParsedWorkflowProgress { + const parsed: ParsedWorkflowProgress = { phases: [], agents: [], logs: [] }; + if (!Array.isArray(value)) { + return parsed; + } + // Later entries for the same index win: snapshots are cumulative and the + // runner may re-emit an agent slot on retry. + const agentsByIndex = new Map(); + const phasesByIndex = new Map< + number, + { index: number; title: string; kind?: string | undefined } + >(); + for (const raw of value) { + const entry = asRecord(raw); + if (!entry) { + continue; + } + switch (entry.type) { + case "workflow_agent": { + const agent = parseAgentEntry(entry); + if (agent) { + agentsByIndex.set(agent.index, agent); + } + break; + } + case "workflow_phase": { + const index = asNumber(entry.index); + const title = asString(entry.title); + if (index !== undefined && title !== undefined) { + phasesByIndex.set(index, { + index, + title, + ...(asString(entry.kind) !== undefined ? { kind: asString(entry.kind) } : {}), + }); + } + break; + } + case "workflow_log": { + const message = asString(entry.message); + if (message !== undefined) { + parsed.logs.push(message); + } + break; + } + default: + break; + } + } + parsed.agents = [...agentsByIndex.values()].toSorted((a, b) => a.index - b.index); + parsed.phases = [...phasesByIndex.values()].toSorted((a, b) => a.index - b.index); + return parsed; +} + +function parseUsage(value: unknown): WorkflowRunUsage | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + const totalTokens = asNumber(record.total_tokens); + const toolUses = asNumber(record.tool_uses); + const durationMs = asNumber(record.duration_ms); + if (totalTokens === undefined && toolUses === undefined && durationMs === undefined) { + return undefined; + } + return { + ...(totalTokens !== undefined ? { totalTokens } : {}), + ...(toolUses !== undefined ? { toolUses } : {}), + ...(durationMs !== undefined ? { durationMs } : {}), + }; +} + +export function groupWorkflowAgentsByPhase(parsed: { + phases: ReadonlyArray<{ index: number; title: string; kind?: string | undefined }>; + agents: ReadonlyArray; +}): WorkflowRunPhase[] { + const phases = new Map(); + for (const phase of parsed.phases) { + phases.set(phase.index, { ...phase, agents: [] }); + } + const UNPHASED = -1; + for (const agent of parsed.agents) { + const phaseIndex = agent.phaseIndex ?? UNPHASED; + let phase = phases.get(phaseIndex); + if (!phase) { + phase = { + index: phaseIndex, + title: agent.phaseTitle ?? (phaseIndex === UNPHASED ? "Agents" : `Phase ${phaseIndex}`), + agents: [], + }; + phases.set(phaseIndex, phase); + } + phase.agents.push(agent); + } + return [...phases.values()] + .filter( + (phase) => phase.agents.length > 0 || parsed.phases.some((p) => p.index === phase.index), + ) + .toSorted((a, b) => a.index - b.index); +} + +type MutableWorkflowRun = WorkflowRun; + +function isWorkflowTaskStartedPayload(payload: Record): boolean { + return payload.taskType === "local_workflow" || asString(payload.workflowName) !== undefined; +} + +/** Task ids owned by a workflow run — used to suppress duplicate work-log rows. */ +export function collectWorkflowTaskIds( + activities: ReadonlyArray, +): Set { + const taskIds = new Set(); + for (const activity of activities) { + const payload = asRecord(activity.payload); + const taskId = payload ? asString(payload.taskId) : undefined; + if (!taskId) { + continue; + } + if ( + activity.kind === "task.workflow-updated" || + activity.kind === "task.workflow-meta" || + (activity.kind === "task.started" && + payload !== undefined && + isWorkflowTaskStartedPayload(payload)) + ) { + taskIds.add(taskId); + } + } + return taskIds; +} + +/** + * Millisecond timestamps collide, so equal-time activities are ordered by + * provider sequence when present, then by lifecycle rank — a task.completed + * must never be applied before the task.started that creates its run. + */ +const WORKFLOW_ACTIVITY_RANK: Record = { + "task.started": 0, + "task.workflow-meta": 1, + "task.workflow-updated": 1, + "task.completed": 2, +}; + +function compareWorkflowActivityOrder( + a: OrchestrationThreadActivity, + b: OrchestrationThreadActivity, +): number { + if (a.sequence !== undefined && b.sequence !== undefined && a.sequence !== b.sequence) { + return a.sequence - b.sequence; + } + const byTime = a.createdAt.localeCompare(b.createdAt); + if (byTime !== 0) { + return byTime; + } + const byRank = (WORKFLOW_ACTIVITY_RANK[a.kind] ?? 1) - (WORKFLOW_ACTIVITY_RANK[b.kind] ?? 1); + if (byRank !== 0) { + return byRank; + } + return a.id.localeCompare(b.id); +} + +export function deriveWorkflowRuns( + activities: ReadonlyArray, + options?: { readonly sessionActive?: boolean | undefined }, +): WorkflowRun[] { + const ordered = [...activities].toSorted(compareWorkflowActivityOrder); + const workflowTaskIds = collectWorkflowTaskIds(activities); + const runs = new Map(); + + const ensureRun = (taskId: string, activity: OrchestrationThreadActivity): MutableWorkflowRun => { + const existing = runs.get(taskId); + if (existing) { + return existing; + } + const run: MutableWorkflowRun = { + taskId, + status: "running", + createdAt: activity.createdAt, + updatedAt: activity.createdAt, + turnId: activity.turnId, + revision: 0, + phases: [], + logs: [], + agentCounts: { total: 0, queued: 0, running: 0, done: 0, error: 0 }, + }; + runs.set(taskId, run); + return run; + }; + + for (const activity of ordered) { + const payload = asRecord(activity.payload); + if (!payload) { + continue; + } + const taskId = asString(payload.taskId); + if (!taskId) { + continue; + } + + switch (activity.kind) { + case "task.started": { + if (!isWorkflowTaskStartedPayload(payload) && !runs.has(taskId)) { + break; + } + const run = ensureRun(taskId, activity); + run.revision += 1; + run.createdAt = activity.createdAt; + run.turnId = activity.turnId; + const name = asString(payload.workflowName); + if (name !== undefined) { + run.name = name; + } + const detail = asString(payload.detail); + if (detail !== undefined) { + run.description = detail; + } + break; + } + case "task.workflow-updated": { + const run = ensureRun(taskId, activity); + run.revision += 1; + run.updatedAt = activity.createdAt; + const description = asString(payload.description); + if (description !== undefined && run.description === undefined) { + run.description = description; + } + const parsed = parseWorkflowProgress(payload.workflowProgress); + run.phases = groupWorkflowAgentsByPhase(parsed); + run.logs = parsed.logs; + run.agentCounts = { + total: parsed.agents.length, + queued: parsed.agents.filter((agent) => agent.status === "queued").length, + running: parsed.agents.filter((agent) => agent.status === "running").length, + done: parsed.agents.filter((agent) => agent.status === "done").length, + error: parsed.agents.filter((agent) => agent.status === "error").length, + }; + const usage = parseUsage(payload.usage); + if (usage !== undefined) { + run.usage = usage; + } + break; + } + case "task.workflow-meta": { + const run = ensureRun(taskId, activity); + run.revision += 1; + run.updatedAt = activity.createdAt; + const name = asString(payload.workflowName); + if (name !== undefined) { + run.name = name; + } + run.handles = { + ...(asString(payload.runId) !== undefined ? { runId: asString(payload.runId) } : {}), + ...(asString(payload.taskType) !== undefined + ? { taskType: asString(payload.taskType) } + : {}), + ...(asString(payload.scriptPath) !== undefined + ? { scriptPath: asString(payload.scriptPath) } + : {}), + ...(asString(payload.transcriptDir) !== undefined + ? { transcriptDir: asString(payload.transcriptDir) } + : {}), + ...(asString(payload.sessionUrl) !== undefined + ? { sessionUrl: asString(payload.sessionUrl) } + : {}), + ...(asString(payload.warning) !== undefined + ? { warning: asString(payload.warning) } + : {}), + }; + break; + } + case "task.completed": { + // Order-robust terminal handling: a completion for a known workflow + // task creates the run if its task.started has not been applied yet + // (adopted runs can carry inverted provider sequences across CLI + // restarts); the later-applied started only fills metadata and can + // never resurrect a terminal status. + if (!runs.has(taskId) && !workflowTaskIds.has(taskId)) { + break; + } + const run = ensureRun(taskId, activity); + run.revision += 1; + run.updatedAt = activity.createdAt; + run.status = + payload.status === "failed" + ? "failed" + : payload.status === "stopped" + ? "stopped" + : "completed"; + const detail = asString(payload.detail); + if (detail !== undefined) { + run.completionSummary = detail; + } + break; + } + default: + break; + } + } + + // A workflow cannot outlive its provider session: when the session is gone + // and no task_notification ever arrived (crash, interrupt, app restart), + // surface the run as stopped instead of running forever. Runs derived only + // from snapshot/meta activities (no task.started — e.g. after a checkpoint + // revert trimmed it) are kept intentionally: partial history still renders. + const sessionActive = options?.sessionActive ?? true; + return [...runs.values()] + .map((run) => + run.status === "running" && !sessionActive ? terminalizeInterruptedRun(run) : run, + ) + .toSorted((a, b) => a.createdAt.localeCompare(b.createdAt) || a.taskId.localeCompare(b.taskId)); +} + +/** + * Settle a run whose session died before a terminal task notification: + * the run becomes "stopped" and its in-flight agents settle to "error" so + * nothing keeps rendering (or polling) as live work. + */ +function terminalizeInterruptedRun(run: WorkflowRun): WorkflowRun { + const settleAgent = (agent: WorkflowRunAgent): WorkflowRunAgent => + agent.status === "running" || agent.status === "queued" + ? { ...agent, status: "error", error: agent.error ?? "Interrupted before completion" } + : agent; + const phases = run.phases.map((phase) => ({ ...phase, agents: phase.agents.map(settleAgent) })); + const agents = phases.flatMap((phase) => phase.agents); + return { + ...run, + status: "stopped", + phases, + agentCounts: { + total: agents.length, + queued: agents.filter((agent) => agent.status === "queued").length, + running: agents.filter((agent) => agent.status === "running").length, + done: agents.filter((agent) => agent.status === "done").length, + error: agents.filter((agent) => agent.status === "error").length, + }, + }; +} + +export function isRemoteWorkflowRun(run: WorkflowRun): boolean { + return run.handles?.taskType === "remote_agent" || run.handles?.sessionUrl !== undefined; +} + +export function workflowRunTitle(run: WorkflowRun): string { + return run.name ?? run.description ?? "Workflow"; +} + +export function formatWorkflowDuration(durationMs: number): string { + const totalSeconds = Math.max(0, Math.round(durationMs / 1000)); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + if (minutes === 0) { + return `${seconds}s`; + } + const hours = Math.floor(minutes / 60); + if (hours === 0) { + return `${minutes}m ${seconds.toString().padStart(2, "0")}s`; + } + return `${hours}h ${(minutes % 60).toString().padStart(2, "0")}m`; +} + +export function formatWorkflowTokens(totalTokens: number): string { + if (totalTokens < 1000) { + return `${totalTokens}`; + } + if (totalTokens < 1_000_000) { + return `${(totalTokens / 1000).toFixed(totalTokens < 10_000 ? 1 : 0)}k`; + } + return `${(totalTokens / 1_000_000).toFixed(1)}M`; +} diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d9e19889721..e3f1b4d3509 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -130,6 +130,10 @@ "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" + }, + "./state/workflow": { + "types": "./src/state/workflow.ts", + "default": "./src/state/workflow.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index a0c3cbe771f..bcea86e845c 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -44,6 +44,7 @@ export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">; export type RevertThreadCheckpointInput = CommandInput<"thread.checkpoint.revert">; export type StopThreadSessionInput = CommandInput<"thread.session.stop">; +export type StopThreadTaskInput = CommandInput<"thread.task.stop">; type DispatchTag = typeof ORCHESTRATION_WS_METHODS.dispatchCommand; type CommandEffect = Effect.Effect< @@ -254,3 +255,15 @@ export const stopThreadSession: (input: StopThreadSessionInput) => CommandEffect createdAt: metadata.createdAt, }); }); + +export const stopThreadTask: (input: StopThreadTaskInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.stopThreadTask", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.task.stop", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index aab5110e9cf..20158750ac4 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -14,12 +14,14 @@ import { type SetThreadRuntimeModeInput, type StartThreadTurnInput, type StopThreadSessionInput, + type StopThreadTaskInput, type UnarchiveThreadInput, type UpdateThreadMetadataInput, archiveThread, createThread, deleteThread, interruptThreadTurn, + stopThreadTask, respondToThreadApproval, respondToThreadUserInput, revertThreadCheckpoint, @@ -44,6 +46,7 @@ export type { SetThreadRuntimeModeInput, StartThreadTurnInput, StopThreadSessionInput, + StopThreadTaskInput, UnarchiveThreadInput, UpdateThreadMetadataInput, } from "../operations/commands.ts"; @@ -136,5 +139,11 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + stopTask: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:stop-task", + execute: (input: StopThreadTaskInput) => stopThreadTask(input), + scheduler, + concurrency, + }), }; } diff --git a/packages/client-runtime/src/state/workflow.ts b/packages/client-runtime/src/state/workflow.ts new file mode 100644 index 00000000000..4c679919fda --- /dev/null +++ b/packages/client-runtime/src/state/workflow.ts @@ -0,0 +1,38 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; + +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; + +/** + * Workflow-run inspection atoms (Claude Agent SDK workflow artifacts). + * + * `readScript` and `readJournal` are cached queries — the script never + * changes for a given path within a run, and journal reads are refreshed by + * re-query. `readAgentTranscript` is an imperative command because the + * caller drives cursor-paged polling while a transcript pane is open. + */ +export function createWorkflowEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + return { + readScript: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:workflow:read-script", + tag: WS_METHODS.workflowReadScript, + staleTimeMs: 30_000, + }), + readJournal: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:workflow:read-journal", + tag: WS_METHODS.workflowReadJournal, + staleTimeMs: 5_000, + // The journal grows while a run is live and the query only mounts + // while the Logs tab is open — poll so new results appear without a + // manual refresh. + refreshIntervalMs: 4_000, + }), + readAgentTranscript: createEnvironmentRpcCommand(runtime, { + label: "environment-data:workflow:read-agent-transcript", + tag: WS_METHODS.workflowReadAgentTranscript, + }), + }; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 43270efdec7..645fe2273ea 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -16,6 +16,7 @@ export * from "./server.ts"; export * from "./settings.ts"; export * from "./git.ts"; export * from "./vcs.ts"; +export * from "./workflow.ts"; export * from "./sourceControl.ts"; export * from "./orchestration.ts"; export * from "./editor.ts"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 623fed0917b..522eeb5d206 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -657,6 +657,18 @@ const ThreadSessionStopCommand = Schema.Struct({ createdAt: IsoDateTime, }); +/** + * Stop one background task (e.g. a running workflow) inside an active + * provider session, without interrupting the session itself. + */ +const ThreadTaskStopCommand = Schema.Struct({ + type: Schema.Literal("thread.task.stop"), + commandId: CommandId, + threadId: ThreadId, + taskId: TrimmedNonEmptyString, + createdAt: IsoDateTime, +}); + const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectCreateCommand, ProjectMetaUpdateCommand, @@ -674,6 +686,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + ThreadTaskStopCommand, ]); export type DispatchableClientOrchestrationCommand = typeof DispatchableClientOrchestrationCommand.Type; @@ -695,6 +708,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadUserInputRespondCommand, ThreadCheckpointRevertCommand, ThreadSessionStopCommand, + ThreadTaskStopCommand, ]); export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; @@ -799,6 +813,7 @@ export const OrchestrationEventType = Schema.Literals([ "thread.checkpoint-revert-requested", "thread.reverted", "thread.session-stop-requested", + "thread.task-stop-requested", "thread.session-set", "thread.proposed-plan-upserted", "thread.turn-diff-completed", @@ -951,6 +966,12 @@ export const ThreadSessionStopRequestedPayload = Schema.Struct({ createdAt: IsoDateTime, }); +export const ThreadTaskStopRequestedPayload = Schema.Struct({ + threadId: ThreadId, + taskId: TrimmedNonEmptyString, + createdAt: IsoDateTime, +}); + export const ThreadSessionSetPayload = Schema.Struct({ threadId: ThreadId, session: OrchestrationSession, @@ -1089,6 +1110,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.session-stop-requested"), payload: ThreadSessionStopRequestedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.task-stop-requested"), + payload: ThreadTaskStopRequestedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.session-set"), diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 94fb007a7bc..ec51fb8e645 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -95,6 +95,12 @@ export const ProviderStopSessionInput = Schema.Struct({ }); export type ProviderStopSessionInput = typeof ProviderStopSessionInput.Type; +export const ProviderStopTaskInput = Schema.Struct({ + threadId: ThreadId, + taskId: TrimmedNonEmptyString, +}); +export type ProviderStopTaskInput = typeof ProviderStopTaskInput.Type; + export const ProviderRespondToRequestInput = Schema.Struct({ threadId: ThreadId, requestId: ApprovalRequestId, diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index eb2563eff00..745733d98d3 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -14,6 +14,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId, ProviderDriverKind } from "./providerInstance.ts"; +import { WorkflowProgressEntry, WorkflowRunHandles } from "./workflow.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const UnknownRecordSchema = Schema.Record(Schema.String, Schema.Unknown); @@ -177,6 +178,7 @@ const ProviderRuntimeEventType = Schema.Literals([ "task.started", "task.progress", "task.completed", + "task.workflowMeta", "hook.started", "hook.progress", "hook.completed", @@ -227,6 +229,7 @@ const UserInputResolvedType = Schema.Literal("user-input.resolved"); const TaskStartedType = Schema.Literal("task.started"); const TaskProgressType = Schema.Literal("task.progress"); const TaskCompletedType = Schema.Literal("task.completed"); +const TaskWorkflowMetaType = Schema.Literal("task.workflowMeta"); const HookStartedType = Schema.Literal("hook.started"); const HookProgressType = Schema.Literal("hook.progress"); const HookCompletedType = Schema.Literal("hook.completed"); @@ -463,6 +466,9 @@ const TaskStartedPayload = Schema.Struct({ taskId: RuntimeTaskId, description: Schema.optional(TrimmedNonEmptyStringSchema), taskType: Schema.optional(TrimmedNonEmptyStringSchema), + toolUseId: Schema.optional(TrimmedNonEmptyStringSchema), + /** meta.name from the workflow script; set when taskType is "local_workflow". */ + workflowName: Schema.optional(TrimmedNonEmptyStringSchema), }); export type TaskStartedPayload = typeof TaskStartedPayload.Type; @@ -472,9 +478,22 @@ const TaskProgressPayload = Schema.Struct({ summary: Schema.optional(TrimmedNonEmptyStringSchema), usage: Schema.optional(Schema.Unknown), lastToolName: Schema.optional(TrimmedNonEmptyStringSchema), + /** + * Cumulative workflow snapshot (phases, agents, narration) for + * `local_workflow` tasks. Normalized and size-capped by the adapter. + */ + workflowProgress: Schema.optional(Schema.Array(WorkflowProgressEntry)), }); export type TaskProgressPayload = typeof TaskProgressPayload.Type; +/** + * Emitted once per workflow run when the Workflow tool result is observed — + * carries the run handles (script path, transcript dir, run id) that the + * progress stream does not repeat. + */ +const TaskWorkflowMetaPayload = WorkflowRunHandles; +export type TaskWorkflowMetaPayload = WorkflowRunHandles; + const TaskCompletedPayload = Schema.Struct({ taskId: RuntimeTaskId, status: Schema.Literals(["completed", "failed", "stopped"]), @@ -842,6 +861,13 @@ const ProviderRuntimeTaskCompletedEvent = Schema.Struct({ }); export type ProviderRuntimeTaskCompletedEvent = typeof ProviderRuntimeTaskCompletedEvent.Type; +const ProviderRuntimeTaskWorkflowMetaEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: TaskWorkflowMetaType, + payload: TaskWorkflowMetaPayload, +}); +export type ProviderRuntimeTaskWorkflowMetaEvent = typeof ProviderRuntimeTaskWorkflowMetaEvent.Type; + const ProviderRuntimeHookStartedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: HookStartedType, @@ -996,6 +1022,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeTaskStartedEvent, ProviderRuntimeTaskProgressEvent, ProviderRuntimeTaskCompletedEvent, + ProviderRuntimeTaskWorkflowMetaEvent, ProviderRuntimeHookStartedEvent, ProviderRuntimeHookProgressEvent, ProviderRuntimeHookCompletedEvent, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 48c5d9a774d..4fb7f707262 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -143,6 +143,15 @@ import { SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; +import { + WorkflowInspectionError, + WorkflowReadAgentTranscriptInput, + WorkflowReadAgentTranscriptResult, + WorkflowReadJournalInput, + WorkflowReadJournalResult, + WorkflowReadScriptInput, + WorkflowReadScriptResult, +} from "./workflow.ts"; export const WS_METHODS = { // Project registry methods @@ -179,6 +188,11 @@ export const WS_METHODS = { // Review methods reviewGetDiffPreview: "review.getDiffPreview", + // Workflow inspection methods (Claude Agent SDK workflow runs) + workflowReadScript: "workflow.readScript", + workflowReadJournal: "workflow.readJournal", + workflowReadAgentTranscript: "workflow.readAgentTranscript", + // Terminal methods terminalOpen: "terminal.open", terminalAttach: "terminal.attach", @@ -478,6 +492,24 @@ export const WsReviewGetDiffPreviewRpc = Rpc.make(WS_METHODS.reviewGetDiffPrevie error: Schema.Union([ReviewDiffPreviewError, EnvironmentAuthorizationError]), }); +export const WsWorkflowReadScriptRpc = Rpc.make(WS_METHODS.workflowReadScript, { + payload: WorkflowReadScriptInput, + success: WorkflowReadScriptResult, + error: Schema.Union([WorkflowInspectionError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowReadJournalRpc = Rpc.make(WS_METHODS.workflowReadJournal, { + payload: WorkflowReadJournalInput, + success: WorkflowReadJournalResult, + error: Schema.Union([WorkflowInspectionError, EnvironmentAuthorizationError]), +}); + +export const WsWorkflowReadAgentTranscriptRpc = Rpc.make(WS_METHODS.workflowReadAgentTranscript, { + payload: WorkflowReadAgentTranscriptInput, + success: WorkflowReadAgentTranscriptResult, + error: Schema.Union([WorkflowInspectionError, EnvironmentAuthorizationError]), +}); + export const WsTerminalOpenRpc = Rpc.make(WS_METHODS.terminalOpen, { payload: TerminalOpenInput, success: TerminalSessionSnapshot, @@ -719,6 +751,9 @@ export const WsRpcGroup = RpcGroup.make( WsVcsSwitchRefRpc, WsVcsInitRpc, WsReviewGetDiffPreviewRpc, + WsWorkflowReadScriptRpc, + WsWorkflowReadJournalRpc, + WsWorkflowReadAgentTranscriptRpc, WsTerminalOpenRpc, WsTerminalAttachRpc, WsTerminalWriteRpc, diff --git a/packages/contracts/src/workflow.test.ts b/packages/contracts/src/workflow.test.ts new file mode 100644 index 00000000000..5ee8cacc09d --- /dev/null +++ b/packages/contracts/src/workflow.test.ts @@ -0,0 +1,145 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + WorkflowAgentProgressEntry, + WorkflowInspectionError, + WorkflowLogProgressEntry, + WorkflowPhaseProgressEntry, + WorkflowProgressEntry, + WorkflowRunHandles, +} from "./workflow.ts"; + +const decodeAgent = Schema.decodeUnknownSync(WorkflowAgentProgressEntry); +const decodePhase = Schema.decodeUnknownSync(WorkflowPhaseProgressEntry); +const decodeLog = Schema.decodeUnknownSync(WorkflowLogProgressEntry); +const decodeEntry = Schema.decodeUnknownSync(WorkflowProgressEntry); +const decodeHandles = Schema.decodeUnknownSync(WorkflowRunHandles); + +describe("WorkflowProgressEntry variants", () => { + it("decodes a minimal workflow_agent entry and leaves optional fields absent", () => { + const agent = decodeAgent({ type: "workflow_agent", index: 0, state: "start" }); + expect(agent).toEqual({ type: "workflow_agent", index: 0, state: "start" }); + expect(agent.label).toBeUndefined(); + expect(agent.phaseIndex).toBeUndefined(); + expect(agent.startedAt).toBeUndefined(); + }); + + it("decodes a workflow_agent with the full optional surface, including isolation literals", () => { + const agent = decodeAgent({ + type: "workflow_agent", + index: 3, + state: "done", + label: "reviewer", + phaseIndex: 1, + phaseTitle: "Review", + agentId: "agent-3", + agentType: "code-reviewer", + model: "claude", + fallbackModel: "haiku", + isolation: "worktree", + attempt: 2, + queuedAt: 10, + startedAt: 20, + lastProgressAt: 30, + cached: true, + remoteSessionId: "remote-1", + lastToolName: "Bash", + lastToolSummary: "ran tests", + promptPreview: "do the thing", + resultPreview: "done", + error: "none", + }); + expect(agent.isolation).toBe("worktree"); + expect(agent.phaseIndex).toBe(1); + expect(agent.cached).toBe(true); + }); + + it("rejects an unknown isolation literal", () => { + expect(() => + decodeAgent({ type: "workflow_agent", index: 0, state: "start", isolation: "cloud" }), + ).toThrow(); + }); + + // Effect's Schema.Struct defaults to onExcessProperty: "ignore", so unknown + // extra keys DECODE SUCCESSFULLY and are stripped rather than rejected. + it("accepts and strips unknown extra keys on a workflow_agent", () => { + const agent = decodeAgent({ + type: "workflow_agent", + index: 0, + state: "start", + somethingNew: "from a future SDK", + }); + expect(agent).toEqual({ type: "workflow_agent", index: 0, state: "start" }); + expect(agent).not.toHaveProperty("somethingNew"); + }); + + it("decodes a minimal workflow_phase and keeps optional kind absent when omitted", () => { + const phase = decodePhase({ type: "workflow_phase", index: 0, title: "Plan" }); + expect(phase).toEqual({ type: "workflow_phase", index: 0, title: "Plan" }); + expect(phase.kind).toBeUndefined(); + }); + + it("decodes a workflow_log entry", () => { + const log = decodeLog({ type: "workflow_log", message: "starting up" }); + expect(log).toEqual({ type: "workflow_log", message: "starting up" }); + }); + + it("decodes each variant through the union by its discriminant", () => { + expect(decodeEntry({ type: "workflow_agent", index: 0, state: "start" }).type).toBe( + "workflow_agent", + ); + expect(decodeEntry({ type: "workflow_phase", index: 0, title: "Plan" }).type).toBe( + "workflow_phase", + ); + expect(decodeEntry({ type: "workflow_log", message: "hi" }).type).toBe("workflow_log"); + }); + + it("rejects an unknown entry type in the union", () => { + expect(() => decodeEntry({ type: "workflow_mystery", index: 0 })).toThrow(); + }); +}); + +describe("WorkflowRunHandles", () => { + it("decodes with only the required taskId", () => { + const handles = decodeHandles({ taskId: "task-1" }); + expect(handles).toEqual({ taskId: "task-1" }); + expect(handles.runId).toBeUndefined(); + expect(handles.sessionUrl).toBeUndefined(); + }); + + it("requires taskId", () => { + expect(() => decodeHandles({ runId: "wf_abc" })).toThrow(); + }); + + it("rejects a blank (untrimmed-empty) taskId", () => { + expect(() => decodeHandles({ taskId: " " })).toThrow(); + }); + + it("decodes the full remote handle surface", () => { + const handles = decodeHandles({ + taskId: "task-1", + runId: "wf_abc", + workflowName: "spec", + taskType: "remote_agent", + scriptPath: "/x/s.js", + transcriptDir: "/x/t", + sessionUrl: "https://example.com/run", + warning: "degraded", + }); + expect(handles.sessionUrl).toBe("https://example.com/run"); + expect(handles.taskType).toBe("remote_agent"); + }); +}); + +describe("WorkflowInspectionError", () => { + it("derives a stable message from operation and detail", () => { + const error = new WorkflowInspectionError({ + operation: "readScript", + reason: "not-found", + detail: "no such file", + }); + expect(error.message).toBe("Workflow inspection failed in readScript: no such file"); + expect(error.reason).toBe("not-found"); + }); +}); diff --git a/packages/contracts/src/workflow.ts b/packages/contracts/src/workflow.ts new file mode 100644 index 00000000000..d58f13fcce9 --- /dev/null +++ b/packages/contracts/src/workflow.ts @@ -0,0 +1,168 @@ +import * as Schema from "effect/Schema"; + +import { TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** + * Contracts for Claude Agent SDK workflow-run visibility. + * + * A "workflow" is a background orchestration task the Claude Agent SDK runs + * in-process (the `Workflow` tool). The SDK streams a cumulative progress + * snapshot on every `task_progress` message via the (currently undocumented) + * `workflow_progress` field, and writes per-agent transcripts plus a result + * journal to a transcript directory on disk. These schemas model the subset + * the server forwards to clients. + * + * Every field that originates from the undocumented SDK surface is optional: + * the adapter normalizes entries defensively, and clients must tolerate + * absent fields so an SDK upgrade degrades to less detail, never to a + * decode failure. + */ + +/** + * One `agent()` call inside a workflow run. `index` is the SDK's stable + * per-run agent ordinal; snapshots are merged last-write-wins by `index`. + * `state` is an open string ("start" | "done" | "error" today) — clients + * must render unknown states as "running". + */ +export const WorkflowAgentProgressEntry = Schema.Struct({ + type: Schema.Literal("workflow_agent"), + index: Schema.Number, + state: Schema.String, + label: Schema.optional(Schema.String), + phaseIndex: Schema.optional(Schema.Number), + phaseTitle: Schema.optional(Schema.String), + agentId: Schema.optional(Schema.String), + agentType: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + fallbackModel: Schema.optional(Schema.String), + isolation: Schema.optional(Schema.Literals(["worktree", "remote"])), + attempt: Schema.optional(Schema.Number), + queuedAt: Schema.optional(Schema.Number), + startedAt: Schema.optional(Schema.Number), + lastProgressAt: Schema.optional(Schema.Number), + cached: Schema.optional(Schema.Boolean), + remoteSessionId: Schema.optional(Schema.String), + lastToolName: Schema.optional(Schema.String), + lastToolSummary: Schema.optional(Schema.String), + promptPreview: Schema.optional(Schema.String), + resultPreview: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), + /** Cumulative output tokens, tool calls, and wall-clock duration for this + * agent, as reported by the SDK snapshot. */ + tokens: Schema.optional(Schema.Number), + toolCalls: Schema.optional(Schema.Number), + durationMs: Schema.optional(Schema.Number), +}); +export type WorkflowAgentProgressEntry = typeof WorkflowAgentProgressEntry.Type; + +export const WorkflowPhaseProgressEntry = Schema.Struct({ + type: Schema.Literal("workflow_phase"), + index: Schema.Number, + title: Schema.String, + kind: Schema.optional(Schema.String), +}); +export type WorkflowPhaseProgressEntry = typeof WorkflowPhaseProgressEntry.Type; + +/** A `log()` narration line emitted by the workflow script. */ +export const WorkflowLogProgressEntry = Schema.Struct({ + type: Schema.Literal("workflow_log"), + message: Schema.String, +}); +export type WorkflowLogProgressEntry = typeof WorkflowLogProgressEntry.Type; + +export const WorkflowProgressEntry = Schema.Union([ + WorkflowAgentProgressEntry, + WorkflowPhaseProgressEntry, + WorkflowLogProgressEntry, +]); +export type WorkflowProgressEntry = typeof WorkflowProgressEntry.Type; + +/** + * Handles returned by the Workflow tool result. `transcriptDir` and + * `scriptPath` are server-local paths — clients echo them back to the + * workflow inspection RPCs, which re-validate them structurally before + * touching disk. `sessionUrl` replaces the local handles for remote runs. + */ +export const WorkflowRunHandles = Schema.Struct({ + taskId: TrimmedNonEmptyString, + runId: Schema.optional(TrimmedNonEmptyString), + workflowName: Schema.optional(TrimmedNonEmptyString), + taskType: Schema.optional(TrimmedNonEmptyString), + scriptPath: Schema.optional(TrimmedNonEmptyString), + transcriptDir: Schema.optional(TrimmedNonEmptyString), + sessionUrl: Schema.optional(TrimmedNonEmptyString), + warning: Schema.optional(TrimmedNonEmptyString), +}); +export type WorkflowRunHandles = typeof WorkflowRunHandles.Type; + +export class WorkflowInspectionError extends Schema.TaggedErrorClass()( + "WorkflowInspectionError", + { + operation: Schema.String, + reason: Schema.Literals(["invalid-path", "not-found", "read-failed", "unsupported"]), + detail: Schema.String, + /** Underlying failure for read-failed/not-found wraps; absent for pure + * validation reasons. Matches the GitCommandError convention. */ + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Workflow inspection failed in ${this.operation}: ${this.detail}`; + } +} + +export const WorkflowReadScriptInput = Schema.Struct({ + scriptPath: TrimmedNonEmptyString, +}); +export type WorkflowReadScriptInput = typeof WorkflowReadScriptInput.Type; + +export const WorkflowReadScriptResult = Schema.Struct({ + source: Schema.String, + truncated: Schema.Boolean, +}); +export type WorkflowReadScriptResult = typeof WorkflowReadScriptResult.Type; + +export const WorkflowReadJournalInput = Schema.Struct({ + transcriptDir: TrimmedNonEmptyString, +}); +export type WorkflowReadJournalInput = typeof WorkflowReadJournalInput.Type; + +/** + * One journal record per agent. `resultJson` is the agent's return value + * re-serialized as JSON, truncated server-side; `resultTruncated` marks the + * clip. Agents with a `started` record but no `result` yet report + * `hasResult: false`. + */ +export const WorkflowJournalEntry = Schema.Struct({ + agentId: Schema.String, + hasResult: Schema.Boolean, + resultJson: Schema.optional(Schema.String), + resultTruncated: Schema.optional(Schema.Boolean), +}); +export type WorkflowJournalEntry = typeof WorkflowJournalEntry.Type; + +export const WorkflowReadJournalResult = Schema.Struct({ + entries: Schema.Array(WorkflowJournalEntry), + truncated: Schema.Boolean, +}); +export type WorkflowReadJournalResult = typeof WorkflowReadJournalResult.Type; + +export const WorkflowReadAgentTranscriptInput = Schema.Struct({ + transcriptDir: TrimmedNonEmptyString, + agentId: TrimmedNonEmptyString, + /** Zero-based line cursor; omit to read from the start. */ + afterLine: Schema.optional(Schema.Int), +}); +export type WorkflowReadAgentTranscriptInput = typeof WorkflowReadAgentTranscriptInput.Type; + +/** + * Raw transcript JSONL lines starting after the cursor. `nextLine` is the + * cursor for the next page; `complete` means the read reached end-of-file + * (more lines may still be appended while the agent runs — poll again). + */ +export const WorkflowReadAgentTranscriptResult = Schema.Struct({ + lines: Schema.Array(Schema.String), + nextLine: Schema.Int, + complete: Schema.Boolean, +}); +export type WorkflowReadAgentTranscriptResult = typeof WorkflowReadAgentTranscriptResult.Type;