diff --git a/apps/server/src/plugins/Workflows.testkit.ts b/apps/server/src/plugins/Workflows.testkit.ts index 09d275452d6f..2395e43e8b5e 100644 --- a/apps/server/src/plugins/Workflows.testkit.ts +++ b/apps/server/src/plugins/Workflows.testkit.ts @@ -240,6 +240,18 @@ export const fixture = Effect.gen(function* () { if (!Effect.isEffect(result)) return yield* Effect.die("Expected request"); return yield* result; }); + /** A subscription API on the currently running plugin (after any restart). */ + const stream = (method: string, input: unknown) => + Stream.unwrap( + Effect.suspend(() => runtime.registry.api(`plugins.workflows.${method}`)).pipe( + Effect.flatMap((api) => { + const result = api.invoke(input); + return Stream.isStream(result) + ? Effect.succeed(result) + : Effect.die("Expected a subscription"); + }), + ), + ); const query = (runId: string) => invoke("get", { environmentId, projectId, runId }).pipe(Effect.flatMap(decodeRun)); const reconcile = invoke("reconcile", { environmentId, projectId }).pipe(Effect.asVoid); @@ -276,6 +288,7 @@ export const fixture = Effect.gen(function* () { query, reconcile, invoke, + stream, report, launches, commands, diff --git a/apps/server/src/plugins/WorkflowsRun.integration.test.ts b/apps/server/src/plugins/WorkflowsRun.integration.test.ts new file mode 100644 index 000000000000..3be9dd6533cb --- /dev/null +++ b/apps/server/src/plugins/WorkflowsRun.integration.test.ts @@ -0,0 +1,600 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as Yaml from "yaml"; +import { + Definition as DefinitionSchema, + Run, + RunSummary, + StartPreview, + ThreadLink, + type Definition, +} from "@t3tools/plugin-workflows/contracts"; +import { completed, fixture, sequence } from "./Workflows.testkit.ts"; + +const decodeRun = Schema.decodeUnknownEffect(Run); +const decodeRuns = Schema.decodeUnknownEffect(Schema.Array(RunSummary)); +const decodePreview = Schema.decodeUnknownEffect(StartPreview); +const decodeLink = Schema.decodeUnknownEffect(Schema.NullOr(ThreadLink)); + +/** A project catalog file and the saved-start request the Run dialog sends for it. */ +const harness = Effect.gen(function* () { + const test = yield* fixture; + const fs = yield* FileSystem.FileSystem; + const catalog = `${test.directory}/.t3code/workflows`; + yield* fs.makeDirectory(catalog, { recursive: true }); + const save = (definition: Definition) => + fs.writeFileString(`${catalog}/sequence.yaml`, Yaml.stringify(definition)); + yield* save(sequence); + const scope = { environmentId: test.environmentId, projectId: test.projectId }; + const startSaved = ( + clientRequestId: string, + options: { + readonly revision?: number; + readonly task?: string; + readonly workspace?: "new-worktree" | "current"; + } = {}, + ) => + test.invoke("launch", { + ...scope, + clientRequestId, + definitionId: "sequence", + revision: options.revision ?? 1, + task: options.task ?? "Ship the change", + workspace: options.workspace ?? "current", + }); + const command = (method: string, runId: string, clientRequestId: string, revision: number) => + test.invoke(method, { ...scope, runId, clientRequestId, expectedRevision: revision }); + const list = (before?: string) => + test + .invoke("list", { ...scope, ...(before === undefined ? {} : { before }) }) + .pipe(Effect.flatMap(decodeRuns)); + /** The thread link stream's current value. */ + const thread = (threadId: string) => + test.stream("thread", { ...scope, threadId }).pipe( + Stream.take(1), + Stream.runCollect, + Effect.flatMap((links) => decodeLink(links[0])), + ); + return { test, save, scope, startSaved, command, list, thread }; +}); + +it.effect("starts one saved revision across a lost response and a later catalog edit", () => + Effect.scoped( + Effect.gen(function* () { + const { test, save, scope, startSaved, command, list } = yield* harness; + const preview = yield* test + .invoke("preview", { ...scope, definitionId: "sequence" }) + .pipe(Effect.flatMap(decodePreview)); + expect(preview).toMatchObject({ + definitionId: "sequence", + revision: 1, + source: ".t3code/workflows/sequence.yaml", + runnable: true, + agents: [{ nodeId: "implement", providerInstanceId: "codex", model: "fixture" }], + workspace: { path: test.directory, branch: "main" }, + }); + + const first = yield* startSaved("intent-1").pipe(Effect.flatMap(decodeRun)); + expect(first).toMatchObject({ + state: "running", + input: { task: "Ship the change" }, + definition: { id: "sequence", revision: 1 }, + source: { trigger: "manual", catalogSource: ".t3code/workflows/sequence.yaml" }, + workspace: { type: "current" }, + // Cancel is authorized as soon as the start is acknowledged. + allowedActions: ["cancel"], + }); + + // The response was lost, then the workflow was edited before the client retried. + yield* save({ ...sequence, revision: 2, title: "Sequence v2" }); + const retried = yield* startSaved("intent-1").pipe(Effect.flatMap(decodeRun)); + expect(retried.id).toBe(first.id); + expect(retried.definition.revision).toBe(1); + expect(yield* list()).toHaveLength(1); + + // The same identity cannot be reused for different input. + const reused = yield* startSaved("intent-1", { task: "Something else" }).pipe( + Effect.flip, + Effect.orDie, + ); + expect(reused).toMatchObject({ code: "conflict" }); + // A new intent for the reviewed revision is refused with the current one named. + const changed = yield* startSaved("intent-2").pipe(Effect.flip, Effect.orDie); + expect(changed).toMatchObject({ code: "conflict" }); + expect(changed.message).toContain("revision 2"); + expect(yield* list()).toHaveLength(1); + + // Run again is a new identity that resolves the current saved revision. + const again = yield* startSaved("intent-3", { revision: 2, workspace: "new-worktree" }).pipe( + Effect.flatMap(decodeRun), + ); + expect(again.id).not.toBe(first.id); + expect(again).toMatchObject({ + definition: { revision: 2, title: "Sequence v2" }, + workspace: { type: "exact-ref" }, + }); + expect((yield* list()).map((run) => run.id)).toEqual([again.id, first.id]); + + yield* test.reconcile; + const launched = yield* test.query(first.id); + expect(launched.revision).toBeGreaterThan(first.revision); + // A cancel decided against an older snapshot is refused rather than applied blindly. + const stale = yield* command("cancel", first.id, "cancel-0", first.revision).pipe( + Effect.flip, + Effect.orDie, + ); + expect(stale).toMatchObject({ code: "conflict" }); + const canceled = yield* command("cancel", first.id, "cancel-1", launched.revision).pipe( + Effect.flatMap(decodeRun), + ); + expect(canceled).toMatchObject({ state: "canceled", allowedActions: [] }); + // A retried cancel returns its original result instead of failing as terminal. + const replay = yield* command("cancel", first.id, "cancel-1", launched.revision).pipe( + Effect.flatMap(decodeRun), + ); + expect(replay.revision).toBe(canceled.revision); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "keeps accepted claims, settlement, gate revisions and thread ownership distinct across restart", + () => + Effect.scoped( + Effect.gen(function* () { + const { test, startSaved, command, thread } = yield* harness; + const started = yield* startSaved("intent").pipe(Effect.flatMap(decodeRun)); + yield* test.reconcile; + const threadId = (yield* test.query(started.id)).attempts[0]!.threadId!; + yield* test.report(threadId, completed); + yield* test.reconcile; + // The report is accepted while native execution is still running. + const reported = yield* test.query(started.id); + expect(reported).toMatchObject({ + state: "running", + trace: [], + attempts: [ + { phase: "reported", report: { outcome: "completed", data: { ready: true } } }, + ], + }); + expect(yield* thread(threadId)).toMatchObject({ + runId: started.id, + attemptId: reported.attempts[0]!.id, + nodeId: "implement", + phase: "reported", + reportAccepted: true, + workflowTitle: "Sequence", + nodeTitle: "Implement", + }); + expect(yield* thread("unowned-thread")).toBeNull(); + + test.settle(threadId); + yield* test.reconcile; + const gated = yield* test.wait(started.id, (run) => run.state === "awaiting-review"); + expect(gated.gate).toMatchObject({ nodeId: "review", revision: gated.revision }); + expect(gated.allowedActions).toEqual(["cancel", "approve", "request-changes"]); + expect(gated.trace.at(-1)).toMatchObject({ nodeId: "implement", chosen: "review" }); + // Attention opens the run page with this exact run selected. + const [attention] = yield* test.registry + .attention(test.environmentId) + .pipe(Stream.take(1), Stream.runCollect); + expect(attention?.items.find((item) => item.id === started.id)?.link).toEqual({ + pageId: "workflows.runs", + projectId: test.projectId, + state: { run: started.id }, + threadId, + }); + + const stale = yield* test + .invoke("gate", { + environmentId: test.environmentId, + projectId: test.projectId, + runId: started.id, + clientRequestId: "stale", + expectedRevision: gated.revision - 1, + decision: "approve", + }) + .pipe(Effect.flip, Effect.orDie); + expect(stale).toMatchObject({ code: "conflict" }); + expect((yield* test.query(started.id)).revision).toBe(gated.revision); + const approve = () => + test + .invoke("gate", { + environmentId: test.environmentId, + projectId: test.projectId, + runId: started.id, + clientRequestId: "approve", + expectedRevision: gated.gate!.revision, + decision: "approve", + }) + .pipe(Effect.flatMap(decodeRun)); + const approved = yield* approve(); + expect(approved).toMatchObject({ state: "completed", allowedActions: [] }); + expect((yield* approve()).revision).toBe(approved.revision); + // Cancel racing the completed run is refused with the run left as it settled. + const late = yield* command("cancel", started.id, "late-cancel", gated.revision).pipe( + Effect.flip, + Effect.orDie, + ); + expect(late).toMatchObject({ code: "conflict" }); + + // A later manual message in the thread does not reopen the historical attempt. + const state = test.threads.get(threadId)!; + test.threads.set(threadId, { + ...state, + runs: [...state.runs, { id: "manual-follow-up", status: "running" }], + }); + yield* test.reconcile; + const after = yield* test.query(started.id); + expect(after.revision).toBe(approved.revision); + expect(after.attempts[0]!.phase).toBe("completed"); + expect(yield* thread(threadId)).toMatchObject({ + attemptId: reported.attempts[0]!.id, + phase: "completed", + runState: "completed", + }); + + yield* test.restart; + const restored = yield* test.query(started.id); + expect(restored).toEqual(after); + expect((yield* thread(threadId))?.attemptId).toBe(reported.attempts[0]!.id); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("exposes Resume and Retry only with their server-owned recovery targets", () => + Effect.scoped( + Effect.gen(function* () { + const { test, startSaved, command } = yield* harness; + const started = yield* startSaved("intent").pipe(Effect.flatMap(decodeRun)); + expect(started.recovery).toEqual({ retryNodeId: null, resumeAttemptId: null }); + yield* test.reconcile; + const threadId = (yield* test.query(started.id)).attempts[0]!.threadId!; + // Native Stop interrupts the attempt; it is never treated as success. + test.settle(threadId, "interrupted"); + yield* test.reconcile; + yield* test.reconcile; + const interrupted = yield* test.wait(started.id, (run) => run.state === "unresolved"); + const attempt = interrupted.attempts[0]!; + expect(attempt.phase).toBe("interrupted"); + expect(interrupted.allowedActions).toEqual(["cancel", "retry", "resume"]); + expect(interrupted.recovery).toEqual({ + retryNodeId: "implement", + resumeAttemptId: attempt.id, + }); + + const retried = yield* command("retry", started.id, "retry", interrupted.revision).pipe( + Effect.flatMap(decodeRun), + ); + expect(retried.state).toBe("running"); + expect(retried.attempts).toHaveLength(2); + expect(retried.attempts[1]).toMatchObject({ nodeId: "implement", phase: "launching" }); + expect(retried.attempts[1]!.id).not.toBe(attempt.id); + expect(retried.recovery).toEqual({ retryNodeId: null, resumeAttemptId: null }); + // Resume is no longer allowed once the retry superseded the interrupted attempt. + const resume = yield* command("resume", started.id, "resume", retried.revision).pipe( + Effect.flip, + Effect.orDie, + ); + expect(resume).toMatchObject({ code: "unsupported" }); + yield* test.reconcile; + const second = (yield* test.query(started.id)).attempts[1]!; + expect(second.threadId).not.toBe(threadId); + + // A missing report leaves only Retry: there is no session to resume safely. + test.settle(second.threadId!); + yield* test.reconcile; + yield* test.wait(started.id, (run) => run.attempts[1]!.reminderSent); + test.settle(second.threadId!); + yield* test.reconcile; + const missing = yield* test.wait(started.id, (run) => run.state === "unresolved"); + expect(missing.attempts[1]).toMatchObject({ report: null }); + expect(missing.allowedActions).toEqual(["cancel", "retry"]); + expect(missing.recovery).toEqual({ retryNodeId: "implement", resumeAttemptId: null }); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("keeps an old run selectable and live outside the latest-run page", () => + Effect.scoped( + Effect.gen(function* () { + const { test, startSaved, command, list } = yield* harness; + const oldest = yield* startSaved("intent-0").pipe(Effect.flatMap(decodeRun)); + for (let index = 1; index <= 20; index++) yield* startSaved(`intent-${index}`); + const latest = yield* list(); + expect(latest).toHaveLength(20); + expect(latest.some((run) => run.id === oldest.id)).toBe(false); + const older = yield* list(latest.at(-1)!.id); + expect(older.map((run) => run.id)).toEqual([oldest.id]); + expect(older[0]?.source).toEqual({ + trigger: "manual", + catalogSource: ".t3code/workflows/sequence.yaml", + }); + + const api = yield* test.registry.api("plugins.workflows.watch"); + const stream = api.invoke({ + environmentId: test.environmentId, + projectId: test.projectId, + runId: oldest.id, + }); + if (!Stream.isStream(stream)) return yield* Effect.die("Expected a run subscription"); + const updates = yield* Queue.unbounded(); + const fiber = yield* stream.pipe( + Stream.mapEffect((value) => decodeRun(value)), + Stream.runForEach((run) => Queue.offer(updates, run)), + Effect.forkScoped, + ); + const current = yield* Queue.take(updates); + expect(current.id).toBe(oldest.id); + yield* command("cancel", oldest.id, "cancel-oldest", current.revision); + let next = yield* Queue.take(updates); + while (next.state !== "canceled") next = yield* Queue.take(updates); + expect(next).toMatchObject({ id: oldest.id, state: "canceled" }); + yield* Fiber.interrupt(fiber); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("opens a long run on its newest visits with overview totals from the full snapshot", () => + Effect.scoped( + Effect.gen(function* () { + const { test, startSaved, command } = yield* harness; + const started = yield* startSaved("intent").pipe(Effect.flatMap(decodeRun)); + // Each native Stop followed by Retry admits one more visit in a new thread. + for (let visit = 1; visit <= 52; visit++) { + yield* test.reconcile; + const running = yield* test.wait( + started.id, + (run) => run.attempts.at(-1)?.phase === "running", + ); + if (visit === 52) break; + test.settle(running.attempts.at(-1)!.threadId!, "interrupted"); + yield* test.reconcile; + yield* test.reconcile; + const stopped = yield* test.wait(started.id, (run) => run.state === "unresolved"); + yield* command("retry", started.id, `retry-${visit}`, stopped.revision); + } + const newest = yield* test.query(started.id); + expect(newest.history).toMatchObject({ tail: true, offset: 2, limit: 50, attempts: 52 }); + expect(newest.attempts).toHaveLength(50); + expect(newest.attempts.at(-1)!.phase).toBe("running"); + expect(newest.overview).toMatchObject({ + visits: 52, + completedVisits: 0, + activeAttempts: [{ id: newest.attempts.at(-1)!.id, phase: "running" }], + review: null, + }); + const oldest = yield* test + .invoke("get", { + environmentId: test.environmentId, + projectId: test.projectId, + runId: started.id, + historyOffset: 0, + }) + .pipe(Effect.flatMap(decodeRun)); + expect(oldest.history).toMatchObject({ tail: false, offset: 0 }); + expect(oldest.attempts[0]!.phase).toBe("interrupted"); + expect(oldest.attempts.some((attempt) => attempt.phase === "running")).toBe(false); + // The overview does not depend on the loaded page. + expect(oldest.overview).toEqual(newest.overview); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +const direct = { + ...sequence, + atLimit: "done", + nodes: [ + { ...sequence.nodes[0]!, next: { to: "done" } }, + ...sequence.nodes.filter((node) => node.kind === "end"), + ], +} as Definition; + +it.effect.each(["completion-first", "cancel-first"] as const)( + "settles a cancel and natural completion decided on one revision exactly once: %s", + (order) => + Effect.scoped( + Effect.gen(function* () { + const { test, save, startSaved, command } = yield* harness; + yield* save(direct); + const started = yield* startSaved("intent").pipe(Effect.flatMap(decodeRun)); + yield* test.reconcile; + const observed = yield* test.wait( + started.id, + (run) => run.attempts[0]?.phase === "running", + ); + const threadId = observed.attempts[0]!.threadId!; + // The user's Cancel and the agent's completion both act on the observed revision. + if (order === "completion-first") { + yield* test.report(threadId, completed); + test.settle(threadId); + yield* test.reconcile; + yield* test.wait(started.id, (run) => run.state === "completed"); + const cancel = yield* command("cancel", started.id, "cancel", observed.revision).pipe( + Effect.flip, + Effect.orDie, + ); + expect(cancel).toMatchObject({ code: "conflict" }); + expect(cancel.message).toContain("revision changed"); + } else { + const canceled = yield* command("cancel", started.id, "cancel", observed.revision).pipe( + Effect.flatMap(decodeRun), + ); + expect(canceled.state).toBe("canceled"); + const report = yield* test.report(threadId, completed).pipe(Effect.flip); + expect(report).toMatchObject({ code: "conflict" }); + test.settle(threadId); + yield* test.reconcile; + } + const final = yield* test.query(started.id); + expect(final.state).toBe(order === "completion-first" ? "completed" : "canceled"); + expect(final.attempts[0]!.phase).toBe( + order === "completion-first" ? "completed" : "canceled", + ); + expect(final.allowedActions).toEqual([]); + // Exactly one terminal edge: completion routes to the end, cancellation routes nowhere. + expect(final.trace.map((item) => item.chosen)).toEqual( + order === "completion-first" ? ["done"] : [], + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +const loopAgent = (id: string, title: string, next: string) => ({ + id, + kind: "agent", + title, + modelSelection: { instanceId: "codex", model: "fixture" }, + runtimeMode: "approval-required", + instruction: title, + report: { fields: [{ name: "again", type: "boolean", required: true }] }, + next: { to: next }, +}); +const again = { op: "eq", path: "data.again", value: true }; +const loop = Schema.decodeUnknownSync(DefinitionSchema)({ + version: 1, + id: "loop", + revision: 1, + title: "Loop", + entry: "work1", + atLimit: "gate", + maxVisits: 1000, + nodes: [ + loopAgent("work1", "Work one", "d1"), + { + id: "d1", + kind: "decision", + title: "Again one", + source: "work1", + rules: [{ when: again, route: { to: "work1", repeat: { max: 20, atLimit: "gate" } } }], + otherwise: { to: "work2" }, + }, + loopAgent("work2", "Work two", "d2"), + { + id: "d2", + kind: "decision", + title: "Again two", + source: "work2", + rules: [{ when: again, route: { to: "work1", repeat: { max: 20, atLimit: "gate" } } }], + otherwise: { to: "done" }, + }, + { + id: "gate", + kind: "human", + title: "Review", + approve: { to: "done" }, + changes: { to: "done" }, + }, + { id: "done", kind: "end", title: "Done", outcome: "completed" }, + ], +}); + +it.effect("keeps every visit's routing reachable when route history outgrows visits", () => + Effect.scoped( + Effect.gen(function* () { + const test = yield* fixture; + const started = yield* test.start(loop); + let work1 = 0; + // Two routing records per visit: agent → decision, decision → next step. + for (let visit = 1; visit <= 56; visit++) { + yield* test.reconcile; + const running = yield* test.wait( + started.id, + (run) => run.overview!.visits === visit && run.attempts.at(-1)?.phase === "running", + ); + const attempt = running.attempts.at(-1)!; + const repeat = attempt.nodeId === "work1" ? ++work1 <= 20 : true; + yield* test.report(attempt.threadId!, { + ...completed, + clientRetryKey: `visit-${visit}`, + data: { again: repeat }, + }); + test.settle(attempt.threadId!); + } + yield* test.reconcile; + const read = (input: { historyOffset?: number; attemptId?: string; traceOffset?: number }) => + test + .invoke("get", { + environmentId: test.environmentId, + projectId: test.projectId, + runId: started.id, + ...input, + }) + .pipe(Effect.flatMap(decodeRun)); + const newest = yield* test.wait(started.id, (run) => run.overview!.visits === 57); + expect(newest.history!.trace).toBeGreaterThan(newest.history!.attempts); + for (const page of [ + yield* read({ historyOffset: 0 }), + yield* read({ historyOffset: 50 }), + newest, + ]) { + const routing = [...page.trace, ...(page.relatedTrace ?? [])]; + for (const attempt of page.attempts.filter((item) => item.phase === "completed")) { + // Its own outgoing edge and the decision that consumed its report are both present. + expect(routing.some((item) => item.attemptId === attempt.id)).toBe(true); + expect( + routing.some( + (item) => + item.sourceIds.includes(`${attempt.id}:report`) && item.nodeId !== attempt.nodeId, + ), + ).toBe(true); + } + } + // Route history pages independently and its oldest entries are reachable. + const firstRoutes = yield* read({ traceOffset: 0 }); + expect(firstRoutes.history).toMatchObject({ traceOffset: 0, traceTail: false }); + expect(firstRoutes.trace[0]).toMatchObject({ nodeId: "work1", chosen: "d1" }); + // A deep-linked visit opens the page that contains it, not the newest page. + const oldest = (yield* read({ historyOffset: 0 })).attempts[3]!; + const located = yield* read({ attemptId: oldest.id }); + expect(located.history).toMatchObject({ offset: 0, tail: false }); + expect(located.attempts.some((item) => item.id === oldest.id)).toBe(true); + const latest = yield* read({ attemptId: newest.attempts.at(-1)!.id }); + expect(latest.history).toMatchObject({ tail: true }); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("updates a thread's link as its run commits, ignoring other runs' commits", () => + Effect.scoped( + Effect.gen(function* () { + const { test, scope, startSaved } = yield* harness; + const started = yield* startSaved("intent").pipe(Effect.flatMap(decodeRun)); + yield* test.reconcile; + const threadId = (yield* test.wait(started.id, (run) => run.attempts[0]?.phase === "running")) + .attempts[0]!.threadId!; + const links = yield* Queue.unbounded(); + yield* test.stream("thread", { ...scope, threadId }).pipe( + Stream.mapEffect((value) => decodeLink(value)), + Stream.runForEach((link) => Queue.offer(links, link)), + Effect.forkScoped, + ); + expect(yield* Queue.take(links)).toMatchObject({ phase: "running", reportAccepted: false }); + // Another run's commits do not re-send this thread's link. + yield* startSaved("other"); + yield* test.report(threadId, completed); + expect(yield* Queue.take(links)).toMatchObject({ phase: "reported", reportAccepted: true }); + test.settle(threadId); + yield* test.reconcile; + let next = yield* Queue.take(links); + while (next?.phase !== "completed") next = yield* Queue.take(links); + expect(next).toMatchObject({ runState: "awaiting-review", reportAccepted: true }); + // An unowned thread answers once with no link. + const unowned = yield* Queue.unbounded(); + yield* test.stream("thread", { ...scope, threadId: "plain-thread" }).pipe( + Stream.runForEach((value) => Queue.offer(unowned, value)), + Effect.forkScoped, + ); + expect(yield* Queue.take(unowned)).toBeNull(); + yield* startSaved("third"); + yield* test.reconcile; + expect(yield* Queue.size(unowned)).toBe(0); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/plugins/compiled.ts b/apps/server/src/plugins/compiled.ts index 9d14eda74b60..6054baebe10a 100644 --- a/apps/server/src/plugins/compiled.ts +++ b/apps/server/src/plugins/compiled.ts @@ -11,6 +11,8 @@ import { Capabilities, Skill, ProjectSummary, + StartPreview, + ThreadLink, } from "@t3tools/plugin-workflows/contracts"; import { Host, type ServerPlugin } from "@t3tools/plugin-host-contract/server"; import { PluginError } from "@t3tools/plugin-host-contract/schema"; @@ -37,6 +39,8 @@ const decodeReports = Schema.decodeUnknownEffect(Reports); const WorkflowRuns = Schema.Array(RunSummary); const CatalogEntries = Schema.Array(CatalogEntry); const decodeWorkflowRuns = Schema.decodeUnknownEffect(WorkflowRuns); +const decodeWorkflowRun = Schema.decodeUnknownEffect(WorkflowRun); +const decodeThreadLink = Schema.decodeUnknownEffect(Schema.NullOr(ThreadLink)); /** Trusted server modules explicitly included in this build. An empty list is supported. */ export class CompiledPlugins extends Context.Reference>( @@ -100,6 +104,46 @@ const request = ), ); +/** A plugin stream API whose items are re-decoded at the transport boundary. */ +const subscription = ( + registry: Registry.PluginRegistry["Service"], + id: string, + input: unknown, + decode: (value: unknown) => Effect.Effect, +) => + Stream.unwrap( + registry.api(id).pipe( + Effect.map((api) => { + const result = api.invoke(input); + return Stream.isStream(result) + ? result + : Stream.fail( + new PluginError({ + pluginId: "workflows", + code: "validation", + operation: id, + message: "This API was registered as a request instead of a subscription.", + }), + ); + }), + ), + ).pipe( + Stream.mapEffect((result) => + decode(result).pipe( + Effect.mapError( + (cause) => + new PluginError({ + pluginId: "workflows", + code: "validation", + operation: id, + message: "The plugin returned an invalid subscription response.", + cause, + }), + ), + ), + ), + ); + export const handlers = (registry: Registry.PluginRegistry["Service"]) => ({ "plugins.catalog": (input: { readonly environmentId: string }) => registry.catalog.pipe( @@ -139,6 +183,14 @@ export const handlers = (registry: Registry.PluginRegistry["Service"]) => ({ request(registry, workflowRpcs.projects._tag, input, Schema.Array(ProjectSummary)), [workflowRpcs.start._tag]: (input: typeof workflowRpcs.start.payloadSchema.Type) => request(registry, workflowRpcs.start._tag, input, WorkflowRun), + [workflowRpcs.preview._tag]: (input: typeof workflowRpcs.preview.payloadSchema.Type) => + request(registry, workflowRpcs.preview._tag, input, StartPreview), + [workflowRpcs.launch._tag]: (input: typeof workflowRpcs.launch.payloadSchema.Type) => + request(registry, workflowRpcs.launch._tag, input, WorkflowRun), + [workflowRpcs.thread._tag]: (input: typeof workflowRpcs.thread.payloadSchema.Type) => + subscription(registry, workflowRpcs.thread._tag, input, decodeThreadLink), + [workflowRpcs.watch._tag]: (input: typeof workflowRpcs.watch.payloadSchema.Type) => + subscription(registry, workflowRpcs.watch._tag, input, decodeWorkflowRun), [workflowRpcs.get._tag]: (input: typeof workflowRpcs.get.payloadSchema.Type) => request(registry, workflowRpcs.get._tag, input, WorkflowRun), [workflowRpcs.list._tag]: (input: typeof workflowRpcs.list.payloadSchema.Type) => diff --git a/apps/web/integration/PluginClientPermissions.integration.test.ts b/apps/web/integration/PluginClientPermissions.integration.test.ts index f7666e91265a..cb9482a0a00d 100644 --- a/apps/web/integration/PluginClientPermissions.integration.test.ts +++ b/apps/web/integration/PluginClientPermissions.integration.test.ts @@ -224,6 +224,32 @@ it.live( fingerprint: "0".repeat(64), definition, }), + () => + workflows.startSaved({ + projectId, + clientRequestId: "guarded", + definitionId: "guarded", + revision: 1, + task: "", + workspace: "new-worktree", + }), + ...(["cancel", "retry", "resume"] as const).map( + (action) => () => + workflows[action]({ + projectId, + runId: "missing-run", + clientRequestId: action, + expectedRevision: 1, + }), + ), + () => + workflows.gate({ + projectId, + runId: "missing-run", + clientRequestId: "gate", + expectedRevision: 1, + decision: "approve", + }), ]) { yield* Effect.promise(() => expect(command()).rejects.toMatchObject({ _tag: "EnvironmentAuthorizationError" }), @@ -236,6 +262,11 @@ it.live( "plugins.fixture.schedule", "plugins.workflows.save", "plugins.workflows.replace", + "plugins.workflows.launch", + "plugins.workflows.cancel", + "plugins.workflows.retry", + "plugins.workflows.resume", + "plugins.workflows.gate", ].includes(method), ), ).toEqual([]); diff --git a/apps/web/integration/WorkflowAuthoring.integration.test.tsx b/apps/web/integration/WorkflowAuthoring.integration.test.tsx index a8e78bad84fd..21bb5d71f612 100644 --- a/apps/web/integration/WorkflowAuthoring.integration.test.tsx +++ b/apps/web/integration/WorkflowAuthoring.integration.test.tsx @@ -264,6 +264,8 @@ function track(client: WorkflowClient) { }; const permissionWaiters: Array<(value: WorkflowPermissions) => void> = []; const wrapped: WorkflowClient = { + // Run APIs are not exercised by authoring; they pass through untracked. + ...client, subscribePermissions: (listener) => client.subscribePermissions((value) => { listener(value); diff --git a/apps/web/integration/WorkflowRun.integration.test.tsx b/apps/web/integration/WorkflowRun.integration.test.tsx new file mode 100644 index 000000000000..b7d8c543209e --- /dev/null +++ b/apps/web/integration/WorkflowRun.integration.test.tsx @@ -0,0 +1,1552 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + WS_METHODS, + type EnvironmentId, + type PluginCatalog, + type ServerConfig, +} from "@t3tools/contracts"; +import { Host, type ServerPlugin } from "@t3tools/plugin-host-contract/server"; +import type { + PluginCommandReceipt, + PluginLifecycleItem, + PluginThreadState, +} from "@t3tools/plugin-host-contract/schema"; +import { PluginError } from "@t3tools/plugin-host-contract/schema"; +import { plugin as workflowPlugin } from "@t3tools/plugin-workflows/server"; +import { + authoringTimings, + preloadWorkflowPages, + web as workflowsWeb, +} from "@t3tools/plugin-workflows/web"; +import type { + Run, + ThreadLink, + WorkflowClient, + WorkflowPermissions, +} from "@t3tools/plugin-workflows/contracts"; +import { + PrimaryConnectionTarget, + AVAILABLE_CONNECTION_STATE, + type PreparedConnection, + type ConnectionCatalogEntry, + type NetworkStatus, + EnvironmentSupervisor as Supervisor, +} from "@t3tools/client-runtime/connection"; +import type { RpcSession } from "@t3tools/client-runtime/rpc"; +import { + RouterContextProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { act, useMemo, useSyncExternalStore } from "react"; +import { create, type ReactTestInstance, type ReactTestRenderer } from "react-test-renderer"; +import * as Yaml from "yaml"; +import { vi } from "vite-plus/test"; + +import * as Auth from "../../server/src/auth/EnvironmentAuth.ts"; +import * as Projects from "../../server/src/project/ProjectService.ts"; +import * as Registry from "../../../packages/plugin-host-adapter/src/PluginRegistry.ts"; +import { + startEnvironment, + makeClient, + origin, +} from "../../server/src/plugins/PluginHost.testkit.ts"; +import { makeReplayServerConfig } from "../../server/src/orchestration-v2/testkit/ProviderReplayHarness.ts"; + +const binding = vi.hoisted(() => ({ + configs: new Map(), + contexts: new Map>(), +})); + +// Bind the production client to each test environment's authenticated server session. +vi.mock("../src/connection/runtime", async () => { + const { Atom } = await import("effect/reactivity"); + const Layer = await import("effect/Layer"); + const Effect = await import("effect/Effect"); + const Stream = await import("effect/Stream"); + const SubscriptionRef = await import("effect/SubscriptionRef"); + const { FetchHttpClient } = await import("effect/http"); + const { EnvironmentRegistry } = await import("@t3tools/client-runtime/connection"); + return { + connectionAtomRuntime: Atom.runtime( + Layer.unwrap( + Effect.gen(function* () { + const entries = yield* SubscriptionRef.make< + ReadonlyMap + >(new Map()); + const networkStatus = yield* SubscriptionRef.make("online"); + return Layer.mock(EnvironmentRegistry.EnvironmentRegistry)({ + entries, + networkStatus, + run: (environmentId, effect) => + Effect.suspend(() => { + const context = binding.contexts.get(environmentId); + return context === undefined + ? Effect.die("Unexpected client environment") + : Effect.provide(effect, context); + }), + followStream: (environmentId, stream) => + Stream.unwrap( + Effect.sync(() => { + const context = binding.contexts.get(environmentId); + if (context === undefined) throw new Error("Unexpected client environment"); + return Stream.concat(Stream.provideContext(stream, context), Stream.never); + }), + ), + }); + }), + ).pipe(Layer.merge(FetchHttpClient.layer)), + ), + }; +}); +// Hover popups need a DOM; the header's action button is what these tests drive. +vi.mock("../src/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: unknown }) => children, + TooltipTrigger: ({ render }: { render: unknown }) => render, + TooltipPopup: () => null, +})); +// Overlays portal into a document, which this node harness lacks. Swap them for in-place +// doubles that keep the role, accessible name and blocker wiring the plugin flows rely on. +vi.mock("../src/plugins/design", async (importOriginal) => { + const actual = await importOriginal(); + const { useBlocker } = await import("@tanstack/react-router"); + const Dialog: typeof actual.pluginDesign.Dialog = ({ + open, + title, + description, + children, + footer, + }) => + open ? ( +
+

{title}

+ {description === undefined ? null :

{description}

} + {children} + {footer} +
+ ) : null; + const NavigationGuard: typeof actual.pluginDesign.NavigationGuard = ({ + when, + title, + description, + onDiscard, + keepLabel, + }) => { + const blocker = useBlocker({ shouldBlockFn: () => when, withResolver: true }); + const { Button } = actual.pluginDesign; + return blocker.status === "blocked" ? ( +
+

{title}

+ {description === undefined ? null :

{description}

} + + {keepLabel === undefined ? null : ( + + )} + +
+ ) : null; + }; + const Tooltip: typeof actual.pluginDesign.Tooltip = ({ children }) => children; + return { + ...actual, + pluginDesign: { ...actual.pluginDesign, Dialog, NavigationGuard, Tooltip }, + }; +}); + +vi.mock("../src/state/session", async () => { + const { Atom } = await import("effect/reactivity"); + const Option = await import("effect/Option"); + return { + environmentSession: { + preparedConnectionValueAtom: Atom.family(() => Atom.make(Option.some(true))), + initialConfigValueAtom: Atom.family((environmentId: string) => + Atom.make(binding.configs.get(environmentId) ?? null), + ), + }, + }; +}); + +import { createWorkflowsClient } from "../src/plugins/workflowsClient"; +import { createPluginWebContext } from "../src/plugins/context"; +import { bind } from "../src/plugins/contributions"; +import { pluginDesign } from "../src/plugins/design"; +import { PluginPageContent } from "../src/plugins/PluginPageContent"; +import { PluginProjectActions } from "../src/plugins/PluginSlots"; +import { environmentSession } from "../src/state/session"; +import { validatePluginSearch } from "../src/plugins/pageLink"; +import { availableCatalogAtom } from "../src/plugins/runtime"; +import { AppAtomRegistryProvider, appAtomRegistry } from "../src/rpc/atomRegistry"; + +const projectId = ProjectId.make("same-project"); +const HEAD = "a".repeat(40); +const sequence = (revision: number, title: string) => ({ + version: 1, + id: "sequence", + revision, + title, + entry: "work", + atLimit: "review", + nodes: [ + { + id: "work", + kind: "agent", + title: "Work", + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "approval-required", + instruction: "Do the work", + report: { fields: [{ name: "ready", type: "boolean", required: true }] }, + next: { to: "review" }, + }, + { + id: "review", + kind: "human", + title: "Human review", + approve: { to: "done" }, + changes: { to: "work", repeat: { max: 1, atLimit: "review" } }, + }, + { id: "done", kind: "end", title: "Done", outcome: "completed" }, + ], +}); +// The pull request head the replayed forge reports; a review freezes the value it saw. +const forge = { head: HEAD }; +const verdict = { + fields: [{ name: "verdict", type: "enum", required: true, values: ["pass", "changes"] }], +}; +const direct = { + ...sequence(1, "Quick change"), + id: "quick", + atLimit: "done", + nodes: [ + { ...sequence(1, "").nodes[0], next: { to: "done" } }, + { id: "done", kind: "end", title: "Done", outcome: "completed" }, + ], +}; +const review = { + version: 1, + id: "review", + revision: 1, + title: "Frozen review", + entry: "reviews", + atLimit: "gate", + nodes: [ + { + id: "reviews", + kind: "parallel", + title: "Reviewers", + pullRequest: { repository: "test/repo", number: 1 }, + branches: [ + { + id: "code", + title: "Code", + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "approval-required", + interactionMode: "plan", + instruction: "Review the code", + report: verdict, + }, + ], + next: "join", + }, + { + id: "join", + kind: "join", + title: "Wait for all", + fork: "reviews", + rules: [ + { when: { op: "eq", path: "result", value: "all_completed" }, route: { to: "gate" } }, + ], + otherwise: { to: "stop" }, + }, + { + id: "gate", + kind: "human", + title: "Human review", + approve: { to: "done" }, + changes: { to: "done" }, + }, + { id: "done", kind: "end", title: "Done", outcome: "completed" }, + { id: "stop", kind: "end", title: "Stopped", outcome: "unresolved" }, + ], +}; + +// Provider turns are replayed at the execution boundary; the plugin, catalog, persistence, +// authorization and RPC transport are real. +const threads = new Map(); +const receipts = new Map(); +const replayedWorkflows: ServerPlugin = { + ...workflowPlugin, + acquire: Effect.gen(function* () { + const host = yield* Host; + return yield* workflowPlugin.acquire.pipe( + Effect.provideService( + Host, + Host.of({ + ...host, + providers: () => + Effect.succeed([ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + displayName: "Codex", + toolsSupported: true, + available: true, + reason: null, + runtimeModes: ["approval-required", "full-access"], + models: [ + { slug: "gpt-5.4", name: "GPT-5.4", isCustom: false, optionDescriptors: [] }, + ], + }, + ]), + skills: () => Effect.succeed([]), + workspace: (id) => + host + .workspace(id) + .pipe(Effect.map((value) => ({ ...value, branch: "main", head: HEAD }))), + resolveRef: () => Effect.succeed(HEAD), + prepareWorkspace: (input) => + host + .workspace(input.projectId) + .pipe(Effect.map((value) => ({ path: value.path, branch: input.key, head: HEAD }))), + verifyWorkspace: () => Effect.succeed({ head: HEAD, clean: true }), + verifyPullRequestHead: () => Effect.sync(() => ({ head: forge.head, branch: "feature" })), + cancelPending: () => Effect.void, + receipt: (id) => Effect.sync(() => receipts.get(id) ?? null), + launch: (input) => + Effect.sync(() => { + const previous = receipts.get(input.commandId); + if (previous) return previous; + threads.set(input.threadId!, { + environmentId: input.environmentId, + projectId: input.projectId, + threadId: input.threadId!, + title: input.title, + runtimeMode: input.runtimeMode, + workspacePath: + input.workspace.type === "existing" ? input.workspace.path : "/replayed", + branch: "main", + runs: [{ id: `${input.threadId}:run`, status: "running" }], + outstandingWork: [], + requests: [], + checkpoints: [], + nativeSession: { id: `${input.threadId}:native`, canResume: true }, + }); + const receipt: PluginCommandReceipt = { + commandId: input.commandId, + threadId: input.threadId!, + cursor: 1, + status: "accepted", + error: null, + }; + receipts.set(input.commandId, receipt); + return receipt; + }), + inspect: (target) => + Effect.suspend(() => { + const state = threads.get(target.threadId); + return state + ? Effect.succeed(state) + : Effect.fail( + new PluginError({ + pluginId: "host", + code: "unavailable", + operation: "inspect", + message: "Thread is unavailable", + }), + ); + }), + send: (input) => + Effect.sync(() => { + const receipt: PluginCommandReceipt = { + commandId: input.commandId, + threadId: input.threadId, + cursor: 2, + status: "accepted", + error: null, + }; + receipts.set(input.commandId, receipt); + return receipt; + }), + interrupt: () => Effect.succeed(null), + lifecycle: () => Stream.never, + reconcile: () => + Effect.succeed({ + kind: "snapshot", + cursor: 1, + replayGap: true, + threads: [...threads.values()], + } satisfies PluginLifecycleItem), + }), + ), + ); + }), +}; + +/** Records requests and subscription deliveries so the test awaits exact milestones. */ +function track(client: WorkflowClient) { + const queued = new Map>>(); + const waiting = new Map }) => void>>(); + const record = (method: string, promise: Promise
): Promise => { + const waiter = waiting.get(method)?.shift(); + if (waiter) waiter({ promise }); + else queued.set(method, [...(queued.get(method) ?? []), promise]); + return promise; + }; + const next = (method: string) => + new Promise<{ promise: Promise }>((resolve) => { + const promise = queued.get(method)?.shift(); + if (promise) resolve({ promise }); + else waiting.set(method, [...(waiting.get(method) ?? []), resolve]); + }); + let runs: ReadonlyArray = []; + const runWaiters: Array<{ predicate: (run: Run) => boolean; resolve: () => void }> = []; + // Deliveries of the currently open run-history subscription only. + let listed: ReadonlyArray<{ readonly id: string; readonly state: string }> | undefined; + const listWaiters: Array<{ + predicate: (runs: NonNullable) => boolean; + resolve: () => void; + }> = []; + const permissionWaiters: Array<(value: WorkflowPermissions) => void> = []; + let openSubscriptions = 0; + const release = (close: () => void) => { + openSubscriptions++; + return () => { + openSubscriptions--; + close(); + }; + }; + let loseNextStart = false; + let failNextStart: Error | null = null; + let launches = 0; + // Open run subscriptions' error callbacks, to deliver a stream failure after loading. + const runErrors = new Set<(message: string) => void>(); + let link: ThreadLink | null | undefined; + const linkWaiters: Array<{ + predicate: (link: ThreadLink | null) => boolean; + resolve: () => void; + }> = []; + let beforeCancel: (() => Promise) | null = null; + const wrapped: WorkflowClient = { + ...client, + subscribePermissions: (listener) => + client.subscribePermissions((value) => { + listener(value); + for (const waiter of permissionWaiters.splice(0)) waiter(value); + }), + projects: () => record("projects", client.projects()), + library: (input) => record("library", client.library(input)), + runs: (input) => record("runs", client.runs(input)), + preview: (input) => record("preview", client.preview(input)), + watchThread: (input, onLink, onError) => { + link = undefined; + return release( + client.watchThread( + input, + (value) => { + onLink(value); + link = value; + for (const waiter of linkWaiters.filter((item) => item.predicate(value))) { + linkWaiters.splice(linkWaiters.indexOf(waiter), 1); + waiter.resolve(); + } + }, + onError, + ), + ); + }, + startSaved: (input) => { + launches++; + // The registry refuses before the service could consult the stored result. + const failure = failNextStart; + failNextStart = null; + if (failure) return record("launch", Promise.reject(failure)); + return record( + "launch", + client.startSaved(input).then((run) => { + // The server committed the start, but its response is lost on the way back. + if (loseNextStart) { + loseNextStart = false; + throw new Error("The connection closed before the response arrived."); + } + return run; + }), + ); + }, + cancel: (input) => { + // Another actor's change may commit first, leaving this request's revision stale. + const before = beforeCancel; + beforeCancel = null; + return record( + "cancel", + (before ? before() : Promise.resolve()).then(() => client.cancel(input)), + ); + }, + gate: (input) => record("gate", client.gate(input)), + subscribeRuns: (id, onRuns, onError) => { + listed = undefined; + return release( + client.subscribeRuns( + id, + (value) => { + onRuns(value); + listed = value; + for (const waiter of listWaiters.filter((item) => item.predicate(value))) { + listWaiters.splice(listWaiters.indexOf(waiter), 1); + waiter.resolve(); + } + }, + onError, + ), + ); + }, + watchRun: (input, onRun, onError) => { + runErrors.add(onError); + const close = release( + client.watchRun( + input, + (run) => { + onRun(run); + runs = [run]; + for (const waiter of runWaiters.filter((item) => item.predicate(run))) { + runWaiters.splice(runWaiters.indexOf(waiter), 1); + waiter.resolve(); + } + }, + onError, + ), + ); + return () => { + runErrors.delete(onError); + close(); + }; + }, + }; + return { + client: wrapped, + settle: async (method: string) => { + const call = await next(method); + await act(async () => { + await call.promise.catch(() => undefined); + }); + }, + /** Await the most recent call (or the next one), dropping older ones it supersedes. */ + settleLatest: async (method: string) => { + const latest = queued.get(method)?.pop(); + queued.delete(method); + const call = latest === undefined ? await next(method) : { promise: latest }; + await act(async () => { + await call.promise.catch(() => undefined); + }); + }, + /** Resolves once the view's latest run delivery matches. */ + run: async (predicate: (run: Run) => boolean) => { + const latest = runs.at(-1); + if (latest === undefined || !predicate(latest)) + await new Promise((resolve) => runWaiters.push({ predicate, resolve })); + await act(async () => {}); + }, + /** Resolves once the open run-history subscription's latest delivery matches. */ + listed: async (predicate: (runs: NonNullable) => boolean) => { + if (listed === undefined || !predicate(listed)) + await new Promise((resolve) => listWaiters.push({ predicate, resolve })); + await act(async () => {}); + }, + permissions: (predicate: (value: WorkflowPermissions) => boolean) => + new Promise((resolve) => { + const check = (value: WorkflowPermissions) => + predicate(value) ? resolve() : permissionWaiters.push(check); + permissionWaiters.push(check); + }), + /** Holds the next Cancel until the returned release runs, after another change commits. */ + holdNextCancel: () => { + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + beforeCancel = () => held; + return () => release(); + }, + /** Resolves once the open thread-link subscription's latest delivery matches. */ + link: async (predicate: (link: ThreadLink | null) => boolean) => { + if (link === undefined || !predicate(link)) + await new Promise((resolve) => linkWaiters.push({ predicate, resolve })); + await act(async () => {}); + }, + /** Delivers a stream failure to the open run views after they loaded. */ + failRunStream: (message: string) => + act(async () => { + // Later waits need a delivery from the resubscribed stream. + runs = []; + for (const onError of runErrors) onError(message); + }), + failNextStart: (error: Error) => { + failNextStart = error; + }, + launches: () => launches, + loseNextStart: () => { + loseNextStart = true; + }, + openSubscriptions: () => openSubscriptions, + }; +} + +const memoryStorage = () => { + const values = new Map(); + return { + values, + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => void values.set(key, value), + removeItem: (key: string) => void values.delete(key), + }; +}; +const flush = () => act(() => new Promise((resolve) => setImmediate(resolve))); +const textOf = (node: ReactTestInstance): string => + node.children.map((child) => (typeof child === "string" ? child : textOf(child))).join(""); + +it.live( + "starts, inspects and decides a workflow run through the hosted pages on the selected environment", + () => + Effect.scoped( + Effect.gen(function* () { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const timings = { ...authoringTimings }; + Object.assign(authoringTimings, { + searchDelayMs: 0, + validationDelayMs: 0, + draftDelayMs: 0, + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => Object.assign(authoringTimings, timings)), + ); + yield* Effect.promise(preloadWorkflowPages); + const fs = yield* FileSystem.FileSystem; + const environments = yield* Effect.forEach( + ["Workstation", "Workstation (2)"], + (label, index) => + Effect.gen(function* () { + const config = { + ...(yield* makeReplayServerConfig(`workflow-run-${index}`)), + noBrowser: true, + traceTimingEnabled: false, + }; + const server = yield* startEnvironment(config, [replayedWorkflows]); + const environmentId = Context.get(server.context, Host).environmentId; + yield* Context.get(server.context, Projects.ProjectService).create({ + commandId: CommandId.make("same-project"), + projectId, + title: "Same name", + workspaceRoot: config.baseDir, + }); + const rpc = yield* makeClient(server.context, [ + AuthOrchestrationReadScope, + AuthOrchestrationOperateScope, + ]); + const snapshot = yield* rpc[WS_METHODS.subscribeServerConfig]({}).pipe( + Stream.runHead, + ); + if (Option.isNone(snapshot) || snapshot.value.type !== "snapshot") + return yield* Effect.die("Expected config"); + const target = new PrimaryConnectionTarget({ + environmentId, + label, + httpBaseUrl: origin(server.context), + wsBaseUrl: origin(server.context).replace("http", "ws"), + }); + const auth = Context.get(server.context, Auth.EnvironmentAuth); + const prepare = (token: string): PreparedConnection => ({ + environmentId, + label, + httpBaseUrl: origin(server.context), + socketUrl: target.wsBaseUrl, + httpAuthorization: { _tag: "Bearer", token }, + target, + }); + const readOnly = yield* auth.issueSession({ scopes: [AuthOrchestrationReadScope] }); + const writable = yield* auth.issueSession({ + scopes: [AuthOrchestrationReadScope, AuthOrchestrationOperateScope], + }); + const prepared = yield* SubscriptionRef.make(Option.some(prepare(readOnly.token))); + const session: RpcSession = { + client: rpc, + initialConfig: Effect.succeed(snapshot.value.config), + subscribeServerConfig: (input) => rpc[WS_METHODS.subscribeServerConfig](input), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + const context = yield* Layer.build( + Layer.mock(Supervisor.EnvironmentSupervisor)({ + target, + session: yield* SubscriptionRef.make(Option.some(session)), + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + prepared, + }), + ); + binding.configs.set(environmentId, snapshot.value.config); + binding.contexts.set(environmentId, context); + const registry = Context.get(server.context, Registry.PluginRegistry); + const catalog: PluginCatalog = yield* registry.catalog; + const invoke = Effect.fnUntraced(function* (method: string, input: unknown) { + const api = yield* registry.api(`plugins.workflows.${method}`); + const result = api.invoke(input); + if (!Effect.isEffect(result)) return yield* Effect.die("Expected request"); + return yield* result; + }); + return { + label, + environmentId, + catalog, + registry, + invoke, + directory: `${config.baseDir}/.t3code/workflows`, + tracked: track(createWorkflowsClient(environmentId)), + grantWrite: SubscriptionRef.set(prepared, Option.some(prepare(writable.token))), + }; + }), + ); + const [first, second] = environments as [ + (typeof environments)[number], + (typeof environments)[number], + ]; + // Streams wait for each environment's published plugin catalog. + for (const environment of environments) + yield* Effect.promise( + () => + new Promise((resolve) => { + const release = appAtomRegistry.subscribe( + availableCatalogAtom(environment.environmentId), + (value) => { + if (value !== null) { + queueMicrotask(() => release()); + resolve(); + } + }, + { immediate: true }, + ); + }), + ); + yield* fs.makeDirectory(first.directory, { recursive: true }); + yield* fs.writeFileString( + `${first.directory}/sequence.yaml`, + Yaml.stringify(sequence(1, "Implement and review")), + ); + const scope = { environmentId: first.environmentId, projectId }; + const reconcile = first.invoke("reconcile", scope); + + const storage = memoryStorage(); + const connections = new Map(); + const rootRoute = createRootRoute(); + const router = createRouter({ + routeTree: rootRoute.addChildren([ + createRoute({ + getParentRoute: () => rootRoute, + path: "/plugins/$environmentId/$pluginId/$pageId", + validateSearch: validatePluginSearch, + }), + createRoute({ getParentRoute: () => rootRoute, path: "/$environmentId/$threadId" }), + ]), + history: createMemoryHistory({ + initialEntries: [ + `/plugins/${first.environmentId}/workflows/workflows.library?pluginProjectId=${projectId}`, + ], + }), + }); + const loaded = new Set<() => void>(); + const unsubscribe = router.history.subscribe(() => { + void router.load().then(() => { + for (const notify of loaded) notify(); + }); + }); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + yield* Effect.promise(() => router.load()); + const bound = new Map( + environments.map((environment) => [ + environment.environmentId as string, + { environment, plugin: bind(workflowsWeb, environment.tracked.client) }, + ]), + ); + const toasts: Array = []; + const contextFor = ( + environment: (typeof environments)[number], + input: { + projectId: ProjectId | null; + threadId?: ThreadId | null; + state?: Record; + }, + ) => + createPluginWebContext({ + environmentId: environment.environmentId, + environmentLabel: environment.label, + descriptor: environment.catalog.plugins.find( + (item) => item.manifest.id === "workflows", + )!, + projectId: input.projectId, + threadId: input.threadId ?? null, + pageState: input.state ?? {}, + connection: connections.get(environment.environmentId) ?? "connected", + navigate: router.navigate, + storage, + // Transient outcomes are host toasts; record them instead of rendering a toaster. + design: { toast: (toast) => void toasts.push(toast.title) }, + }); + function Harness() { + const href = useSyncExternalStore( + (notify) => { + loaded.add(notify); + return () => loaded.delete(notify); + }, + () => router.state.location.href, + ); + const location = useMemo(() => router.state.location, [href]); + const [, first = "", second = "", , pageId = ""] = location.pathname + .split("/") + .map(decodeURIComponent); + // Thread routes render the contributed context strip, as the chat header does. + const threadRoute = first !== "plugins"; + const environmentId = threadRoute ? first : second; + const search = validatePluginSearch(location.search as Record); + const target = bound.get(environmentId)!; + const connection = connections.get(environmentId) ?? "connected"; + const stateKey = JSON.stringify(search.pluginState ?? {}); + const context = useMemo( + () => + contextFor(target.environment, { + projectId: threadRoute ? projectId : (search.pluginProjectId ?? null), + threadId: threadRoute ? ThreadId.make(second) : null, + state: JSON.parse(stateKey) as Record, + }), + [target, search.pluginProjectId, stateKey, connection, threadRoute, second], + ); + return threadRoute ? ( +
+ {target.plugin.threadContext.map((item) => ( +
{item.render(context)}
+ ))} +
+ ) : ( + + ); + } + let renderer: ReactTestRenderer | undefined; + const mount = () => + act(async () => { + renderer = create( + + + , + ); + }); + const rerender = () => + act(async () => + renderer!.update( + + + , + ), + ); + const unmount = () => act(async () => renderer?.unmount()); + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await unmount(); + appAtomRegistry.dispose(); + vi.unstubAllGlobals(); + }), + ); + const root = () => renderer!.root; + const page = () => textOf(root()); + const buttons = (label: string) => + root().findAll( + (node) => + node.type === pluginDesign.Button && + (node.props.ariaLabel === label || textOf(node) === label), + ); + const button = (label: string) => buttons(label)[0]!; + const control = (type: unknown, key: string) => + root().find( + (node) => node.type === type && (node.props.id === key || node.props.ariaLabel === key), + ); + const click = (label: string) => act(async () => button(label).props.onClick()); + // Host popups (combobox, segmented switch, breadcrumb) are driven through the same + // handlers the host controls call. + const choose = (key: string, value: string) => + act(async () => control(pluginDesign.Combobox, key).props.onChange(value)); + const toggle = (key: string, value: string) => + act(async () => control(pluginDesign.SegmentedControl, key).props.onChange(value)); + const crumb = (label: string) => + act(async () => + root() + .find((node) => node.type === pluginDesign.PageHeader) + .props.breadcrumb.find((item: { label: string }) => item.label === label) + .onSelect(), + ); + const runRows = () => + root().findAll( + (node) => + node.type === pluginDesign.ListRow && + String(node.props.openLabel).startsWith("Open run "), + ); + const labelled = (label: string) => + root().findAll( + (node) => node.props["aria-label"] === label && typeof node.type === "string", + ); + const write = (key: string, value: string) => + act(async () => control(pluginDesign.Textarea, key).props.onChange(value)); + const go = (environmentId: EnvironmentId, pageId: string, state?: Record) => + act(async () => { + await router.navigate({ + to: "/plugins/$environmentId/$pluginId/$pageId", + params: { environmentId, pluginId: "workflows", pageId }, + search: { pluginProjectId: projectId, ...(state ? { pluginState: state } : {}) }, + }); + }); + const promise = (run: () => Promise
) => Effect.promise(run); + const { tracked } = first; + + // Library entry point: Run opens the shared start dialog in place with the saved workflow. + yield* promise(mount); + yield* promise(() => tracked.settle("projects")); + yield* promise(() => tracked.settle("library")); + yield* promise(() => click("Run Implement and review")); + yield* promise(() => tracked.settle("library")); + yield* promise(() => tracked.settle("preview")); + expect(router.state.location.pathname).toContain("workflows.library"); + const libraryDialog = page(); + expect(libraryDialog).toContain("Run workflow"); + expect(libraryDialog).toContain("Saved revision 1"); + expect(libraryDialog).toContain("Work: Codex · gpt-5.4 · approval-required"); + expect(libraryDialog).toContain(`From main at ${HEAD.slice(0, 12)}`); + expect( + labelled("Run workflow").filter((node) => node.props.role === "dialog"), + ).toHaveLength(1); + // A read-only pairing cannot start until operate access is granted. + expect(libraryDialog).toContain("This connection cannot start workflows."); + expect(buttons("Run workflow").at(-1)!.props.disabled).toBe(true); + // Cancel keeps the library where it was. + yield* promise(() => click("Cancel")); + expect( + labelled("Run workflow").filter((node) => node.props.role === "dialog"), + ).toHaveLength(0); + expect(router.state.location.pathname).toContain("workflows.library"); + + // The chat header's project action opens the dialog in place and keeps it, with its + // typed text, through a connection drop. + { + let header!: ReactTestRenderer; + yield* promise(() => + act(async () => { + header = create( + + + + + , + ); + }), + ); + yield* promise(flush); + const headerDialogs = () => + header.root.findAll( + (node) => + node.props["aria-label"] === "Run workflow" && + typeof node.type === "string" && + node.props.role === "dialog", + ); + const prepared = environmentSession.preparedConnectionValueAtom(first.environmentId); + // The mounted header subscribes to the catalog stream; wait for its first snapshot. + yield* promise( + () => + new Promise((resolve) => { + const release = appAtomRegistry.subscribe( + availableCatalogAtom(first.environmentId), + (value) => { + if (value !== null) { + queueMicrotask(() => release()); + resolve(); + } + }, + { immediate: true }, + ); + }), + ); + yield* promise(flush); + yield* promise(() => + act(async () => + header.root + .findAll( + (node) => node.props["aria-label"] === "Run workflow" && node.props.onClick, + ) + .at(0)! + .props.onClick(), + ), + ); + yield* promise(flush); + expect(headerDialogs()).toHaveLength(1); + expect(router.state.location.pathname).toContain("workflows.library"); + yield* promise(flush); + yield* promise(() => + act(async () => + header.root + .find( + (node) => node.type === pluginDesign.Textarea && node.props.ariaLabel === "Task", + ) + .props.onChange("keep this text"), + ), + ); + appAtomRegistry.set(prepared, Option.none()); + yield* promise(flush); + expect(headerDialogs()).toHaveLength(1); + expect( + header.root.find( + (node) => node.type === pluginDesign.Textarea && node.props.ariaLabel === "Task", + ).props.value, + ).toBe("keep this text"); + appAtomRegistry.set(prepared, Option.some(true)); + yield* promise(flush); + yield* promise(() => + act(async () => + header.root + .find((node) => node.type === pluginDesign.Button && textOf(node) === "Cancel") + .props.onClick(), + ), + ); + expect(headerDialogs()).toHaveLength(0); + expect(router.state.location.pathname).toContain("workflows.library"); + yield* promise(() => act(async () => header.unmount())); + } + + // Project entry point: the same dialog and service, choosing the workflow explicitly. + // The chat header opens its dialog in place; its link is the Runs page deep link. + const projectAction = workflowsWeb.projectActions.find( + (item) => item.id === "workflows.project-run", + )!; + expect(projectAction.dialog).toBeDefined(); + const link = projectAction.link(projectId); + expect(link).toEqual({ pageId: "workflows.runs", projectId, state: { start: "1" } }); + yield* promise(() => go(first.environmentId, link.pageId, link.state)); + yield* promise(() => tracked.settle("projects")); + yield* promise(() => tracked.settle("library")); + yield* promise(() => tracked.listed((runs) => runs.length === 0)); + expect(page()).toContain("No runs yet"); + expect(control(pluginDesign.Combobox, "Workflow").props.value).toBe(""); + yield* promise(() => choose("Workflow", "sequence")); + yield* promise(() => tracked.settle("preview")); + expect(page()).toContain("Saved revision 1"); + const granted = tracked.permissions((value) => value.start && value.cancel && value.gate); + yield* first.grantWrite; + yield* promise(() => granted); + yield* promise(flush); + yield* promise(() => toggle("Workspace", "current")); + yield* promise(() => write("Task", "Ship the release notes")); + expect(page()).toContain("Works in"); + + // The start commits but its response is lost: the intent and its input are retained. + tracked.loseNextStart(); + yield* promise(() => act(async () => buttons("Run workflow").at(-1)!.props.onClick())); + yield* promise(() => tracked.settle("launch")); + expect(page()).toContain("The start request did not complete"); + const intents = [...storage.values.keys()].filter((key) => key.includes(":entry:start")); + expect(intents).toHaveLength(1); + expect(control(pluginDesign.Textarea, "Task").props.value).toBe("Ship the release notes"); + expect(button("Retry start request").props.disabled, "retry-start").toBe(false); + + // The workflow is edited, then the page reloads: the retained intent reconciles to the + // committed run instead of starting a second one from the new revision. + yield* fs.writeFileString( + `${first.directory}/sequence.yaml`, + Yaml.stringify(sequence(2, "Implement and review v2")), + ); + yield* promise(unmount); + expect(tracked.openSubscriptions()).toBe(0); + yield* promise(mount); + yield* promise(() => tracked.settle("launch")); + yield* promise(flush); + const runId = (router.state.location.search as { pluginState?: { run?: string } }) + .pluginState?.run; + expect(runId).toMatch(/^workflow-/); + expect([...storage.values.keys()].filter((key) => key.includes(":entry:start"))).toEqual( + [], + ); + const listed = (yield* first.invoke("list", scope)) as ReadonlyArray<{ id: string }>; + expect(listed.map((run) => run.id)).toEqual([runId]); + + // The run view: persisted snapshot, start source, workspace and allowed actions. + yield* promise(() => tracked.run((run) => run.id === runId)); + // The overview is one line; full values are its tooltips. + const tip = (content: string) => + root().findAll( + (node) => node.type === pluginDesign.Tooltip && node.props.label === content, + ); + expect(page()).toContain("Manual · sequence.yaml"); + expect(tip("Manual · .t3code/workflows/sequence.yaml")).toHaveLength(1); + expect(tip("sequence · revision 1")).toHaveLength(1); + expect(page()).toContain("Current checkout"); + expect(page()).toContain("Cancel run"); + // Without a canvas (no layout measurement here) the Route list is the run's graph. + expect(labelled("Route list")).toHaveLength(1); + yield* reconcile; + yield* promise(() => tracked.run((run) => run.attempts[0]?.phase === "running")); + const running = (yield* first.invoke("get", { ...scope, runId })) as Run; + const threadId = running.attempts[0]!.threadId!; + expect(running.input).toEqual({ task: "Ship the release notes" }); + + // An accepted report while execution still runs is a claim, not a finished step. + const reportTool = (yield* first.registry.tools).find( + (item) => item.tool.id === "plugin_workflows_report", + )!; + yield* reportTool.tool.invoke( + { + version: 1, + clientRetryKey: "report", + outcome: "completed", + summary: "Notes drafted", + data: { ready: true }, + evidence: [{ kind: "file", reference: "NOTES.md" }], + }, + { + environmentId: first.environmentId, + projectId, + threadId, + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "native-session", + runtimeMode: "approval-required", + }, + ); + yield* promise(() => tracked.run((run) => run.attempts[0]?.phase === "reported")); + yield* promise(() => toggle("Run view", "steps")); + yield* promise(() => click("Visit 1: Work, Execution still running")); + yield* promise(flush); + expect(router.state.location.search).toMatchObject({ + pluginState: { run: runId, node: "work", attempt: running.attempts[0]!.id }, + }); + const evidence = page(); + expect(evidence).toContain("Report accepted (completed claim)"); + expect(evidence).toContain("Execution still running"); + expect(evidence).toContain("Agent report"); + expect(evidence).toContain("completed claim"); + expect(evidence).toContain("file: NOTES.md"); + expect(evidence).toContain("Not routed yet"); + + // The native thread shows its owning run and links back to the exact attempt. + yield* promise(() => + act(async () => { + await router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: first.environmentId, threadId }, + }); + }), + ); + const threadRoute = (id: string) => + act(async () => { + await router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: first.environmentId, threadId: ThreadId.make(id) }, + }); + }); + yield* promise(() => tracked.link((link) => link?.phase === "reported")); + expect(page()).toContain("Implement and review · Work"); + expect(page()).toContain("report accepted"); + + // The strip follows its run: settlement updates it without reopening the thread. + const state = threads.get(threadId)!; + threads.set(threadId, { + ...state, + runs: state.runs.map((run) => ({ ...run, status: "completed" })), + }); + yield* reconcile; + yield* promise(() => tracked.link((link) => link?.phase === "completed")); + expect(page()).toContain( + "Completed · report accepted · later messages are not part of this attempt", + ); + + // Switching to an ordinary thread clears the strip at once, before its lookup answers, + // and stays clear when the lookup answers that no workflow owns it. + yield* promise(() => threadRoute("plain-thread")); + expect(buttons("Open workflow run")).toHaveLength(0); + yield* promise(() => tracked.link((link) => link === null)); + expect(buttons("Open workflow run")).toHaveLength(0); + // Disconnected, no lookup runs and the previous thread's link is not carried over. + yield* promise(() => threadRoute(threadId)); + yield* promise(() => tracked.link((link) => link?.runId === runId)); + connections.set(first.environmentId, "disconnected"); + yield* promise(rerender); + // Without a live subscription the same thread's link is marked as possibly outdated. + expect(page()).toContain("Last loaded state"); + expect(buttons("Open workflow run")).toHaveLength(1); + yield* promise(() => threadRoute("plain-thread")); + expect(buttons("Open workflow run")).toHaveLength(0); + connections.delete(first.environmentId); + yield* promise(() => threadRoute(threadId)); + yield* promise(() => tracked.link((link) => link?.runId === runId)); + yield* promise(() => click("View route")); + yield* promise(flush); + expect(router.state.location.search).toMatchObject({ + pluginProjectId: projectId, + pluginState: { + run: runId, + node: "work", + attempt: running.attempts[0]!.id, + focus: "route", + }, + }); + yield* promise(() => tracked.run((run) => run.id === runId)); + + // Settlement routed to the human gate; the decision is bound to its displayed revision. + yield* promise(() => tracked.run((run) => run.state === "awaiting-review")); + expect(page()).toContain("Needs your decision"); + // The decision alert carries the next action; the overview does not repeat it. + expect(buttons("Approve")).toHaveLength(1); + expect(page()).not.toContain("Decide on the human gate"); + expect(page()).toContain("Work → Human review"); + + // A failed run subscription keeps the snapshot visibly stale with actions held; Retry + // resubscribes and reloads the run. + yield* promise(() => tracked.failRunStream("The run subscription ended.")); + expect(page()).toContain("Not updating"); + expect(page()).toContain("The run subscription ended."); + expect(button("Approve").props.disabled).toBe(true); + yield* promise(() => click("Retry")); + yield* promise(() => tracked.run((run) => run.state === "awaiting-review")); + expect(page()).not.toContain("Not updating"); + expect(button("Approve").props.disabled, "approve-1").toBe(false); + + // Disconnected: the last snapshot stays visible, decisions wait for reconnection. + connections.set(first.environmentId, "disconnected"); + yield* promise(rerender); + expect(page()).toContain("Last loaded state"); + expect(button("Approve").props.disabled).toBe(true); + expect(page()).toContain("Actions wait until this environment reconnects"); + connections.delete(first.environmentId); + yield* promise(rerender); + yield* promise(() => tracked.run((run) => run.state === "awaiting-review")); + expect(button("Approve").props.disabled, "approve-2").toBe(false); + yield* promise(() => click("Approve")); + yield* promise(() => tracked.settle("gate")); + yield* promise(() => tracked.run((run) => run.state === "completed")); + expect(toasts).toContain("Approve applied"); + // A finished run has no next action, so the overview names none. + expect( + root().findAll((node) => node.type === "dt" && textOf(node) === "Next"), + ).toHaveLength(0); + expect(page()).not.toContain("Cancel run"); + + // Run again from the project: a new intent whose response is lost reconciles by itself + // once the environment reconnects. + yield* fs.writeFileString(`${first.directory}/quick.yaml`, Yaml.stringify(direct)); + yield* promise(() => click("Run workflow")); + yield* promise(flush); + yield* promise(() => tracked.settleLatest("library")); + yield* promise(() => choose("Workflow", "quick")); + yield* promise(() => tracked.settleLatest("preview")); + // The registry refuses before the service could consult a stored result: the intent and + // its identity are kept rather than released for a possibly duplicate start. + tracked.failNextStart( + new PluginError({ + pluginId: "workflows", + operation: "plugins.workflows.launch", + code: "unavailable", + message: "This plugin API is unavailable in the selected environment.", + }), + ); + yield* promise(() => act(async () => buttons("Run workflow").at(-1)!.props.onClick())); + yield* promise(() => tracked.settle("launch")); + expect(page()).toContain("The start request did not complete"); + expect(page()).toContain("This plugin API is unavailable in the selected environment."); + expect( + [...storage.values.keys()].filter((key) => key.includes(":entry:start")), + ).toHaveLength(1); + // Opening Run for another workflow shows the pending request instead of resending it. + const sent = tracked.launches(); + yield* promise(() => + go(first.environmentId, "workflows.runs", { start: "1", workflow: "sequence" }), + ); + yield* promise(() => tracked.settleLatest("library")); + yield* promise(() => tracked.settleLatest("preview")); + yield* promise(flush); + expect(tracked.launches()).toBe(sent); + expect(page()).toContain("Unconfirmed start of quick (revision 1)"); + // Reopened for its own workflow, the intent is resent; this response is lost too. + tracked.loseNextStart(); + yield* promise(() => go(first.environmentId, "workflows.runs", { start: "1" })); + yield* promise(() => tracked.settle("launch")); + expect(tracked.launches()).toBe(sent + 1); + expect(page()).toContain("The start request did not complete"); + connections.set(first.environmentId, "disconnected"); + yield* promise(rerender); + expect(page()).toContain("Starting waits until this environment reconnects."); + connections.delete(first.environmentId); + yield* promise(rerender); + yield* promise(() => tracked.settle("launch")); + yield* promise(flush); + yield* promise(() => tracked.settleLatest("library")); + yield* promise(() => tracked.settleLatest("preview")); + const quickId = (router.state.location.search as { pluginState?: { run?: string } }) + .pluginState?.run; + expect(quickId).toMatch(/^workflow-/); + expect(quickId).not.toBe(runId); + expect([...storage.values.keys()].filter((key) => key.includes(":entry:start"))).toEqual( + [], + ); + const quickRuns = (yield* first.invoke("list", scope)) as ReadonlyArray<{ + definition: { id: string }; + }>; + expect(quickRuns.filter((run) => run.definition.id === "quick")).toHaveLength(1); + + // Cancel races natural completion: the completion commits first, so the Cancel decided + // on the displayed revision is refused and the page shows the run's actual outcome. + yield* reconcile; + yield* promise(() => + tracked.run((run) => run.id === quickId && run.attempts[0]?.phase === "running"), + ); + const quick = (yield* first.invoke("get", { ...scope, runId: quickId })) as Run; + const quickThread = quick.attempts[0]!.threadId!; + yield* reportTool.tool.invoke( + { + version: 1, + clientRetryKey: "quick", + outcome: "completed", + summary: "Done", + data: { ready: true }, + evidence: [], + }, + { + environmentId: first.environmentId, + projectId, + threadId: quickThread, + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "native-session", + runtimeMode: "approval-required", + }, + ); + yield* promise(() => tracked.run((run) => run.attempts[0]?.phase === "reported")); + const releaseCancel = tracked.holdNextCancel(); + yield* promise(() => click("Cancel run")); + const settled = threads.get(quickThread)!; + threads.set(quickThread, { + ...settled, + runs: settled.runs.map((run) => ({ ...run, status: "completed" })), + }); + yield* reconcile; + releaseCancel(); + yield* promise(() => tracked.settle("cancel")); + yield* promise(() => tracked.run((run) => run.state === "completed")); + expect(page()).toContain("Cancel run not applied"); + expect(page()).toContain("The run revision changed. Reload before deciding."); + // The run's own end step shows the outcome, not only the header badge. + const endStep = root().find( + (node) => + node.type === "li" && + node.findAll( + (child) => + child.type === pluginDesign.Button && child.props.ariaLabel === "End: Done", + ).length === 1, + ); + expect(textOf(endStep)).toContain("Completed"); + expect(buttons("Cancel run")).toHaveLength(0); + + // A gate decision on a frozen review whose pull request head moved is not applied. + yield* fs.writeFileString(`${first.directory}/review.yaml`, Yaml.stringify(review)); + yield* promise(() => + go(first.environmentId, "workflows.runs", { start: "1", workflow: "review" }), + ); + yield* promise(() => tracked.settleLatest("library")); + yield* promise(() => tracked.settleLatest("preview")); + expect(page()).toContain( + "Reviewers · Code: Codex · gpt-5.4 · approval-required · plan mode", + ); + yield* promise(() => act(async () => buttons("Run workflow").at(-1)!.props.onClick())); + yield* promise(() => tracked.settle("launch")); + yield* promise(flush); + const reviewId = (router.state.location.search as { pluginState?: { run?: string } }) + .pluginState?.run; + yield* reconcile; + yield* promise(() => + tracked.run((run) => run.id === reviewId && run.attempts[0]?.phase === "running"), + ); + const reviewing = (yield* first.invoke("get", { ...scope, runId: reviewId })) as Run; + const reviewThread = reviewing.attempts[0]!.threadId!; + yield* reportTool.tool.invoke( + { + version: 1, + clientRetryKey: "review", + outcome: "completed", + summary: "Looks good", + data: { verdict: "pass" }, + evidence: [], + }, + { + environmentId: first.environmentId, + projectId, + threadId: reviewThread, + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "native-session", + runtimeMode: "approval-required", + }, + ); + const reviewed = threads.get(reviewThread)!; + threads.set(reviewThread, { + ...reviewed, + runs: reviewed.runs.map((run) => ({ ...run, status: "completed" })), + }); + yield* reconcile; + yield* promise(() => tracked.run((run) => run.state === "awaiting-review")); + expect(page()).toContain("reviews 1/1 reported, 1/1 settled"); + expect(page()).toContain(`reviewed head ${forge.head.slice(0, 12)}`); + forge.head = "d".repeat(40); + yield* promise(() => click("Approve")); + yield* promise(() => tracked.settle("gate")); + yield* promise(() => tracked.run((run) => run.state === "unresolved")); + expect(page()).toContain("Decision not applied"); + expect(page()).toContain("The pull request head changed after review."); + expect(page()).toContain("Unresolved"); + + // A deep link to a visit older than the newest page opens that exact visit. + const deep = (yield* first.invoke("launch", { + ...scope, + clientRequestId: "deep", + definitionId: "quick", + revision: 1, + task: "", + workspace: "current", + })) as Run; + // Each drain is a deterministic server milestone; a few cover launch and settlement. + const drainUntil = (predicate: (run: Run) => boolean) => + Effect.gen(function* () { + for (let drains = 0; drains < 5; drains++) { + yield* reconcile; + const current = (yield* first.invoke("get", { ...scope, runId: deep.id })) as Run; + if (predicate(current)) return current; + } + return yield* Effect.die("The run did not reach the expected state."); + }); + for (let visit = 1; visit <= 52; visit++) { + const current = yield* drainUntil( + (run) => run.overview?.visits === visit && run.attempts.at(-1)?.phase === "running", + ); + if (visit === 52) break; + const stopped = current.attempts.at(-1)!.threadId!; + const native = threads.get(stopped)!; + threads.set(stopped, { + ...native, + runs: native.runs.map((run) => ({ ...run, status: "interrupted" })), + }); + const unresolved = yield* drainUntil((run) => run.state === "unresolved"); + yield* first.invoke("retry", { + ...scope, + runId: deep.id, + clientRequestId: `deep-${visit}`, + expectedRevision: unresolved.revision, + }); + } + const firstVisit = ( + (yield* first.invoke("get", { ...scope, runId: deep.id, historyOffset: 0 })) as Run + ).attempts[0]!; + yield* promise(() => + go(first.environmentId, "workflows.runs", { run: deep.id, attempt: firstVisit.id }), + ); + yield* promise(() => tracked.run((run) => run.id === deep.id)); + expect(page()).toContain("Earlier visits: 1–50 of 52"); + expect(page()).toContain(firstVisit.id); + expect(page()).toContain("Routing stopped at this visit"); + // Overview totals still describe the whole run, including its newest active visit. + expect(page()).toContain("Progress52 visits"); + expect(page()).toContain("Active attemptsWork (Running)"); + + // An old intent's run is still found from the history list, which is live. + yield* promise(() => crumb("Runs")); + yield* promise(() => + tracked.listed((runs) => + runs.some((run) => run.id === runId && run.state === "completed"), + ), + ); + expect(page()).toContain("Implement and review"); + expect(page()).toContain("Completed"); + + // Older runs stay listed across the live page's boundary when a new run starts. + const extra = (id: string) => + first.invoke("launch", { + ...scope, + clientRequestId: id, + definitionId: "quick", + revision: 1, + task: "", + workspace: "current", + }); + let newest = ""; + for (let index = 0; index < 18; index++) + newest = ((yield* extra(`history-${index}`)) as Run).id; + yield* promise(() => tracked.listed((runs) => runs[0]?.id === newest)); + yield* promise(() => click("Load older runs")); + yield* promise(() => tracked.settle("runs")); + const live = (yield* first.invoke("list", scope)) as ReadonlyArray<{ id: string }>; + const olderRuns = (yield* first.invoke("list", { + ...scope, + before: live.at(-1)!.id, + })) as ReadonlyArray<{ id: string }>; + expect(olderRuns.length).toBeGreaterThan(0); + expect(runRows()).toHaveLength(live.length + olderRuns.length); + const pushedOut = live.at(-1)!.id; + const added = ((yield* extra("history-new")) as Run).id; + yield* promise(() => + tracked.listed( + (runs) => runs[0]?.id === added && !runs.some((run) => run.id === pushedOut), + ), + ); + // The run pushed out of the live page joins the older ones: listed once, none lost. + // Every run is listed once: the new live page, the pushed-out run, and the older page. + expect(runRows()).toHaveLength(live.length + olderRuns.length + 1); + + // The similarly named second environment has no runs and does not own the thread. + yield* promise(() => go(second.environmentId, "workflows.runs")); + yield* promise(() => second.tracked.settle("projects")); + yield* promise(() => second.tracked.listed((runs) => runs.length === 0)); + expect(page()).toContain("Workstation (2)"); + expect(page()).toContain("No runs yet"); + yield* promise(() => + act(async () => { + await router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: second.environmentId, threadId }, + }); + }), + ); + yield* promise(() => second.tracked.link((link) => link === null)); + expect(buttons("Open workflow run")).toHaveLength(0); + yield* promise(unmount); + expect(tracked.openSubscriptions()).toBe(0); + expect(second.tracked.openSubscriptions()).toBe(0); + }), + ).pipe(Effect.provide(NodeServices.layer)), + { timeout: 120_000 }, +); diff --git a/apps/web/src/plugins/PluginSlots.tsx b/apps/web/src/plugins/PluginSlots.tsx index b67919356006..7814e1a09464 100644 --- a/apps/web/src/plugins/PluginSlots.tsx +++ b/apps/web/src/plugins/PluginSlots.tsx @@ -3,8 +3,9 @@ import { useNavigate, useParams } from "@tanstack/react-router"; import type { EnvironmentId, PluginPageState, PluginTarget, ProjectId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; -import { useMemo, useState } from "react"; +import { Fragment, useEffect, useMemo, useState } from "react"; import type { PluginWebContext } from "@t3tools/plugin-host-contract/web"; +import { RenderErrorBoundary } from "../components/RenderErrorBoundary"; import { Button } from "../components/ui/button"; import { SidebarMenu, @@ -12,6 +13,7 @@ import { SidebarMenuItem, useSidebar, } from "../components/ui/sidebar"; +import { toastManager } from "../components/ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { PluginIcon } from "./pluginIcons"; import { projectFor, useRouteProject, type RouteProject } from "./routeProject"; @@ -245,6 +247,15 @@ export function PluginSidebarAttention() { ); } +/** Tells the user a project action dialog failed, then closes it. */ +function ProjectActionFailed({ title, setOpen }: { title: string; setOpen: (open: null) => void }) { + useEffect(() => { + toastManager.add({ type: "error", title: `Couldn't open ${title}` }); + setOpen(null); + }, [title, setOpen]); + return null; +} + export function PluginProjectActions({ environmentId, projectId, @@ -252,28 +263,50 @@ export function PluginProjectActions({ environmentId: EnvironmentId; projectId: ProjectId; }) { - const { contributions } = usePluginContributions(environmentId, projectId); + // The action whose dialog is open over the current view. + const [open, setOpen] = useState<{ pluginId: string; actionId: string } | null>(null); + // An open dialog survives a connection drop so its draft stays; the plugin gates mutations. + const { contributions } = usePluginContributions(environmentId, projectId, null, { + retainWhileDisconnected: open !== null, + }); return (
{contributions.flatMap((plugin) => plugin.projectActions .filter((item) => plugin.context.descriptor.manifest.web.projectActions.includes(item.id)) .map((item) => ( - - plugin.context.navigate(item.link(projectId))} - > - - - } - /> - {item.title} - + + + + item.dialog === undefined + ? plugin.context.navigate(item.link(projectId)) + : setOpen({ pluginId: plugin.manifest.id, actionId: item.id }) + } + > + + + } + /> + {item.title} + + {item.dialog !== undefined && + open?.pluginId === plugin.manifest.id && + open.actionId === item.id ? ( + // A failed chunk or render shows a toast and closes; reopening resets it. + } + resetKeys={[open]} + > + {item.dialog(plugin.context, () => setOpen(null))} + + ) : null} + )), )}
@@ -291,7 +324,11 @@ export function PluginThreadContext({ target }: { target: PluginTarget }) { {contributions.flatMap((plugin) => plugin.threadContext .filter((item) => plugin.context.descriptor.manifest.web.threadContext.includes(item.id)) - .map((item) =>
{item.render(plugin.context)}
), + .map((item) => ( + +
{item.render(plugin.context)}
+
+ )), )} ); diff --git a/apps/web/src/plugins/contributions.ts b/apps/web/src/plugins/contributions.ts index 0884577f383e..fe9c84411158 100644 --- a/apps/web/src/plugins/contributions.ts +++ b/apps/web/src/plugins/contributions.ts @@ -22,7 +22,19 @@ export function bind(plugin: WebPlugin, client: Client) { ), })), navigation: plugin.navigation, - projectActions: plugin.projectActions, + projectActions: plugin.projectActions.map(({ dialog, ...action }) => ({ + ...action, + ...(dialog === undefined + ? {} + : { + dialog: (context: PluginWebContext, onClose: () => void) => + createElement( + Suspense, + { fallback: null }, + createElement(dialog, { ...context, client, onClose }), + ), + }), + })), threadContext: plugin.threadContext.map((contribution) => ({ id: contribution.id, render: (context: PluginWebContext) => contribution.render({ ...context, client }), diff --git a/apps/web/src/plugins/design.tsx b/apps/web/src/plugins/design.tsx index a24a5fddab8d..e811a0488fc7 100644 --- a/apps/web/src/plugins/design.tsx +++ b/apps/web/src/plugins/design.tsx @@ -9,7 +9,7 @@ import { SearchIcon, TriangleAlertIcon, } from "lucide-react"; -import { Fragment, useLayoutEffect, type ComponentProps } from "react"; +import { Fragment, useLayoutEffect, useState, type ComponentProps } from "react"; import { SettingsGroup } from "../components/settings/SettingsGroup"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "../components/ui/alert"; import { @@ -23,26 +23,37 @@ import { import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { - Empty, - EmptyContent, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from "../components/ui/empty"; + Combobox, + ComboboxEmpty, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxSearchInput, + ComboboxTrigger, +} from "../components/ui/combobox"; import { Dialog, + DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "../components/ui/dialog"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "../components/ui/empty"; import { Input } from "../components/ui/input"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../components/ui/input-group"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../components/ui/menu"; import { Select, + SelectButton, SelectItem, SelectPopup, SelectTrigger, @@ -50,6 +61,7 @@ import { } from "../components/ui/select"; import { Sheet, SheetHeader, SheetPopup, SheetTitle } from "../components/ui/sheet"; import { Textarea } from "../components/ui/textarea"; +import { toastManager } from "../components/ui/toast"; import { Toggle, ToggleGroup } from "../components/ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { @@ -123,6 +135,7 @@ function PluginDialog({ open, onOpenChange, title, + description, children, footer, }: ComponentProps) { @@ -131,9 +144,10 @@ function PluginDialog({ {title} + {description === undefined ? null : {description}} {children} - {footer === undefined ? null : {footer}} + {footer === undefined || footer === null ? null : {footer}} ); @@ -259,6 +273,88 @@ function PageHeader({ breadcrumb, children }: ComponentProps) { + const [open, setOpen] = useState(false); + const [localQuery, setLocalQuery] = useState(""); + const query = controlledQuery ?? localQuery; + const setQuery = onQueryChange ?? setLocalQuery; + const needle = query.trim().toLowerCase(); + // A plugin-owned search already narrowed `options`; otherwise match labels here. + const shown = + onQueryChange !== undefined || needle === "" + ? options + : options.filter((option) => option.label.toLowerCase().includes(needle)); + const values = shown.map((option) => option.value); + const selected = options.find((option) => option.value === value)?.label; + return ( + { + setOpen(next); + if (next) setQuery(""); + }} + value={value === "" ? null : value} + onValueChange={(next) => { + if (typeof next !== "string") return; + setOpen(false); + onChange(next); + }} + {...(disabled === undefined ? {} : { disabled })} + > + } + > + {selected ?? {placeholder ?? ""}} + + + setQuery(event.target.value)} + /> + {emptyText} + + {shown.map((option, index) => ( + + {option.label} + {option.detail === undefined ? null : ( + {option.detail} + )} + + + ))} + + + + ); +} + const alertIcons = { info: InfoIcon, warning: TriangleAlertIcon, @@ -346,6 +442,7 @@ export const pluginDesign: PluginDesign = { ), + Combobox: PluginCombobox, Badge: ({ variant = "outline", children }) => {children}, Tooltip: ({ label, children }) => ( @@ -353,6 +450,13 @@ export const pluginDesign: PluginDesign = { {label} ), + toast: ({ title, description, variant = "success" }) => { + toastManager.add({ + type: variant, + title, + ...(description === undefined ? {} : { description }), + }); + }, Icon: PluginIcon, Menu: ({ ariaLabel, items, trigger, disabled }) => (
@@ -489,6 +593,7 @@ export const pluginDesign: PluginDesign = { ); }, PageHeader, + Dialog: PluginDialog, Sheet: ({ open, onOpenChange, title, side = "right", children }) => ( @@ -499,6 +604,5 @@ export const pluginDesign: PluginDesign = { ), - Dialog: PluginDialog, NavigationGuard, }; diff --git a/apps/web/src/plugins/workflowsClient.ts b/apps/web/src/plugins/workflowsClient.ts index 9bf03231c5cb..9226f100eb3d 100644 --- a/apps/web/src/plugins/workflowsClient.ts +++ b/apps/web/src/plugins/workflowsClient.ts @@ -1,26 +1,42 @@ import type { EnvironmentId } from "@t3tools/contracts"; import type { CatalogEntry, + GateInput, + CommandInput, LibraryInput, + PreviewInput, ReadInput, ReplaceInput, SaveInput, + StartSavedInput, WorkflowClient, } from "@t3tools/plugin-workflows/contracts"; +import { PluginError } from "@t3tools/contracts"; +import type { EnvironmentRegistry } from "@t3tools/client-runtime/connection"; import type { ProjectId, ProviderInstanceId } from "@t3tools/plugin-host-contract/schema"; -import { request, requestGuarded } from "@t3tools/client-runtime/rpc"; +import { + request, + requestGuarded, + subscribe, + type EnvironmentRpcInput, + type EnvironmentRpcStreamFailure, + type EnvironmentRpcStreamValue, + type EnvironmentSubscriptionRpcTag, +} from "@t3tools/client-runtime/rpc"; import { createEnvironmentCommand, createEnvironmentRpcCommand, + followStreamInEnvironment, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; +import * as Stream from "effect/Stream"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import { Atom } from "effect/reactivity"; +import { Atom, type AsyncResult } from "effect/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "../rpc/atomRegistry"; -import { authorizePluginApi } from "./runtime"; +import { authorizePluginApi, availableCatalogAtom } from "./runtime"; const plugin = { id: "workflows", displayName: "Workflows" }; const settle = (result: AtomCommandResult): A => { @@ -96,13 +112,150 @@ const replace = createEnvironmentRpcCommand(connectionAtomRuntime, { Effect.andThen(requestGuarded("plugins.workflows.replace", input)), ), }); +const preview = createEnvironmentCommand(connectionAtomRuntime, { + label: "plugins.workflows.preview", + execute: (input: PreviewInput) => + authorize(input.environmentId, "plugins.workflows.preview").pipe( + Effect.andThen(request("plugins.workflows.preview", input)), + ), +}); +const runs = createEnvironmentCommand(connectionAtomRuntime, { + label: "plugins.workflows.list", + execute: (input: { + environmentId: EnvironmentId; + projectId: ProjectId; + before?: string | undefined; + }) => + authorize(input.environmentId, "plugins.workflows.list").pipe( + Effect.andThen(request("plugins.workflows.list", input)), + ), +}); +const startSaved = createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "plugins.workflows.launch", + tag: "plugins.workflows.launch", + execute: (input: StartSavedInput) => + authorize(input.environmentId, "plugins.workflows.launch").pipe( + Effect.andThen(requestGuarded("plugins.workflows.launch", input)), + ), +}); +const cancel = createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "plugins.workflows.cancel", + tag: "plugins.workflows.cancel", + execute: (input: CommandInput) => + authorize(input.environmentId, "plugins.workflows.cancel").pipe( + Effect.andThen(requestGuarded("plugins.workflows.cancel", input)), + ), +}); +const retry = createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "plugins.workflows.retry", + tag: "plugins.workflows.retry", + execute: (input: CommandInput) => + authorize(input.environmentId, "plugins.workflows.retry").pipe( + Effect.andThen(requestGuarded("plugins.workflows.retry", input)), + ), +}); +const resume = createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "plugins.workflows.resume", + tag: "plugins.workflows.resume", + execute: (input: CommandInput) => + authorize(input.environmentId, "plugins.workflows.resume").pipe( + Effect.andThen(requestGuarded("plugins.workflows.resume", input)), + ), +}); +const gate = createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "plugins.workflows.gate", + tag: "plugins.workflows.gate", + execute: (input: GateInput) => + authorize(input.environmentId, "plugins.workflows.gate").pipe( + Effect.andThen(requestGuarded("plugins.workflows.gate", input)), + ), +}); const permissionsAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => ({ save: get(save.permissionAtom(environmentId)), replace: get(replace.permissionAtom(environmentId)), + start: get(startSaved.permissionAtom(environmentId)), + cancel: get(cancel.permissionAtom(environmentId)), + retry: get(retry.permissionAtom(environmentId)), + resume: get(resume.permissionAtom(environmentId)), + gate: get(gate.permissionAtom(environmentId)), })), ); +/** Whether a workflow stream API is published; an unloaded catalog waits instead of failing. */ +const publication = ( + catalog: ReturnType extends Atom.Atom ? A : never, + environmentId: EnvironmentId, + method: string, +) => + catalog === null + ? "loading" + : catalog.environmentId === environmentId && + catalog.plugins.some( + (item) => + item.manifest.id === plugin.id && + item.manifest.hostVersion === 1 && + item.manifest.server.api.includes(method) && + item.status === "available", + ) + ? "published" + : "unpublished"; +const unpublished = () => + Stream.fail( + new PluginError({ + pluginId: plugin.id, + operation: "subscribe", + code: "unavailable", + message: "Workflows is unavailable or disconnected in this environment.", + }), + ); +// Keyed by the exact request so each view owns, and releases, its own subscription. +const streamFamily = (method: TTag) => + Atom.family((key: string) => { + const input = JSON.parse(key) as EnvironmentRpcInput & { environmentId: EnvironmentId }; + return connectionAtomRuntime + .atom( + ( + get, + ): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure | PluginError, + EnvironmentRegistry.EnvironmentRegistry + > => { + const state = publication( + get(availableCatalogAtom(input.environmentId)), + input.environmentId, + method, + ); + return state === "published" + ? followStreamInEnvironment(input.environmentId, subscribe(method, input)) + : state === "loading" + ? Stream.never + : unpublished(); + }, + ) + .pipe(Atom.setIdleTTL(0)); + }); +const runsAtom = streamFamily("plugins.workflows.subscribe"); +const runAtom = streamFamily("plugins.workflows.watch"); +const threadAtom = streamFamily("plugins.workflows.thread"); +const listen = ( + atom: Atom.Atom>, + onValue: (value: A) => void, + onError: (message: string) => void, +) => + appAtomRegistry.subscribe( + atom, + (result) => { + if (result._tag === "Success") onValue(result.value); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + onError(error instanceof Error ? error.message : "The workflow subscription ended."); + } + }, + { immediate: true }, + ); + /** Every call is bound to one environment; a disconnected target fails instead of falling back. */ export function createWorkflowsClient(environmentId: EnvironmentId): WorkflowClient { const target = (input: I) => ({ environmentId, input: { ...input, environmentId } }); @@ -118,5 +271,31 @@ export function createWorkflowsClient(environmentId: EnvironmentId): WorkflowCli capabilities: async (projectId) => settle(await capabilities.run(appAtomRegistry, target({ projectId }))), skills: async (input) => settle(await skills.run(appAtomRegistry, target(input))), + preview: async (input) => settle(await preview.run(appAtomRegistry, target(input))), + startSaved: async (input) => settle(await startSaved.run(appAtomRegistry, target(input))), + runs: async (input) => settle(await runs.run(appAtomRegistry, target(input))), + subscribeRuns: (projectId, onRuns, onError) => + listen(runsAtom(JSON.stringify({ environmentId, projectId })), onRuns, onError), + watchRun: (input, onRun, onError) => + listen( + runAtom( + JSON.stringify({ + environmentId, + projectId: input.projectId, + runId: input.runId, + ...(input.historyOffset === undefined ? {} : { historyOffset: input.historyOffset }), + ...(input.attemptId === undefined ? {} : { attemptId: input.attemptId }), + ...(input.traceOffset === undefined ? {} : { traceOffset: input.traceOffset }), + }), + ), + onRun, + onError, + ), + watchThread: (input, onLink, onError) => + listen(threadAtom(JSON.stringify({ environmentId, ...input })), onLink, onError), + cancel: async (input) => settle(await cancel.run(appAtomRegistry, target(input))), + retry: async (input) => settle(await retry.run(appAtomRegistry, target(input))), + resume: async (input) => settle(await resume.run(appAtomRegistry, target(input))), + gate: async (input) => settle(await gate.run(appAtomRegistry, target(input))), }; } diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 33a67d49a7f3..f99f26d6ef93 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -50,6 +50,9 @@ type RpcMethod = WsRpcProtocolClient[TTag]; export type EnvironmentSubscriptionRpcTag = | "plugins.attention" | "plugins.fixture.subscribe" + | "plugins.workflows.subscribe" + | "plugins.workflows.watch" + | "plugins.workflows.thread" | typeof WS_METHODS.codexAuthCallbackSubscribe | typeof WS_METHODS.providerAuthSubscribe | typeof WS_METHODS.providerInstallSubscribe diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index e956b24132b7..48da0dead6aa 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -18,6 +18,11 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.serverCheckOtlpEndpoint]: AuthSettingsWriteScope, "plugins.workflows.save": AuthOrchestrationOperateScope, "plugins.workflows.replace": AuthOrchestrationOperateScope, + "plugins.workflows.launch": AuthOrchestrationOperateScope, + "plugins.workflows.cancel": AuthOrchestrationOperateScope, + "plugins.workflows.retry": AuthOrchestrationOperateScope, + "plugins.workflows.resume": AuthOrchestrationOperateScope, + "plugins.workflows.gate": AuthOrchestrationOperateScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, diff --git a/packages/plugin-host-contract/src/web.ts b/packages/plugin-host-contract/src/web.ts index c1f0ad03c1dc..34ca169fde6c 100644 --- a/packages/plugin-host-contract/src/web.ts +++ b/packages/plugin-host-contract/src/web.ts @@ -117,12 +117,43 @@ export interface PluginDesign { }>; } >; + /** + * A searchable picker: a select-like trigger whose popup has a search field. With + * `onQueryChange` the plugin owns the search (for example a server query) and `options` are + * the results (keep the selected option among them so the trigger can name it); without it + * the host filters `options` by label. + */ + readonly Combobox: ComponentType< + ControlProps & { + readonly value: string; + readonly onChange: (value: string) => void; + readonly options: ReadonlyArray<{ + readonly value: string; + readonly label: string; + /** Trailing muted text, such as a revision. */ + readonly detail?: string; + readonly disabled?: boolean; + }>; + /** Trigger text while nothing is selected. */ + readonly placeholder?: string; + readonly searchPlaceholder?: string; + readonly emptyText?: string; + readonly query?: string; + readonly onQueryChange?: (query: string) => void; + } + >; readonly Badge: ComponentType<{ readonly children: ReactNode; readonly variant?: "outline" | "secondary" | "info" | "success" | "warning" | "error"; }>; /** Shows `label` on hover and focus of its single child element, e.g. a truncated text. */ readonly Tooltip: ComponentType<{ readonly label: string; readonly children: ReactElement }>; + /** A transient app toast for an outcome that needs no further action. */ + readonly toast: (input: { + readonly title: string; + readonly description?: string; + readonly variant?: "success" | "info" | "warning" | "error"; + }) => void; /** A host-owned icon, for icon buttons in host slots such as the chat header. */ readonly Icon: ComponentType<{ readonly name: PluginIconName }>; /** An overflow (`…`) menu, or a menu behind custom trigger content. */ @@ -185,21 +216,26 @@ export interface PluginDesign { readonly breadcrumb: ReadonlyArray; readonly children?: ReactNode; }>; - /** Labeled panel for narrow layouts; plugins render the same content inline when wide. */ - readonly Sheet: ComponentType<{ + /** + * The app's modal dialog: a title, optional one-line description, a scrolling body and a + * footer for its actions. Closing (Escape, the close button, the backdrop) calls + * `onOpenChange(false)`; the plugin decides whether to close. + */ + readonly Dialog: ComponentType<{ readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly title: string; - readonly side?: "left" | "right" | "bottom"; + readonly description?: string; + readonly footer?: ReactNode; readonly children: ReactNode; }>; - /** A modal dialog with a title, its content and footer actions, such as a short form. */ - readonly Dialog: ComponentType<{ + /** Labeled panel for narrow layouts; plugins render the same content inline when wide. */ + readonly Sheet: ComponentType<{ readonly open: boolean; readonly onOpenChange: (open: boolean) => void; readonly title: string; + readonly side?: "left" | "right" | "bottom"; readonly children: ReactNode; - readonly footer?: ReactNode; }>; /** * Holds in-app navigation while `when` is true and offers Keep editing or Discard, plus @@ -251,6 +287,14 @@ export interface WebPlugin { readonly title: string; readonly icon: PluginIconName; readonly link: (projectId: ProjectId) => PluginPageLink; + /** + * Opens this dialog over the current view instead of navigating to `link`, so dismissing it + * keeps the user where they were. The dialog calls `onClose` when dismissed and navigates + * itself when its action leads somewhere. + */ + readonly dialog?: ComponentType< + PluginWebContext & { readonly client: Client; readonly onClose: () => void } + >; }>; readonly threadContext: ReadonlyArray<{ readonly id: string; diff --git a/packages/plugin-workflows/src/Catalog.ts b/packages/plugin-workflows/src/Catalog.ts index f2266075c7ab..d7782547795a 100644 --- a/packages/plugin-workflows/src/Catalog.ts +++ b/packages/plugin-workflows/src/Catalog.ts @@ -17,6 +17,7 @@ import * as Yaml from "yaml"; import { Definition, limits, + type Agent, type AuthoringEntry, type Capabilities, type CatalogEntry, @@ -30,6 +31,7 @@ import { type SaveInput, type ScopeInput, type Skill, + type StartPreview, } from "./contracts.ts"; import { agentLocations, definitionDiagnostics } from "./definition.ts"; import { digest, error, protect } from "./encoding.ts"; @@ -69,6 +71,14 @@ export class Catalog extends Context.Service< readonly projects: (input: { readonly environmentId: EnvironmentId; }) => Effect.Effect, PluginError>; + /** + * What starting a saved workflow would run, from cached discovery. Starting still + * re-resolves and rescans; this read never invalidates provider caches. + */ + readonly preview: ( + input: Scope, + definitionId: string, + ) => Effect.Effect; /** Resolve an authored snapshot for execution without applying public redaction. */ readonly resolve: ( input: Scope, @@ -641,6 +651,78 @@ const make = Effect.gen(function* () { Effect.map((projects) => projects.map(({ id, title }) => ({ id, title }))), ), ); + const preview = (input: Scope, definitionId: string) => + protect( + "preview", + Effect.gen(function* () { + const entry = (yield* scan(input)).find( + (entry) => entry.definition?.id === definitionId && !entry.duplicate, + ); + if (!entry?.definition) + return yield* error( + "preview", + "This workflow is no longer in the project catalog.", + "unavailable", + ); + const shared = yield* discovery(input.projectId, false); + const validation = yield* evaluate(input, entry, shared); + const discovered = yield* shared.providers; + const providers = discovered._tag === "Success" ? discovered.success : []; + const workspace = yield* host.workspace(input.projectId); + const definition = entry.definition; + const agents = definition.nodes + .flatMap( + ( + node, + ): ReadonlyArray<{ + readonly node: Definition["nodes"][number]; + readonly branch: { readonly id: string; readonly title: string } | null; + readonly agent: Agent; + }> => + node.kind === "agent" + ? [{ node, branch: null, agent: node }] + : node.kind === "parallel" + ? node.branches.map((branch) => ({ node, branch, agent: branch })) + : [], + ) + .map(({ node, branch, agent }) => ({ + nodeId: node.id, + branchId: branch?.id ?? null, + title: branch ? `${node.title} · ${branch.title}` : node.title, + providerInstanceId: agent.modelSelection.instanceId, + providerName: + providers.find((provider) => provider.instanceId === agent.modelSelection.instanceId) + ?.displayName ?? null, + model: agent.modelSelection.model, + runtimeMode: agent.runtimeMode, + interactionMode: agent.interactionMode ?? ("default" as const), + skill: agent.skill ?? null, + })); + const texts = [ + definition.title, + ...validation.reasons, + ...agents.map((agent) => agent.title), + workspace.path, + workspace.branch ?? "", + ]; + const visible = [...(yield* Display.displayTexts(host, texts))]; + return { + definitionId: definition.id, + title: visible.shift()!, + revision: definition.revision, + source: entry.source, + packaged: entry.packaged, + runnable: validation.runnable, + reasons: validation.reasons.map(() => visible.shift()!), + agents: agents.map((agent) => ({ ...agent, title: visible.shift()! })), + workspace: { + path: visible.shift()!, + branch: workspace.branch === null ? (visible.shift(), null) : visible.shift()!, + head: workspace.head, + }, + } satisfies StartPreview; + }), + ); const displayEntry = (entry: CatalogEntry) => Display.displayCatalog(host, [entry]).pipe(Effect.map((entries) => entries[0]!)); return Catalog.of({ @@ -661,6 +743,7 @@ const make = Effect.gen(function* () { capabilities, skills, projects, + preview, resolve: (input, definitionId) => list(input, true).pipe( Effect.map((entries) => entries.find((entry) => entry.definition?.id === definitionId)), diff --git a/packages/plugin-workflows/src/Workflow.ts b/packages/plugin-workflows/src/Workflow.ts index 255b50285c58..05e19393cf6f 100644 --- a/packages/plugin-workflows/src/Workflow.ts +++ b/packages/plugin-workflows/src/Workflow.ts @@ -29,6 +29,10 @@ import { RunSummary, RunListInput, StartInput, + StartSavedInput, + ThreadInput, + type StartSource, + type ThreadLink, ReportInput, ReportReceipt, CommandInput, @@ -68,6 +72,12 @@ export class Workflow extends Context.Service< { readonly attention: Stream.Stream, PluginError>; readonly start: (input: StartInput) => Effect.Effect; + /** Start the catalog's saved snapshot; the receipt is consulted before the catalog. */ + readonly startSaved: (input: StartSavedInput) => Effect.Effect; + /** One run's display snapshot, re-emitted whenever its revision changes. */ + readonly watch: (input: typeof RunInput.Type) => Stream.Stream; + /** The run and attempt a native thread was launched for, if any. */ + readonly thread: (input: ThreadInput) => Stream.Stream; readonly get: (input: typeof RunInput.Type) => Effect.Effect; readonly list: ( input: typeof RunListInput.Type, @@ -103,6 +113,7 @@ const encodeReceipt = Schema.encodeEffect(Schema.fromJsonString(ReportReceipt)); const decodeDisplay = Schema.decodeUnknownEffect(Run); const decodeSummary = Schema.decodeUnknownEffect(RunSummary); const decodeStart = Schema.decodeUnknownEffect(StartInput); +const decodeStartSaved = Schema.decodeUnknownEffect(StartSavedInput); const activeRun = (state: PluginThreadState) => state.runs.findLast( (run) => @@ -156,6 +167,9 @@ const make = Effect.gen(function* () { let reconcileAfter = 0; let outboxAfter = 0; const projectVersions = new Map(); + // Committed run revisions and new thread bindings, so watchers skip unrelated commits. + const runVersions = new Map(); + let bindingVersion = 0; yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); const checks = new Map>(); const environment = (environmentId: string) => @@ -212,6 +226,8 @@ const make = Effect.gen(function* () { yield* enqueue(run, interruptId(attempt), "interrupt", attempt.id); } mutation++; + runVersions.set(run.id, run.revision); + if (run.attempts.some((attempt) => attempt.phase === "launching")) bindingVersion++; projectVersions.set(run.projectId, (projectVersions.get(run.projectId) ?? 0) + 1); return run; }); @@ -237,15 +253,98 @@ const make = Effect.gen(function* () { }), ); // Queries page durable history; routing always reads the complete private snapshot. - const display = Effect.fnUntraced(function* (run: Run, offset = 0, limit = 50, tail = false) { + // Without an explicit offset the newest page is shown. The overview is computed from the + // complete snapshot so it never depends on which history page is loaded. + const display = Effect.fnUntraced(function* ( + run: Run, + request: Pick = {}, + ) { + const limit = 50; + const newest = Math.max(0, run.attempts.length - limit); + // A requested attempt opens the page that contains it. + const located = request.attemptId + ? run.attempts.findIndex((attempt) => attempt.id === request.attemptId) + : -1; + const offset = + request.historyOffset ?? + (located >= 0 && located < newest ? Math.floor(located / limit) * limit : newest); + const tail = request.historyOffset === undefined && offset === newest; + const attempts = run.attempts.slice(offset, offset + limit); + // Route history pages independently: it grows at its own rate. + const traceOffset = request.traceOffset ?? Math.max(0, run.trace.length - limit); + const trace = run.trace.slice(traceOffset, traceOffset + limit); + const pageIds = new Set( + attempts.flatMap((attempt) => [ + attempt.id, + `${attempt.id}:report`, + ...(attempt.reviewId ? [attempt.reviewId] : []), + ]), + ); + const shown = new Set(trace.map((item) => item.id)); + // Routing records of this page's visits stay reachable wherever route history is paged. + const relatedTrace = run.trace + .filter( + (item) => + !shown.has(item.id) && + ((item.attemptId !== null && pageIds.has(item.attemptId)) || + item.sourceIds.some((id) => pageIds.has(id))), + ) + .slice(-limits.relatedTrace); + const reviewIds = new Set([ + ...attempts.flatMap((attempt) => (attempt.reviewId ? [attempt.reviewId] : [])), + ...(run.gate?.reviewId ? [run.gate.reviewId] : []), + ]); + const reviews = run.reviews.filter( + (review, index) => index >= run.reviews.length - limit || reviewIds.has(review.id), + ); + const review = run.reviews.at(-1); + const branches = review + ? review.branches.map((branch) => + run.attempts.find((attempt) => attempt.id === branch.attemptId), + ) + : []; const page = { ...run, - attempts: tail ? run.attempts.slice(-limit) : run.attempts.slice(offset, offset + limit), - trace: tail ? run.trace.slice(-limit) : run.trace.slice(offset, offset + limit), - reviews: tail ? run.reviews.slice(-limit) : run.reviews.slice(offset, offset + limit), + // Recovery targets come from the same rules that admit Retry and Resume. + recovery: { + retryNodeId: run.allowedActions.includes("retry") ? (recoveryNode(run) ?? null) : null, + resumeAttemptId: run.allowedActions.includes("resume") + ? (latestAttempt(run, run.currentNode)?.id ?? null) + : null, + }, + overview: { + visits: run.attempts.length, + completedVisits: run.attempts.filter((attempt) => attempt.phase === "completed").length, + activeAttempts: run.attempts + .filter((attempt) => !terminalAttempt(attempt)) + .slice(-limits.activeAttempts) + .map(({ id, nodeId, branchId, generation, threadId, phase }) => ({ + id, + nodeId, + branchId, + generation, + threadId, + phase, + })), + review: review + ? { + id: review.id, + required: review.branches.length, + reported: branches.filter((attempt) => attempt?.report).length, + settled: branches.filter((attempt) => attempt && terminalAttempt(attempt)).length, + result: review.result, + } + : null, + }, + attempts, + trace, + relatedTrace, + reviews, history: { offset, tail, + traceOffset, + traceTail: traceOffset + limit >= run.trace.length, limit, attempts: run.attempts.length, trace: run.trace.length, @@ -278,6 +377,7 @@ const make = Effect.gen(function* () { gate: run.gate, allowedActions: run.allowedActions, createdAt: run.createdAt, + ...(run.source === undefined ? {} : { source: run.source }), attempts: run.attempts.slice(-5).map((attempt) => ({ id: attempt.id, nodeId: attempt.nodeId, @@ -333,6 +433,109 @@ const make = Effect.gen(function* () { ); }), ); + const watch = (input: typeof RunInput.Type) => + Stream.unwrap( + Effect.gen(function* () { + yield* environment(input.environmentId); + // Subscribe before the first read so no committed revision is missed. + const subscription = yield* PubSub.subscribe(changes); + let observed: number | null = null; + const next = protect( + "watch", + Effect.gen(function* () { + if (observed !== null && (runVersions.get(input.runId) ?? observed) === observed) + return []; + const run = yield* get(input); + observed = run.revision; + return [yield* display(run, input)]; + }), + ); + return Stream.concat( + Stream.fromEffect(next), + Stream.fromSubscription(subscription).pipe(Stream.mapEffect(() => next)), + ).pipe(Stream.flatMap((runs) => Stream.fromIterable(runs))); + }), + ); + const threadLink = Effect.fnUntraced(function* (input: ThreadInput) { + const [binding] = yield* sql<{ + run_id: string; + attempt_id: string; + }>`SELECT run_id, attempt_id FROM workflow_bindings WHERE thread_id = ${input.threadId}`; + if (!binding) return null; + const run = yield* load(binding.run_id); + const attempt = run.attempts.find((attempt) => attempt.id === binding.attempt_id); + if (run.projectId !== input.projectId || !attempt) return null; + const node = run.definition.nodes.find((node) => node.id === attempt.nodeId); + const branch = + node?.kind === "parallel" + ? node.branches.find((branch) => branch.id === attempt.branchId) + : undefined; + const [workflowTitle, nodeTitle] = yield* Display.displayTexts( + host, + [ + run.definition.title, + branch && node ? `${node.title} · ${branch.title}` : (node?.title ?? attempt.nodeId), + ], + [input.threadId], + ); + return { + revision: run.revision, + link: { + runId: run.id, + attemptId: attempt.id, + nodeId: attempt.nodeId, + branchId: attempt.branchId, + generation: attempt.generation, + workflowTitle: workflowTitle!.slice(0, 240).trim(), + nodeTitle: nodeTitle!.slice(0, 240).trim(), + runState: run.state, + phase: attempt.phase, + reportAccepted: attempt.report !== null, + } satisfies ThreadLink, + }; + }); + /** A thread's owning run and attempt, re-emitted when that run commits a new revision. */ + const thread = (input: ThreadInput) => + Stream.unwrap( + Effect.gen(function* () { + yield* environment(input.environmentId); + const subscription = yield* PubSub.subscribe(changes); + let observed: { readonly runId: string; readonly revision: number } | null = null; + let observedBindings = bindingVersion; + let sent: string | null = null; + const read = protect( + "thread", + Effect.gen(function* () { + observedBindings = bindingVersion; + const found = yield* threadLink(input); + if (found !== null) observed = { runId: found.link.runId, revision: found.revision }; + return found?.link ?? null; + }), + ); + // A new run revision often leaves this attempt's link unchanged; send only changes. + const fresh = (link: ThreadLink | null) => { + const encoded = canonical(link); + if (encoded === sent) return []; + sent = encoded; + return [link]; + }; + // Unowned threads are re-checked only when new bindings commit. + const changed = () => + observed === null + ? observedBindings !== bindingVersion + : (runVersions.get(observed.runId) ?? observed.revision) !== observed.revision; + return Stream.concat( + Stream.fromEffect(read.pipe(Effect.map(fresh))), + Stream.fromSubscription(subscription).pipe( + Stream.mapEffect(() => + changed() + ? read.pipe(Effect.map((link) => (link === null ? [] : fresh(link)))) + : Effect.succeed([]), + ), + ), + ).pipe(Stream.flatMap((links) => Stream.fromIterable(links))); + }), + ); const attentionItems = protect( "attention", Effect.gen(function* () { @@ -365,6 +568,7 @@ const make = Effect.gen(function* () { link: { pageId: "workflows.runs", projectId: run.projectId, + state: { run: run.id }, ...(relatedThread ? { threadId: relatedThread } : {}), }, }; @@ -492,11 +696,18 @@ const make = Effect.gen(function* () { "unsupported", ); }); - const start = Effect.fn("Workflows.start")(function* (requested: StartInput) { + const runIdentity = (input: { readonly projectId: string; readonly clientRequestId: string }) => + hash([host.environmentId, input.projectId, input.clientRequestId]).pipe( + Effect.map((value) => `workflow-${value.slice(0, 32)}`), + ); + const start = Effect.fn("Workflows.start")(function* ( + requested: StartInput, + options: { readonly digest?: string; readonly source?: StartSource } = {}, + ) { const input = yield* decodeStart(requested); yield* environment(input.environmentId); - const id = `workflow-${(yield* hash([input.environmentId, input.projectId, input.clientRequestId])).slice(0, 32)}`; - const requestDigest = yield* hash(input); + const id = yield* runIdentity(input); + const requestDigest = options.digest ?? (yield* hash(input)); const [previous] = yield* sql<{ digest: string; result: string; @@ -580,6 +791,7 @@ const make = Effect.gen(function* () { gate: null, allowedActions: [], createdAt: now, + source: options.source ?? { trigger: "manual", catalogSource: null }, }; yield* sql`INSERT INTO workflow_runs (id, project_id, data, state) VALUES (${run.id}, ${run.projectId}, ${yield* encodeRun(run)}, ${run.state})`; if (resolvedWorkspace.type === "exact-ref") @@ -591,6 +803,47 @@ const make = Effect.gen(function* () { }), ); }); + const startSaved = Effect.fn("Workflows.startSaved")(function* (requested: StartSavedInput) { + const input = yield* decodeStartSaved(requested); + yield* environment(input.environmentId); + const id = yield* runIdentity(input); + const requestDigest = yield* hash(["saved", input]); + // A committed intent returns its run even if the catalog changed after it started. + const [previous] = yield* sql<{ + digest: string; + result: string; + }>`SELECT digest, result FROM workflow_commands WHERE id = ${id}`; + if (previous) { + if (previous.digest !== requestDigest) + return yield* error("start", "This retry identity belongs to different input.", "conflict"); + return yield* decodeRun(previous.result); + } + const entry = yield* catalog.resolve(input, input.definitionId); + if (!entry?.definition) + return yield* error( + "start", + "This workflow is no longer in the project catalog.", + "unavailable", + ); + if (entry.definition.revision !== input.revision) + return yield* error( + "start", + `The saved workflow changed to revision ${entry.definition.revision} after you reviewed revision ${input.revision}. Review it and start again.`, + "conflict", + ); + if (!entry.runnable) return yield* error("start", entry.reasons.join(" "), "unsupported"); + return yield* start( + { + environmentId: input.environmentId, + projectId: input.projectId, + clientRequestId: input.clientRequestId, + definition: entry.definition, + input: input.task.trim() === "" ? {} : { task: input.task }, + ...(input.workspace === "current" ? { workspace: { type: "current" as const } } : {}), + }, + { digest: requestDigest, source: { trigger: "manual", catalogSource: entry.source } }, + ); + }); const report = Effect.fn("Workflows.report")(function* ( requested: ReportInput, caller: PluginToolCaller, @@ -1909,19 +2162,25 @@ const make = Effect.gen(function* () { "The scheduled workflow is unavailable or invalid.", "unavailable", ); - return yield* start({ - environmentId: host.environmentId, - projectId: input.projectId, - clientRequestId, - definition: entry.definition, - input: input.input, - }); + return yield* start( + { + environmentId: host.environmentId, + projectId: input.projectId, + clientRequestId, + definition: entry.definition, + input: input.input, + }, + { source: { trigger: "schedule", catalogSource: entry.source } }, + ); }); return Workflow.of({ attention, start: (input) => protect("start", start(input).pipe(Effect.flatMap((run) => display(run)))), - get: (input) => - protect("get", get(input).pipe(Effect.flatMap((run) => display(run, input.historyOffset)))), + startSaved: (input) => + protect("start", startSaved(input).pipe(Effect.flatMap((run) => display(run)))), + watch, + thread, + get: (input) => protect("get", get(input).pipe(Effect.flatMap((run) => display(run, input)))), list, subscribe, reconcile, diff --git a/packages/plugin-workflows/src/contracts.ts b/packages/plugin-workflows/src/contracts.ts index 5d7b5a6a0f42..092eb809f5d2 100644 --- a/packages/plugin-workflows/src/contracts.ts +++ b/packages/plugin-workflows/src/contracts.ts @@ -32,6 +32,10 @@ export const limits = { evidence: 20, repeats: 20, visits: 1_000, + /** Active attempts listed in a run overview: one per parallel branch plus the main step. */ + activeAttempts: 64, + /** Routing records of one history page's visits returned beside the route-history page. */ + relatedTrace: 200, timeoutMs: { default: 7_200_000, min: 60_000, max: 86_400_000 }, } as const; const text = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(4_000)); @@ -294,6 +298,20 @@ export const ReviewSet = Schema.Struct({ ), consumed: Schema.Boolean, }); +/** How a run was started; retained with its snapshot so later catalog edits cannot rewrite it. */ +export const StartSource = Schema.Struct({ + trigger: Schema.Literals(["manual", "schedule"]), + /** Catalog source the saved snapshot was resolved from; null for a submitted definition. */ + catalogSource: Schema.NullOr(Schema.String), +}); +export type StartSource = typeof StartSource.Type; +/** Server-owned targets of the currently allowed recovery actions; clients never derive them. */ +export const RecoveryTarget = Schema.Struct({ + /** Step where Retry admits a new attempt (with a new thread). */ + retryNodeId: Schema.NullOr(Id), + /** Attempt whose retained native session Resume continues. */ + resumeAttemptId: Schema.NullOr(Schema.String), +}); export const Run = Schema.Struct({ id: Schema.String, environmentId: EnvironmentId, @@ -329,10 +347,46 @@ export const Run = Schema.Struct({ Schema.Literals(["cancel", "retry", "resume", "approve", "request-changes"]), ), createdAt: Schema.Number, + /** Absent on runs persisted before start sources were recorded. */ + source: Schema.optional(StartSource), + /** Read-time field; present on every displayed run. */ + recovery: Schema.optional(RecoveryTarget), + /** Read-time totals over the complete snapshot, independent of the loaded history page. */ + overview: Schema.optional( + Schema.Struct({ + visits: Schema.Int, + completedVisits: Schema.Int, + activeAttempts: Schema.Array( + Schema.Struct({ + id: Schema.String, + nodeId: Id, + branchId: Schema.NullOr(Id), + generation: Schema.Int, + threadId: Schema.NullOr(ThreadId), + phase: Attempt.fields.phase, + }), + ).check(Schema.isMaxLength(limits.activeAttempts)), + review: Schema.NullOr( + Schema.Struct({ + id: Schema.String, + required: Schema.Int, + reported: Schema.Int, + settled: Schema.Int, + result: ReviewSet.fields.result, + }), + ), + }), + ), + /** Routing records of this page's visits that are not on the loaded route-history page. */ + relatedTrace: Schema.optional(Schema.Array(Trace).check(Schema.isMaxLength(limits.relatedTrace))), history: Schema.optional( Schema.Struct({ + /** First visit on this page; `tail` when it is the newest page. */ offset: Schema.Int, tail: Schema.Boolean, + /** Route history pages independently of visits. */ + traceOffset: Schema.optional(Schema.Int), + traceTail: Schema.optional(Schema.Boolean), limit: Schema.Int, attempts: Schema.Int, trace: Schema.Int, @@ -356,6 +410,7 @@ export const RunSummary = Schema.Struct({ gate: Run.fields.gate, allowedActions: Run.fields.allowedActions, createdAt: Schema.Number, + source: Run.fields.source, attempts: Schema.Array( Schema.Struct({ id: Attempt.fields.id, @@ -539,17 +594,85 @@ export type StartInput = typeof StartInput.Type; export const RunInput = Schema.Struct({ ...ScopeInput.fields, runId: Schema.String, + /** First visit of a history page; omitted, the newest page (or `attemptId`'s page). */ historyOffset: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), + /** Opens the visit page containing this attempt when no offset is given. */ + attemptId: Schema.optional(Schema.String.check(Schema.isMaxLength(256))), + /** First entry of a route-history page; omitted, the newest entries. */ + traceOffset: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), +}); +export type RunInput = typeof RunInput.Type; +/** Bounded free-form task text recorded as the run input; the server owns its field name. */ +export const taskLimit = 4_000; +export const StartSavedInput = Schema.Struct({ + ...ScopeInput.fields, + /** One immutable identity per start intent; retries return the run it created. */ + clientRequestId: key, + definitionId: Id, + /** The saved revision the user reviewed; a different current revision is a conflict. */ + revision: Schema.Int.check(Schema.isGreaterThan(0)), + task: Schema.String.check(Schema.isMaxLength(taskLimit)), + workspace: Schema.Literals(["new-worktree", "current"]), +}); +export type StartSavedInput = typeof StartSavedInput.Type; +export const PreviewInput = Schema.Struct({ ...ScopeInput.fields, definitionId: Id }); +export type PreviewInput = typeof PreviewInput.Type; +/** What a start would run: the saved snapshot's identity, providers and workspace. */ +export const StartPreview = Schema.Struct({ + definitionId: Id, + title: Schema.String, + revision: Schema.Int, + source: Schema.String, + packaged: Schema.Boolean, + runnable: Schema.Boolean, + reasons: Schema.Array(Schema.String), + agents: Schema.Array( + Schema.Struct({ + nodeId: Id, + branchId: Schema.NullOr(Id), + title: Schema.String, + providerInstanceId: Schema.String, + providerName: Schema.NullOr(Schema.String), + model: Schema.String, + runtimeMode: RuntimeMode, + interactionMode: Schema.Literals(["default", "plan"]), + skill: Schema.NullOr(Schema.String), + }), + ).check(Schema.isMaxLength(limits.nodes * 32)), + workspace: Schema.Struct({ + path: Schema.String, + branch: Schema.NullOr(Schema.String), + head: Schema.NullOr(Schema.String), + }), }); +export type StartPreview = typeof StartPreview.Type; +export const ThreadInput = Schema.Struct({ ...ScopeInput.fields, threadId: ThreadId }); +export type ThreadInput = typeof ThreadInput.Type; +/** Historical owner of a native thread: the exact run and attempt it was launched for. */ +export const ThreadLink = Schema.Struct({ + runId: Schema.String, + attemptId: Schema.String, + nodeId: Id, + branchId: Schema.NullOr(Id), + generation: Schema.Int, + workflowTitle: Schema.String, + nodeTitle: Schema.String, + runState: Run.fields.state, + phase: Attempt.fields.phase, + reportAccepted: Schema.Boolean, +}); +export type ThreadLink = typeof ThreadLink.Type; export const CommandInput = Schema.Struct({ ...RunInput.fields, clientRequestId: key, expectedRevision: Schema.Int, }); +export type CommandInput = typeof CommandInput.Type; export const GateInput = Schema.Struct({ ...CommandInput.fields, decision: Schema.Literals(["approve", "request-changes"]), }); +export type GateInput = typeof GateInput.Type; export const SaveInput = Schema.Struct({ ...ScopeInput.fields, definition: Definition, @@ -607,6 +730,28 @@ export const rpcs = { error, }), start: Rpc.make("plugins.workflows.start", { payload: StartInput, success: Run, error }), + preview: Rpc.make("plugins.workflows.preview", { + payload: PreviewInput, + success: StartPreview, + error, + }), + launch: Rpc.make("plugins.workflows.launch", { + payload: StartSavedInput, + success: Run, + error, + }), + watch: Rpc.make("plugins.workflows.watch", { + payload: RunInput, + success: Run, + error, + stream: true, + }), + thread: Rpc.make("plugins.workflows.thread", { + payload: ThreadInput, + success: Schema.NullOr(ThreadLink), + error, + stream: true, + }), get: Rpc.make("plugins.workflows.get", { payload: RunInput, success: Run, error }), list: Rpc.make("plugins.workflows.list", { payload: RunListInput, @@ -646,6 +791,10 @@ export const apiScopes = { [rpcs.skills._tag]: AuthOrchestrationReadScope, [rpcs.projects._tag]: AuthOrchestrationReadScope, [rpcs.start._tag]: AuthOrchestrationOperateScope, + [rpcs.preview._tag]: AuthOrchestrationReadScope, + [rpcs.launch._tag]: AuthOrchestrationOperateScope, + [rpcs.watch._tag]: AuthOrchestrationReadScope, + [rpcs.thread._tag]: AuthOrchestrationReadScope, [rpcs.get._tag]: AuthOrchestrationReadScope, [rpcs.list._tag]: AuthOrchestrationReadScope, [rpcs.subscribe._tag]: AuthOrchestrationReadScope, @@ -684,17 +833,23 @@ export const manifest = { scheduleTargets: ["workflows.start"], }, web: { - pages: ["workflows.library", "workflows.editor"], + pages: ["workflows.library", "workflows.editor", "workflows.runs"], navigation: ["workflows.navigation"], - projectActions: ["workflows.project"], - threadContext: [], + projectActions: ["workflows.project", "workflows.project-run"], + threadContext: ["workflows.thread"], }, } satisfies PluginManifest; export interface WorkflowPermissions { readonly save: boolean; readonly replace: boolean; + readonly start: boolean; + readonly cancel: boolean; + readonly retry: boolean; + readonly resume: boolean; + readonly gate: boolean; } +export type RunCommand = Omit; type Scoped = Omit; /** Environment-bound client the host supplies to workflow pages; rejections carry `_tag`/`message`. */ export interface WorkflowClient { @@ -715,4 +870,31 @@ export interface WorkflowClient { readonly projectId: ProjectId; readonly providerInstanceId: ProviderInstanceId; }) => Promise>; + readonly preview: (input: Scoped) => Promise; + readonly startSaved: (input: Scoped) => Promise; + readonly runs: (input: Scoped) => Promise>; + /** Latest runs of one project, live; returns the unsubscribe function. */ + readonly subscribeRuns: ( + projectId: ProjectId, + onRuns: (runs: ReadonlyArray) => void, + onError: (message: string) => void, + ) => () => void; + /** One run, live, whether or not it is among the latest; returns the unsubscribe function. */ + readonly watchRun: ( + input: Scoped, + onRun: (run: Run) => void, + onError: (message: string) => void, + ) => () => void; + /** The thread's owning run and attempt, live; returns the unsubscribe function. */ + readonly watchThread: ( + input: Scoped, + onLink: (link: ThreadLink | null) => void, + onError: (message: string) => void, + ) => () => void; + readonly cancel: (input: RunCommand) => Promise; + readonly retry: (input: RunCommand) => Promise; + readonly resume: (input: RunCommand) => Promise; + readonly gate: ( + input: RunCommand & { readonly decision: "approve" | "request-changes" }, + ) => Promise; } diff --git a/packages/plugin-workflows/src/display.ts b/packages/plugin-workflows/src/display.ts index dde8e1b8124c..d80344ca9c53 100644 --- a/packages/plugin-workflows/src/display.ts +++ b/packages/plugin-workflows/src/display.ts @@ -48,6 +48,15 @@ const redact = ( }); const title = (value: string, text: Text, limit = 4_000) => text(value, limit).trim(); +const source = (value: Run["source"], text: Text) => + value === undefined + ? {} + : { + source: { + ...value, + catalogSource: value.catalogSource === null ? null : text(value.catalogSource, Infinity), + }, + }; const threadIds = (run: Run) => run.attempts.flatMap((attempt) => (attempt.threadId ? [attempt.threadId] : [])); @@ -142,12 +151,22 @@ export const projectDefinition = (value: Definition, text: Text): Definition => }), }); +const traceItem = (item: Run["trace"][number], text: Text) => ({ + ...item, + reason: text(item.reason, Infinity), + considered: item.considered.map((choice) => ({ + ...choice, + predicate: predicate(choice.predicate, text), + })), +}); + export const displayRun = (host: Host["Service"], run: Run) => redact(host, threadIds(run), (text) => ({ ...run, definition: projectDefinition(run.definition, text), input: data(run.input, text), reason: run.reason === null ? null : text(run.reason, Infinity), + ...source(run.source, text), workspace: workspace(run.workspace, text), workspacePath: run.workspacePath === null ? null : text(run.workspacePath, Infinity), branch: run.branch === null ? null : text(run.branch, Infinity), @@ -191,14 +210,10 @@ export const displayRun = (host: Host["Service"], run: Run) => stderr: text(attempt.check.stderr, Infinity), }, })), - trace: run.trace.map((item) => ({ - ...item, - reason: text(item.reason, Infinity), - considered: item.considered.map((choice) => ({ - ...choice, - predicate: predicate(choice.predicate, text), - })), - })), + trace: run.trace.map((item) => traceItem(item, text)), + ...(run.relatedTrace === undefined + ? {} + : { relatedTrace: run.relatedTrace.map((item) => traceItem(item, text)) }), reviews: run.reviews.map((review) => ({ ...review, pullRequest: pullRequest(review.pullRequest, text), @@ -210,6 +225,7 @@ export const displaySummary = (host: Host["Service"], run: Run, summary: RunSumm ...summary, definition: { ...summary.definition, title: title(summary.definition.title, text, 240) }, reason: summary.reason === null ? null : text(summary.reason, 500), + ...source(summary.source, text), attempts: summary.attempts.map((attempt) => ({ ...attempt, reason: attempt.reason === null ? null : text(attempt.reason, 500), @@ -222,10 +238,14 @@ export const displaySummary = (host: Host["Service"], run: Run, summary: RunSumm })); /** Redact independent display texts in one host call, preserving order. */ -export const displayTexts = (host: Host["Service"], texts: ReadonlyArray) => +export const displayTexts = ( + host: Host["Service"], + texts: ReadonlyArray, + threadIds: ReadonlyArray = [], +) => texts.length === 0 ? Effect.succeed>([]) - : redact(host, [], (text) => texts.map((value) => text(value, Infinity))); + : redact(host, threadIds, (text) => texts.map((value) => text(value, Infinity))); export const displayCatalog = (host: Host["Service"], entries: ReadonlyArray) => protect( diff --git a/packages/plugin-workflows/src/server.ts b/packages/plugin-workflows/src/server.ts index 640339112a6e..6a2ccfee3374 100644 --- a/packages/plugin-workflows/src/server.ts +++ b/packages/plugin-workflows/src/server.ts @@ -56,6 +56,11 @@ export const plugin: ServerPlugin = { skills: (input: typeof rpcs.skills.payloadSchema.Type) => catalog.skills(input), projects: (input: typeof rpcs.projects.payloadSchema.Type) => catalog.projects(input), start: workflows.start, + preview: (input: typeof rpcs.preview.payloadSchema.Type) => + catalog.preview(input, input.definitionId), + launch: workflows.startSaved, + watch: workflows.watch, + thread: workflows.thread, get: workflows.get, list: workflows.list, subscribe: workflows.subscribe, diff --git a/packages/plugin-workflows/src/web/Editor.tsx b/packages/plugin-workflows/src/web/Editor.tsx index eef2d5254d52..d68ecd8513e7 100644 --- a/packages/plugin-workflows/src/web/Editor.tsx +++ b/packages/plugin-workflows/src/web/Editor.tsx @@ -17,6 +17,7 @@ import type { } from "../contracts.ts"; import { ProjectsAlert, + noPermissions, draftKey, projectCrumb, unusedWorkflowId, @@ -102,10 +103,7 @@ function Editor(props: PageProps & { readonly workflow: string | null }) { const [capabilities, setCapabilities] = useState(null); const [capabilitiesError, setCapabilitiesError] = useState(null); const [capabilitiesAttempt, setCapabilitiesAttempt] = useState(0); - const [permissions, setPermissions] = useState({ - save: false, - replace: false, - }); + const [permissions, setPermissions] = useState(noPermissions); const [view, setView] = useState(() => pageState.import === "1" || pageState.view === "yaml" ? "yaml" : defaultView(), ); diff --git a/packages/plugin-workflows/src/web/Flow.tsx b/packages/plugin-workflows/src/web/Flow.tsx index 38162d709f29..e6338255846c 100644 --- a/packages/plugin-workflows/src/web/Flow.tsx +++ b/packages/plugin-workflows/src/web/Flow.tsx @@ -1,5 +1,5 @@ import { ArrowDownIcon, ArrowUpIcon, CornerDownRightIcon, Trash2Icon } from "lucide-react"; -import type { KeyboardEvent } from "react"; +import type { KeyboardEvent, ReactNode } from "react"; import type { Capabilities, Definition, Node, Problem } from "../contracts.ts"; import type { PageProps } from "./common.tsx"; import { editableKinds, kindLabels, routeList, type EditableKind } from "./editing.ts"; @@ -55,7 +55,8 @@ export function Palette({ /** * The Routes view: steps in reading order, each with its outgoing routes on one line apiece. - * Alt+Arrow reorders the reading order and Delete removes a step, without dragging. + * Alt+Arrow reorders the reading order and Delete removes a step, without dragging. A run view + * shows it read-only with each step's recorded `status`. */ export function RouteList({ props, @@ -66,6 +67,7 @@ export function RouteList({ onSelect, onMove, onRemove, + renderStatus, }: { readonly props: PageProps; readonly definition: Definition; @@ -75,6 +77,7 @@ export function RouteList({ readonly onSelect: (id: string) => void; readonly onMove: (id: string, offset: -1 | 1) => void; readonly onRemove: (id: string) => void; + readonly renderStatus?: (node: Node) => ReactNode; }) { const { Button, Badge, Menu } = props; const routes = routeList(definition); @@ -128,6 +131,7 @@ export function RouteList({ {definition.entry === node.id ? Start : null} {definition.atLimit === node.id ? Run limit : null} {node.kind === "end" ? {node.outcome} : null} + {renderStatus?.(node)} {errors > 0 ? ( {errors} to fix ) : warnings > 0 ? ( diff --git a/packages/plugin-workflows/src/web/Graph.tsx b/packages/plugin-workflows/src/web/Graph.tsx index 10884094d350..ee20c74d7a82 100644 --- a/packages/plugin-workflows/src/web/Graph.tsx +++ b/packages/plugin-workflows/src/web/Graph.tsx @@ -12,6 +12,7 @@ import { ReactFlowProvider, getSmoothStepPath, getViewportForBounds, + useNodesInitialized, useReactFlow, useStore, useStoreApi, @@ -49,12 +50,27 @@ import { type RepeatLoop, } from "./flowGraph.ts"; import { stepButtonId } from "./Flow.tsx"; -import { KindIcon } from "./kinds.tsx"; +import { KindIcon, statusIcons, statusTone } from "./kinds.tsx"; +import type { StepState, StepStatus } from "./run.ts"; + +/** A run painted onto its definition: box states, recorded routes and the current step. */ +export interface RunOverlay { + /** Keyed by box id: a step id, or `step/branch` for a reviewer lane. */ + readonly steps: ReadonlyMap; + /** Edge ids that recorded routing decisions took (see `takenEdges`). */ + readonly taken: ReadonlySet; + /** The step an unfinished run is at; null once it finished. */ + readonly current: string | null; + /** Visit counts cover the whole run only when every visit is loaded. */ + readonly complete: boolean; + /** Status of a box with no loaded visit: not visited, or not on this history page. */ + readonly unseen: string; +} interface GraphActions { readonly readOnly: boolean; readonly removable: (stepId: string) => boolean; - readonly onSelect: (stepId: string | null) => void; + readonly onSelect: (stepId: string | null, boxId?: string) => void; readonly onRemove: (stepId: string) => void; readonly Tooltip: PluginDesign["Tooltip"] | undefined; } @@ -65,6 +81,13 @@ type StepData = { readonly selected: boolean; readonly errors: number; readonly warnings: number; + /** Present on a run's graph; a box without a recorded visit is pending. */ + readonly run: { + readonly state: StepState | null; + readonly current: boolean; + readonly complete: boolean; + readonly unseen: string; + } | null; }; type StepNode = | FlowCanvasNode @@ -77,6 +100,8 @@ type RouteData = { readonly label: Point | null; /** For repeat back edges: the loop's vertical run and detours, clear of every box. */ readonly loop: RepeatLoop | null; + /** On a run's graph, whether a recorded routing decision took this edge. */ + readonly taken: boolean | null; }; type RouteEdge = Edge; @@ -113,6 +138,14 @@ function WithTooltip({ const Tooltip = use(GraphActionsContext)?.Tooltip; return Tooltip === undefined || !needed ? children : {children}; } +const statusBorder: Record = { + pending: "border-dashed border-border", + running: "border-info/64", + reported: "border-info/48", + completed: "border-success/40", + failed: "border-destructive/56", + stopped: "border-warning/56", +}; function StepCard({ data, @@ -144,21 +177,28 @@ function StepCard({ zoom, }); }; - const { node, selected, errors, warnings } = data; + const { node, selected, errors, warnings, run } = data; const removable = actions?.removable(node.id) ?? false; + const status = run === null ? null : (run.state?.status ?? "pending"); + const StatusIcon = status === null ? null : statusIcons[status]; return (
0 - ? "border-destructive/56" - : node.kind === "missing" - ? "" - : "border-border", + : run?.current + ? "border-info ring-3 ring-info/20" + : status !== null + ? statusBorder[status] + : errors > 0 + ? "border-destructive/56" + : node.kind === "missing" + ? "" + : "border-border", ].join(" ")} > 1 ? `, ${run.state.visits} visits` : ""}` + }`} aria-pressed={selected} onFocus={reveal} aria-keyshortcuts={removable ? "Delete" : undefined} @@ -199,10 +243,21 @@ function StepCard({ {node.title} {kindText(node.kind)} - {node.detail === null ? "" : ` · ${node.detail}`} + {run === null + ? node.detail === null + ? "" + : ` · ${node.detail}` + : run.state === null + ? "" + : ` · ${run.state.word}`} {/* Markers sit inside the card: route arrows land anywhere along its top edge. */} + {run?.current ? ( + + Current + + ) : null} {node.entry ? ( Start @@ -213,6 +268,17 @@ function StepCard({ Run limit ) : null} + {StatusIcon === null || status === null ? null : ( + + {run?.complete && run.state && run.state.visits > 1 ? ( + ×{run.state.visits} + ) : null} + + + )} {errors > 0 || warnings > 0 ? ( 0 ? "text-destructive" : "text-warning"}`} @@ -257,6 +323,8 @@ const labelTone: Partial> = { atLimit: "border-warning/40 text-warning-foreground", }; +const takenStroke = "var(--primary)"; + function RouteLine({ id, sourceX, @@ -300,8 +368,10 @@ function RouteLine({ path={path} {...(markerEnd === undefined ? {} : { markerEnd })} style={{ - stroke: strokes[data.edge.kind] ?? defaultStroke, - strokeWidth: 1.5, + stroke: data.taken ? takenStroke : (strokes[data.edge.kind] ?? defaultStroke), + strokeWidth: data.taken ? 2.25 : 1.5, + // A run's graph keeps untaken routes readable but behind the path the run took. + ...(data.taken === false ? { opacity: 0.48 } : {}), ...(data.edge.kind === "atLimit" ? { strokeDasharray: "5 4" } : {}), }} /> @@ -315,7 +385,7 @@ function RouteLine({ ? `translate(${label.x + 6}px, ${label.y}px) translateY(-50%)` : `translate(-50%, -50%) translate(${label.x}px, ${label.y}px)`, }} - className={`nopan ${data.edge.fullLabel === data.edge.label ? "" : "pointer-events-auto "}absolute whitespace-nowrap rounded-md border bg-background px-1.5 text-[11px] font-medium leading-[18px] ${labelTone[data.edge.kind] ?? "border-border text-muted-foreground"}`} + className={`nopan ${data.edge.fullLabel === data.edge.label ? "" : "pointer-events-auto "}absolute whitespace-nowrap rounded-md border bg-background px-1.5 text-[11px] font-medium leading-[18px] ${data.taken ? "border-primary/40 text-primary" : (labelTone[data.edge.kind] ?? "border-border text-muted-foreground")} ${data.taken === false ? "opacity-64" : ""}`} > {data.edge.label}
@@ -409,9 +479,29 @@ function graphBounds( : { x: left, y: top, width: right - left, height: bottom - top }; } +/** Focuses a step's box once the canvas has mounted and measured it. */ +function FocusStep({ + stepId, + onFocused, +}: { + readonly stepId: string; + readonly onFocused: () => void; +}) { + const initialized = useNodesInitialized(); + useEffect(() => { + if (!initialized || typeof document === "undefined") return; + const box = document.getElementById(stepButtonId(stepId)); + if (box === null) return; + box.focus(); + onFocused(); + }, [initialized, stepId, onFocused]); + return null; +} + /** - * The workflow as a top-to-bottom graph. Selecting a box selects its step; `readOnly` drops - * removal so the same canvas can present a run's definition snapshot. + * The workflow as a top-to-bottom graph. Selecting a box selects its step (and names the box, + * for reviewer lanes); `readOnly` drops removal so the same canvas can present a run's + * definition snapshot, with `run` painting its recorded state. Run state never relays out. */ export function Graph({ definition, @@ -421,15 +511,22 @@ export function Graph({ onSelect, onRemove, Tooltip, + run = null, + focus = null, + onFocused, }: { readonly definition: Definition; readonly selected: string | null; readonly problems: ReadonlyArray; readonly readOnly: boolean; - readonly onSelect: (stepId: string | null) => void; + readonly onSelect: (stepId: string | null, boxId?: string) => void; readonly onRemove?: (stepId: string) => void; /** The host tooltip, for titles and route labels shortened to fit. */ readonly Tooltip?: PluginDesign["Tooltip"]; + readonly run?: RunOverlay | null; + /** A step whose box takes keyboard focus once it exists; `onFocused` reports it. */ + readonly focus?: string | null; + readonly onFocused?: () => void; }) { const graph = useMemo(() => flowGraph(definition), [definition]); // Layout is keyed on structure, not on the definition object: edits that only change text @@ -476,9 +573,18 @@ export function Graph({ measured: size, data: { node, - selected: node.kind !== "branch" && selected === node.stepId, + selected: selected === node.id || (node.kind !== "branch" && selected === node.stepId), errors: count?.errors ?? 0, warnings: count?.warnings ?? 0, + run: + run === null + ? null + : { + state: run.steps.get(node.id) ?? null, + current: run.current === node.id, + complete: run.complete, + unseen: run.unseen, + }, }, }; }), @@ -494,13 +600,25 @@ export function Graph({ data: {}, })), ], - [graph, layout, loops, counts, selected], + [graph, layout, loops, counts, selected, run], ); const edges = useMemo(() => { const titles = new Map(graph.nodes.map((node) => [node.id, node.title])); + const taken = (edge: FlowEdge) => { + if (run === null) return null; + // Reviewer lanes have no routing record: a lane that was launched took its fork and, + // once settled, its join. + if (edge.kind === "fork") return run.steps.has(edge.target); + if (edge.kind === "join") { + const status = run.steps.get(edge.source)?.status; + return status === "completed" || status === "reported"; + } + return run.taken.has(edge.id); + }; return graph.edges.map((edge) => { const route = layout.routes.get(edge.id); - const color = markerColors[edge.kind] ?? "var(--muted-foreground)"; + const isTaken = taken(edge); + const color = isTaken ? takenStroke : (markerColors[edge.kind] ?? "var(--muted-foreground)"); return { id: edge.id, type: "route", @@ -515,10 +633,11 @@ export function Graph({ points: route?.points ?? null, label: route?.label ?? null, loop: loops.get(edge.id) ?? null, + taken: isTaken, }, }; }); - }, [graph, layout, loops]); + }, [graph, layout, loops, run]); const removableIds = useMemo( () => new Set( @@ -564,13 +683,16 @@ export function Graph({ panOnScroll onPaneClick={() => onSelect(null)} onNodeClick={(_, node) => { - if (node.type === "step") onSelect(node.data.node.stepId); + if (node.type === "step") onSelect(node.data.node.stepId, node.data.node.id); }} attributionPosition="bottom-right" > + {focus === null || onFocused === undefined ? null : ( + + )} diff --git a/packages/plugin-workflows/src/web/Library.tsx b/packages/plugin-workflows/src/web/Library.tsx index 00ca8bd8f977..f63c3f7c1d5f 100644 --- a/packages/plugin-workflows/src/web/Library.tsx +++ b/packages/plugin-workflows/src/web/Library.tsx @@ -5,6 +5,8 @@ import { FileCodeIcon, FilePenLineIcon, FolderIcon, + HistoryIcon, + PlayIcon, PlusIcon, UploadIcon, WorkflowIcon, @@ -26,6 +28,7 @@ import { } from "./common.tsx"; import { authoringTimings, debounce } from "./timings.ts"; import { importYaml, newDefinition, slug } from "./editing.ts"; +import { StartDialogInPlace } from "./Start.tsx"; const PAGE_SIZE = 20; const plural = (count: number, noun: string) => `${count} ${noun}${count === 1 ? "" : "s"}`; @@ -60,6 +63,8 @@ function Library(props: PageProps) { const [name, setName] = useState(null); const [importText, setImportText] = useState(null); const [actionError, setActionError] = useState(null); + // The workflow whose Run dialog is open over the library. + const [running, setRunning] = useState(null); const offline = connection === "disconnected"; const [localDrafts, setLocalDrafts] = useState(() => projectId === null ? [] : projectDrafts(props.drafts, projectId), @@ -162,6 +167,14 @@ function Library(props: PageProps) { {projectId === null ? null : ( <> + , onSelect: startImport }]} @@ -423,40 +436,54 @@ function Library(props: PageProps) { .filter((part) => part !== null) .join(" · ")} actions={ - , - onSelect: () => open(entry, { repair: "1" }), - }, - { - label: "Import replacement", - icon: , - onSelect: () => open(entry, { repair: "1", import: "1" }), - }, - ] - : []), - ...(entry.packaged - ? [ - { - label: "Clone to edit", - icon: , - onSelect: () => clone(entry), - }, - ] - : []), - { - label: "Export YAML", - icon: , - onSelect: () => open(entry, { view: "yaml" }), - }, - ]} - /> + <> + {invalid ? null : ( + + )} + , + onSelect: () => open(entry, { repair: "1" }), + }, + { + label: "Import replacement", + icon: , + onSelect: () => open(entry, { repair: "1", import: "1" }), + }, + ] + : []), + ...(entry.packaged + ? [ + { + label: "Clone to edit", + icon: , + onSelect: () => clone(entry), + }, + ] + : []), + { + label: "Export YAML", + icon: , + onSelect: () => open(entry, { view: "yaml" }), + }, + ]} + /> + } > {entry.reasons.length === 0 ? null : ( @@ -504,6 +531,18 @@ function Library(props: PageProps) { )} + {running === null ? null : ( + project.id === projectId)?.title ?? projectId + } + workflow={running} + onClose={() => setRunning(null)} + /> + )} ); } diff --git a/packages/plugin-workflows/src/web/RunView.tsx b/packages/plugin-workflows/src/web/RunView.tsx new file mode 100644 index 000000000000..ead03990f665 --- /dev/null +++ b/packages/plugin-workflows/src/web/RunView.tsx @@ -0,0 +1,1231 @@ +import type { PluginBreadcrumbItem } from "@t3tools/plugin-host-contract/web"; +import type { ProjectId } from "@t3tools/plugin-host-contract/schema"; +import { + ChevronLeftIcon, + CheckIcon, + ChevronRightIcon, + CircleStopIcon, + CopyIcon, + CornerDownRightIcon, + RepeatIcon, + TriangleAlertIcon, + XIcon, + MessageSquareIcon, + PanelRightIcon, + RotateCcwIcon, + StepForwardIcon, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import type { Attempt, Run, WorkflowPermissions } from "../contracts.ts"; +import { errorMessage, rejected, useNarrow, type PageProps } from "./common.tsx"; +import { RouteList, stepButtonId } from "./Flow.tsx"; +import { Graph, type RunOverlay } from "./Graph.tsx"; +import { KindIcon, StatusIcon } from "./kinds.tsx"; +import { + attemptTitle, + clockTime, + formatTime, + isActive, + relativeTime, + sourceShort, + workspaceShort, + nextAction, + nodeTitle, + phaseLabels, + phaseStatus, + predicateText, + repeatLimit, + reportStatus, + runStateLabels, + runStateVariant, + short, + sourceText, + stepStates, + takenEdges, + workspaceText, +} from "./run.ts"; + +type Action = Run["allowedActions"][number]; +const actionLabels: Record = { + cancel: "Cancel run", + retry: "Retry with a new attempt", + resume: "Resume retained session", + approve: "Approve", + "request-changes": "Request changes", +}; +/** One submitted command; its identity and expected revision survive retries of the request. */ +interface Pending { + readonly action: Action; + readonly clientRequestId: string; + readonly expectedRevision: number; + readonly inFlight: boolean; + readonly message: string | null; +} +interface Notice { + readonly variant: "info" | "warning" | "error"; + readonly title: string; + readonly detail: string | null; +} +const commandId = (action: Action) => + `${action}-${Array.from(crypto.getRandomValues(new Uint8Array(12)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join("")}`; +const HISTORY_PAGE = 50; + +type View = "graph" | "routes" | "steps"; +/** React Flow needs layout measurement; without it (server rendering, tests) Routes is shown. */ +const canvasSupported = () => typeof ResizeObserver !== "undefined"; + +/** One run, live from the server: its graph, visits, evidence and allowed actions. */ +export function RunView({ + props, + projectId, + runId, + permissions, + breadcrumb, + runWorkflow, + projectsAlert, +}: { + readonly props: PageProps; + readonly projectId: ProjectId; + readonly runId: string; + readonly permissions: WorkflowPermissions; + readonly breadcrumb: (title: string) => ReadonlyArray; + readonly runWorkflow: ReactNode; + readonly projectsAlert: ReactNode; +}) { + const { client, connection, pageState, Button, Badge, Alert, PageHeader, SegmentedControl } = + props; + const offline = connection === "disconnected"; + const narrow = useNarrow(); + // Undefined follows the newest visits (or the page of `locate`); a number pins a page. + const [historyOffset, setHistoryOffset] = useState(undefined); + // A deep-linked visit opens the history page that contains it. + const [locate, setLocate] = useState(pageState.attempt); + const [traceOffset, setTraceOffset] = useState(undefined); + const [run, setRun] = useState(null); + const [loadError, setLoadError] = useState(null); + const [subscription, setSubscription] = useState(0); + const [pending, setPending] = useState(null); + const [notice, setNotice] = useState(null); + const [view, setView] = useState(() => (canvasSupported() ? "graph" : "routes")); + const [inspectorOpen, setInspectorOpen] = useState(false); + + // View-scoped: this subscription follows only this run and closes when the view leaves. + useEffect(() => { + if (offline) return; + setLoadError(null); + return client.watchRun( + { + projectId, + runId, + ...(historyOffset !== undefined + ? { historyOffset } + : locate !== undefined + ? { attemptId: locate } + : {}), + ...(traceOffset === undefined ? {} : { traceOffset }), + }, + (value) => { + setLoadError(null); + setRun(value); + }, + setLoadError, + ); + }, [client, projectId, runId, historyOffset, locate, traceOffset, offline, subscription]); + // "View route" from a thread focuses that step's box once it exists: the graph reports it + // after its boxes are measured; the list views focus it once rendered. + const [routeFocus, setRouteFocus] = useState(() => + pageState.focus === "route" ? (pageState.node ?? null) : null, + ); + const routeFocused = useCallback(() => setRouteFocus(null), []); + useEffect(() => { + if (run === null || routeFocus === null || view === "graph") return; + const box = + typeof document === "undefined" ? null : document.getElementById(stepButtonId(routeFocus)); + if (box === null) return; + box.focus(); + setRouteFocus(null); + }, [run, routeFocus, view]); + const overlay = useMemo(() => { + if (run === null) return null; + const history = run.history; + // Visits and routing are paged; a partial page cannot say a step was never visited. + const complete = + history === undefined || + (history.offset === 0 && + history.tail && + (history.traceOffset ?? 0) === 0 && + history.traceTail !== false); + return { + steps: stepStates(run), + taken: takenEdges(run.definition, [...(run.relatedTrace ?? []), ...run.trace]), + current: run.state === "running" || run.state === "awaiting-review" ? run.currentNode : null, + complete, + unseen: complete ? "Not visited" : "Not on this history page", + }; + }, [run]); + + if (run === null || overlay === null) + return ( + <> + {runWorkflow} +
+ {projectsAlert} + {loadError !== null ? ( + setSubscription((value) => value + 1)} + > + Retry + + } + > + {loadError} + + ) : ( +

+ {offline ? "This run loads after reconnecting." : "Loading run…"} +

+ )} +
+ + ); + + const definition = run.definition; + // A named visit is shown only when it is on this page; it never falls back to another visit. + const selectedAttempt = + pageState.attempt !== undefined + ? run.attempts.find((attempt) => attempt.id === pageState.attempt) + : pageState.node === undefined + ? undefined + : run.attempts.findLast( + (attempt) => + attempt.nodeId === pageState.node && + (pageState.branch === undefined || attempt.branchId === pageState.branch), + ); + const elsewhere = pageState.attempt !== undefined && selectedAttempt === undefined; + const selectedNode = selectedAttempt?.nodeId ?? pageState.node ?? null; + const selectedBranch = selectedAttempt?.branchId ?? pageState.branch ?? null; + const selectedBox = + selectedNode === null + ? null + : selectedBranch === null + ? selectedNode + : `${selectedNode}/${selectedBranch}`; + // The view keeps its last snapshot after the subscription fails, but marks it stale. + const stale = loadError !== null; + const select = (state: { node: string; attempt?: string; branch?: string | null } | null) => { + props.navigate( + { + pageId: "workflows.runs", + projectId, + state: + state === null + ? { run: run.id } + : { + run: run.id, + node: state.node, + ...(state.attempt === undefined ? {} : { attempt: state.attempt }), + ...(state.branch == null ? {} : { branch: state.branch }), + }, + }, + { replace: true }, + ); + if (narrow && state !== null) setInspectorOpen(true); + }; + // Totals come from the server's complete snapshot, not from the loaded history page. + const overview = run.overview; + const firstVisit = run.history?.offset ?? 0; + const recovery = run.recovery; + const gateNode = run.gate ? definition.nodes.find((node) => node.id === run.gate!.nodeId) : null; + const gateReview = run.gate?.reviewId + ? run.reviews.find((review) => review.id === run.gate!.reviewId) + : undefined; + const allowed = (action: Action) => + run.allowedActions.includes(action) && + (action === "approve" || action === "request-changes" ? permissions.gate : permissions[action]); + const busy = pending?.inFlight === true; + const send = (action: Action) => { + const command: Pending = + pending?.action === action + ? { ...pending, inFlight: true, message: null } + : { + action, + clientRequestId: commandId(action), + // Gate decisions are bound to the displayed gate revision. + expectedRevision: + (action === "approve" || action === "request-changes") && run.gate + ? run.gate.revision + : run.revision, + inFlight: true, + message: null, + }; + setPending(command); + setNotice(null); + const input = { + projectId, + runId: run.id, + clientRequestId: command.clientRequestId, + expectedRevision: command.expectedRevision, + }; + const request = + action === "approve" || action === "request-changes" + ? client.gate({ ...input, decision: action }) + : client[action](input); + request.then( + (result) => { + // The view's subscription delivers the committed state; the result only explains it. + setPending(null); + // A decision the server could not apply stays until dismissed; success is transient. + if ((action === "approve" || action === "request-changes") && result.state === "unresolved") + setNotice({ + variant: "warning", + title: "Decision not applied", + detail: result.reason ?? "The reviewed input could not be verified.", + }); + else props.toast({ title: `${actionLabels[action]} applied` }); + }, + (cause: unknown) => { + if ( + rejected(cause, action === "approve" || action === "request-changes" ? "gate" : action) + ) { + // The server refused it; refresh to the current state and decide again. + setPending(null); + setNotice({ + variant: "warning", + title: `${actionLabels[action]} not applied`, + detail: `${errorMessage(cause)} The current run state is shown.`, + }); + setSubscription((value) => value + 1); + } else + setPending({ + ...command, + inFlight: false, + message: errorMessage(cause), + }); + }, + ); + }; + const actionButton = ( + action: Action, + variant: "default" | "outline" | "ghost", + icon?: ReactNode, + size: "xs" | "sm" = "xs", + ) => + allowed(action) ? ( + + ) : null; + const active = overview?.activeAttempts ?? []; + const resumeAttempt = recovery?.resumeAttemptId + ? run.attempts.find((attempt) => attempt.id === recovery.resumeAttemptId) + : undefined; + const recoverable = allowed("resume") || allowed("retry"); + const decisionShown = + run.gate !== null && + gateNode !== null && + gateNode !== undefined && + (allowed("approve") || allowed("request-changes")); + const limit = definition.maxVisits ?? 100; + const next = nextAction(run, active); + const alerts = [ + projectsAlert, + stale && !offline ? ( + setSubscription((value) => value + 1)}> + Retry + + } + > + {loadError} Actions wait until the run reloads. + + ) : null, + decisionShown ? ( + + {actionButton("request-changes", "outline")} + {actionButton("approve", "default")} + + } + > + {gateNode.title} + {gateReview ? ` · reviewed head ${short(gateReview.head)}` : ""} + + ) : null, + recoverable ? ( + + {actionButton("retry", "outline", )} + {actionButton("resume", "default", )} + + } + > + + {run.reason === null ? null : {run.reason}} + {allowed("resume") ? ( + + Resume:{" "} + {resumeAttempt + ? attemptTitle(definition, resumeAttempt) + : nodeTitle(definition, run.currentNode)} + {recovery?.resumeAttemptId ? ` · attempt ${short(recovery.resumeAttemptId)}` : ""} + + ) : null} + {allowed("retry") && recovery?.retryNodeId ? ( + Retry: new attempt of {nodeTitle(definition, recovery.retryNodeId)} + ) : null} + + + ) : run.reason === null ? null : ( + + {run.reason} + + ), + run.automationStopped ? ( + + ) : null, + run.allowedActions.length > 0 && !run.allowedActions.some((action) => allowed(action)) ? ( + + ) : null, + pending?.message ? ( + setPending(null)}> + Dismiss + + } + > + {pending.message} Retry sends the same request. + + ) : null, + notice === null ? null : ( + setNotice(null)}> + Dismiss + + } + > + {notice.detail} + + ), + ].filter((alert) => alert !== null && alert !== undefined && alert !== false); + const history = run.history; + const paged = history !== undefined && history.attempts > history.limit; + const steps = ( +
    + {run.attempts.map((attempt, index) => ( +
  1. + + + + + + {phaseLabels[attempt.phase]} + {attempt.report ? " · report accepted" : ""} + {attempt.generation > 1 ? ` · generation ${attempt.generation}` : ""} + +
  2. + ))} +
+ ); + const inspector = ( + + select({ node: attempt.nodeId, attempt: attempt.id, branch: attempt.branchId }) + } + onTraceOffset={setTraceOffset} + elsewhere={ + elsewhere ? ( + { + setHistoryOffset(undefined); + setLocate(pageState.attempt); + setSubscription((value) => value + 1); + }} + > + Show this visit + + } + /> + ) : null + } + /> + ); + const views = [ + ...(canvasSupported() ? [{ value: "graph", label: "Graph" }] : []), + { value: "routes", label: "Routes" }, + { value: "steps", label: "Steps" }, + ]; + return ( + <> + + + {runStateLabels[run.state]} + {offline ? Last loaded state : null} + {stale && !offline ? Not updating : null} + + {actionButton("cancel", "ghost", , "sm")} + {runWorkflow} + +
+ + {relativeTime(run.createdAt)} + + + {sourceShort(run.source)} + + + {String(definition.revision)} + + + {workspaceShort(run)} + + {overview === undefined ? null : ( + + {[ + // The same server visit count the run list shows. + `${run.visits} ${run.visits === 1 ? "visit" : "visits"}`, + // The whole-run visit bound only matters once it is close. + run.visits >= limit * 0.8 ? `${run.visits}/${limit} allowed` : null, + overview.review + ? `reviews ${overview.review.reported}/${overview.review.required} reported, ${overview.review.settled}/${overview.review.required} settled` + : null, + ] + .filter((part) => part !== null) + .join(" · ")} + + )} + {active.length === 0 ? null : ( + + {active + .map( + (attempt) => `${attemptTitle(definition, attempt)} (${phaseLabels[attempt.phase]})`, + ) + .join(", ")} + + )} + {decisionShown || recoverable || next === null ? null : ( + + {next} + + )} +
+ {alerts.length === 0 && !(offline && run.allowedActions.length > 0) && !busy ? null : ( +
+ {alerts} + {offline && run.allowedActions.length > 0 ? ( +

+ Actions wait until this environment reconnects. +

+ ) : null} + {busy ? ( +

+ Sending {actionLabels[pending.action].toLowerCase()}… +

+ ) : null} +
+ )} +
+
+
+ setView(value as View)} + /> + {paged ? ( + + ) : null} + {narrow ? ( + + + + ) : null} +
+ {view === "graph" ? ( +
+ + select( + stepId === null + ? null + : { + node: stepId, + branch: + boxId !== undefined && boxId.startsWith(`${stepId}/`) + ? boxId.slice(stepId.length + 1) + : null, + }, + ) + } + /> +
+ ) : view === "routes" ? ( +
+ select(selectedNode === id ? null : { node: id })} + onMove={() => {}} + onRemove={() => {}} + renderStatus={(node) => { + const state = overlay.steps.get(node.id); + return ( + + + + {state?.label ?? overlay.unseen} + {overlay.complete && state && state.visits > 1 + ? ` · ${state.visits} visits` + : ""} + + + ); + }} + /> +
+ ) : ( +
+ {run.attempts.length === 0 ? ( +

No step visited yet

+ ) : ( + steps + )} +
+ )} +
+ {narrow ? null : ( + + )} +
+ {narrow ? ( + + {inspector} + + ) : null} + + ); +} + +/** One overview fact on a single line; `detail` is the full value, on hover. */ +function Fact({ + props, + label, + detail, + children, +}: { + readonly props: PageProps; + readonly label: string; + readonly detail?: string; + readonly children: string; +}) { + return ( +
+
{label}
+
+ {detail === undefined ? ( + children + ) : ( + + {children} + + )} +
+
+ ); +} + +/** + * The right panel: route history for the whole run, or the selected step's visits with the + * selected visit's evidence. Accepted claims, check output and routing stay separate. + */ +function Inspector({ + props, + run, + projectId, + nodeId, + box, + attempt, + overlay, + firstVisit, + onSelectAttempt, + onTraceOffset, + elsewhere, +}: { + readonly props: PageProps; + readonly run: Run; + readonly projectId: ProjectId; + readonly nodeId: string | null; + readonly box: string | null; + readonly attempt: Attempt | null; + readonly overlay: RunOverlay; + readonly firstVisit: number; + readonly onSelectAttempt: (attempt: Attempt) => void; + readonly onTraceOffset: (offset: number | undefined) => void; + readonly elsewhere: ReactNode; +}) { + const { Button, Badge } = props; + const definition = run.definition; + const node = nodeId === null ? undefined : definition.nodes.find((item) => item.id === nodeId); + if (elsewhere) + return ( +
+

+ Evidence +

+ {elsewhere} +
+ ); + if (nodeId === null) { + const history = run.history; + const traceStart = history?.traceOffset ?? 0; + return ( +
+

+ Route history +

+ {history !== undefined && history.trace > history.limit ? ( + + ) : null} + {run.trace.length === 0 ? ( +

No route taken yet

+ ) : ( +
    + {run.trace.map((item) => ( + + ))} +
+ )} +
+ ); + } + const state = box === null ? undefined : overlay.steps.get(box); + const lane = + node?.kind === "parallel" && box !== null && box.startsWith(`${node.id}/`) + ? node.branches.find((branch) => branch.id === box.slice(node.id.length + 1)) + : undefined; + // Visits of the selected box on the loaded history page, numbered per step; the run-wide + // visit number stays as secondary context. + const visits = run.attempts.flatMap((item, index) => + item.nodeId === nodeId && (lane === undefined || item.branchId === lane.id) + ? [{ attempt: item, overall: firstVisit + index + 1 }] + : [], + ); + // Only agent steps and reviewer lanes run in native threads. + const threaded = node?.kind === "agent" || node?.kind === "parallel"; + // Routing records that consumed this visit come with its page even when route history is + // paged elsewhere; node-level records come from the loaded route-history page. + const related = attempt + ? new Set([attempt.id, `${attempt.id}:report`, ...(attempt.reviewId ? [attempt.reviewId] : [])]) + : null; + const routing = [...(run.relatedTrace ?? []), ...run.trace] + .filter((item) => + related + ? (item.attemptId !== null && related.has(item.attemptId)) || + item.sourceIds.some((id) => related.has(id)) + : item.nodeId === nodeId, + ) + .toSorted((left, right) => left.at - right.at); + const inputs = [...new Set(routing.flatMap((item) => item.sourceIds))]; + // Per-step numbers are only true when the page starts at the run's first visit. + const perStep = firstVisit === 0; + return ( +
+
+

+ {node === undefined ? null : ( + + )} + + {attempt + ? attemptTitle(definition, attempt) + : lane + ? `${node?.title} · ${lane.title}` + : nodeTitle(definition, nodeId)} + +

+ + + {state?.label ?? overlay.unseen} + {state && state.visits > 0 + ? ` · ${state.visits} ${state.visits === 1 ? "visit" : "visits"}${overlay.complete ? "" : " on this page"}` + : ""} + +
+ {visits.length > 1 ? ( +
    + {visits.map(({ attempt: item, overall }, index) => ( +
  1. + +
  2. + ))} +
+ ) : null} + {attempt === null ? ( + visits.length === 0 ? ( +

+ {node?.kind === "decision" || node?.kind === "join" || node?.kind === "human" + ? "Routing step · no agent visit" + : "No visit on this history page"} +

+ ) : null + ) : ( + <> +
+
Execution
+
+ + {phaseLabels[attempt.phase]} + {attempt.generation > 1 ? ` · generation ${attempt.generation}` : ""} + {attempt.resumeCount > 0 ? ` · resumed ${attempt.resumeCount}×` : ""} +
+
Report
+
{reportStatus(attempt)}
+ {attempt.reason === null ? null : ( + <> +
Stop reason
+
{attempt.reason}
+ + )} + {attempt.deadline === null ? null : ( + <> +
Deadline
+
{formatTime(attempt.deadline)}
+ + )} + {attempt.skill === null ? null : ( + <> +
Skill
+
+ {attempt.skill.name} + {attempt.skill.fingerprint + ? ` · ${short(attempt.skill.fingerprint)}` + : attempt.skill.limitation + ? ` · ${attempt.skill.limitation}` + : ""} +
+ + )} +
+ {attempt.threadId ? ( +
+ +
+ ) : threaded ? ( +

No native thread

+ ) : null} + {attempt.report === null ? null : ( +
+

+ Agent report + {attempt.report.outcome} claim + + {clockTime(attempt.report.receipt.acceptedAt)} + +

+

{attempt.report.summary}

+ {Object.keys(attempt.report.data).length ? ( +
+ {Object.entries(attempt.report.data).map(([name, value]) => ( + + ))} +
+ ) : null} + {attempt.report.evidence.length ? ( +
    + {attempt.report.evidence.map((item) => ( +
  • + {item.kind}: {item.reference} +
  • + ))} +
+ ) : null} +
+ )} + {attempt.check === null ? null : ( +
+

+ Check + + {attempt.check.outcome} + + + exit {attempt.check.exitCode ?? "none"} + {attempt.check.timedOut ? " · timed out" : ""} + {attempt.check.interrupted ? " · interrupted" : ""} + +

+ {attempt.check.stdout ? ( +
+                  {attempt.check.stdout}
+                
+ ) : null} + {attempt.check.stderr ? ( +
+                  {attempt.check.stderr}
+                
+ ) : null} +
+ )} + {Object.keys(attempt.input).length ? ( +
+

Recorded input

+
+ {Object.entries(attempt.input).map(([name, value]) => ( + + ))} +
+
+ ) : null} + + )} +
+

Routing

+ {routing.length === 0 ? ( +

+ {attempt + ? isActive(attempt) + ? "Not routed yet" + : "Routing stopped at this visit" + : "No routing record on this page"} +

+ ) : ( +
    + {routing.map((item) => ( + + ))} +
+ )} +
+ {attempt === null ? null : ( +
+ + Details + +
+ + {attempt.threadId ? ( + + ) : null} + {attempt.report ? ( + + ) : null} + {inputs.map((id) => ( + + ))} +
+
+ )} +
+ ); +} + +/** A recorded routing decision as one timeline row; the server's reason is on hover. */ +function RouteRow({ + props, + definition, + item, + detailed = false, +}: { + readonly props: PageProps; + readonly definition: Run["definition"]; + readonly item: Run["trace"][number]; + readonly detailed?: boolean; +}) { + const { Badge } = props; + const limit = repeatLimit(definition, item.nodeId, item.chosen); + const atLimit = limit !== null && item.chosen === limit.atLimit && item.chosen !== limit.to; + const Icon = atLimit + ? TriangleAlertIcon + : item.repeatCount !== null + ? RepeatIcon + : CornerDownRightIcon; + return ( +
  • +
    + + + + + {nodeTitle(definition, item.nodeId)} → {nodeTitle(definition, item.chosen)} + + + + {atLimit ? ( + at limit + ) : item.repeatCount !== null ? ( + + repeat {item.repeatCount} + {limit ? `/${limit.max}` : ""} + + ) : null} + + + {clockTime(item.at)} + + +
    + {detailed && item.considered.length ? ( +
      + {item.considered.map((choice) => ( +
    • + {choice.matched ? ( + + ) : ( + + )} + + {choice.matched ? "Matched: " : "Did not match: "} + {predicateText(choice.predicate)} + +
    • + ))} +
    + ) : null} +
  • + ); +} + +/** An internal identifier with a copy action, kept out of the way under Details. */ +function Identifier({ + props, + label, + value, +}: { + readonly props: PageProps; + readonly label: string; + readonly value: string; +}) { + return ( +
    +
    {label}
    +
    {value}
    + void navigator.clipboard?.writeText(value)} + > + + +
    + ); +} + +function Pair({ name, value }: { readonly name: string; readonly value: string }) { + return ( + <> +
    {name}
    +
    {value}
    + + ); +} diff --git a/packages/plugin-workflows/src/web/Runs.tsx b/packages/plugin-workflows/src/web/Runs.tsx new file mode 100644 index 000000000000..f3ab4beffc5c --- /dev/null +++ b/packages/plugin-workflows/src/web/Runs.tsx @@ -0,0 +1,298 @@ +import type { PluginBreadcrumbItem } from "@t3tools/plugin-host-contract/web"; +import type { ProjectId } from "@t3tools/plugin-host-contract/schema"; +import { FolderIcon, HistoryIcon, PlayIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { RunSummary } from "../contracts.ts"; +import { + ProjectsAlert, + errorMessage, + projectCrumb, + usePermissions, + useProjects, + type PageProps, +} from "./common.tsx"; +import { RunStateIcon } from "./kinds.tsx"; +import { RunView } from "./RunView.tsx"; +import { StartDialog } from "./Start.tsx"; +import { formatTime, nextAction, runStateLabels, runStateVariant } from "./run.ts"; + +export function RunsPageView(props: PageProps) { + return ; +} + +function Runs(props: PageProps) { + const { projectId, pageState, Button, PageHeader, ListGroup, ListRow, Empty } = props; + const projects = useProjects(props); + const permissions = usePermissions(props.client); + const runId = pageState.run ?? null; + const library = () => + props.navigate( + projectId === null + ? { pageId: "workflows.library" } + : { pageId: "workflows.library", projectId }, + ); + const crumbs: ReadonlyArray = [ + projectCrumb(props, projects.projects, "workflows.runs"), + { label: "Workflows", onSelect: library }, + ]; + const start = (primary: boolean) => + projectId === null ? null : ( + + ); + const dialog = + projectId === null || pageState.start !== "1" ? null : ( + project.id === projectId)?.title ?? projectId + } + workflow={pageState.workflow} + permissions={permissions} + onClose={() => + props.navigate({ + pageId: "workflows.runs", + projectId, + ...(runId === null ? {} : { state: { run: runId } }), + }) + } + /> + ); + if (projectId !== null && runId !== null) + return ( + <> + [ + ...crumbs, + { + label: "Runs", + onSelect: () => props.navigate({ pageId: "workflows.runs", projectId }), + }, + { label: title }, + ]} + runWorkflow={start(false)} + projectsAlert={ + projects.error === null ? null : + } + /> + {dialog} + + ); + return ( + <> + {start(true)} +
    +
    + + {projectId === null ? ( + projects.projects === null ? ( + projects.error === null ? ( +

    + Loading projects… +

    + ) : null + ) : projects.projects.length === 0 ? ( + } /> + ) : ( + + {projects.projects.map((project) => ( + } + onOpen={() => + props.navigate({ pageId: "workflows.runs", projectId: project.id }) + } + /> + ))} + + ) + ) : ( + + props.navigate({ pageId: "workflows.runs", projectId, state: { start: "1" } }) + } + /> + )} +
    +
    + {dialog} + + ); +} + +/** Latest runs live, then older pages on request; any run opens its own live view. */ +function History({ + props, + projectId, + onStart, +}: { + readonly props: PageProps; + readonly projectId: ProjectId; + readonly onStart: () => void; +}) { + const { client, connection, Button, Badge, Alert, Empty, ListGroup, ListRow } = props; + const offline = connection === "disconnected"; + const [latest, setLatest] = useState | null>(null); + const [error, setError] = useState(null); + const [attempt, setAttempt] = useState(0); + const [older, setOlder] = useState>([]); + const [olderState, setOlderState] = useState< + | { readonly kind: "idle" | "loading" | "end" } + | { readonly kind: "failed"; readonly message: string } + >({ kind: "idle" }); + useEffect(() => { + if (offline) return; + setError(null); + return client.subscribeRuns( + projectId, + (runs) => { + setError(null); + setLatest((previous) => { + // Once older pages are shown, runs pushed out of the latest page join them, so the + // boundary between the live page and loaded older pages never loses a run. + const dropped = (previous ?? []).filter( + (run) => !runs.some((item) => item.id === run.id), + ); + if (dropped.length > 0) + setOlder((current) => + current.length === 0 + ? current + : [ + ...dropped, + ...current.filter((run) => !dropped.some((item) => item.id === run.id)), + ], + ); + return runs; + }); + }, + setError, + ); + }, [client, projectId, offline, attempt]); + const runs = [ + ...(latest ?? []), + ...older.filter((run) => !latest?.some((item) => item.id === run.id)), + ]; + const loadOlder = () => { + const last = runs.at(-1); + if (last === undefined) return; + setOlderState({ kind: "loading" }); + client.runs({ projectId, before: last.id }).then( + (page) => { + setOlder((current) => [...current, ...page]); + setOlderState({ kind: page.length < 20 ? "end" : "idle" }); + }, + (cause: unknown) => setOlderState({ kind: "failed", message: errorMessage(cause) }), + ); + }; + return ( + <> + {error === null ? null : ( + setAttempt((value) => value + 1)}> + Retry + + } + > + {error} + + )} + {latest === null ? ( + offline ? ( + + ) : error === null ? ( +

    + Loading runs… +

    + ) : null + ) : runs.length === 0 ? ( + }> + + + ) : ( + + {runs.map((run) => { + const needsYou = run.state === "awaiting-review" || run.state === "unresolved"; + return ( + } + openLabel={`Open run ${run.definition.title} from ${formatTime(run.createdAt)}`} + onOpen={() => + props.navigate({ pageId: "workflows.runs", projectId, state: { run: run.id } }) + } + badges={ + <> + {runStateLabels[run.state]} + {run.source?.trigger === "schedule" ? ( + Scheduled + ) : null} + + } + description={[ + formatTime(run.createdAt), + `Revision ${run.definition.revision}`, + `${run.visits} ${run.visits === 1 ? "visit" : "visits"}`, + needsYou ? nextAction(run) : null, + ] + .filter((part) => part !== null) + .join(" · ")} + /> + ); + })} + + )} + {offline && latest !== null ? ( +

    + Last loaded runs +

    + ) : null} + {latest !== null && latest.length >= 20 && olderState.kind !== "end" ? ( +
    + +
    + ) : null} + {olderState.kind === "failed" ? ( + + {olderState.message} + + ) : null} + + ); +} diff --git a/packages/plugin-workflows/src/web/Start.tsx b/packages/plugin-workflows/src/web/Start.tsx new file mode 100644 index 000000000000..4073975ba353 --- /dev/null +++ b/packages/plugin-workflows/src/web/Start.tsx @@ -0,0 +1,499 @@ +import type { ProjectId } from "@t3tools/plugin-host-contract/schema"; +import { BotIcon, GitBranchIcon, FolderIcon } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + taskLimit, + type LibraryEntry, + type StartPreview, + type StartSavedInput, + type WorkflowPermissions, +} from "../contracts.ts"; +import { errorMessage, rejected, usePermissions, useProjects, type PageProps } from "./common.tsx"; +import { authoringTimings, debounce } from "./timings.ts"; +import { short } from "./run.ts"; + +type Intent = Omit; +const intentKey = (projectId: ProjectId) => `start:${projectId}`; +/** Stable for one start intent; generated without `crypto.randomUUID`, which insecure origins lack. */ +const requestId = () => + `start-${Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => + byte.toString(16).padStart(2, "0"), + ).join("")}`; +function readIntent(props: PageProps, projectId: ProjectId): Intent | null { + const raw = props.drafts.read(intentKey(projectId)); + if (raw === null) return null; + try { + const value = JSON.parse(raw) as Partial; + return typeof value.clientRequestId === "string" && + typeof value.definitionId === "string" && + typeof value.revision === "number" && + typeof value.task === "string" && + (value.workspace === "new-worktree" || value.workspace === "current") + ? (value as Intent) + : null; + } catch { + return null; + } +} + +type Status = + | { readonly kind: "idle" } + | { readonly kind: "starting" } + | { readonly kind: "failed"; readonly message: string } + | { readonly kind: "rejected"; readonly message: string }; + +const entryLabel = (entry: LibraryEntry) => entry.title ?? entry.definitionId ?? entry.source; + +/** + * The one Run workflow dialog for project and library entry points. The server resolves the + * saved identity and revision; this form only names them and the free-form task. + */ +export function StartDialog({ + props, + projectId, + projectTitle, + workflow, + permissions, + onClose, +}: { + readonly props: PageProps; + readonly projectId: ProjectId; + readonly projectTitle: string; + /** Preselected workflow, for example the library row it was opened from. */ + readonly workflow?: string | undefined; + readonly permissions: WorkflowPermissions; + readonly onClose: () => void; +}) { + const { client, connection, Button, Badge, Combobox, SegmentedControl, Textarea, Alert } = props; + const offline = connection === "disconnected"; + const [intent, setIntent] = useState(() => readIntent(props, projectId)); + const [definitionId, setDefinitionId] = useState( + intent?.definitionId ?? workflow ?? null, + ); + const [task, setTask] = useState(intent?.task ?? ""); + const [workspace, setWorkspace] = useState( + intent?.workspace ?? "new-worktree", + ); + const [query, setQuery] = useState(""); + const [search, setSearch] = useState(""); + const [entries, setEntries] = useState | null>(null); + // The chosen entry stays nameable while a search narrows it out of the results. + const [chosen, setChosen] = useState(null); + const [entriesError, setEntriesError] = useState(null); + const [entriesAttempt, setEntriesAttempt] = useState(0); + const [preview, setPreview] = useState(null); + const [previewError, setPreviewError] = useState(null); + const [previewAttempt, setPreviewAttempt] = useState(0); + const [status, setStatus] = useState({ kind: "idle" }); + const inFlight = useRef(false); + + useEffect(() => { + if (query === search) return; + return debounce(() => setSearch(query), authoringTimings.searchDelayMs); + }, [query, search]); + useEffect(() => { + if (offline) return; + let active = true; + setEntriesError(null); + client + .library({ projectId, limit: 50, ...(search.trim() === "" ? {} : { query: search.trim() }) }) + .then( + (page) => { + if (active) + setEntries( + page.entries.filter((entry) => entry.definitionId !== null && !entry.duplicate), + ); + }, + (cause: unknown) => { + if (active) setEntriesError(errorMessage(cause)); + }, + ); + return () => { + active = false; + }; + }, [client, projectId, search, offline, entriesAttempt]); + useEffect(() => { + if (definitionId === null || offline) return; + let active = true; + setPreviewError(null); + client.preview({ projectId, definitionId }).then( + (value) => { + if (active) setPreview(value); + }, + (cause: unknown) => { + if (active) { + setPreview(null); + setPreviewError(errorMessage(cause)); + } + }, + ); + return () => { + active = false; + }; + }, [client, projectId, definitionId, offline, previewAttempt]); + + const forget = useCallback(() => { + props.drafts.remove(intentKey(projectId)); + setIntent(null); + }, [props.drafts, projectId]); + const submit = useCallback( + (request: Intent) => { + if (inFlight.current) return; + inFlight.current = true; + setStatus({ kind: "starting" }); + client.startSaved({ projectId, ...request }).then( + (run) => { + inFlight.current = false; + forget(); + setStatus({ kind: "idle" }); + props.navigate({ pageId: "workflows.runs", projectId, state: { run: run.id } }); + }, + (cause: unknown) => { + inFlight.current = false; + // A rejected request committed nothing, so its identity is released; the task stays. + if (rejected(cause, "start")) { + forget(); + setStatus({ kind: "rejected", message: errorMessage(cause) }); + setPreviewAttempt((value) => value + 1); + } else setStatus({ kind: "failed", message: errorMessage(cause) }); + }, + ); + }, + [client, projectId, forget, props], + ); + // A stored intent is reconciled once connected: the server returns its run if it started. + // It is resent automatically only when this dialog was opened for the same workflow; + // opening Run for another workflow shows it for an explicit Retry or Discard instead. + const reconciled = useRef(false); + const requested = workflow; + const other = intent !== null && requested !== undefined && requested !== intent.definitionId; + useEffect(() => { + // Each reconnection reconciles a still-pending intent once. + if (offline) reconciled.current = false; + if (offline || intent === null || other || reconciled.current || !permissions.start) return; + reconciled.current = true; + submit(intent); + }, [offline, intent, other, permissions.start, submit]); + + const run = () => { + // A pending intent is resent unchanged; the server answers from its receipt first. + if (intent !== null) return submit(intent); + if (preview === null) return; + const next: Intent = { + clientRequestId: requestId(), + definitionId: preview.definitionId, + revision: preview.revision, + task, + workspace, + }; + if (!props.drafts.write(intentKey(projectId), JSON.stringify(next))) { + setStatus({ + kind: "rejected", + message: "This browser could not keep the start request. Free some site storage.", + }); + return; + } + // This send is the intent's reconciliation; only a reconnect or reopening resends it. + reconciled.current = true; + setIntent(next); + submit(next); + }; + const discard = () => { + forget(); + setStatus({ kind: "idle" }); + // The dialog returns to the workflow it was opened for. + if (requested !== undefined && requested !== definitionId) { + setDefinitionId(requested); + setPreview(null); + } + }; + const locked = intent !== null; + const starting = status.kind === "starting"; + const blocked = + offline || + !permissions.start || + starting || + (intent === null && (preview === null || !preview.runnable)); + const picked = + entries?.find((entry) => entry.definitionId === definitionId) ?? + (chosen?.definitionId === definitionId ? chosen : null); + const options = [ + ...(picked !== null && !entries?.includes(picked) ? [picked] : []), + ...(entries ?? []), + ].map((entry) => ({ + value: entry.definitionId!, + label: entryLabel(entry), + detail: entry.runnable ? `Revision ${entry.revision}` : "Needs attention", + })); + const where = + preview === null + ? null + : workspace === "new-worktree" + ? `From ${preview.workspace.branch ?? "the current commit"}${preview.workspace.head ? ` at ${short(preview.workspace.head)}` : ""}` + : `Works in ${preview.workspace.path}${preview.workspace.branch ? ` on ${preview.workspace.branch}` : ""} with its local changes`; + const footer = ( + <> + {locked && !starting ? ( + + ) : null} + + + + ); + return ( + { + if (!open && !starting) onClose(); + }} + title="Run workflow" + description={`${projectTitle} · ${props.environmentLabel}`} + footer={footer} + > +
    +
    + + {locked ? ( +

    {preview?.title ?? intent.definitionId}

    + ) : ( + { + setChosen(entries?.find((entry) => entry.definitionId === value) ?? null); + setDefinitionId(value); + setPreview(null); + setStatus({ kind: "idle" }); + }} + options={options} + placeholder={ + definitionId ?? (entries === null ? "Loading workflows…" : "Choose a workflow") + } + searchPlaceholder="Search workflows" + emptyText={search.trim() ? "No matching workflows" : "No saved workflows yet"} + query={query} + onQueryChange={setQuery} + /> + )} +
    + {entriesError === null ? null : ( + setEntriesAttempt((value) => value + 1)} + > + Retry + + } + > + {entriesError} + + )} + {/* Workspace and Task stay in place while a workflow is picked and loads, so the + dialog does not grow under the pointer; only the summary line fills in. */} + {definitionId !== null && previewError !== null ? ( + setPreviewAttempt((value) => value + 1)} + > + Retry + + } + > + {previewError} + + ) : ( +
    + {preview === null ? ( + definitionId === null || offline ? null : ( +

    + Loading the saved workflow… +

    + ) + ) : ( + <> + Saved revision {preview.revision} + {intent !== null && intent.revision !== preview.revision ? ( + Requested revision {intent.revision} + ) : null} + {preview.packaged ? Packaged : null} + {preview.runnable ? null : Cannot run} + + {preview.source} + + + )} +
    + )} + {preview === null || preview.reasons.length === 0 ? null : ( + +
      + {preview.reasons.map((reason, index) => ( +
    • {reason}
    • + ))} +
    +
    + )} + {preview === null || preview.agents.length === 0 ? null : ( +
      + {preview.agents.map((agent) => ( +
    • + + + {agent.title}: {agent.providerName ?? agent.providerInstanceId} · {agent.model} ·{" "} + {agent.runtimeMode} + {agent.interactionMode === "plan" ? " · plan mode" : ""} + {agent.skill ? ` · skill ${agent.skill}` : ""} + +
    • + ))} +
    + )} +
    + Workspace + {locked ? ( +

    + {workspace === "new-worktree" ? "New worktree" : "Current checkout"} +

    + ) : ( +
    + setWorkspace(value as Intent["workspace"])} + options={[ + { value: "new-worktree", label: "New worktree" }, + { value: "current", label: "Current checkout" }, + ]} + /> +
    + )} +

    + {where === null ? null : ( + <> + {workspace === "new-worktree" ? ( + + ) : ( + + )} + {where} + + )} +

    +
    +
    +
    + + + {task.length}/{taskLimit} + +
    +