diff --git a/apps/server/src/plugins/Workflows.testkit.ts b/apps/server/src/plugins/Workflows.testkit.ts index 09d275452d6f..7aed770c4295 100644 --- a/apps/server/src/plugins/Workflows.testkit.ts +++ b/apps/server/src/plugins/Workflows.testkit.ts @@ -52,6 +52,8 @@ export const fixture = Effect.gen(function* () { let head = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; let dirty = false; let providerAvailable = true; + let forgeAvailable = true; + let redactions = 0; let lostAcknowledgement = false; let checkExecution = Effect.succeed({ exitCode: 0, @@ -68,7 +70,11 @@ export const fixture = Effect.gen(function* () { const host = Host.of({ environmentId, cancelPending: () => Effect.void, - redact: (input) => Effect.succeed(input.text), + redact: (input) => + Effect.sync(() => { + redactions++; + return input.text; + }), projects: () => Effect.succeed([{ id: projectId, title: "Workflow", workspaceRoot: directory }]), providers: () => @@ -94,7 +100,19 @@ export const fixture = Effect.gen(function* () { yield* Queue.offer(checkStarts, input.command); return yield* checkExecution; }), - verifyPullRequestHead: () => Effect.succeed({ head, branch: "feature" }), + verifyPullRequestHead: () => + Effect.suspend(() => + forgeAvailable + ? Effect.succeed({ head, branch: "feature" }) + : Effect.fail( + new PluginError({ + pluginId: "host", + code: "unavailable", + operation: "verify-pull-request-head", + message: "The forge is unreachable.", + }), + ), + ), receipt: (id) => Effect.succeed(receipts.get(id) ?? null), retryPreparation: () => Effect.fail( @@ -240,6 +258,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 +306,7 @@ export const fixture = Effect.gen(function* () { query, reconcile, invoke, + stream, report, launches, commands, @@ -291,6 +322,10 @@ export const fixture = Effect.gen(function* () { holdCheck: () => { checkExecution = Effect.never; }, + /** Result the next check commands return. */ + setCheck: (result: { exitCode: number; timedOut: boolean; stdout: string }) => { + checkExecution = Effect.succeed({ ...result, stderr: "" }); + }, threads, restart: Effect.gen(function* () { yield* runtime.close; @@ -327,6 +362,14 @@ export const fixture = Effect.gen(function* () { setHead: (value: string) => { head = value; }, + /** Display reads redact through the host; counting them shows when a view re-read. */ + redactions: () => redactions, + /** Whether the forge can verify the pull request head. */ + setForgeAvailable: (value: boolean) => { + forgeAvailable = value; + }, + /** The host's attention stream of the currently running plugin (after any restart). */ + hostAttention: Stream.unwrap(Effect.sync(() => runtime.registry.attention(environmentId))), setDirty: (value: boolean) => { dirty = value; }, diff --git a/apps/server/src/plugins/WorkflowsAttentionIsolation.integration.test.ts b/apps/server/src/plugins/WorkflowsAttentionIsolation.integration.test.ts new file mode 100644 index 000000000000..2562e58dc67a --- /dev/null +++ b/apps/server/src/plugins/WorkflowsAttentionIsolation.integration.test.ts @@ -0,0 +1,197 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Host } from "@t3tools/plugin-host-contract/server"; +import { CommandId, EventId, MessageId, NodeId, RuntimeRequestId } from "@t3tools/contracts"; +import { AttentionPage, Run } from "@t3tools/plugin-workflows/contracts"; +import { sequence } from "./Workflows.testkit.ts"; +import { makeCoreWorkflowFixture } from "./WorkflowsCore.testkit.ts"; + +const decodeRun = Schema.decodeUnknownEffect(Run); +const decodePage = Schema.decodeUnknownEffect(AttentionPage); + +it.live( + "keeps workflow attention when its thread is read or snoozed and clears each request on its own answer", + () => + Effect.scoped( + Effect.gen(function* () { + const test = yield* makeCoreWorkflowFixture; + // The replayed provider reports its execution interrupted once the test says so; the + // thread, its requests, read state and snooze are real core state. + let interrupted = false; + const runtime = yield* test.boot( + Host.of({ + ...test.core, + lifecycle: () => Stream.never, + inspect: (target) => + test.core.inspect(target).pipe( + Effect.map((state) => + interrupted + ? { + ...state, + runs: state.runs.map((run) => ({ ...run, status: "interrupted" })), + resultRunId: state.runs.at(-1)?.id ?? null, + outstandingWork: [], + checkpoints: [], + } + : state, + ), + ), + }), + ); + const attention = runtime + .invoke("attention-page", test.scope) + .pipe(Effect.flatMap(decodePage)); + const started = yield* runtime + .invoke("start", { + ...test.scope, + definition: sequence, + clientRequestId: "start", + input: {}, + workspace: { type: "current" }, + }) + .pipe(Effect.flatMap(decodeRun)); + yield* runtime.invoke("reconcile", test.scope); + const threadId = started.attempts[0]!.threadId!; + yield* test.threads.dispatch({ + type: "message.dispatch", + threadId, + commandId: CommandId.make("deferred"), + messageId: MessageId.make("message"), + text: "Provider native execution", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "agent", + creationSource: "mcp", + }); + const records = yield* test.threads.getProjectThreadRecords({ ...test.scope, threadId }, [ + "runs", + ]); + const began = DateTime.nowUnsafe(); + const native = { ...records.runs[0]!, status: "waiting" as const, startedAt: began }; + // Two questions wait in the thread's native queue, answered in order. + const request = (id: string, offset: number) => ({ + id: RuntimeRequestId.make(id), + nodeId: NodeId.make(`${id}-node`), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input" as const, + status: "pending" as const, + responseCapability: { type: "message" as const }, + createdAt: DateTime.makeUnsafe(DateTime.toEpochMillis(began) + offset), + resolvedAt: null, + }); + const approval = request("approval", 0); + const question = request("question", 1); + yield* test.sink.write({ + events: [ + { + id: EventId.make("native-wait-run"), + type: "run.updated", + threadId, + runId: native.id, + occurredAt: began, + payload: native, + }, + ...[approval, question].map((item) => ({ + id: EventId.make(`${item.id}-pending`), + type: "runtime-request.updated" as const, + threadId, + occurredAt: began, + payload: item, + })), + ], + }); + yield* runtime.invoke("reconcile", test.scope); + const waiting = yield* attention; + expect(waiting.total).toBe(1); + const items = waiting.runs[0]!.items; + expect( + items.map((item) => [item.kind, item.threadId, item.request?.id, item.request?.position]), + ).toEqual([ + ["needs-input", threadId, "approval", 1], + ["needs-input", threadId, "question", 2], + ]); + + // Reading the thread is presentation; nothing workflow-owned moves. Native + // attention keeps its priority: a thread with a pending request cannot be snoozed. + const visit = (id: string) => + test.threads.dispatch({ + type: "thread.visit", + commandId: CommandId.make(id), + threadId, + visitedAt: DateTime.formatIso(DateTime.nowUnsafe()), + }); + const snooze = (id: string) => + test.threads.dispatch({ + type: "thread.snooze", + commandId: CommandId.make(id), + threadId, + snoozedUntil: DateTime.formatIso(DateTime.add(DateTime.nowUnsafe(), { hours: 4 })), + }); + yield* visit("visit"); + expect((yield* Effect.result(snooze("snooze-refused")))._tag).toBe("Failure"); + yield* runtime.invoke("reconcile", test.scope); + expect(yield* attention).toEqual(waiting); + const unchanged = yield* runtime + .invoke("get", { ...test.scope, runId: started.id }) + .pipe(Effect.flatMap(decodeRun)); + expect(unchanged.attempts[0]).toMatchObject({ phase: "waiting-input", report: null }); + + // Each item clears only on its own native resolution. + const answered = DateTime.nowUnsafe(); + yield* test.sink.write({ + events: [ + { + id: EventId.make("approval-resolved"), + type: "runtime-request.updated", + threadId, + occurredAt: answered, + payload: { ...approval, status: "resolved", resolvedAt: answered }, + }, + ], + }); + yield* runtime.invoke("reconcile", test.scope); + const remaining = (yield* attention).runs[0]!.items; + expect(remaining.map((item) => [item.id, item.request?.position])).toEqual([ + [items[1]!.id, 1], + ]); + // The last answer arrives, then native execution is interrupted: the run stops for a person. + yield* test.sink.write({ + events: [ + { + id: EventId.make("question-resolved"), + type: "runtime-request.updated", + threadId, + occurredAt: answered, + payload: { ...question, status: "resolved", resolvedAt: answered }, + }, + ], + }); + interrupted = true; + yield* runtime.invoke("reconcile", test.scope); + const stopped = yield* attention; + expect(stopped.runs[0]!.items).toEqual([ + expect.objectContaining({ + kind: "interrupted", + attemptId: started.attempts[0]!.id, + threadId, + request: null, + }), + ]); + // Without a pending request the thread can be snoozed; the workflow decision stays. + yield* snooze("snooze"); + yield* visit("visit-again"); + yield* runtime.invoke("reconcile", test.scope); + expect(yield* attention).toEqual(stopped); + expect( + (yield* runtime + .invoke("get", { ...test.scope, runId: started.id }) + .pipe(Effect.flatMap(decodeRun))).state, + ).toBe("unresolved"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/plugins/WorkflowsDecisions.test.ts b/apps/server/src/plugins/WorkflowsDecisions.test.ts new file mode 100644 index 000000000000..9add1e0dc951 --- /dev/null +++ b/apps/server/src/plugins/WorkflowsDecisions.test.ts @@ -0,0 +1,434 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { CatalogEntry, Definition, Run } from "@t3tools/plugin-workflows/contracts"; +import { completed, fixture, sequence } from "./Workflows.testkit.ts"; + +const decodeRun = Schema.decodeUnknownEffect(Run); +const decodeEntry = Schema.decodeUnknownEffect(CatalogEntry); +const decodeDefinition = Schema.decodeUnknownEffect(Definition); +const implement = sequence.nodes[0]!; + +/** Implement, run checks, then route on the recorded check result with one bounded repeat. */ +const checked = (maxVisits?: number) => + decodeDefinition({ + version: 1, + id: "checked", + revision: 1, + title: "Checked implementation", + entry: "implement", + atLimit: "review", + ...(maxVisits === undefined ? {} : { maxVisits }), + nodes: [ + { ...implement, next: { to: "checks" } }, + { + id: "checks", + kind: "check", + title: "Checks", + command: "project-check", + args: [], + next: { to: "decide" }, + }, + { + id: "decide", + kind: "decision", + title: "Check result", + source: "checks", + rules: [ + { + when: { + op: "all", + terms: [ + { op: "eq", path: "outcome", value: "completed" }, + { + op: "any", + terms: [ + { op: "eq", path: "exitCode", value: 0 }, + { op: "eq", path: "timedOut", value: true }, + ], + }, + ], + }, + route: { to: "review" }, + }, + ], + otherwise: { to: "implement", repeat: { max: 1, atLimit: "review" } }, + }, + { + id: "review", + kind: "human", + title: "Human review", + approve: { to: "done" }, + changes: { to: "stopped" }, + }, + { id: "done", kind: "end", title: "Done", outcome: "completed" }, + { id: "stopped", kind: "end", title: "Changes requested", outcome: "failed" }, + ], + }); + +it.effect( + "routes on recorded check results through an initial visit, one repeat and At limit, then a human decision", + () => + Effect.scoped( + Effect.gen(function* () { + const test = yield* fixture; + const scope = { environmentId: test.environmentId, projectId: test.projectId }; + const definition = yield* checked(); + expect(yield* test.invoke("validate", { ...scope, definition })).toMatchObject({ + runnable: true, + problems: [], + }); + test.setCheck({ exitCode: 1, timedOut: false, stdout: "1 test failed" }); + const started = yield* test.start(definition); + const implementOnce = Effect.fnUntraced(function* (visit: number) { + yield* test.reconcile; + const run = yield* test.wait( + started.id, + (run) => + run.attempts.filter((attempt) => attempt.nodeId === "implement").length === visit && + run.attempts.at(-1)?.phase === "running", + ); + const threadId = run.attempts.at(-1)!.threadId!; + yield* test.report(threadId, completed); + test.settle(threadId); + yield* test.reconcile; + }); + + // Initial visit: the failed check result drives the decision to its Otherwise repeat. + yield* implementOnce(1); + const repeated = yield* test.wait(started.id, (run) => + run.trace.some((item) => item.nodeId === "decide"), + ); + const check = repeated.attempts.find((attempt) => attempt.nodeId === "checks")!; + expect(check).toMatchObject({ + phase: "failed", + check: { outcome: "failed", exitCode: 1, stdout: "1 test failed" }, + }); + expect(repeated.trace.find((item) => item.nodeId === "checks")).toMatchObject({ + route: "next", + chosen: "decide", + sourceIds: [check.id], + }); + expect(repeated.trace.find((item) => item.nodeId === "decide")).toMatchObject({ + route: "otherwise", + chosen: "implement", + considered: [{ matched: false }], + reason: "Admitted a bounded repeat.", + repeatCount: 1, + repeat: { max: 1, atLimit: "review", exhausted: false }, + }); + + // A restart keeps the counter; the repeated visit's failure exhausts it. + yield* test.restart; + yield* implementOnce(2); + const limited = yield* test.wait(started.id, (run) => run.state === "awaiting-review"); + expect(limited.repeats).toEqual({ "decide:implement": 1 }); + expect(limited.attempts.filter((attempt) => attempt.nodeId === "implement")).toHaveLength( + 2, + ); + expect(limited.trace.at(-1)).toMatchObject({ + nodeId: "decide", + route: "otherwise", + chosen: "review", + reason: "The repeat limit was reached.", + repeatCount: 1, + repeat: { max: 1, atLimit: "review", exhausted: true }, + }); + expect(limited).toMatchObject({ + automationStopped: true, + gate: { nodeId: "review" }, + allowedActions: ["cancel", "approve", "request-changes"], + }); + yield* test.restart; + const reloaded = yield* test.query(started.id); + expect(reloaded.repeats).toEqual(limited.repeats); + expect(reloaded.trace).toEqual(limited.trace); + + // The human decision follows its authored destination and is recorded with its route. + const decided = yield* test + .invoke("gate", { + ...scope, + runId: started.id, + clientRequestId: "changes", + expectedRevision: reloaded.revision, + decision: "request-changes", + }) + .pipe(Effect.flatMap(decodeRun)); + expect(decided).toMatchObject({ state: "failed", currentNode: "stopped" }); + expect(decided.trace.at(-1)).toMatchObject({ + nodeId: "review", + route: "changes", + chosen: "stopped", + reason: "Human decision: request-changes.", + }); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("takes the first matching nested rule on a passing check and approves to its end", () => + Effect.scoped( + Effect.gen(function* () { + const test = yield* fixture; + const scope = { environmentId: test.environmentId, projectId: test.projectId }; + test.setCheck({ exitCode: 0, timedOut: false, stdout: "ok" }); + const started = yield* test.start(yield* checked()); + yield* test.reconcile; + const running = yield* test.wait(started.id, (run) => run.attempts[0]?.phase === "running"); + yield* test.report(running.attempts[0]!.threadId!, completed); + test.settle(running.attempts[0]!.threadId!); + yield* test.reconcile; + const review = yield* test.wait(started.id, (run) => run.state === "awaiting-review"); + expect(review.trace.at(-1)).toMatchObject({ + nodeId: "decide", + route: "rules.0", + chosen: "review", + considered: [{ matched: true }], + reason: "First matching rule.", + repeatCount: null, + }); + expect(review.trace.at(-1)?.repeat).toBeUndefined(); + expect(review).toMatchObject({ automationStopped: false, repeats: {} }); + const approved = yield* test + .invoke("gate", { + ...scope, + runId: started.id, + clientRequestId: "approve", + expectedRevision: review.revision, + decision: "approve", + }) + .pipe(Effect.flatMap(decodeRun)); + expect(approved).toMatchObject({ state: "completed", currentNode: "done" }); + expect(approved.trace.at(-1)).toMatchObject({ route: "approve", chosen: "done" }); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("sends the run to At limit when the whole-run visit bound is exhausted", () => + Effect.scoped( + Effect.gen(function* () { + const test = yield* fixture; + test.setCheck({ exitCode: 0, timedOut: false, stdout: "ok" }); + // Implement and the check use both visits; the decision would be the third. + const started = yield* test.start(yield* checked(2)); + yield* test.reconcile; + const running = yield* test.wait(started.id, (run) => run.attempts[0]?.phase === "running"); + yield* test.report(running.attempts[0]!.threadId!, completed); + test.settle(running.attempts[0]!.threadId!); + yield* test.reconcile; + const limited = yield* test.wait(started.id, (run) => run.state === "awaiting-review"); + expect(limited).toMatchObject({ automationStopped: true, currentNode: "review" }); + expect(limited.trace.at(-1)).toMatchObject({ + nodeId: "checks", + route: "next", + chosen: "review", + reason: "The whole-run visit limit was reached.", + }); + expect(limited.trace.some((item) => item.nodeId === "decide")).toBe(false); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "locates invalid operands, removed fields and repeat targets at their exact controls", + () => + Effect.scoped( + Effect.gen(function* () { + const test = yield* fixture; + const scope = { environmentId: test.environmentId, projectId: test.projectId }; + const valid = yield* checked(); + const definition = yield* decodeDefinition({ + ...valid, + nodes: valid.nodes.flatMap((node): ReadonlyArray => + node.kind === "decision" + ? [ + { + ...node, + rules: [ + { + when: { + op: "all", + terms: [ + { op: "eq", path: "outcome", value: "passed" }, + { + op: "any", + terms: [ + { op: "gt", path: "timedOut", value: 1 }, + { op: "eq", path: "data.removed", value: true }, + ], + }, + ], + }, + route: { to: "gone" }, + }, + ], + otherwise: { to: "implement", repeat: { max: 1, atLimit: "done-removed" } }, + }, + ] + : [node], + ), + }); + const entry = yield* test + .invoke("validate", { ...scope, definition }) + .pipe(Effect.flatMap(decodeEntry)); + expect(entry.runnable).toBe(false); + const located = (entry.problems ?? []).map(({ severity, nodeId, control }) => ({ + severity, + nodeId, + control, + })); + for (const control of [ + "rules.0", + "otherwise.repeat", + "rules.0.when.0", + "rules.0.when.1.0", + "rules.0.when.1.1", + ]) + expect(located).toContainEqual({ severity: "error", nodeId: "decide", control }); + expect( + yield* test + .invoke("save", { ...scope, definition, expectedRevision: null }) + .pipe(Effect.flip, Effect.orDie), + ).toMatchObject({ code: "validation" }); + expect(test.launches).toHaveLength(0); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("does not spend a repeat that the whole-run visit limit diverts", () => + Effect.scoped( + Effect.gen(function* () { + const test = yield* fixture; + test.setCheck({ exitCode: 1, timedOut: false, stdout: "1 test failed" }); + // Implement, the check and the decision use all three visits; the repeat cannot run. + const started = yield* test.start(yield* checked(3)); + yield* test.reconcile; + const running = yield* test.wait(started.id, (run) => run.attempts[0]?.phase === "running"); + yield* test.report(running.attempts[0]!.threadId!, completed); + test.settle(running.attempts[0]!.threadId!); + yield* test.reconcile; + const limited = yield* test.wait(started.id, (run) => run.state === "awaiting-review"); + expect(limited).toMatchObject({ automationStopped: true, repeats: {} }); + expect(limited.trace.at(-1)).toMatchObject({ + nodeId: "decide", + route: "otherwise", + chosen: "review", + reason: "The whole-run visit limit was reached.", + repeatCount: 0, + repeat: { max: 1, atLimit: "review", exhausted: false, outcome: "visit-limit" }, + }); + expect(limited.attempts.filter((attempt) => attempt.nodeId === "implement")).toHaveLength(1); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "withholds a human gate's Request changes repeat that the whole-run visit limit would divert", + () => + Effect.scoped( + Effect.gen(function* () { + const test = yield* fixture; + const scope = { environmentId: test.environmentId, projectId: test.projectId }; + // Implement and the gate use both visits, so Request changes cannot repeat Implement. + const started = yield* test.start(yield* decodeDefinition({ ...sequence, maxVisits: 2 })); + yield* test.reconcile; + const running = yield* test.wait(started.id, (run) => run.attempts[0]?.phase === "running"); + yield* test.report(running.attempts[0]!.threadId!, completed); + test.settle(running.attempts[0]!.threadId!); + yield* test.reconcile; + const gate = yield* test.wait(started.id, (run) => run.state === "awaiting-review"); + expect(gate).toMatchObject({ + automationStopped: false, + allowedActions: ["cancel", "approve"], + withheld: [ + { action: "request-changes", to: "implement", repeat: true, cause: "visit-limit" }, + ], + }); + // A direct request is refused rather than silently returning to the same gate. + expect( + yield* test + .invoke("gate", { + ...scope, + runId: started.id, + clientRequestId: "changes", + expectedRevision: gate.revision, + decision: "request-changes", + }) + .pipe(Effect.flip, Effect.orDie), + ).toMatchObject({ code: "conflict" }); + const after = yield* test.query(started.id); + expect(after).toMatchObject({ repeats: {}, revision: gate.revision }); + expect(after.trace).toHaveLength(gate.trace.length); + expect(after.attempts).toHaveLength(1); + const approved = yield* test + .invoke("gate", { + ...scope, + runId: started.id, + clientRequestId: "approve", + expectedRevision: gate.revision, + decision: "approve", + }) + .pipe(Effect.flatMap(decodeRun)); + expect(approved).toMatchObject({ state: "completed", withheld: [] }); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "rejects two repeats to one step and decisions on steps that do not run before them", + () => + Effect.scoped( + Effect.gen(function* () { + const test = yield* fixture; + const scope = { environmentId: test.environmentId, projectId: test.projectId }; + const valid = yield* checked(); + const definition = yield* decodeDefinition({ + ...valid, + nodes: valid.nodes.map((node): unknown => + node.kind === "decision" + ? { + ...node, + source: "review", + rules: [ + { + when: { op: "eq", path: "outcome", value: "failed" }, + route: { to: "implement", repeat: { max: 2, atLimit: "review" } }, + }, + ], + } + : node, + ), + }); + const entry = yield* test + .invoke("validate", { ...scope, definition }) + .pipe(Effect.flatMap(decodeEntry)); + expect(entry.runnable).toBe(false); + expect(entry.problems).toContainEqual({ + severity: "error", + nodeId: "decide", + control: "otherwise.repeat", + message: + "decide: Rule 1 already repeats back to implement, and repeats from one step to the same step share one counter. Return to a different step or keep a single repeat route.", + }); + const upstream = yield* decodeDefinition({ + ...valid, + nodes: [ + ...valid.nodes.map((node) => + node.kind === "decision" ? { ...node, source: "later" } : node, + ), + { ...implement, id: "later", next: { to: "done" } }, + ], + }); + const later = yield* test + .invoke("validate", { ...scope, definition: upstream }) + .pipe(Effect.flatMap(decodeEntry)); + expect(later.problems).toContainEqual({ + severity: "error", + nodeId: "decide", + control: "source", + message: + "decide: later never runs before this decision, so there is no result to decide on. Choose an earlier step.", + }); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/plugins/WorkflowsParallelAttention.test.ts b/apps/server/src/plugins/WorkflowsParallelAttention.test.ts new file mode 100644 index 000000000000..fd9d9d408a0d --- /dev/null +++ b/apps/server/src/plugins/WorkflowsParallelAttention.test.ts @@ -0,0 +1,538 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import { AttentionPage, Definition, Run } from "@t3tools/plugin-workflows/contracts"; +import * as Queue from "effect/Queue"; +import { fixture, completed, parallel, sequence } from "./Workflows.testkit.ts"; + +const decodeRun = Schema.decodeUnknownEffect(Run); +const decodePage = Schema.decodeUnknownEffect(AttentionPage); +const decodeDefinition = Schema.decodeUnknownEffect(Definition); +const HEAD = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const NEW_HEAD = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +type Fork = Extract; +const fork = parallel.nodes.find((node): node is Fork => node.kind === "parallel")!; +/** `count` reviewers that all use the same installed skill, each with its own identity. */ +const reviewers = (count: number, timeoutMs?: number) => + decodeDefinition({ + ...parallel, + nodes: parallel.nodes.map((node) => + node.kind === "parallel" + ? { + ...node, + branches: Array.from({ length: count }, (_, index) => ({ + ...fork.branches[0]!, + id: `r${index + 1}`, + title: `Reviewer ${index + 1}`, + skill: "code-review", + instruction: `Review focus ${index + 1}`, + ...(timeoutMs === undefined ? {} : { timeoutMs }), + })), + } + : node, + ), + }); +const pass = { ...completed, data: { verdict: "pass" } }; + +const harness = Effect.gen(function* () { + const test = yield* fixture; + const scope = { environmentId: test.environmentId, projectId: test.projectId }; + const attention = (input: { readonly before?: string; readonly limit?: number } = {}) => + test.invoke("attention-page", { ...scope, ...input }).pipe(Effect.flatMap(decodePage)); + const command = (method: string, run: Run, clientRequestId: string) => + test + .invoke(method, { ...scope, runId: run.id, expectedRevision: run.revision, clientRequestId }) + .pipe(Effect.flatMap(decodeRun)); + return { test, scope, attention, command }; +}); + +it.effect( + "keeps more than five same-skill reviewers distinct and decides only on the persisted join", + () => + Effect.scoped( + Effect.gen(function* () { + const { test, attention } = yield* harness; + const run = yield* test.start(yield* reviewers(6)); + yield* test.reconcile; + const forked = yield* test.wait( + run.id, + (run) => + run.attempts.length === 6 && run.attempts.every((item) => item.phase === "running"), + ); + // Same skill, distinct identities, threads and isolated checkouts of one frozen head. + expect(forked.attempts.map((attempt) => attempt.branchId)).toEqual([ + "r1", + "r2", + "r3", + "r4", + "r5", + "r6", + ]); + expect(new Set(forked.attempts.map((attempt) => attempt.threadId)).size).toBe(6); + expect(new Set(forked.attempts.map((attempt) => attempt.skill?.name))).toEqual( + new Set(["code-review"]), + ); + const review = forked.overview!.review!; + expect(review).toMatchObject({ required: 6, reported: 0, settled: 0, result: null }); + expect(new Set(review.branches.map((branch) => branch.workspace?.path)).size).toBe(6); + expect(review.branches.every((branch) => branch.workspace?.frozenHead === HEAD)).toBe(true); + // Running reviewers are progress, not a decision for anyone. + expect((yield* attention()).total).toBe(0); + + // An early "changes" verdict is a claim; its siblings keep running. + const [first, ...rest] = forked.attempts; + yield* test.report(first!.threadId!, { ...completed, data: { verdict: "changes" } }); + yield* test.reconcile; + const early = yield* test.query(run.id); + expect(early.overview!.review).toMatchObject({ reported: 1, settled: 0, result: null }); + expect(early.attempts.map((attempt) => attempt.phase)).toEqual([ + "reported", + "running", + "running", + "running", + "running", + "running", + ]); + expect(early.state).toBe("running"); + + // Every report accepted while one execution still runs remains pending. + for (const attempt of rest) yield* test.report(attempt.threadId!, pass); + for (const attempt of forked.attempts.slice(0, 5)) test.settle(attempt.threadId!); + yield* test.reconcile; + const reported = yield* test.query(run.id); + expect(reported.overview!.review).toMatchObject({ + required: 6, + reported: 6, + settled: 5, + result: null, + }); + expect(reported.currentNode).toBe("join"); + expect(reported.trace).toEqual([]); + expect((yield* attention()).total).toBe(0); + + test.settle(forked.attempts[5]!.threadId!); + yield* test.reconcile; + const joined = yield* test.wait(run.id, (run) => run.state === "awaiting-review"); + expect(joined.overview!.review).toMatchObject({ + reported: 6, + settled: 6, + result: "all_completed", + }); + expect(joined.trace.at(-1)).toMatchObject({ nodeId: "join", chosen: "review" }); + const page = yield* attention(); + expect(page).toMatchObject({ total: 1, before: null }); + expect(page.runs[0]!.items).toEqual([ + expect.objectContaining({ + kind: "needs-review", + nodeId: "review", + gateRevision: joined.gate!.revision, + }), + ]); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("explains failed, missing-report and timed-out reviewers in the aggregate", () => + Effect.scoped( + Effect.gen(function* () { + const { test, attention } = yield* harness; + const run = yield* test.start(yield* reviewers(3, 60_000)); + yield* test.reconcile; + const forked = yield* test.wait( + run.id, + (run) => + run.attempts.length === 3 && run.attempts.every((item) => item.phase === "running"), + ); + const [failed, missing] = forked.attempts.map((attempt) => attempt.threadId!); + yield* test.report(failed!, { ...pass, outcome: "failed" }); + test.settle(failed!); + // Settled without a report: one reminder, then settled again without one. + test.settle(missing!); + yield* test.reconcile; + yield* test.wait(run.id, (run) => run.attempts[1]!.reminderSent); + test.settle(missing!); + yield* test.reconcile; + const partial = yield* test.query(run.id); + expect(partial.overview!.review).toMatchObject({ settled: 2, result: null }); + // The last reviewer passes its deadline while still running. + yield* TestClock.adjust("2 minutes"); + yield* test.reconcile; + const stopped = yield* test.wait(run.id, (run) => run.state === "unresolved"); + const review = stopped.overview!.review!; + expect(review).toMatchObject({ result: "failed", reported: 1, settled: 3 }); + expect(review.branches.map((branch) => [branch.id, branch.phase, branch.reason])).toEqual([ + ["r1", "failed", null], + ["r2", "unresolved", "Execution settled after one reminder without an accepted report."], + ["r3", "unresolved", "The review branch deadline expired."], + ]); + // An unsuccessful sibling never cancels the others. + expect(review.branches.some((branch) => branch.phase === "canceled")).toBe(false); + expect(stopped.stop).toEqual({ kind: "failed", attemptId: null }); + expect((yield* attention()).runs[0]!.items).toEqual([ + expect.objectContaining({ kind: "failed", nodeId: "unresolved" }), + ]); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("cancels every reviewer of the active generation from the backend", () => + Effect.scoped( + Effect.gen(function* () { + const { test, attention, command } = yield* harness; + const run = yield* test.start(yield* reviewers(3)); + yield* test.reconcile; + const forked = yield* test.wait( + run.id, + (run) => + run.attempts.length === 3 && run.attempts.every((item) => item.phase === "running"), + ); + yield* test.report(forked.attempts[0]!.threadId!, pass); + const canceled = yield* command("cancel", yield* test.query(run.id), "cancel"); + expect(canceled.state).toBe("canceled"); + expect(canceled.overview!.review).toMatchObject({ result: "canceled", reported: 1 }); + expect(canceled.overview!.review!.branches.map((branch) => branch.phase)).toEqual([ + "canceled", + "canceled", + "canceled", + ]); + expect((yield* attention()).total).toBe(0); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "reruns a stale review as a new generation on the verified head without mixing generations", + () => + Effect.scoped( + Effect.gen(function* () { + const { test, attention, command } = yield* harness; + const run = yield* test.start(yield* reviewers(2)); + yield* test.reconcile; + const forked = yield* test.wait( + run.id, + (run) => + run.attempts.length === 2 && run.attempts.every((item) => item.phase === "running"), + ); + for (const attempt of forked.attempts) { + yield* test.report(attempt.threadId!, pass); + test.settle(attempt.threadId!); + } + test.setHead(NEW_HEAD); + yield* test.reconcile; + const stale = yield* test.wait(run.id, (run) => run.state === "unresolved"); + expect(stale.overview!.review).toMatchObject({ + generation: 1, + head: HEAD, + result: "stale", + cause: "head-changed", + }); + expect(stale.stop).toEqual({ kind: "review-stale", attemptId: null }); + expect(stale.allowedActions).toContain("retry"); + expect(stale.recovery).toMatchObject({ retryNodeId: "reviews" }); + expect((yield* attention()).runs[0]!.items).toEqual([ + expect.objectContaining({ kind: "review-stale" }), + ]); + + // Retry is the rerun; repeating its request identity returns the same committed result. + yield* command("retry", stale, "rerun"); + const replay = yield* command("retry", stale, "rerun"); + expect(replay.revision).toBe(stale.revision + 1); + yield* test.reconcile; + const rerun = yield* test.wait( + run.id, + (run) => + run.attempts.length === 4 && + run.attempts.slice(2).every((item) => item.phase === "running"), + ); + const second = rerun.overview!.review!; + expect(second).toMatchObject({ generation: 2, head: NEW_HEAD, result: null, reported: 0 }); + // The new generation lists only its own attempts, threads and frozen head. + expect(second.branches.map((branch) => branch.attemptId)).toEqual( + rerun.attempts.slice(2).map((attempt) => attempt.id), + ); + expect(rerun.attempts.slice(2).map((attempt) => attempt.generation)).toEqual([2, 2]); + expect( + second.branches.every( + (branch) => + branch.workspace?.frozenHead === NEW_HEAD && + !forked.attempts.some((attempt) => attempt.threadId === branch.threadId), + ), + ).toBe(true); + expect(rerun.reviews.map((review) => [review.generation, review.result])).toEqual([ + [1, "stale"], + [2, null], + ]); + expect((yield* attention()).total).toBe(0); + + // An unverifiable head never admits a generation. + test.setForgeAvailable(false); + for (const attempt of rerun.attempts.slice(2)) { + yield* test.report(attempt.threadId!, pass); + test.settle(attempt.threadId!); + } + yield* test.reconcile; + const unverifiable = yield* test.wait(run.id, (run) => run.state === "unresolved"); + expect(unverifiable.overview!.review).toMatchObject({ + generation: 2, + result: "unresolved", + cause: "head-unverifiable", + }); + expect(unverifiable.stop).toEqual({ kind: "review-unverifiable", attemptId: null }); + yield* command("retry", unverifiable, "rerun-again"); + yield* test.reconcile; + const refused = yield* test.wait(run.id, (run) => run.state === "unresolved"); + expect(refused.reviews).toHaveLength(2); + expect(refused.stop).toEqual({ kind: "review-unverifiable", attemptId: null }); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect( + "lists every pending native request in queue order and clears each on its own resolution", + () => + Effect.scoped( + Effect.gen(function* () { + const { test, attention } = yield* harness; + const run = yield* test.start(yield* reviewers(2)); + yield* test.reconcile; + const forked = yield* test.wait( + run.id, + (run) => + run.attempts.length === 2 && run.attempts.every((item) => item.phase === "running"), + ); + const [a, b] = forked.attempts.map((attempt) => attempt.threadId!); + const request = (id: string, kind: string, createdAt: number, resolved = false) => ({ + id, + kind, + status: resolved ? "resolved" : "pending", + createdAt, + resolvedAt: resolved ? createdAt + 1 : null, + }); + const ask = (threadId: string, requests: ReadonlyArray>) => + test.threads.set(threadId, { ...test.threads.get(threadId)!, requests }); + ask(a!, [request("a2", "user-input", 2), request("a1", "approval", 1)]); + ask(b!, [request("b1", "approval", 3)]); + yield* test.reconcile; + const waiting = yield* attention(); + expect(waiting.total).toBe(1); + const items = waiting.runs[0]!.items; + expect( + items.map((item) => [ + item.kind, + item.branchId, + item.request?.id, + item.request?.position, + item.request?.pending, + ]), + ).toEqual([ + ["needs-input", "r1", "a1", 1, 2], + ["needs-input", "r1", "a2", 2, 2], + ["needs-input", "r2", "b1", 1, 1], + ]); + const host = yield* test.hostAttention.pipe( + Stream.filter((summary) => summary.pluginId === "workflows"), + Stream.runHead, + ); + expect(host._tag === "Some" && host.value).toMatchObject({ + total: 1, + items: [ + { + id: run.id, + reason: "Needs input: 3 native requests", + link: { + pageId: "workflows.runs", + state: { run: run.id, attempt: forked.attempts[0]!.id }, + }, + }, + ], + }); + + // Answering the first request clears only that item; the next one moves up. + ask(a!, [request("a2", "user-input", 2), request("a1", "approval", 1, true)]); + yield* test.reconcile; + const answered = (yield* attention()).runs[0]!.items; + expect(answered.map((item) => [item.id, item.request?.position])).toEqual([ + [items[1]!.id, 1], + [items[2]!.id, 1], + ]); + // A restart keeps the remaining requests. + yield* test.restart; + expect((yield* attention()).runs[0]!.items.map((item) => item.id)).toEqual([ + items[1]!.id, + items[2]!.id, + ]); + ask(a!, [request("a2", "user-input", 2, true)]); + ask(b!, [request("b1", "approval", 3, true)]); + yield* test.reconcile; + expect(yield* attention()).toMatchObject({ total: 0, runs: [] }); + expect((yield* test.query(run.id)).state).toBe("running"); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("counts every distinct run needing attention beyond the capped newest pages", () => + Effect.scoped( + Effect.gen(function* () { + const { test, attention } = yield* harness; + const stopped = yield* decodeDefinition({ + version: 1, + id: "stopped", + revision: 1, + title: "Stopped", + entry: "stop", + atLimit: "stop", + nodes: [{ id: "stop", kind: "end", title: "Stop", outcome: "unresolved" }], + }); + const ids: string[] = []; + for (let index = 0; index < 130; index++) + ids.push((yield* test.start(stopped, `start-${index}`)).id); + const newest = ids.toReversed(); + const first = yield* attention(); + expect(first.total).toBe(130); + expect(first.runs.map((run) => run.runId)).toEqual(newest.slice(0, 25)); + const second = yield* attention({ before: first.before! }); + expect(second.total).toBe(130); + expect(second.runs.map((run) => run.runId)).toEqual(newest.slice(25, 50)); + expect(second.runs[0]!.items).toEqual([expect.objectContaining({ kind: "unresolved" })]); + // A widened read returns the newest runs up to the server's bound, never more. + const widened = yield* attention({ limit: 100 }); + expect(widened.runs.map((run) => run.runId)).toEqual(newest.slice(0, 100)); + expect(widened).toMatchObject({ total: 130, before: newest[99] }); + expect((yield* Effect.result(attention({ limit: 101 })))._tag).toBe("Failure"); + // The host summary lists at most 100 runs but reports the full count. + const host = yield* test.hostAttention.pipe( + Stream.filter((summary) => summary.pluginId === "workflows"), + Stream.runHead, + ); + expect(host._tag === "Some" && [host.value.items.length, host.value.total]).toEqual([ + 100, 130, + ]); + yield* test.restart; + expect((yield* attention()).total).toBe(130); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("locates unsupported reviewer reporting and removed-reviewer rules on the server", () => + Effect.scoped( + Effect.gen(function* () { + const { test, scope } = yield* harness; + const definition = yield* reviewers(2); + const invalid = yield* decodeDefinition({ + ...definition, + nodes: definition.nodes.map((node) => + node.kind === "parallel" + ? { + ...node, + branches: node.branches.map((branch, index) => + index === 1 + ? { ...branch, modelSelection: { instanceId: "unconfigured", model: "x" } } + : branch, + ), + } + : node.kind === "join" + ? { + ...node, + rules: [ + { + when: { op: "eq", path: "branches.r9.data.verdict", value: "pass" }, + route: { to: "review" }, + }, + ], + } + : node, + ), + }); + const entry = (yield* test.invoke("validate", { ...scope, definition: invalid })) as { + readonly runnable: boolean; + readonly problems: ReadonlyArray<{ readonly nodeId?: string; readonly control?: string }>; + }; + expect(entry.runnable).toBe(false); + expect(entry.problems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ nodeId: "reviews", control: "branches.1.modelSelection" }), + expect.objectContaining({ + nodeId: "join", + control: "rules.0.when", + message: expect.stringContaining("reviewer r9, which is no longer in reviews"), + }), + ]), + ); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +const stoppedDefinition = decodeDefinition({ + version: 1, + id: "stopped", + revision: 1, + title: "Stopped", + entry: "stop", + atLimit: "stop", + nodes: [{ id: "stop", kind: "end", title: "Stop", outcome: "unresolved" }], +}); + +it.effect("re-reads attention only after commits that can change it", () => + Effect.scoped( + Effect.gen(function* () { + const { test, scope } = yield* harness; + yield* test.start(yield* stoppedDefinition, "stopped"); + const run = yield* test.start(sequence, "running"); + yield* test.reconcile; + const pages = yield* Queue.unbounded(); + yield* test.stream("attention", scope).pipe( + Stream.mapEffect((value) => decodePage(value)), + Stream.runForEach((page) => Queue.offer(pages, page)), + Effect.forkScoped, + ); + expect((yield* Queue.take(pages)).total).toBe(1); + const before = test.redactions(); + // A report on a running step cannot change attention; the request it waits on can. + const threadId = run.attempts[0]!.threadId!; + test.threads.set(threadId, { + ...test.threads.get(threadId)!, + requests: [ + { id: "question", kind: "user-input", status: "pending", createdAt: 1, resolvedAt: null }, + ], + }); + yield* test.report(threadId, completed); + const waiting = yield* Queue.take(pages); + expect(waiting.total).toBe(2); + // One read of the two listed runs, after the relevant commit only. + expect(test.redactions() - before).toBe(2); + expect(yield* Queue.size(pages)).toBe(0); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("counts every pending request beyond the retained queue", () => + Effect.scoped( + Effect.gen(function* () { + const { test, attention } = yield* harness; + const run = yield* test.start(sequence); + yield* test.reconcile; + const threadId = run.attempts[0]!.threadId!; + test.threads.set(threadId, { + ...test.threads.get(threadId)!, + requests: Array.from({ length: 40 }, (_, index) => ({ + id: `request-${index}`, + kind: "user-input", + status: "pending", + createdAt: index, + resolvedAt: null, + })), + }); + yield* test.reconcile; + const items = (yield* attention()).runs[0]!.items; + expect(items).toHaveLength(32); + expect(items.map((item) => [item.request?.position, item.request?.pending]).at(-1)).toEqual([ + 32, 40, + ]); + expect((yield* test.query(run.id)).attempts[0]).toMatchObject({ pendingRequests: 40 }); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); 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..71e4732e0469 --- /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 and gate 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, node: "review" }, + }); + expect(attention?.total).toBe(1); + + 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/WorkflowsValidation.worker.ts b/apps/server/src/plugins/WorkflowsValidation.worker.ts index ec757dc9132e..9358741c68cd 100644 --- a/apps/server/src/plugins/WorkflowsValidation.worker.ts +++ b/apps/server/src/plugins/WorkflowsValidation.worker.ts @@ -16,7 +16,8 @@ const definition = Schema.decodeUnknownSync(Definition)({ id: "complexity", revision: 1, title: "Complexity", - entry: "d0", + // The source runs first so every decision has a result to read. + entry: "source", atLimit: "gate", nodes: [ ...Array.from({ length: count }, (_, index) => { @@ -38,7 +39,7 @@ const definition = Schema.decodeUnknownSync(Definition)({ runtimeMode: "approval-required", instruction: "Source", report: { fields: [{ name: "ready", type: "boolean", required: true }] }, - next: { to: "gate" }, + next: { to: "d0" }, }, { id: "gate", kind: "human", title: "Gate", approve: { to: "end" }, changes: { to: "end" } }, { id: "end", kind: "end", title: "End", outcome: "completed" }, diff --git a/apps/server/src/plugins/compiled.ts b/apps/server/src/plugins/compiled.ts index 9d14eda74b60..5186cc9191de 100644 --- a/apps/server/src/plugins/compiled.ts +++ b/apps/server/src/plugins/compiled.ts @@ -11,6 +11,9 @@ import { Capabilities, Skill, ProjectSummary, + StartPreview, + ThreadLink, + AttentionPage, } 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 +40,9 @@ 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)); +const decodeAttentionPage = Schema.decodeUnknownEffect(AttentionPage); /** Trusted server modules explicitly included in this build. An empty list is supported. */ export class CompiledPlugins extends Context.Reference>( @@ -100,6 +106,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 +185,19 @@ 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.attention._tag]: (input: typeof workflowRpcs.attention.payloadSchema.Type) => + subscription(registry, workflowRpcs.attention._tag, input, decodeAttentionPage), + [workflowRpcs.attentionPage._tag]: ( + input: typeof workflowRpcs.attentionPage.payloadSchema.Type, + ) => request(registry, workflowRpcs.attentionPage._tag, input, AttentionPage), [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/WorkflowAttention.integration.test.tsx b/apps/web/integration/WorkflowAttention.integration.test.tsx new file mode 100644 index 000000000000..6f3843a01e2b --- /dev/null +++ b/apps/web/integration/WorkflowAttention.integration.test.tsx @@ -0,0 +1,881 @@ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + AuthOrchestrationReadScope, + CommandId, + ProjectId, + ProviderInstanceId, + 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 { preloadWorkflowPages, web as workflowsWeb } from "@t3tools/plugin-workflows/web"; +import type { AttentionPage, Run, WorkflowClient } 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 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 { 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)), + ), + }; +}); +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 { validatePluginSearch } from "../src/plugins/pageLink"; +import { availableCatalogAtom } from "../src/plugins/runtime"; +import { appAtomRegistry } from "../src/rpc/atomRegistry"; + +const projectId = ProjectId.make("review-project"); +const HEAD = "a".repeat(40); +// The pull request head the replayed forge reports; a review freezes the value it saw. +const forge = { head: HEAD }; +const reviewer = (id: string, title: string) => ({ + id, + title, + skill: undefined, + modelSelection: { instanceId: "codex", model: "gpt-5.4" }, + runtimeMode: "approval-required", + interactionMode: "plan", + instruction: `Review the ${title.toLowerCase()} of the change`, + report: { + fields: [{ name: "verdict", type: "enum", required: true, values: ["pass", "changes"] }], + }, +}); +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: [reviewer("code", "Code"), reviewer("security", "Security"), reviewer("ux", "UX")], + 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 serverStreams = { open: 0, closed: [] as Array<() => void> }; +/** Resolves once the server has closed every attention stream it served. */ +const attentionStreamsClosed = () => + new Promise((resolve) => + serverStreams.open === 0 ? resolve() : serverStreams.closed.push(resolve), + ); +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), + }), + ), + // Count the server's open attention streams, so cleanup is checked at the transport. + Effect.map((services) => ({ + ...services, + api: services.api.map((api) => + api.rpc._tag === "plugins.workflows.attention" + ? { + ...api, + invoke: (input: unknown) => { + const result = api.invoke(input); + return Stream.isStream(result) + ? Stream.unwrap( + Effect.sync(() => { + serverStreams.open++; + return result.pipe( + Stream.ensuring( + Effect.sync(() => { + serverStreams.open--; + if (serverStreams.open === 0) + for (const resolve of serverStreams.closed.splice(0)) resolve(); + }), + ), + ); + }), + ) + : result; + }, + } + : api, + ), + })), + ); + }), +}; + +/** Awaits exact subscription deliveries and counts the subscriptions views hold open. */ +function track(client: WorkflowClient) { + let open = 0; + const hold = (close: () => void) => { + open++; + return () => { + open--; + close(); + }; + }; + const latest: { run?: Run; attention?: AttentionPage } = {}; + const waiters: Array<{ check: () => boolean; resolve: () => void }> = []; + const notify = () => { + for (const waiter of waiters.filter((item) => item.check())) { + waiters.splice(waiters.indexOf(waiter), 1); + waiter.resolve(); + } + }; + // Deliveries held back, so a test can look at the screen before a snapshot arrives. + let held: Array<() => void> | null = null; + const until = async (check: () => boolean) => { + if (!check()) await new Promise((resolve) => waiters.push({ check, resolve })); + await act(async () => {}); + }; + const wrapped: WorkflowClient = { + ...client, + watchRun: (input, onRun, onError) => + hold( + client.watchRun( + input, + (run) => { + onRun(run); + latest.run = run; + notify(); + }, + onError, + ), + ), + subscribeAttention: (scope, onPage, onError) => + hold( + client.subscribeAttention( + scope, + (page) => { + const deliver = () => { + onPage(page); + latest.attention = page; + notify(); + }; + if (held === null) deliver(); + else held.push(deliver); + }, + onError, + ), + ), + }; + return { + client: wrapped, + holdAttention: () => { + held = []; + }, + releaseAttention: () => + act(async () => { + const pending = held ?? []; + held = null; + for (const deliver of pending) deliver(); + }), + run: (predicate: (run: Run) => boolean) => + until(() => latest.run !== undefined && predicate(latest.run)), + attention: (predicate: (page: AttentionPage) => boolean) => + until(() => latest.attention !== undefined && predicate(latest.attention)), + open: () => open, + }; +} + +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( + "resolves parallel review attention through the hosted pages with exact native requests", + () => + Effect.scoped( + Effect.gen(function* () { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + yield* Effect.promise(preloadWorkflowPages); + const config = { + ...(yield* makeReplayServerConfig("workflow-attention")), + 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("review-project"), + projectId, + title: "Review project", + workspaceRoot: config.baseDir, + }); + const rpc = yield* makeClient(server.context, [AuthOrchestrationReadScope]); + 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: "Workstation", + httpBaseUrl: origin(server.context), + wsBaseUrl: origin(server.context).replace("http", "ws"), + }); + const auth = Context.get(server.context, Auth.EnvironmentAuth); + // A read-only pairing: attention and evidence need no operate access. + const reader = yield* auth.issueSession({ scopes: [AuthOrchestrationReadScope] }); + 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: yield* SubscriptionRef.make( + Option.some({ + environmentId, + label: "Workstation", + httpBaseUrl: origin(server.context), + socketUrl: target.wsBaseUrl, + httpAuthorization: { _tag: "Bearer", token: reader.token }, + target, + }), + ), + }), + ); + 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; + }); + yield* Effect.promise( + () => + new Promise((resolve) => { + const release = appAtomRegistry.subscribe( + availableCatalogAtom(environmentId), + (value) => { + if (value !== null) { + queueMicrotask(() => release()); + resolve(); + } + }, + { immediate: true }, + ); + }), + ); + const scope = { environmentId, projectId }; + const reconcile = invoke("reconcile", scope); + const tracked = track(createWorkflowsClient(environmentId)); + const plugin = bind(workflowsWeb, tracked.client); + const descriptor = catalog.plugins.find((item) => item.manifest.id === "workflows")!; + + 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/${environmentId}/workflows/workflows.attention`], + }), + }); + 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 storage = memoryStorage(); + let connection: "connected" | "disconnected" = "connected"; + 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 = "", , , pageId = ""] = location.pathname + .split("/") + .map(decodeURIComponent); + const search = validatePluginSearch(location.search as Record); + const stateKey = JSON.stringify(search.pluginState ?? {}); + const pageContext = useMemo( + () => + createPluginWebContext({ + environmentId, + environmentLabel: "Workstation", + descriptor, + projectId: search.pluginProjectId ?? null, + threadId: null, + pageState: JSON.parse(stateKey) as Record, + connection, + navigate: router.navigate, + storage, + }), + [search.pluginProjectId, stateKey, connection], + ); + // The native thread route stands in for the existing thread view and its controls. + return first !== "plugins" ? ( +

Native thread view

+ ) : ( + + ); + } + let renderer: ReactTestRenderer | undefined; + const mount = () => + act(async () => { + renderer = create( + + + , + ); + }); + const unmount = () => act(async () => renderer?.unmount()); + const rerender = () => + act(async () => + renderer!.update( + + + , + ), + ); + yield* Effect.addFinalizer(() => + Effect.promise(async () => { + await unmount(); + appAtomRegistry.dispose(); + vi.unstubAllGlobals(); + }), + ); + const page = () => textOf(renderer!.root); + const buttons = (label: string) => + renderer!.root.findAll( + (node) => + node.type === pluginDesign.Button && + (node.props.ariaLabel === label || textOf(node) === label), + ); + const promise = (run: () => Promise
) => Effect.promise(run); + + // Nothing needs attention yet; the empty state is stated, not implied. + yield* promise(mount); + yield* promise(() => tracked.attention((value) => value.total === 0)); + expect(page()).toContain("Nothing needs your attention"); + + // Three reviewers run; two native requests wait in one thread and one in another. + const started = (yield* invoke("start", { + ...scope, + clientRequestId: "review", + definition: review, + input: {}, + workspace: { type: "current" }, + })) as Run; + yield* reconcile; + const forked = (yield* invoke("get", { ...scope, runId: started.id })) as Run; + expect(forked.attempts.map((attempt) => attempt.phase)).toEqual([ + "running", + "running", + "running", + ]); + const [code, security] = forked.attempts.map((attempt) => attempt.threadId!); + const request = (id: string, kind: string, createdAt: number, resolved = false) => ({ + id, + kind, + status: resolved ? "resolved" : "pending", + createdAt, + resolvedAt: resolved ? createdAt + 1 : null, + }); + const ask = (threadId: string, requests: PluginThreadState["requests"]) => + threads.set(threadId, { ...threads.get(threadId)!, requests }); + ask(code!, [request("approve-1", "approval", 1), request("question-1", "user_input", 2)]); + ask(security!, [request("approve-2", "approval", 3)]); + yield* reconcile; + yield* promise(() => tracked.attention((value) => value.runs[0]?.items.length === 3)); + const listed = page(); + expect(listed).toContain("1 run"); + // Each request keeps its native queue position in its own thread. + expect(listed).toContain( + "Reviewers · CodeNeeds inputApproval request 1 of 2 · answered next", + ); + expect(listed).toContain( + "Reviewers · CodeNeeds inputUser input request 2 of 2 · after 1 earlier", + ); + expect(listed).toContain( + "Reviewers · SecurityNeeds inputApproval request 1 of 1 · answered next", + ); + // Generic navigation opens the existing native thread and its own controls. + yield* promise(() => + act(async () => + buttons( + "Open the thread of Reviewers · Code to answer its request", + )[0]!.props.onClick(), + ), + ); + yield* promise(flush); + expect(decodeURIComponent(router.state.location.pathname)).toBe( + `/${environmentId}/${code}`, + ); + expect(page()).toBe("Native thread view"); + expect(tracked.open()).toBe(0); + + // Back on the page, answering the first request clears only that item. + yield* promise(() => + act(async () => { + await router.navigate({ + to: "/plugins/$environmentId/$pluginId/$pageId", + params: { environmentId, pluginId: "workflows", pageId: "workflows.attention" }, + search: {}, + }); + }), + ); + ask(code!, [ + request("approve-1", "approval", 1, true), + request("question-1", "user_input", 2), + ]); + yield* reconcile; + yield* promise(() => + tracked.attention( + (value) => + value.runs[0]?.items.length === 2 && + value.runs[0]!.items[0]!.request?.id === "question-1", + ), + ); + expect(page()).toContain( + "Reviewers · CodeNeeds inputUser input request 1 of 1 · answered next", + ); + expect(page()).not.toContain("Approval request 1 of 2"); + + // The run view lists every required reviewer and keeps reports apart from settlement. + yield* promise(() => + act(async () => { + await router.navigate({ + to: "/plugins/$environmentId/$pluginId/$pageId", + params: { environmentId, pluginId: "workflows", pageId: "workflows.runs" }, + search: { pluginProjectId: projectId, pluginState: { run: started.id } }, + }); + }), + ); + yield* promise(() => tracked.run((run) => run.id === started.id)); + ask(code!, []); + ask(security!, []); + const reportTool = (yield* registry.tools).find( + (item) => item.tool.id === "plugin_workflows_report", + )!; + for (const attempt of forked.attempts) + yield* reportTool.tool.invoke( + { + version: 1, + clientRetryKey: "review", + outcome: "completed", + summary: "Reviewed", + data: { verdict: attempt.branchId === "code" ? "changes" : "pass" }, + evidence: [], + }, + { + environmentId, + projectId, + threadId: attempt.threadId!, + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "native-session", + runtimeMode: "approval-required", + }, + ); + for (const threadId of [code!, security!]) { + const state = threads.get(threadId)!; + threads.set(threadId, { + ...state, + runs: state.runs.map((run) => ({ ...run, status: "completed" })), + }); + } + yield* reconcile; + yield* promise(() => + tracked.run( + (run) => run.overview?.review?.reported === 3 && run.overview.review.settled === 2, + ), + ); + const pending = page(); + expect(pending).toContain("Parallel review: Reviewers · generation 1"); + expect(pending).toContain(`test/repo#1 at committed head ${HEAD.slice(0, 12)}`); + expect(pending).toContain("Wait for all · All 3 reviewers required"); + // Reports and settlement are counted apart; only every settled reviewer decides. + expect(pending).toContain("Waiting for all3/3 reported · 2/3 settled"); + expect(buttons("Show the evidence of reviewer Security")[0]!.props.tooltip).toContain( + "Review the security of the change", + ); + expect(pending).toContain("Execution still running"); + expect(pending).not.toContain("Canceled"); + + // Older runs come from the same live read, widened: every listed run is current. + const ux = forked.attempts[2]!.threadId!; + ask(ux, [request("ux-1", "user_input", 4), request("ux-2", "user_input", 5)]); + yield* reconcile; + const stopped = (index: number) => + invoke("start", { + ...scope, + clientRequestId: `stopped-${index}`, + definition: { + version: 1, + id: "stopped", + revision: 1, + title: `Stopped ${String(index).padStart(3, "0")}`, + entry: "stop", + atLimit: "stop", + nodes: [{ id: "stop", kind: "end", title: "Stop", outcome: "unresolved" }], + }, + input: {}, + workspace: { type: "current" }, + }) as Effect.Effect; + const created: Run[] = []; + for (let index = 0; index < 26; index++) created.push(yield* stopped(index)); + const listedTitles = () => page().match(/Stopped \d{3}/g) ?? []; + yield* promise(() => + act(async () => { + await router.navigate({ + to: "/plugins/$environmentId/$pluginId/$pageId", + params: { environmentId, pluginId: "workflows", pageId: "workflows.attention" }, + search: {}, + }); + }), + ); + yield* promise(() => + tracked.attention((value) => value.total === 27 && value.runs.length === 25), + ); + expect(page()).toContain("27 runs"); + expect(page()).toContain("Newest 25 of 27"); + expect(page()).not.toContain("Frozen review"); + // Widening keeps the current runs on screen until the wider snapshot arrives. + tracked.holdAttention(); + yield* promise(() => act(async () => buttons("Load older runs")[0]!.props.onClick())); + yield* promise(flush); + expect(listedTitles()).toHaveLength(25); + expect(page()).toContain("Loading older runs…"); + yield* promise(tracked.releaseAttention); + yield* promise(() => tracked.attention((value) => value.runs.length === 27)); + expect(listedTitles()).toHaveLength(26); + expect(listedTitles()).toContain("Stopped 000"); + expect(page()).toContain( + "Reviewers · UXNeeds inputUser input request 2 of 2 · after 1 earlier", + ); + expect(page()).not.toContain("Load older runs"); + // A change to an older run alone refreshes it, and nothing collapses meanwhile. + ask(ux, [request("ux-1", "user_input", 4, true), request("ux-2", "user_input", 5)]); + yield* reconcile; + yield* promise(() => + tracked.attention( + (value) => value.runs.find((run) => run.runId === started.id)?.items.length === 1, + ), + ); + expect(page()).toContain( + "Reviewers · UXNeeds inputUser input request 1 of 1 · answered next", + ); + expect(listedTitles()).toHaveLength(26); + // (a) An older run resolved on the server is no longer listed. + yield* invoke("cancel", { + ...scope, + runId: created[0]!.id, + clientRequestId: "cancel-oldest", + expectedRevision: created[0]!.revision, + }); + yield* promise(() => tracked.attention((value) => value.total === 26)); + expect(listedTitles()).toHaveLength(25); + expect(listedTitles()).not.toContain("Stopped 000"); + // (b) A new run pushes the newest page's oldest run into the older range: listed once. + yield* stopped(26); + yield* promise(() => + tracked.attention( + (value) => value.total === 27 && value.runs[0]?.workflowTitle === "Stopped 026", + ), + ); + expect(listedTitles()).toHaveLength(26); + expect(listedTitles().filter((title) => title === "Stopped 001")).toHaveLength(1); + // Offline, the last snapshot (older runs included) stays visible and marked. + connection = "disconnected"; + yield* promise(rerender); + expect(page()).toContain("Last loaded"); + expect(listedTitles()).toHaveLength(26); + expect(page()).toContain("Frozen review"); + expect(page()).not.toContain("Loading older runs…"); + connection = "connected"; + yield* promise(rerender); + yield* promise(() => tracked.attention((value) => value.runs.length === 27)); + expect(page()).not.toContain("Last loaded"); + // Past the server's bound, the page says how many it shows and how to narrow it. + for (let index = 27; index < 102; index++) yield* stopped(index); + yield* promise(() => tracked.attention((value) => value.total === 102)); + for (const expected of [75, 100]) { + yield* promise(() => act(async () => buttons("Load older runs")[0]!.props.onClick())); + yield* promise(() => tracked.attention((value) => value.runs.length === expected)); + } + expect(page()).toContain("102 runs"); + expect(page()).toContain("Newest 100 of 102 · open a run's project to see the rest"); + expect(buttons("Load older runs")).toHaveLength(0); + // The guidance is actionable: a run's project opens that project's attention. + yield* promise(() => + act(async () => + buttons("Show workflow attention for project Review project")[0]!.props.onClick(), + ), + ); + yield* promise(flush); + expect(router.state.location.pathname).toContain("workflows.attention"); + expect(router.state.location.search).toMatchObject({ pluginProjectId: projectId }); + yield* promise(() => tracked.attention((value) => value.total === 102)); + // The project breadcrumb names the scope and switches back to every project. + const project = renderer!.root.find((node) => node.type === pluginDesign.PageHeader).props + .breadcrumb[0]; + expect(project).toMatchObject({ ariaLabel: "Project", value: projectId }); + expect(project.options[0]).toEqual({ value: "", label: "All projects" }); + + // Leaving the pages closes every subscription they opened. + yield* promise(unmount); + expect(tracked.open()).toBe(0); + // The server closed the attention stream it served this page. + yield* promise(attentionStreamsClosed); + expect(serverStreams.open).toBe(0); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/web/integration/WorkflowAuthoring.integration.test.tsx b/apps/web/integration/WorkflowAuthoring.integration.test.tsx index 43d763dbb16d..9030a3e0a737 100644 --- a/apps/web/integration/WorkflowAuthoring.integration.test.tsx +++ b/apps/web/integration/WorkflowAuthoring.integration.test.tsx @@ -216,6 +216,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/WorkflowDecisions.integration.test.tsx b/apps/web/integration/WorkflowDecisions.integration.test.tsx new file mode 100644 index 000000000000..357c4265e6bd --- /dev/null +++ b/apps/web/integration/WorkflowDecisions.integration.test.tsx @@ -0,0 +1,1133 @@ +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 { + Definition, + type Run, + type WorkflowClient, + type 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 Schema from "effect/Schema"; +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)), + ), + }; +}); +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 { validatePluginSearch } from "../src/plugins/pageLink"; +import { availableCatalogAtom } from "../src/plugins/runtime"; +import { appAtomRegistry } from "../src/rpc/atomRegistry"; + +const HEAD = "a".repeat(40); +const projectId = ProjectId.make("same-project"); +const decodeDefinition = Schema.decodeUnknownSync(Definition); +/** Scripted check outcomes: an exit code, or "lost" when execution ends without a result. */ +const checkResults: Array = []; +let checkExecutions = 0; +// Provider turns and check commands 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: 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; + }), + // Check commands are replayed at the execution boundary, one scripted result each. + execute: () => + Effect.suspend(() => { + checkExecutions++; + const result = checkResults.shift() ?? 0; + return result === "lost" + ? Effect.fail( + new PluginError({ + pluginId: "host", + code: "unavailable", + operation: "execute", + message: "The check process ended before reporting.", + }), + ) + : Effect.succeed({ + exitCode: result, + timedOut: false, + stdout: "replayed check output", + stderr: "", + }); + }), + interrupt: () => Effect.succeed(null), + lifecycle: () => Stream.never, + reconcile: () => + Effect.succeed({ + kind: "snapshot", + cursor: 1, + replayGap: true, + threads: [...threads.values()], + } satisfies PluginLifecycleItem), + }), + ), + ); + }), +}; + +/** Records requests and run deliveries so the test awaits exact server 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 }> = []; + const permissionWaiters: Array<(value: WorkflowPermissions) => void> = []; + 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)), + read: (input) => record("read", client.read(input)), + validate: (input) => record("validate", client.validate(input)), + save: (input) => record("save", client.save(input)), + capabilities: (input) => record("capabilities", client.capabilities(input)), + skills: (input) => record("skills", client.skills(input)), + preview: (input) => record("preview", client.preview(input)), + startSaved: (input) => record("launch", client.startSaved(input)), + gate: (input) => record("gate", client.gate(input)), + retry: (input) => record("retry", client.retry(input)), + watchRun: (input, onRun, onError) => + 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 { + 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); + }); + }, + /** Drop calls that need no assertion (for example a page's own project lookup). */ + forget: (method: string) => { + queued.delete(method); + }, + /** Later waits need a delivery from a fresh run subscription (after a reload). */ + forgetRuns: () => { + runs = []; + }, + /** Resolves once the open run view's latest 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 () => {}); + }, + permissions: (predicate: (value: WorkflowPermissions) => boolean) => + new Promise((resolve) => { + const check = (value: WorkflowPermissions) => + predicate(value) ? resolve() : permissionWaiters.push(check); + permissionWaiters.push(check); + }), + }; +} + +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( + "authors and runs a checked decision with one bounded repeat and a human gate over an authenticated connection", + () => + 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"], (label, index) => + Effect.gen(function* () { + const config = { + ...(yield* makeReplayServerConfig(`workflow-decisions-${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] = environments as [(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 }); + 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 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, + }); + 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 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()); + const choose = (key: string, value: string) => + act(async () => control(pluginDesign.Select, key).props.onChange(value)); + 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; + const type = (key: string, value: string) => + act(async () => control(pluginDesign.Input, key).props.onChange(value)); + const keyDown = (nodeId: string, key: string) => + act(async () => + root() + .find( + (node) => + node.type === "li" && + typeof node.props.onKeyDown === "function" && + node.findAll((child) => child.props.id === `wf-step-${nodeId}`).length > 0, + ) + .props.onKeyDown({ altKey: false, key, preventDefault: () => {} }), + ); + const select = (key: string) => control(pluginDesign.Select, key); + // Segmented switches are host controls; drive them through their change handler. + const toggle = (key: string, value: string) => + act(async () => control(pluginDesign.SegmentedControl, key).props.onChange(value)); + // A step's lines in the Routes view. + const routesOf = (nodeId: string) => + textOf( + root().find( + (node) => + node.type === "li" && + typeof node.props.onKeyDown === "function" && + node.findAll((child) => child.props.id === `wf-step-${nodeId}`).length > 0, + ), + ); + const validated = () => promise(() => tracked.settleLatest("validate")); + const runId = () => + (router.state.location.search as { pluginState?: { run?: string } }).pluginState?.run; + const get = (id: string) => + first.invoke("get", { ...scope, runId: id }).pipe(Effect.map((run) => run as Run)); + const reportTool = (yield* first.registry.tools).find( + (item) => item.tool.id === "plugin_workflows_report", + )!; + /** The visit's agent reports completion and its native turn settles. */ + const finishImplementation = (run: Run) => + Effect.gen(function* () { + const threadId = run.attempts.at(-1)!.threadId!; + yield* reportTool.tool.invoke( + { + version: 1, + clientRetryKey: `report-${run.attempts.length}`, + outcome: "completed", + summary: "Implemented", + data: { ready: true }, + evidence: [], + }, + { + environmentId: first.environmentId, + projectId, + threadId, + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "native-session", + runtimeMode: "approval-required", + }, + ); + const native = threads.get(threadId)!; + threads.set(threadId, { + ...native, + runs: native.runs.map((item) => ({ ...item, status: "completed" })), + }); + }); + + // New workflow from the library; every step is added and connected without dragging. + yield* promise(mount); + yield* promise(() => tracked.settle("projects")); + yield* promise(() => tracked.settle("library")); + yield* promise(() => click("New workflow")); + yield* promise(() => type("Workflow name", "Repeat implementation once")); + yield* promise(() => click("Create")); + yield* promise(flush); + // Until this environment's step types load, only agent steps and ends are offered. + expect(button("Check").props.disabled).toBe(true); + expect(button("Check").props.tooltip).toBe("Loading step types…"); + expect(button("Agent step").props.disabled).toBe(false); + yield* promise(() => tracked.settle("capabilities")); + // The backend advertises every kind; a Join is only added with its Parallel group. + expect(button("Check").props.disabled).toBe(false); + expect(button("Decision").props.disabled).toBe(false); + expect(button("Human gate").props.disabled).toBe(false); + expect(button("Parallel group").props.disabled).toBe(false); + expect(button("Join").props.disabled).toBe(true); + yield* promise(() => click("Agent step")); + yield* promise(() => tracked.settle("skills")); + yield* promise(() => write("wf-agent-1-instruction", "Implement the change")); + yield* promise(() => click("Add report field")); + yield* promise(() => type("wf-agent-1-report-fields-0", "ready")); + yield* promise(() => click("Check")); + yield* promise(() => type("wf-check-1-command", "project-check")); + yield* promise(() => write("wf-check-1-args", "--ci")); + // Unsupported check policies are visible but unavailable. + expect(select("wf-check-1-retry").props.disabled).toBe(true); + expect(select("wf-check-1-retry").props.options).toContainEqual({ + value: "automatic", + label: "Retry automatically (not available)", + disabled: true, + }); + expect(select("wf-check-1-workspace").props.value).toBe("run"); + // The check's declared result fields, which decisions read; exitCode is optional. + expect(page()).toContain("exitCode?"); + yield* promise(() => click("Decision")); + expect(select("wf-decision-1-source").props.value).toBe("check-1"); + expect( + select("wf-decision-1-source").props.options.map( + (option: { value: string }) => option.value, + ), + ).toEqual(["agent-1", "check-1"]); + yield* promise(() => click("Human gate")); + yield* promise(() => click("End")); + yield* promise(() => type("wf-end-1-title", "Changes requested")); + yield* promise(() => choose("wf-end-1-outcome", "failed")); + yield* promise(() => click("Human gate: Human gate 1")); + yield* promise(() => choose("wf-human-1-changes", "end-1")); + + // The decision: Match all with a nested Match any group, a destination and Otherwise. + yield* promise(() => click("Decision: Decision 1")); + yield* promise(() => click("Add rule")); + expect(select("wf-decision-1-rules-0-when-0").props.value).toBe("outcome"); + expect(select("wf-decision-1-rules-0-when-0-value").props.value).toBe("completed"); + yield* promise(() => click("Add condition to rule 1")); + yield* promise(() => choose("wf-decision-1-rules-0-when-1", "exitCode")); + expect(control(pluginDesign.Input, "wf-decision-1-rules-0-when-1-value").props.value).toBe( + "0", + ); + yield* promise(() => click("Add group to rule 1")); + yield* promise(() => choose("wf-decision-1-rules-0-when-2-0", "timedOut")); + yield* promise(() => choose("wf-decision-1-rules-0-when-2-0-value", "false")); + yield* promise(() => click("Add condition to this group")); + yield* promise(() => choose("wf-decision-1-rules-0-when-2-1", "interrupted")); + yield* promise(() => choose("wf-decision-1-rules-0-when-2-1-value", "false")); + yield* promise(() => choose("wf-decision-1-rules-0", "human-1")); + const readsAs = + 'If outcome equals "completed" and exitCode equals 0 (false when exitCode is absent) and (timedOut equals false or interrupted equals false)'; + expect(page()).toContain(`Reads as: ${readsAs}`); + // Operators are typed by field: numbers compare by order, enums and text by membership. + expect( + select("wf-decision-1-rules-0-when-1-op").props.options.map( + (option: { value: string }) => option.value, + ), + ).toEqual(["eq", "ne", "gt", "gte", "lt", "lte", "present", "absent"]); + // Otherwise repeats the implementation once, then goes to the human gate. + yield* promise(() => toggle("Otherwise route", "repeat")); + expect(select("wf-decision-1-otherwise").props.value).toBe("agent-1"); + expect(select("wf-decision-1-otherwise-max").props.value).toBe("1"); + yield* promise(() => choose("wf-decision-1-otherwise-repeat", "human-1")); + // Repeat once means two visits in total. + expect(select("wf-decision-1-otherwise-max").props.options[0]).toEqual({ + value: "1", + label: "×1 · 2 visits", + }); + // Rule order is first-match order; a rule back to the agent without a repeat bound is an + // unbounded cycle and blocks saving until it is removed. + yield* promise(() => click("Add rule")); + yield* promise(() => click("1 to fix")); + expect(page()).toContain("Every cycle must cross a bounded repeat route."); + yield* promise(() => click("Decision: Decision 1")); + // Making it a second repeat back to the same step is rejected at the later route's + // At limit control, since both would share one counter. + yield* promise(() => toggle("Rule 2 route", "repeat")); + expect(select("wf-decision-1-rules-1").props.value).toBe("agent-1"); + expect(page()).toContain( + "decision-1: Rule 2 already repeats back to agent-1, and repeats from one step to the same step share one counter.", + ); + expect(select("wf-decision-1-otherwise-repeat").props.invalid).toBe(true); + expect(select("wf-decision-1-rules-1-repeat").props.invalid).toBe(false); + yield* promise(() => click("Move rule 2 up")); + expect(routesOf("decision-1")).toContain( + "Rule 1 outcome = completed ↩ Agent step 1repeat ×1 (2 visits)at limit → Done", + ); + expect(routesOf("decision-1")).toContain( + "Rule 2 outcome = completed and +2 → Human gate 1", + ); + yield* promise(() => click("Remove rule 1")); + expect(page()).not.toContain("Every cycle must cross a bounded repeat route."); + expect(page()).not.toContain("already repeats back to"); + // The run-wide visit bound also ends at the human gate. + yield* promise(() => click("Decision: Decision 1")); + yield* promise(() => choose("wf-workflow-atLimit", "human-1")); + yield* validated(); + expect(page()).not.toContain("to fix"); + expect(routesOf("decision-1")).toContain( + "Rule 1 outcome = completed and +2 → Human gate 1", + ); + expect(routesOf("decision-1")).toContain( + "Otherwise ↩ Agent step 1repeat ×1 (2 visits)at limit → Human gate 1", + ); + expect(routesOf("human-1")).toContain("Request changes → Changes requested"); + + // Changing the source removes fields the conditions use: each is located and blocking. + yield* promise(() => click("Decision: Decision 1")); + // Locally invalid drafts are not sent for server validation; the shared rules locate them. + yield* promise(() => choose("wf-decision-1-source", "agent-1")); + expect(page()).toContain("decision-1: unknown predicate path exitCode."); + expect(select("wf-decision-1-rules-0-when-1").props.invalid).toBe(true); + expect(select("wf-decision-1-rules-0-when-1").props.options[0]).toEqual({ + value: "exitCode", + label: "exitCode (missing field)", + }); + expect(select("wf-decision-1-rules-0-when-0").props.invalid).toBe(false); + yield* promise(() => choose("wf-decision-1-source", "check-1")); + // Removing a route target leaves the route dangling as a repairable error. + yield* promise(() => keyDown("end-1", "Delete")); + expect(routesOf("human-1")).toContain("Request changes → end-1 (missing)"); + yield* promise(() => click("1 to fix")); + expect(page()).toContain("human-1: unknown route target end-1."); + expect(button("Save").props.disabled).toBe(true); + yield* promise(() => click("Show")); + expect(select("wf-human-1-changes").props.invalid).toBe(true); + yield* promise(() => click("End")); + yield* promise(() => type("wf-end-1-title", "Changes requested")); + yield* promise(() => choose("wf-end-1-outcome", "failed")); + yield* validated(); + expect(page()).not.toContain("to fix"); + + // The draft survives a reload of the page. + yield* promise(unmount); + yield* promise(mount); + yield* promise(() => tracked.settle("projects")); + yield* promise(() => tracked.settle("capabilities")); + yield* promise(() => click("Decision: Decision 1")); + expect(page()).toContain(`Reads as: ${readsAs}`); + yield* validated(); + + // Saving needs operate access on this authenticated connection. + expect(button("Save").props.disabled).toBe(true); + const granted = tracked.permissions((value) => value.save && value.start && value.gate); + yield* first.grantWrite; + yield* promise(() => granted); + yield* promise(flush); + yield* promise(() => click("Save")); + yield* promise(() => tracked.settle("save")); + yield* promise(flush); + yield* promise(() => tracked.settle("read")); + expect(page()).toContain("Saved revision 1"); + const saved = decodeDefinition( + Yaml.parse( + yield* fs.readFileString(`${first.directory}/repeat-implementation-once.yaml`), + ), + ); + expect(saved).toMatchObject({ entry: "agent-1", atLimit: "human-1" }); + expect(saved.nodes.find((node) => node.id === "decision-1")).toEqual({ + id: "decision-1", + kind: "decision", + title: "Decision 1", + source: "check-1", + rules: [ + { + when: { + op: "all", + terms: [ + { op: "eq", path: "outcome", value: "completed" }, + { op: "eq", path: "exitCode", value: 0 }, + { + op: "any", + terms: [ + { op: "eq", path: "timedOut", value: false }, + { op: "eq", path: "interrupted", value: false }, + ], + }, + ], + }, + route: { to: "human-1" }, + }, + ], + otherwise: { to: "agent-1", repeat: { max: 1, atLimit: "human-1" } }, + }); + + // Start it; both check runs fail, so Otherwise repeats once and then reaches At limit. + checkResults.push(1, 1); + yield* promise(() => + go(first.environmentId, "workflows.runs", { + start: "1", + workflow: "repeat-implementation-once", + }), + ); + yield* promise(() => tracked.settleLatest("library")); + yield* promise(() => tracked.settleLatest("preview")); + yield* promise(() => act(async () => buttons("Run workflow").at(-1)!.props.onClick())); + yield* promise(() => tracked.settle("launch")); + yield* promise(flush); + const id = runId()!; + expect(id).toMatch(/^workflow-/); + // Launches and check results arrive as persisted run deliveries; native settlement is + // observed when the server reconciles. + yield* reconcile; + for (let visit = 1; visit <= 2; visit++) { + yield* promise(() => + tracked.run( + (run) => + run.id === id && + run.attempts.filter((attempt) => attempt.nodeId === "agent-1").length === visit && + run.attempts.at(-1)?.phase === "running", + ), + ); + yield* finishImplementation(yield* get(id)); + yield* reconcile; + } + yield* promise(() => tracked.run((run) => run.state === "awaiting-review")); + const limited = yield* get(id); + expect(limited).toMatchObject({ + automationStopped: true, + repeats: { "decision-1:agent-1": 1 }, + }); + + // Route history shows the recorded rule, the repeat counter and the limit exit. + const history = page(); + expect(history).toContain("Needs your decision"); + expect(history).toContain("Decision 1 → Agent step 1otherwiserepeat 1/1"); + expect(history).toContain("Decision 1 → Human gate 1otherwiseat limit"); + const tip = (content: string) => + root().findAll( + (node) => node.type === pluginDesign.Tooltip && node.props.content === content, + ); + expect(tip("Visit 2 of up to 2 · at limit → Human gate 1")).toHaveLength(1); + expect(tip("1/1 repeats used · at limit → Human gate 1")).toHaveLength(1); + yield* promise(() => toggle("Run view", "steps")); + // The check visit shows its recorded result as execution evidence. + const checkButton = root().findAll( + (node) => + node.type === pluginDesign.Button && + typeof node.props.ariaLabel === "string" && + /^Visit \d+: Check 1, Failed$/.test(node.props.ariaLabel), + ); + expect(checkButton).toHaveLength(2); + yield* promise(() => act(async () => checkButton[0]!.props.onClick())); + yield* promise(flush); + expect(page()).toContain("Check result recorded (failed)"); + expect(page()).toContain("exit 1"); + // Checks are commands without a native thread. + expect(buttons("Open thread")).toHaveLength(0); + expect(page()).toContain("Check 1 → Decision 1"); + yield* promise(() => toggle("Run view", "routes")); + // The decision's routing lists every rule it considered and why Otherwise was taken. + yield* promise(() => click("Decision: Decision 1")); + yield* promise(flush); + expect(page()).toContain(`Rule 1 did not match: ${readsAs.slice(3)}`); + expect(page()).toContain("Otherwise taken: no rule matched"); + + // A reload restores the same counters and route; nothing is recomputed in the client. + yield* promise(unmount); + tracked.forgetRuns(); + yield* promise(mount); + yield* promise(() => + tracked.run((run) => run.id === id && run.state === "awaiting-review"), + ); + expect(page()).toContain("Decision 1 → Human gate 1otherwiseat limit"); + expect(button("Request changes").props.disabled).toBe(false); + yield* promise(() => click("Approve")); + yield* promise(() => tracked.settle("gate")); + yield* promise(() => tracked.run((run) => run.state === "completed")); + // Deselecting the decision shows the whole route history again. + yield* promise(() => click("Decision: Decision 1")); + expect(page()).toContain("Human gate 1 → Doneapprove"); + const finished = yield* get(id); + expect(finished.trace.at(-1)).toMatchObject({ route: "approve", chosen: "done" }); + expect(finished.attempts.filter((attempt) => attempt.nodeId === "agent-1")).toHaveLength(2); + + // Run again: the check's execution ends without a retained result. The run is unresolved, + // never passed or failed, and the check runs again only on an explicit Retry. + checkResults.push("lost", 0); + yield* promise(() => + go(first.environmentId, "workflows.runs", { + start: "1", + workflow: "repeat-implementation-once", + }), + ); + yield* promise(() => tracked.settleLatest("library")); + yield* promise(() => tracked.settleLatest("preview")); + yield* promise(() => act(async () => buttons("Run workflow").at(-1)!.props.onClick())); + yield* promise(() => tracked.settle("launch")); + yield* promise(flush); + const lostId = runId()!; + expect(lostId).not.toBe(id); + yield* reconcile; + yield* promise(() => + tracked.run((run) => run.id === lostId && run.attempts.at(-1)?.phase === "running"), + ); + yield* finishImplementation(yield* get(lostId)); + const executionsBefore = checkExecutions; + yield* reconcile; + yield* promise(() => tracked.run((run) => run.id === lostId && run.state === "unresolved")); + expect(checkExecutions).toBe(executionsBefore + 1); + // Further reconciliation (as after a restart) neither replays nor resolves the check. + yield* reconcile; + const lost = yield* get(lostId); + expect(lost).toMatchObject({ + state: "unresolved", + allowedActions: ["cancel", "retry"], + reason: "The check result is unresolved. An explicit retry is required.", + }); + expect(lost.attempts.at(-1)).toMatchObject({ + nodeId: "check-1", + phase: "unresolved", + check: { outcome: "unresolved", exitCode: null, interrupted: true }, + }); + expect(lost.trace.some((item) => item.nodeId === "check-1")).toBe(false); + expect(checkExecutions).toBe(executionsBefore + 1); + yield* promise(() => toggle("Run view", "steps")); + const lostVisit = root().findAll( + (node) => + node.type === pluginDesign.Button && + node.props.ariaLabel === "Visit 2: Check 1, Unresolved", + ); + expect(lostVisit).toHaveLength(1); + yield* promise(() => act(async () => lostVisit[0]!.props.onClick())); + yield* promise(flush); + const lostEvidence = page(); + expect(lostEvidence).toContain("Unresolved"); + expect(lostEvidence).toContain( + "No check result was retained; the check was interrupted, so it is neither a pass nor a failure", + ); + expect(lostEvidence).toContain("Routing stopped at this visit"); + expect(lostEvidence).not.toContain("Check result recorded (completed)"); + expect(lostEvidence).not.toContain("Check result recorded (failed)"); + expect(buttons("Resume retained session")).toHaveLength(0); + expect(button("Retry with a new attempt").props.disabled).toBe(false); + yield* promise(() => click("Retry with a new attempt")); + yield* promise(() => tracked.settle("retry")); + yield* promise(() => + tracked.run((run) => run.id === lostId && run.state === "awaiting-review"), + ); + expect(checkExecutions).toBe(executionsBefore + 2); + const retried = yield* get(lostId); + expect(retried.attempts.filter((attempt) => attempt.nodeId === "check-1")).toHaveLength(2); + expect(retried.trace.at(-1)).toMatchObject({ nodeId: "decision-1", route: "rules.0" }); + + // At the whole-run visit limit a gate never offers a repeat it would divert, and says why. + yield* fs.writeFileString( + `${first.directory}/bounded.yaml`, + Yaml.stringify({ + version: 1, + id: "bounded", + revision: 1, + title: "Bounded gate", + entry: "work", + atLimit: "review", + maxVisits: 2, + 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" }, + ], + }), + ); + yield* promise(() => + go(first.environmentId, "workflows.runs", { start: "1", workflow: "bounded" }), + ); + yield* promise(() => tracked.settleLatest("library")); + yield* promise(() => tracked.settleLatest("preview")); + yield* promise(() => act(async () => buttons("Run workflow").at(-1)!.props.onClick())); + yield* promise(() => tracked.settle("launch")); + yield* promise(flush); + const boundedId = runId()!; + yield* reconcile; + yield* promise(() => + tracked.run((run) => run.id === boundedId && run.attempts.at(-1)?.phase === "running"), + ); + yield* finishImplementation(yield* get(boundedId)); + yield* reconcile; + yield* promise(() => + tracked.run((run) => run.id === boundedId && run.state === "awaiting-review"), + ); + expect(buttons("Request changes")).toHaveLength(0); + expect(button("Approve").props.disabled).toBe(false); + // The withheld decision is explained inside the gate's own alert. + expect(page()).toContain("Request changes unavailable · ↩ Work · run visit limit reached"); + expect(page()).not.toContain("Visit or repeat limit reached"); + }), + ).pipe(Effect.provide(NodeServices.layer)), + { timeout: 120_000 }, +); diff --git a/apps/web/integration/WorkflowRun.integration.test.tsx b/apps/web/integration/WorkflowRun.integration.test.tsx new file mode 100644 index 000000000000..c2d2305c3804 --- /dev/null +++ b/apps/web/integration/WorkflowRun.integration.test.tsx @@ -0,0 +1,1400 @@ +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)), + ), + }; +}); +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 { validatePluginSearch } from "../src/plugins/pageLink"; +import { availableCatalogAtom } from "../src/plugins/runtime"; +import { 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 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(flush); + expect(router.state.location.pathname).toContain("workflows.runs"); + expect(router.state.location.search).toMatchObject({ + pluginState: { start: "1", workflow: "sequence" }, + }); + yield* promise(() => tracked.settle("projects")); + yield* promise(() => tracked.settle("library")); + yield* promise(() => tracked.settle("preview")); + yield* promise(() => tracked.listed((runs) => runs.length === 0)); + 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(libraryDialog).toContain("No runs yet"); + 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); + + // Project entry point: the same dialog and service, choosing the workflow explicitly. + const projectAction = workflowsWeb.projectActions.find( + (item) => item.id === "workflows.project-run", + )!; + 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("library")); + 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.content === 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 plain Next route carries no route badge in the history. + expect( + root().findAll((node) => node.type === pluginDesign.Badge && textOf(node) === "next"), + ).toHaveLength(0); + + // 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 22e0d3f426f4..64a215b597e2 100644 --- a/apps/web/src/plugins/PluginSlots.tsx +++ b/apps/web/src/plugins/PluginSlots.tsx @@ -176,6 +176,9 @@ export function PluginSidebarItems() { )); } +/** Newest items listed in the sidebar; the plugin's attention view lists the rest. */ +const ATTENTION_SHOWN = 5; + function EnvironmentPluginAttention({ environmentId, labelEnvironment, @@ -191,7 +194,38 @@ function EnvironmentPluginAttention({ const plugin = contributions.find((item) => item.manifest.id === summary.pluginId); if (plugin === undefined) return []; const prefix = labelEnvironment ? `${environment?.label ?? environmentId} · ` : ""; + // The plugin's own total; a capped item list is never presented as the count. + const count = summary.total ?? (summary.items.length >= 100 ? null : summary.items.length); + const view = plugin.attention; + // The plugin's own attention view, when this client can open it. + const target = view !== null && pageAvailable(plugin, view.link.pageId) ? view : null; + const shown = summary.items.slice(0, ATTENTION_SHOWN); + const more = (count ?? summary.items.length) - shown.length; + const countLabel = `${prefix}${view?.title ?? plugin.manifest.displayName}: ${count ?? "100+"} ${count === 1 ? "needs" : "need"} attention`; return [ + ...(count === 0 && summary.error === undefined + ? [] + : [ + + {target !== null ? ( + plugin.context.navigate(target.link)} + > + + {prefix} + {target.title} + + + {count ?? "100+"} + + + ) : ( +

{countLabel}

+ )} +
, + ]), ...(summary.error === undefined ? [] : [ @@ -202,7 +236,7 @@ function EnvironmentPluginAttention({

, ]), - ...summary.items.map((item) => { + ...shown.map((item) => { const available = pageAvailable(plugin, item.link.pageId); return ( @@ -221,6 +255,25 @@ function EnvironmentPluginAttention({ ); }), + ...(summary.items.length > shown.length + ? [ + + {target !== null ? ( + plugin.context.navigate(target.link)}> + + {more} + {count === null ? "+" : ""} more + + + ) : ( +

+ {more} + {count === null ? "+" : ""} more +

+ )} +
, + ] + : []), ]; }); } diff --git a/apps/web/src/plugins/contributions.ts b/apps/web/src/plugins/contributions.ts index 0884577f383e..42a334ee29cb 100644 --- a/apps/web/src/plugins/contributions.ts +++ b/apps/web/src/plugins/contributions.ts @@ -23,6 +23,7 @@ export function bind(plugin: WebPlugin, client: Client) { })), navigation: plugin.navigation, projectActions: plugin.projectActions, + attention: plugin.attention ?? null, 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 bf199f8f9372..650144956eb1 100644 --- a/apps/web/src/plugins/design.tsx +++ b/apps/web/src/plugins/design.tsx @@ -8,11 +8,29 @@ import { InfoIcon, TriangleAlertIcon, } from "lucide-react"; -import { Fragment, useEffect, useLayoutEffect, useRef, type ComponentProps } from "react"; +import { Fragment, useEffect, useLayoutEffect, useRef, useState, type ComponentProps } from "react"; import { SettingsGroup } from "../components/settings/SettingsGroup"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "../components/ui/alert"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; +import { + 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, @@ -25,6 +43,7 @@ import { Input } from "../components/ui/input"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../components/ui/menu"; import { Select, + SelectButton, SelectItem, SelectPopup, SelectTrigger, @@ -32,6 +51,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 { @@ -212,6 +232,121 @@ 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} + )} + + + ))} + + + + ); +} + +function PluginDialog({ + open, + onOpenChange, + title, + description, + footer, + children, +}: ComponentProps) { + // Without a document (server or test rendering) there is no portal layer; the dialog's + // content renders in place so it keeps its accessible name and controls. + if (typeof document === "undefined") + return open ? ( +
+

{title}

+ {description === undefined ? null :

{description}

} + {children} + {footer} +
+ ) : null; + return ( + + + + {title} + {description === undefined ? null : {description}} + + {children} + {footer === undefined || footer === null ? null : {footer}} + + + ); +} + const alertIcons = { info: InfoIcon, warning: TriangleAlertIcon, @@ -258,7 +393,17 @@ export const pluginDesign: PluginDesign = { onChange={(event) => onChange(event.currentTarget.value)} /> ), - Select: ({ id, ariaLabel, ariaDescribedBy, invalid, disabled, value, onChange, options }) => ( + Select: ({ + id, + ariaLabel, + ariaDescribedBy, + invalid, + disabled, + value, + onChange, + options, + size, + }) => ( ), + Combobox: PluginCombobox, Badge: ({ variant = "outline", children }) => {children}, + Tooltip: ({ content, children }) => + // Without a window (server or test rendering) only the trigger content renders. + typeof window === "undefined" ? ( + {children} + ) : ( + + }>{children} + {content} + + ), + toast: ({ title, description, variant = "success" }) => { + toastManager.add({ + type: variant, + title, + ...(description === undefined ? {} : { description }), + }); + }, Icon: PluginIcon, Menu: ({ ariaLabel, items, trigger, disabled }) => ( @@ -320,10 +493,11 @@ export const pluginDesign: PluginDesign = { ), - SegmentedControl: ({ ariaLabel, value, onChange, options }) => ( + SegmentedControl: ({ ariaLabel, value, onChange, options, disabled }) => ( { const selected = next[0]; @@ -425,6 +599,7 @@ export const pluginDesign: PluginDesign = { ); }, PageHeader, + Dialog: PluginDialog, Sheet: ({ open, onOpenChange, title, side = "right", children }) => ( diff --git a/apps/web/src/plugins/workflowsClient.ts b/apps/web/src/plugins/workflowsClient.ts index 9bf03231c5cb..76960d96c233 100644 --- a/apps/web/src/plugins/workflowsClient.ts +++ b/apps/web/src/plugins/workflowsClient.ts @@ -1,26 +1,36 @@ import type { EnvironmentId } from "@t3tools/contracts"; import type { + AttentionInput, CatalogEntry, + GateInput, + CommandInput, LibraryInput, + PreviewInput, ReadInput, ReplaceInput, + RunInput, SaveInput, + StartSavedInput, + ThreadInput, WorkflowClient, } from "@t3tools/plugin-workflows/contracts"; +import { PluginError } from "@t3tools/contracts"; import type { ProjectId, ProviderInstanceId } from "@t3tools/plugin-host-contract/schema"; -import { request, requestGuarded } from "@t3tools/client-runtime/rpc"; +import { request, requestGuarded, subscribe } 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 +106,211 @@ 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)), })), ); +/** A still-published workflow API; a removed or downgraded plugin fails its streams. */ +/** 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 runsAtom = Atom.family((key: string) => { + const input = JSON.parse(key) as { environmentId: EnvironmentId; projectId: ProjectId }; + return connectionAtomRuntime + .atom((get) => + (() => { + const state = publication( + get(availableCatalogAtom(input.environmentId)), + input.environmentId, + "plugins.workflows.subscribe", + ); + return state === "published" + ? followStreamInEnvironment( + input.environmentId, + subscribe("plugins.workflows.subscribe", input), + ) + : state === "loading" + ? Stream.never + : unpublished(); + })(), + ) + .pipe(Atom.setIdleTTL(0)); +}); +const runAtom = Atom.family((key: string) => { + const input = JSON.parse(key) as RunInput; + return connectionAtomRuntime + .atom((get) => + (() => { + const state = publication( + get(availableCatalogAtom(input.environmentId)), + input.environmentId, + "plugins.workflows.watch", + ); + return state === "published" + ? followStreamInEnvironment( + input.environmentId, + subscribe("plugins.workflows.watch", input), + ) + : state === "loading" + ? Stream.never + : unpublished(); + })(), + ) + .pipe(Atom.setIdleTTL(0)); +}); +const threadAtom = Atom.family((key: string) => { + const input = JSON.parse(key) as ThreadInput; + return connectionAtomRuntime + .atom((get) => + (() => { + const state = publication( + get(availableCatalogAtom(input.environmentId)), + input.environmentId, + "plugins.workflows.thread", + ); + return state === "published" + ? followStreamInEnvironment( + input.environmentId, + subscribe("plugins.workflows.thread", input), + ) + : state === "loading" + ? Stream.never + : unpublished(); + })(), + ) + .pipe(Atom.setIdleTTL(0)); +}); +// One live newest page per environment and project scope, shared by every open view of it. +const attentionAtom = Atom.family((key: string) => { + const input = JSON.parse(key) as AttentionInput; + return connectionAtomRuntime + .atom((get) => + (() => { + const state = publication( + get(availableCatalogAtom(input.environmentId)), + input.environmentId, + "plugins.workflows.attention", + ); + return state === "published" + ? followStreamInEnvironment( + input.environmentId, + subscribe("plugins.workflows.attention", input), + ) + : state === "loading" + ? Stream.never + : unpublished(); + })(), + ) + .pipe(Atom.setIdleTTL(0)); +}); +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 +326,43 @@ 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), + subscribeAttention: ({ projectId, limit }, onPage, onError) => + listen( + attentionAtom( + JSON.stringify({ + environmentId, + ...(projectId === null ? {} : { projectId }), + limit, + }), + ), + onPage, + 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/docs/README.md b/docs/README.md index 850447dd92a5..22896c4961fa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ - [Permission modes](./user/permission-modes.md) - [Terminal history](./user/terminal.md) - [Source control](./user/source-control.md) +- [Workflows](./user/workflows.md) - [Project settings](./user/project-settings.md) - [Appearance and themes](./user/appearance.md) - [Keyboard shortcuts](./user/keybindings.md) diff --git a/docs/internals/plugins.md b/docs/internals/plugins.md index cfe1d0d5d9a1..484295925731 100644 --- a/docs/internals/plugins.md +++ b/docs/internals/plugins.md @@ -10,7 +10,7 @@ Server modules are explicitly composed in [`compiled.ts`](../../apps/server/src/ Host interface version 1 rejects conflicting identities, duplicate contributions, missing capabilities, and incompatible versions before publication. Acquisition and migrations run after core recovery in an environment scope. Failure makes that optional plugin unavailable while ordinary T3 boot remains usable. A missing module retains its last descriptor and private database. Independently built clients gate contributions on both the environment capability and manifest. A server page missing from a client build produces an unavailable page in the normal shell. Capability removal or disconnection gates cached values immediately. -Navigation and attention links are bound to the selected environment, with optional project and thread context and a small page state that the route validates and drops when oversized. Links to pages the server or client does not contribute stay visible but unavailable. A failed or disconnected target never falls back to another server; an open page keeps its last snapshot with `connection: "disconnected"` and must hold mutations until it reconnects. Navigation entries render as icon buttons beside the sidebar's Settings, Pull Requests and Usage, so each names a host-owned icon. A page that renders the host `PageHeader` owns its breadcrumb and actions in the title bar and scrolls its own content below it. Pages get host-owned controls, a navigation guard that can leave while keeping a stored draft, and a draft store scoped to environment and plugin in browser storage with entry and total-size budgets. Drafts are editing state only. `Host.skills` rescans fresh by default; authoring reads pass `fresh: false` because a fresh scan drops other providers' cached workspace snapshots. Attention is a bounded read model owned by the plugin; opening its page or conversation does not resolve it. Pages own their mounted subscriptions. The host shares one attention subscription per environment and closes it when its last reader leaves. +Navigation and attention links are bound to the selected environment, with optional project and thread context and a small page state that the route validates and drops when oversized. Links to pages the server or client does not contribute stay visible but unavailable. A failed or disconnected target never falls back to another server; an open page keeps its last snapshot with `connection: "disconnected"` and must hold mutations until it reconnects. Navigation entries render as icon buttons beside the sidebar's Settings, Pull Requests and Usage, so each names a host-owned icon. A page that renders the host `PageHeader` owns its breadcrumb and actions in the title bar and scrolls its own content below it. Pages get host-owned controls, a navigation guard that can leave while keeping a stored draft, and a draft store scoped to environment and plugin in browser storage with entry and total-size budgets. Drafts are editing state only. `Host.skills` rescans fresh by default; authoring reads pass `fresh: false` because a fresh scan drops other providers' cached workspace snapshots. Attention is a bounded read model owned by the plugin; opening its page or conversation does not resolve it. A plugin may report its own total beside its capped items, and the shell shows that count, never the capped list length, next to the plugin's attention view. Pages own their mounted subscriptions. The host shares one attention subscription per environment and closes it when its last reader leaves. ## Ownership and recovery diff --git a/docs/user/workflows.md b/docs/user/workflows.md new file mode 100644 index 000000000000..dcfb1a8adc5c --- /dev/null +++ b/docs/user/workflows.md @@ -0,0 +1,49 @@ +# Workflows + +A workflow is a reusable process of agent steps, checks, decisions, parallel reviews and +human gates. Each agent step runs in an ordinary thread, so its messages, tools, diffs and +approvals stay where you already work. Workflows belong to one environment and project; the +page always shows which. + +## Build a workflow + +Open **Workflows** from the project navigation and choose **New workflow**. Add steps from +**Add a step**; a new step joins after the selected one. Every agent step submits a structured +report, and decisions route on its declared fields, a check's recorded result, or a review's +join. Switch the canvas to **Routes** to read or edit every route without dragging. Saving keeps your +draft if the connection drops or someone else saved first. + +## Review in parallel + +A **Parallel group** freezes one pull request's committed head when it starts and gives each +reviewer its own isolated worktree at that commit. Add reviewers with their own label, skill, +focus and deadline; two reviewers can use the same skill. Reviewers always work in plan mode +and ask before external actions. + +The group's **Wait for all** join decides only after every reviewer settles. An accepted +report is not a settled reviewer: three reports with one reviewer still running is still +waiting, and an early "changes" verdict does not stop the others. Join rules can read the +result (`all_completed`, `failed`, `unresolved`, `canceled` or `stale`) and each reviewer's +report. + +If the pull request head changes or cannot be verified, the review becomes stale instead of +being used. Choose **Rerun review** in the run to start a new generation +on the newly verified commit. Earlier generations keep their threads and evidence. Nothing is +merged automatically. + +## Run and recover + +Choose **Run workflow** from the project or library. The run view shows each visit, its thread, +whether a report was accepted, and why routing stopped. Only the actions the server allows +are offered: approve or request changes at a human gate, resume a retained session, retry, or +cancel. + +## Answer what needs you + +**Workflow attention** lists the runs that need a person, newest first, with a count of all +of them; when there are more than it can show, pick a project in the breadcrumb to narrow the list. Items are +human gates, each pending approval or question in a workflow thread, and stopped +runs with their reason. Open a request to answer it in its thread; a thread answers its +requests in order, so later ones wait for earlier ones. Each item clears only when its own +gate, request or recovery is resolved. Opening, reading or snoozing a thread does not clear +it, and a thread with a pending request cannot be snoozed. diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 33a67d49a7f3..fd235defaec7 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -50,6 +50,10 @@ type RpcMethod = WsRpcProtocolClient[TTag]; export type EnvironmentSubscriptionRpcTag = | "plugins.attention" | "plugins.fixture.subscribe" + | "plugins.workflows.subscribe" + | "plugins.workflows.watch" + | "plugins.workflows.thread" + | "plugins.workflows.attention" | 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 aa3533be31c0..120c8a406a17 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -15,6 +15,11 @@ export const CLIENT_GUARDED_RPC_SCOPES = { "plugins.fixture.schedule": AuthOrchestrationOperateScope, "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/contracts/src/pluginHost.ts b/packages/contracts/src/pluginHost.ts index 87813b3e16a6..4f85f75b4a64 100644 --- a/packages/contracts/src/pluginHost.ts +++ b/packages/contracts/src/pluginHost.ts @@ -163,10 +163,18 @@ export const PluginAttentionItem = Schema.Struct({ link: PluginPageLink, }); export type PluginAttentionItem = typeof PluginAttentionItem.Type; +/** A plugin's bounded newest-first items with its own count of everything needing attention. */ +export const PluginAttentionSummary = Schema.Struct({ + items: Schema.Array(PluginAttentionItem).check(Schema.isMaxLength(100)), + total: NonNegativeInt, +}); +export type PluginAttentionSummary = typeof PluginAttentionSummary.Type; export const PluginAttention = Schema.Struct({ environmentId: EnvironmentId, pluginId: PluginId, items: Schema.Array(PluginAttentionItem).check(Schema.isMaxLength(100)), + /** Everything needing attention; absent when the plugin reports only its capped items. */ + total: Schema.optional(NonNegativeInt), error: Schema.optional(PluginError), }); export type PluginAttention = typeof PluginAttention.Type; diff --git a/packages/plugin-host-adapter/src/PluginRegistry.ts b/packages/plugin-host-adapter/src/PluginRegistry.ts index ff06af699d40..0bae8b953d4c 100644 --- a/packages/plugin-host-adapter/src/PluginRegistry.ts +++ b/packages/plugin-host-adapter/src/PluginRegistry.ts @@ -448,13 +448,20 @@ const make = Effect.gen(function* () { [...registered].map(([pluginId, service]) => Stream.unwrap( Effect.sync(() => { - let lastItems: PluginAttention["items"] = []; + let last: Pick = { items: [] }; return service.attention.pipe( - Stream.map((items) => ({ environmentId, pluginId, items })), + Stream.map((value) => ({ + environmentId, + pluginId, + ...("items" in value ? value : { items: value }), + })), Stream.mapEffect((item) => decodeAttention(item)), Stream.tap((item) => Effect.sync(() => { - lastItems = item.items; + last = + item.total === undefined + ? { items: item.items } + : { items: item.items, total: item.total }; }), ), Stream.catchCause((cause) => @@ -476,7 +483,7 @@ const make = Effect.gen(function* () { Stream.succeed({ environmentId, pluginId, - items: lastItems, + ...last, error, }), Stream.fail(error), diff --git a/packages/plugin-host-contract/src/server.ts b/packages/plugin-host-contract/src/server.ts index 8e9df2e53a89..9c40d8e3d7b3 100644 --- a/packages/plugin-host-contract/src/server.ts +++ b/packages/plugin-host-contract/src/server.ts @@ -18,6 +18,7 @@ import type * as Rpc from "effect/rpc/Rpc"; import type { PluginAttentionItem, + PluginAttentionSummary, PluginCommandReceipt, PluginError, PluginLaunchInput, @@ -213,7 +214,11 @@ export interface PluginServices { readonly payload: Schema.Json; }) => Effect.Effect; }>; - readonly attention: Stream.Stream, PluginError>; + /** Capped items, or items with the plugin's total so a capped list is never read as the count. */ + readonly attention: Stream.Stream< + ReadonlyArray | PluginAttentionSummary, + PluginError + >; } export class Schedules extends Context.Service< diff --git a/packages/plugin-host-contract/src/web.ts b/packages/plugin-host-contract/src/web.ts index bd54909f0389..7ec848070f78 100644 --- a/packages/plugin-host-contract/src/web.ts +++ b/packages/plugin-host-contract/src/web.ts @@ -108,17 +108,57 @@ export interface PluginDesign { ControlProps & { readonly value: string; readonly onChange: (value: string) => void; + /** `sm` for dense rows such as a condition's field, operator and value. */ + readonly size?: "sm" | "default"; readonly options: ReadonlyArray<{ readonly value: string; readonly label: string; + /** Trailing muted text in the list, such as a type; the trigger shows only the label. */ + readonly detail?: string; readonly disabled?: boolean; }>; } >; + /** + * 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"; }>; + /** + * Supplementary text on hover and focus, such as the full value behind a truncated one. + * `children` is the visible trigger, truncated to its container. + */ + readonly Tooltip: ComponentType<{ readonly content: ReactNode; readonly children: ReactNode }>; + /** 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. */ @@ -134,6 +174,7 @@ export interface PluginDesign { readonly value: string; readonly onChange: (value: string) => void; readonly options: ReadonlyArray<{ readonly value: string; readonly label: string }>; + readonly disabled?: boolean; }>; /** A status or failure notice with optional actions; `children` is the short detail. */ readonly Alert: ComponentType<{ @@ -181,6 +222,19 @@ export interface PluginDesign { readonly breadcrumb: ReadonlyArray; readonly children?: ReactNode; }>; + /** + * 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 description?: string; + readonly footer?: ReactNode; + readonly children: ReactNode; + }>; /** Labeled panel for narrow layouts; plugins render the same content inline when wide. */ readonly Sheet: ComponentType<{ readonly open: boolean; @@ -244,4 +298,9 @@ export interface WebPlugin { readonly id: string; readonly render: (context: PluginWebContext & { readonly client: Client }) => ReactNode; }>; + /** + * The plugin's own view of everything its attention stream counts. The host shows the + * plugin's total beside it; opening it resolves nothing. + */ + readonly attention?: { readonly title: string; readonly link: PluginPageLink }; } 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..4a3cf0797f63 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, @@ -38,6 +42,10 @@ import { ScheduleInput, limits, type Attempt, + type StopKind, + type AttentionInput, + type AttentionKind, + type AttentionPage, } from "./contracts.ts"; import * as Catalog from "./Catalog.ts"; import * as Display from "./display.ts"; @@ -60,14 +68,30 @@ import { commandId, allowedActions, recoveryNode, + withheldDecisions, + attentionItems, + attentionRelevant, type State, } from "./graph.ts"; export class Workflow extends Context.Service< Workflow, { - readonly attention: Stream.Stream, PluginError>; + /** The host's environment summary: capped items with the distinct-run total. */ + readonly attention: Stream.Stream< + { readonly items: ReadonlyArray; readonly total: number }, + PluginError + >; + /** Newest page of runs needing attention, re-emitted when it changes. */ + readonly watchAttention: (input: AttentionInput) => Stream.Stream; + readonly readAttention: (input: AttentionInput) => Effect.Effect; 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 +127,31 @@ 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 attentionLabels: Record = { + "needs-review": "Needs review", + "needs-input": "Needs input", + interrupted: "Interrupted", + "missing-report": "Missing report", + "timed-out": "Timed out", + failed: "Failed", + "review-stale": "Review is stale", + "review-unverifiable": "Review input cannot be verified", + unavailable: "Could not continue", + unresolved: "Unresolved", +}; +/** Server-worded reason for one run, from its stable kinds and counts only. */ +const attentionReason = (run: AttentionPage["runs"][number]) => { + const kinds = [...new Set(run.items.map((item) => item.kind))]; + const requests = run.items.filter((item) => item.request !== null).length; + return kinds + .map((kind) => + kind === "needs-input" && requests > 0 + ? `${attentionLabels[kind]}: ${requests}${run.itemTotal > run.items.length ? "+" : ""} native ${requests === 1 ? "request" : "requests"}` + : attentionLabels[kind], + ) + .join(" · "); +}; const activeRun = (state: PluginThreadState) => state.runs.findLast( (run) => @@ -156,6 +205,13 @@ 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; + // Commits that can change what needs a person; attention reads skip all other commits. + // Runs not seen since start count as relevant, so a restart never hides a resolution. + let attentionVersion = 0; + const attentionRelevance = new Map(); yield* Effect.addFinalizer(() => PubSub.shutdown(changes)); const checks = new Map>(); const environment = (environmentId: string) => @@ -212,15 +268,26 @@ const make = Effect.gen(function* () { yield* enqueue(run, interruptId(attempt), "interrupt", attempt.id); } mutation++; + runVersions.set(run.id, run.revision); + const relevant = attentionRelevant(run); + if (relevant || attentionRelevance.get(run.id) !== false) attentionVersion++; + attentionRelevance.set(run.id, relevant); + if (run.attempts.some((attempt) => attempt.phase === "launching")) bindingVersion++; projectVersions.set(run.projectId, (projectVersions.get(run.projectId) ?? 0) + 1); return run; }); + // A rolled-back commit may have recorded relevance it never wrote; forget it so the next + // persist of any run re-reads attention. + const commit = (action: Effect.Effect) => + sql + .withTransaction(action) + .pipe(Effect.onError(() => Effect.sync(() => attentionRelevance.clear()))); const transaction = (operation: string, action: Effect.Effect) => protect( operation, Effect.gen(function* () { const before = mutation; - const result = yield* sql.withTransaction(action); + const result = yield* commit(action); if (mutation !== before) yield* notify; return result; }).pipe(lock.withPermits(1)), @@ -237,15 +304,128 @@ 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); + // The newest generation's complete required set, whichever history page is loaded. + const branches = review + ? review.branches.flatMap((branch) => { + const attempt = run.attempts.find((attempt) => attempt.id === branch.attemptId); + return attempt ? [{ id: branch.id, attempt }] : []; + }) + : []; 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, + }, + withheld: withheldDecisions(run), + 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, + fork: review.fork, + generation: review.generation, + head: review.head, + pullRequest: review.pullRequest, + required: review.branches.length, + reported: branches.filter(({ attempt }) => attempt.report).length, + settled: branches.filter(({ attempt }) => terminalAttempt(attempt)).length, + result: review.result, + ...(review.cause === undefined ? {} : { cause: review.cause }), + branches: branches.map(({ id, attempt }) => ({ + id, + attemptId: attempt.id, + threadId: attempt.threadId, + phase: attempt.phase, + reason: attempt.reason, + report: attempt.report + ? { + outcome: attempt.report.outcome, + acceptedAt: attempt.report.receipt.acceptedAt, + } + : null, + deadline: attempt.deadline, + workspace: + attempt.launch?.workspace.type === "existing" + ? { + path: attempt.launch.workspace.path, + branch: attempt.launch.workspace.branch, + frozenHead: attempt.launch.workspace.frozenHead ?? null, + } + : null, + })), + } + : 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 +458,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,53 +514,238 @@ const make = Effect.gen(function* () { ); }), ); - const attentionItems = protect( - "attention", - Effect.gen(function* () { - const rows = yield* sql<{ - data: string; - }>`SELECT data FROM workflow_runs WHERE state IN ('awaiting-review', 'unresolved') OR (state = 'running' AND EXISTS (SELECT 1 FROM json_each(workflow_runs.data, '$.attempts') WHERE json_extract(value, '$.phase') = 'waiting-input')) ORDER BY rowid LIMIT 100`; - return yield* Effect.forEach(rows, (row) => - Effect.gen(function* () { - const run = yield* decodeRun(row.data); - const threadIds = run.attempts.flatMap((attempt) => - attempt.threadId ? [attempt.threadId] : [], - ); - const reason = yield* host.redact({ - text: - run.reason ?? - (run.state === "awaiting-review" - ? "The workflow needs a human decision." - : "Native execution requires your input."), - threadIds, - }); - const summary = yield* host.redact({ text: run.definition.title, threadIds }); - const relatedThread = - run.attempts.find((attempt) => attempt.phase === "waiting-input")?.threadId ?? - run.attempts.at(-1)?.threadId; - return { - id: run.id, - summary: summary.slice(0, 240).trim(), - severity: "warning" as const, - reason: reason.slice(0, 500).trim(), - link: { - pageId: "workflows.runs", + 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))); + }), + ); + /** + * Runs needing a person, newest first, with the distinct-run total of the whole scope. The + * same predicate selects and counts, so a capped page is never mistaken for the total. + */ + const attentionPage = (input: { + readonly projectId: string | null; + readonly before: string | null; + readonly limit: number; + }) => + protect( + "attention", + Effect.gen(function* () { + const [count] = yield* sql<{ + total: number; + }>`SELECT COUNT(*) AS total FROM workflow_runs WHERE (${input.projectId} IS NULL OR project_id = ${input.projectId}) AND (state IN ('awaiting-review', 'unresolved') OR (state = 'running' AND EXISTS (SELECT 1 FROM json_each(workflow_runs.data, '$.attempts') WHERE json_extract(value, '$.phase') = 'waiting-input')))`; + const rows = yield* sql<{ + data: string; + }>`SELECT data FROM workflow_runs WHERE (${input.projectId} IS NULL OR project_id = ${input.projectId}) AND (state IN ('awaiting-review', 'unresolved') OR (state = 'running' AND EXISTS (SELECT 1 FROM json_each(workflow_runs.data, '$.attempts') WHERE json_extract(value, '$.phase') = 'waiting-input'))) AND (${input.before} IS NULL OR rowid < (SELECT rowid FROM workflow_runs WHERE id = ${input.before})) ORDER BY rowid DESC LIMIT ${input.limit + 1}`; + const runs = yield* Effect.forEach(rows.slice(0, input.limit), (row) => + Effect.gen(function* () { + const run = yield* decodeRun(row.data); + const all = attentionItems(run); + const listed = all.slice(0, limits.attentionItems); + const label = (nodeId: string, branchId: string | null) => { + const node = run.definition.nodes.find((node) => node.id === nodeId); + const branch = + node?.kind === "parallel" + ? node.branches.find((branch) => branch.id === branchId) + : undefined; + return branch && node ? `${node.title} · ${branch.title}` : (node?.title ?? nodeId); + }; + const [workflowTitle, ...titles] = yield* Display.displayTexts( + host, + [run.definition.title, ...listed.map((item) => label(item.nodeId, item.branchId))], + run.attempts.flatMap((attempt) => (attempt.threadId ? [attempt.threadId] : [])), + ); + const items = listed.map((item, index) => ({ + ...item, + title: titles[index]!.slice(0, 240).trim(), + })); + return { + runId: run.id, projectId: run.projectId, - ...(relatedThread ? { threadId: relatedThread } : {}), + workflowTitle: workflowTitle!.slice(0, 240).trim(), + state: run.state, + revision: run.revision, + createdAt: run.createdAt, + allowedActions: run.allowedActions, + items, + itemTotal: all.length, + }; + }), + ); + return { + total: count?.total ?? 0, + runs, + before: rows.length > input.limit ? (runs.at(-1)?.runId ?? null) : null, + } satisfies AttentionPage; + }), + ); + /** Re-read only after attention-relevant commits, and deliver only pages that differ. */ + const followAttention = (load: Effect.Effect) => + Stream.unwrap( + Effect.gen(function* () { + // Subscribe before the first read so no committed change is missed. + const subscription = yield* PubSub.subscribe(changes); + let sent: string | null = null; + let observed: number | null = null; + // The version is taken before reading, so a commit during the read triggers another. + const read = Effect.suspend(() => { + if (observed === attentionVersion) return Effect.succeed(null); + observed = attentionVersion; + return load; + }); + const fresh = (page: AttentionPage | null) => { + if (page === null) return []; + const encoded = canonical(page); + if (encoded === sent) return []; + sent = encoded; + return [page]; + }; + return Stream.concat( + Stream.fromEffect(read), + Stream.fromSubscription(subscription).pipe(Stream.mapEffect(() => read)), + ).pipe(Stream.flatMap((page) => Stream.fromIterable(fresh(page)))); + }), + ); + const attentionInput = (input: AttentionInput) => + environment(input.environmentId).pipe( + Effect.as({ + projectId: input.projectId ?? null, + before: input.before ?? null, + limit: input.limit ?? limits.attentionRuns, + }), + ); + const watchAttention = (input: AttentionInput) => + Stream.unwrap( + attentionInput(input).pipe(Effect.map((scope) => followAttention(attentionPage(scope)))), + ); + const readAttention = (input: AttentionInput) => + attentionInput(input).pipe(Effect.flatMap(attentionPage)); + /** The host's environment summary: one item per run with its exact place to act. */ + const attention = followAttention( + attentionPage({ projectId: null, before: null, limit: 100 }), + ).pipe( + Stream.map((page) => ({ + total: page.total, + items: page.runs.map((run) => { + const first = run.items[0]; + return { + id: run.runId, + summary: run.workflowTitle || run.runId, + severity: "warning" as const, + reason: attentionReason(run).slice(0, 500), + link: { + pageId: "workflows.runs", + projectId: run.projectId, + state: { + run: run.runId, + ...(first?.attemptId + ? { attempt: first.attemptId } + : first + ? { node: first.nodeId } + : {}), }, - }; - }), - ); - }), - ); - const attention = Stream.unwrap( - Effect.gen(function* () { - const subscription = yield* PubSub.subscribe(changes); - return Stream.concat( - Stream.fromEffect(attentionItems), - Stream.fromSubscription(subscription).pipe(Stream.mapEffect(() => attentionItems)), - ); - }), + }, + }; + }), + })), ); const snapshots = Effect.fnUntraced(function* ( projectId: ProjectId, @@ -492,11 +858,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 +953,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 +965,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, @@ -712,6 +1127,7 @@ const make = Effect.gen(function* () { run.state = "canceled"; run.reason = "Canceled by the user."; run.gate = null; + run.stop = null; for (const review of run.reviews) if (!review.result) review.result = "canceled"; for (const attempt of run.attempts) if (!terminalAttempt(attempt)) { @@ -758,6 +1174,7 @@ const make = Effect.gen(function* () { attempt.resumable = false; run.state = "running"; run.reason = null; + run.stop = null; yield* enqueue(run, `${attempt.id}:resume:${attempt.resumeCount}`, "resume", attempt.id); }), ); @@ -783,6 +1200,7 @@ const make = Effect.gen(function* () { freshness?._tag === "Success" ? "The pull request head changed after review." : "The reviewed pull request head cannot be verified.", + freshness?._tag === "Success" ? "review-stale" : "review-unverifiable", ); return; } @@ -791,6 +1209,7 @@ const make = Effect.gen(function* () { transition(run, node.id, input.decision === "approve" ? node.approve : node.changes, now, { sourceIds: review ? [review.id] : [], reason: `Human decision: ${input.decision}.`, + control: input.decision === "approve" ? "approve" : "changes", }); }), ); @@ -825,7 +1244,7 @@ const make = Effect.gen(function* () { ); const now = yield* Clock.currentTimeMillis; const before = mutation; - yield* sql.withTransaction( + yield* commit( Effect.gen(function* () { const active = activeRun(state); const pendingRequests = state.requests.filter((request) => @@ -864,12 +1283,29 @@ const make = Effect.gen(function* () { return end > accountingFrom && end - request.createdAt >= agent.humanTimeoutMs!; }); attempt.nativeSessionId = state.nativeSession?.id ?? null; + // Why this attempt stopped, recorded with the run's disposition below. + let cause: StopKind = "unresolved"; + // Pending requests in native queue order; a change is new attention to show. + const queued = pendingRequests + .toSorted((left, right) => left.createdAt - right.createdAt) + .slice(0, limits.requests) + .map(({ id, kind, createdAt }) => ({ id, kind, createdAt })); + // The uncapped count keeps "request N of M" truthful beyond the retained list. + const requestsChanged = + canonical(attempt.requests ?? []) !== canonical(queued) || + (attempt.pendingRequests ?? queued.length) !== pendingRequests.length; + if (requestsChanged) { + attempt.requests = queued; + attempt.pendingRequests = pendingRequests.length; + } if (attempt.deadline !== null && until >= attempt.deadline) { attempt.phase = "unresolved"; attempt.reason = "The review branch deadline expired."; + cause = "timed-out"; } else if (humanExpired) { attempt.phase = "unresolved"; attempt.reason = "The human-response deadline expired."; + cause = "timed-out"; } else if (request) { const waitStartedAt = Math.min( now, @@ -889,8 +1325,9 @@ const make = Effect.gen(function* () { if (attempt.remainingMs === 0) { attempt.phase = "unresolved"; attempt.reason = "The execution timeout expired."; + cause = "timed-out"; } else { - if (enteredWait) yield* persist(run); + if (enteredWait || requestsChanged) yield* persist(run); return; } } else { @@ -914,15 +1351,17 @@ const make = Effect.gen(function* () { ) { attempt.phase = "unresolved"; attempt.reason = "The execution timeout expired."; + cause = "timed-out"; } else if ( active || state.outstandingWork.length > 0 || checkpoints.some((checkpoint) => ["pending", "capturing", "running"].includes(checkpoint.status), ) - ) + ) { + if (requestsChanged) yield* persist(run); return; - else { + } else { const execution = state.runs.findLast( (run) => state.resultRunId === undefined || run.id === state.resultRunId, ); @@ -948,6 +1387,7 @@ const make = Effect.gen(function* () { attempt.remainingMs > 0 && (attempt.deadline === null || attempt.deadline > now); attempt.reason = "Native execution was explicitly interrupted."; + cause = "interrupted"; } else if ( generation.some((run) => run.resultRelevant !== false && run.status === "failed") || execution.status === "failed" || @@ -957,8 +1397,11 @@ const make = Effect.gen(function* () { ) { attempt.phase = "failed"; attempt.reason = "Native execution or checkpoint failed."; - } else if (execution.status !== "completed") return; - else if (!attempt.report) { + cause = "failed"; + } else if (execution.status !== "completed") { + if (requestsChanged) yield* persist(run); + return; + } else if (!attempt.report) { if (!attempt.reminderSent) { attempt.remainingMs = Math.max( 0, @@ -972,13 +1415,16 @@ const make = Effect.gen(function* () { } attempt.phase = "unresolved"; attempt.reason = "Execution settled after one reminder without an accepted report."; - } else + cause = "missing-report"; + } else { attempt.phase = attempt.report.outcome === "completed" ? "completed" : attempt.report.outcome === "failed" ? "failed" : "unresolved"; + if (attempt.phase === "failed") cause = "failed"; + } } } if (attempt.branchId === null) { @@ -988,13 +1434,21 @@ const make = Effect.gen(function* () { transition(run, node.id, node.next, now, { attemptId, sourceIds: [attempt.report!.receipt.id], + control: "next", }); else if (node.onUnresolved) transition(run, node.id, node.onUnresolved, now, { attemptId, reason: attempt.reason ?? "The agent reported unsuccessful execution.", + control: "onUnresolved", }); - else unresolved(run, attempt.reason ?? "The agent reported unsuccessful execution."); + else + unresolved( + run, + attempt.reason ?? "The agent reported unsuccessful execution.", + cause, + attempt.id, + ); } } else yield* enqueue(run, `${run.id}:join:${attempt.id}`, "node"); yield* persist(run); @@ -1065,7 +1519,7 @@ const make = Effect.gen(function* () { ? "The execution reminder deadline expired." : "The execution launch deadline expired."; if (owned.branchId) yield* enqueue(current, `${current.id}:join:${owned.id}`, "node"); - else unresolved(current, owned.reason); + else unresolved(current, owned.reason, "timed-out", owned.id); yield* persist(current); }), ); @@ -1113,7 +1567,7 @@ const make = Effect.gen(function* () { if (terminalAttempt(current) || state.state !== "running") return; current.phase = "unresolved"; current.reason = "The owning native thread is unavailable."; - if (!current.branchId) unresolved(state, current.reason); + if (!current.branchId) unresolved(state, current.reason, "unavailable", current.id); else yield* enqueue(state, `${state.id}:join:${current.id}`, "node"); yield* persist(state); }), @@ -1149,13 +1603,21 @@ const make = Effect.gen(function* () { attemptId, sourceIds: [attempt.id], reason: "Recorded deterministic check result.", + control: "next", }); else if (node.onUnresolved) transition(run, node.id, node.onUnresolved, now, { attemptId, reason: "The check was interrupted or timed out.", + control: "onUnresolved", }); - else unresolved(run, "The check result is unresolved. An explicit retry is required."); + else + unresolved( + run, + "The check result is unresolved. An explicit retry is required.", + result!.timedOut ? "timed-out" : result!.interrupted ? "interrupted" : "unresolved", + attemptId, + ); yield* persist(run); }), ); @@ -1181,7 +1643,11 @@ const make = Effect.gen(function* () { const run = yield* load(runId); if (run.state !== "running" || run.currentNode !== node.id) return; run.currentNode = review.fork; - unresolved(run, "The reviewed pull request head changed or cannot be verified."); + unresolved( + run, + "The reviewed pull request head changed or cannot be verified.", + freshness?._tag === "Success" ? "review-stale" : "review-unverifiable", + ); yield* persist(run); }), ); @@ -1199,7 +1665,7 @@ const make = Effect.gen(function* () { ? run.reviews.findLast((review) => review.fork === source.fork) : null; if (!review?.result && !attempt?.check && !attempt?.report) { - unresolved(run, `Decision input ${node.source} is unavailable.`); + unresolved(run, `Decision input ${node.source} is unavailable.`, "unavailable"); yield* persist(run); return; } @@ -1233,7 +1699,11 @@ const make = Effect.gen(function* () { const run = yield* load(runId); if (run.state !== "running" || run.currentNode !== node.id) return; if (freshness._tag !== "Success" || commit?._tag !== "Success") { - unresolved(run, "The intended committed pull request head cannot be verified."); + unresolved( + run, + "The intended committed pull request head cannot be verified.", + "review-unverifiable", + ); yield* persist(run); return; } @@ -1278,7 +1748,11 @@ const make = Effect.gen(function* () { if (run.state !== "running" || run.currentNode !== node.id) return; const current = run.reviews.findLast((review) => review.fork === node.fork); if (current && !current.result && !current.consumed) return; - unresolved(run, `Join ${node.id} has no fresh, unconsumed fork generation.`); + unresolved( + run, + `Join ${node.id} has no fresh, unconsumed fork generation.`, + "unresolved", + ); yield* persist(run); }), ); @@ -1329,6 +1803,18 @@ const make = Effect.gen(function* () { attempt.reason = "The reviewer checkout changed from its frozen input."; } } + // Record what decided the aggregate when the branches alone do not explain it. + const cause = + freshness._tag !== "Success" + ? ("head-unverifiable" as const) + : freshness.success.head !== current.head + ? ("head-changed" as const) + : workspaceEvidence.some((item) => item?._tag !== "Success") + ? ("workspace-unavailable" as const) + : workspaceEvidence.some((item) => item._tag === "Success" && !item.success.valid) + ? ("workspace-changed" as const) + : undefined; + if (cause) current.cause = cause; current.result = freshness._tag !== "Success" ? "unresolved" @@ -1407,7 +1893,7 @@ const make = Effect.gen(function* () { "The resumed execution identity is unavailable.", "service", ); - yield* sql.withTransaction( + yield* commit( Effect.gen(function* () { // The command lock remains held until the new owned execution is retained. if (kind === "resume" && execution) attempt.executionRunId = execution.id; @@ -1417,7 +1903,7 @@ const make = Effect.gen(function* () { attempt.phase = "unresolved"; attempt.reason = receipt.error ?? "The follow-up was rejected."; if (attempt.branchId) yield* enqueue(run, `${run.id}:join:${attempt.id}`, "node"); - else unresolved(run, attempt.reason); + else unresolved(run, attempt.reason, "unavailable", attempt.id); } yield* persist(run); }), @@ -1446,7 +1932,12 @@ const make = Effect.gen(function* () { current.reason = "The reviewed pull request head changed or cannot be verified before execution."; run.currentNode = review?.fork ?? current.nodeId; - unresolved(run, current.reason); + unresolved( + run, + current.reason, + freshness?._tag === "Success" ? "review-stale" : "review-unverifiable", + current.id, + ); yield* persist(run); }), ); @@ -1704,7 +2195,7 @@ const make = Effect.gen(function* () { if (receipt.status === "rejected" || receipt.threadId !== current.threadId) { current.phase = "unresolved"; current.reason = receipt.error ?? "The launch receipt did not match the bound thread."; - if (!current.branchId) unresolved(run, current.reason); + if (!current.branchId) unresolved(run, current.reason, "unavailable", current.id); else yield* enqueue(run, `${run.id}:join:${current.id}`, "node"); } else { current.phase = current.report ? "reported" : "running"; @@ -1750,9 +2241,10 @@ const make = Effect.gen(function* () { const attempt = run.attempts.find((attempt) => attempt.id === item.attempt_id)!; attempt.phase = "unresolved"; attempt.reason = result.failure.message; - if (!attempt.branchId) unresolved(run, result.failure.message); + if (!attempt.branchId) + unresolved(run, result.failure.message, "unavailable", attempt.id); else yield* enqueue(run, `${run.id}:join:${attempt.id}`, "node"); - } else unresolved(run, result.failure.message); + } else unresolved(run, result.failure.message, "unavailable"); yield* persist(run); }), ); @@ -1829,7 +2321,7 @@ const make = Effect.gen(function* () { owned.reason = validation.failure.message; if (owned.branchId) yield* enqueue(current, `${current.id}:join:${owned.id}`, "node"); - else unresolved(current, owned.reason); + else unresolved(current, owned.reason, "unavailable", owned.id); yield* persist(current); }), ); @@ -1909,19 +2401,27 @@ 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, + watchAttention, + readAttention, 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..8d888aceec42 100644 --- a/packages/plugin-workflows/src/contracts.ts +++ b/packages/plugin-workflows/src/contracts.ts @@ -32,6 +32,17 @@ 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, + /** Pending native requests retained per attempt, oldest first. */ + requests: 32, + /** Runs per attention page, and items listed per run on it. */ + attentionRuns: 25, + /** Most runs one attention read returns: the host summary and fully loaded pages. */ + attentionRunsMax: 100, + attentionItems: 64, 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)); @@ -269,8 +280,38 @@ export const Attempt = Schema.Struct({ lastActiveAt: Schema.Number, deadline: Schema.NullOr(Schema.Number), waitStartedAt: Schema.NullOr(Schema.Number), + /** + * The thread's pending native requests in native queue order, as last inspected. Absent on + * attempts recorded before requests were kept. + */ + requests: Schema.optional( + Schema.Array( + Schema.Struct({ id: Schema.String, kind: Schema.String, createdAt: Schema.Number }), + ).check(Schema.isMaxLength(limits.requests)), + ), + /** Every pending native request of the thread, including any beyond the retained list. */ + pendingRequests: Schema.optional(Schema.Int), }); export type Attempt = typeof Attempt.Type; +/** + * Stable, server-owned reasons a run needs a person. Clients word them; they never derive + * them from prose. Ordinary running work is progress and has no reason. + */ +const stopKinds = [ + "interrupted", + "missing-report", + "timed-out", + "failed", + "review-stale", + "review-unverifiable", + "unavailable", + "unresolved", +] as const; +/** Why an unresolved run stopped; set with the disposition, cleared when the run continues. */ +export const StopKind = Schema.Literals(stopKinds); +export type StopKind = typeof StopKind.Type; +export const AttentionKind = Schema.Literals(["needs-review", "needs-input", ...stopKinds]); +export type AttentionKind = typeof AttentionKind.Type; export const Trace = Schema.Struct({ id: Schema.String, nodeId: Id, @@ -280,6 +321,25 @@ export const Trace = Schema.Struct({ chosen: Id, reason: Schema.String, repeatCount: Schema.NullOr(Schema.Int), + /** Inspector control of the authored route that fired (`next`, `rules.0`, `changes`…). */ + route: Schema.optional(Schema.String.check(Schema.isMaxLength(128))), + /** + * The fired route's authored repeat bound and what happened to the repeat: admitted (the + * counter was spent), limit (exhausted, so the route went to its At limit), or diverted by + * the whole-run visit limit or stopped automation (the counter was not spent). Absent for + * ordinary routes and older records. + */ + repeat: Schema.optional( + Schema.Struct({ + max: Schema.Int, + atLimit: Id, + exhausted: Schema.Boolean, + /** Absent on records written before outcomes were kept; `exhausted` then decides. */ + outcome: Schema.optional( + Schema.Literals(["admitted", "limit", "visit-limit", "automation-stopped"]), + ), + }), + ), at: Schema.Number, }); export const ReviewSet = Schema.Struct({ @@ -293,6 +353,56 @@ export const ReviewSet = Schema.Struct({ Schema.Literals(["all_completed", "failed", "unresolved", "canceled", "stale"]), ), consumed: Schema.Boolean, + /** + * What decided an aggregate that the branches alone do not explain: the frozen head + * changed or could not be verified, or a reviewer checkout was unavailable or changed. + */ + cause: Schema.optional( + Schema.Literals([ + "head-changed", + "head-unverifiable", + "workspace-unavailable", + "workspace-changed", + ]), + ), +}); +/** One required reviewer of a review generation, as the server last recorded it. */ +export const ReviewBranch = Schema.Struct({ + id: Id, + attemptId: Schema.String, + threadId: Schema.NullOr(ThreadId), + phase: Attempt.fields.phase, + reason: Schema.NullOr(Schema.String), + report: Schema.NullOr( + Schema.Struct({ + outcome: ReportInput.fields.outcome, + acceptedAt: Schema.Number, + }), + ), + deadline: Schema.NullOr(Schema.Number), + /** The isolated checkout prepared for this reviewer, once its launch was reserved. */ + workspace: Schema.NullOr( + Schema.Struct({ + path: Schema.String, + branch: Schema.NullOr(Schema.String), + frozenHead: Schema.NullOr(Schema.String), + }), + ), +}); +export type ReviewBranch = typeof ReviewBranch.Type; +/** 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, @@ -322,6 +432,10 @@ export const Run = Schema.Struct({ workspacePath: Schema.NullOr(Schema.String), branch: Schema.NullOr(Schema.String), reason: Schema.NullOr(Schema.String), + /** Why an unresolved run stopped. Absent on runs recorded before stop kinds were kept. */ + stop: Schema.optional( + Schema.NullOr(Schema.Struct({ kind: StopKind, attemptId: Schema.NullOr(Schema.String) })), + ), gate: Schema.NullOr( Schema.Struct({ nodeId: Id, revision: Schema.Int, reviewId: Schema.NullOr(Schema.String) }), ), @@ -329,10 +443,70 @@ 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 field: gate decisions left out of `allowedActions` because their authored route + * would be diverted, with the step it leads to and why. + */ + withheld: Schema.optional( + Schema.Array( + Schema.Struct({ + action: Schema.Literals(["approve", "request-changes"]), + to: Id, + repeat: Schema.Boolean, + cause: Schema.Literals(["visit-limit", "automation-stopped"]), + }), + ).check(Schema.isMaxLength(2)), + ), + /** 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)), + /** + * The newest review generation with its complete required branch set, so a join's + * progress never depends on the loaded history page. + */ + review: Schema.NullOr( + Schema.Struct({ + id: Schema.String, + fork: Id, + generation: Schema.Int, + head: Schema.String, + pullRequest: PluginPullRequestRef, + required: Schema.Int, + reported: Schema.Int, + settled: Schema.Int, + result: ReviewSet.fields.result, + cause: ReviewSet.fields.cause, + branches: Schema.Array(ReviewBranch).check(Schema.isMaxLength(32)), + }), + ), + }), + ), + /** 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 +530,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, @@ -386,6 +561,64 @@ export const RunListInput = Schema.Struct({ projectId: ProjectId, before: Schema.optional(Schema.String), }); +export const AttentionInput = Schema.Struct({ + environmentId: EnvironmentId, + /** Only this project's runs; omitted, every project in the environment. */ + projectId: Schema.optional(ProjectId), + /** Continue after this run (newest first); omitted, the newest page. */ + before: Schema.optional(Schema.String.check(Schema.isMaxLength(256))), + /** Newest runs to return; omitted, one page. Loading older runs raises it. */ + limit: Schema.optional( + Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: limits.attentionRunsMax })), + ), +}); +export type AttentionInput = typeof AttentionInput.Type; +/** One thing a person must do for a run, with the exact place to do it. */ +export const AttentionItem = Schema.Struct({ + /** Stable while the underlying condition lasts. */ + id: Schema.String, + kind: AttentionKind, + /** Step (and reviewer) label from the run's snapshot. */ + title: Schema.String, + nodeId: Id, + branchId: Schema.NullOr(Id), + attemptId: Schema.NullOr(Schema.String), + threadId: Schema.NullOr(ThreadId), + /** The gate revision a decision is bound to (needs-review only). */ + gateRevision: Schema.NullOr(Schema.Int), + /** A pending native request and its place in that thread's native queue (1 = answered next). */ + request: Schema.NullOr( + Schema.Struct({ + id: Schema.String, + kind: Schema.String, + position: Schema.Int, + pending: Schema.Int, + createdAt: Schema.Number, + }), + ), +}); +export type AttentionItem = typeof AttentionItem.Type; +export const AttentionRun = Schema.Struct({ + runId: Schema.String, + projectId: ProjectId, + workflowTitle: Schema.String, + state: Run.fields.state, + revision: Schema.Int, + createdAt: Schema.Number, + allowedActions: Run.fields.allowedActions, + items: Schema.Array(AttentionItem).check(Schema.isMaxLength(limits.attentionItems)), + /** Items of this run, including any beyond the listed ones. */ + itemTotal: Schema.Int, +}); +export type AttentionRun = typeof AttentionRun.Type; +export const AttentionPage = Schema.Struct({ + /** Distinct runs needing attention in scope, independent of this page. */ + total: Schema.Int, + runs: Schema.Array(AttentionRun).check(Schema.isMaxLength(limits.attentionRunsMax)), + /** Cursor of the next older page; null on the last page. */ + before: Schema.NullOr(Schema.String), +}); +export type AttentionPage = typeof AttentionPage.Type; /** * A located validation result. `control` names the inspector control that repairs it, * relative to the node (for example `instruction`, `next` or `report.fields.0`). @@ -539,17 +772,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 +908,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, @@ -628,6 +951,18 @@ export const rpcs = { retry: Rpc.make("plugins.workflows.retry", { payload: CommandInput, success: Run, error }), resume: Rpc.make("plugins.workflows.resume", { payload: CommandInput, success: Run, error }), gate: Rpc.make("plugins.workflows.gate", { payload: GateInput, success: Run, error }), + attention: Rpc.make("plugins.workflows.attention", { + payload: AttentionInput, + success: AttentionPage, + error, + stream: true, + }), + // One-shot read of the same model for agents, scripts and tests; pages use `attention`. + attentionPage: Rpc.make("plugins.workflows.attention-page", { + payload: AttentionInput, + success: AttentionPage, + error, + }), schedule: Rpc.make("plugins.workflows.schedule", { payload: ScheduleInput, success: Schema.Void, @@ -646,6 +981,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, @@ -654,6 +993,8 @@ export const apiScopes = { [rpcs.retry._tag]: AuthOrchestrationOperateScope, [rpcs.resume._tag]: AuthOrchestrationOperateScope, [rpcs.gate._tag]: AuthOrchestrationOperateScope, + [rpcs.attention._tag]: AuthOrchestrationReadScope, + [rpcs.attentionPage._tag]: AuthOrchestrationReadScope, [rpcs.schedule._tag]: AuthOrchestrationOperateScope, } as const; export const manifest = { @@ -684,17 +1025,23 @@ export const manifest = { scheduleTargets: ["workflows.start"], }, web: { - pages: ["workflows.library", "workflows.editor"], - navigation: ["workflows.navigation"], - projectActions: ["workflows.project"], - threadContext: [], + pages: ["workflows.library", "workflows.editor", "workflows.runs", "workflows.attention"], + navigation: ["workflows.navigation", "workflows.attention-navigation"], + 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 +1062,37 @@ 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; + /** The newest `limit` runs needing attention, live; returns the unsubscribe function. */ + readonly subscribeAttention: ( + input: { readonly projectId: ProjectId | null; readonly limit: number }, + onPage: (page: AttentionPage) => 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/definition.ts b/packages/plugin-workflows/src/definition.ts index 5984d4b8fd76..ce815d981fb1 100644 --- a/packages/plugin-workflows/src/definition.ts +++ b/packages/plugin-workflows/src/definition.ts @@ -130,6 +130,17 @@ export function routeControls(node: Node): ReadonlyArray<{ control: string; rout return []; } } +const routeNames: Record = { + next: "Next", + onUnresolved: "If unresolved", + otherwise: "Otherwise", + approve: "Approve", + changes: "Request changes", +}; +const routeName = (control: string) => + control.startsWith("rules.") + ? `Rule ${Number(control.split(".")[1]) + 1}` + : (routeNames[control] ?? control); /** Located validation results; errors block publication, warnings never do. */ export function definitionDiagnostics(definition: Definition): Problem[] { const problems: Problem[] = []; @@ -148,8 +159,19 @@ export function definitionDiagnostics(definition: Definition): Problem[] { add("The run At limit destination must be an end or human gate.", { control: "atLimit" }); let edges = 0; for (const node of definition.nodes) { + // Repeat counters are keyed by step and Return to, so two such routes would share one. + const repeatTargets = new Map(); for (const { control, route } of routeControls(node)) { edges++; + if (route.repeat) { + const first = repeatTargets.get(route.to); + if (first === undefined) repeatTargets.set(route.to, control); + else + add( + `${node.id}: ${routeName(first)} already repeats back to ${route.to}, and repeats from one step to the same step share one counter. Return to a different step or keep a single repeat route.`, + { nodeId: node.id, control: `${control}.repeat` }, + ); + } if (!nodes.has(route.to)) add(`${node.id}: unknown route target ${route.to}.`, { nodeId: node.id, control }); const target = nodes.get(route.to); @@ -203,15 +225,28 @@ export function definitionDiagnostics(definition: Definition): Problem[] { return; } if (predicate.op === "all" || predicate.op === "any") { - for (const term of predicate.terms ?? []) visit(term, depth + 1, control); + // Each term is located by its index path, so a problem names the exact condition. + (predicate.terms ?? []).forEach((term, index) => + visit(term, depth + 1, `${control}.${index}`), + ); return; } const field = fields.get(predicate.path ?? ""); if (!field) { - add(`${node.id}: unknown predicate path ${predicate.path}.`, { - nodeId: node.id, - control, - }); + // A join rule that reads a removed reviewer names it, so the dangling rule is obvious. + const reviewer = + node.kind === "join" + ? /^branches\.([^.]+)\./.exec(predicate.path ?? "")?.[1] + : undefined; + const fork = node.kind === "join" ? nodes.get(node.fork) : undefined; + add( + reviewer !== undefined && + fork?.kind === "parallel" && + !fork.branches.some((branch) => branch.id === reviewer) + ? `${node.id}: a rule reads reviewer ${reviewer}, which is no longer in ${fork.id}. Choose another field or remove the condition.` + : `${node.id}: unknown predicate path ${predicate.path}.`, + { nodeId: node.id, control }, + ); return; } if (["gt", "gte", "lt", "lte"].includes(predicate.op) && field.type !== "number") @@ -229,7 +264,7 @@ export function definitionDiagnostics(definition: Definition): Problem[] { control, }); }; - node.rules.forEach((rule, index) => visit(rule.when, 1, `rules.${index}`)); + node.rules.forEach((rule, index) => visit(rule.when, 1, `rules.${index}.when`)); if (terms > limits.predicateTerms) add(`${node.id}: too many predicate terms.`, { nodeId: node.id, control: "rules" }); } @@ -295,6 +330,16 @@ export function definitionDiagnostics(definition: Definition): Problem[] { previous.push(node.id); predecessors.set(destination, previous); } + // A decision can only read a source that runs before it on some path. + for (const node of definition.nodes) { + if (node.kind !== "decision" || !nodes.has(node.source)) continue; + const before = upstreamOf(definition, node.id); + if (!before.has(node.source)) + add( + `${node.id}: ${node.source} never runs before this decision, so there is no result to decide on. Choose an earlier step.`, + { nodeId: node.id, control: "source" }, + ); + } const reachable = new Set([...nodes.keys()].filter(terminal)); const pending = [...reachable]; for (let index = 0; index < pending.length; index++) @@ -324,6 +369,23 @@ export function definitionDiagnostics(definition: Definition): Problem[] { ); return problems; } +/** Steps that run before `id` on some path, following every route and repeat At limit. */ +export function upstreamOf(definition: Definition, id: string): Set { + const predecessors = new Map(); + for (const node of definition.nodes) + for (const route of routes(node)) + for (const to of [route.to, ...(route.repeat ? [route.repeat.atLimit] : [])]) + predecessors.set(to, [...(predecessors.get(to) ?? []), node.id]); + const before = new Set(); + const queue = [id]; + for (let index = 0; index < queue.length; index++) + for (const predecessor of predecessors.get(queue[index]!) ?? []) + if (!before.has(predecessor)) { + before.add(predecessor); + queue.push(predecessor); + } + return before; +} export function definitionProblems(definition: Definition): string[] { return definitionDiagnostics(definition) .filter((problem) => problem.severity === "error") diff --git a/packages/plugin-workflows/src/display.ts b/packages/plugin-workflows/src/display.ts index dde8e1b8124c..90c1aa41359d 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,18 +210,40 @@ 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), })), + ...(run.overview?.review == null + ? {} + : { + overview: { + ...run.overview, + review: { + ...run.overview.review, + pullRequest: pullRequest(run.overview.review.pullRequest, text), + branches: run.overview.review.branches.map((branch) => ({ + ...branch, + reason: branch.reason === null ? null : text(branch.reason, Infinity), + workspace: + branch.workspace === null + ? null + : { + ...branch.workspace, + path: text(branch.workspace.path, Infinity), + branch: + branch.workspace.branch === null + ? null + : text(branch.workspace.branch, Infinity), + }, + })), + }, + }, + }), })); export const displaySummary = (host: Host["Service"], run: Run, summary: RunSummary) => @@ -210,6 +251,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 +264,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/graph.test.ts b/packages/plugin-workflows/src/graph.test.ts new file mode 100644 index 000000000000..b11f404aa9b1 --- /dev/null +++ b/packages/plugin-workflows/src/graph.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vite-plus/test"; +import { examples } from "./examples.ts"; +import { allowedActions, transition, withheldDecisions, type State } from "./graph.ts"; +import { repeatEvidence, withheldNotice } from "./web/run.ts"; + +const implementation = examples.find((definition) => definition.id === "implementation")!; +const gate = implementation.nodes.find((node) => node.kind === "human")!; +/** A run waiting at the human gate after another repeat already stopped automation. */ +const stopped = (): State => + ({ + id: "run", + definition: implementation, + state: "awaiting-review", + revision: 3, + currentNode: gate.id, + visits: 4, + automationStopped: true, + repeats: {}, + attempts: [], + trace: [], + reviews: [], + reason: null, + gate: { nodeId: gate.id, revision: 3, reviewId: null }, + allowedActions: [], + }) as unknown as State; + +describe("repeat routes", () => { + it("does not spend or report a repeat that stopped automation diverts", () => { + const run = stopped(); + if (gate.kind !== "human") throw new Error("Expected a human gate"); + transition(run, gate.id, gate.changes, 1, { + control: "changes", + reason: "Human decision: request-changes.", + }); + expect(run.repeats).toEqual({}); + const item = run.trace.at(-1)!; + expect(item).toMatchObject({ + chosen: "review", + reason: "The exhausted automation bound permits only human gates or an end.", + repeatCount: 0, + repeat: { exhausted: false, outcome: "automation-stopped" }, + }); + expect(repeatEvidence(implementation, item)).toEqual({ + label: "not repeated", + limit: true, + detail: "Automation stopped · 0/1 repeats used · → Human review", + }); + }); + + it("spends the counter only when the repeat is admitted", () => { + const run = { ...stopped(), automationStopped: false, state: "running" } as State; + if (gate.kind !== "human") throw new Error("Expected a human gate"); + transition(run, gate.id, { to: gate.id, repeat: { max: 1, atLimit: "done" } }, 1, { + control: "changes", + }); + expect(run.repeats).toEqual({ "review:review": 1 }); + expect(repeatEvidence(implementation, run.trace.at(-1)!)).toEqual({ + label: "repeat 1/1", + limit: false, + detail: "Visit 2 of up to 2 · at limit → Done", + }); + }); + + it("offers only gate decisions the server would admit as authored, and says why", () => { + const run = stopped(); + expect(allowedActions(run)).toEqual(["cancel", "approve"]); + const [withheld] = withheldDecisions(run); + expect(withheld).toEqual({ + action: "request-changes", + to: "implement", + repeat: true, + cause: "automation-stopped", + }); + expect(withheldNotice(implementation, withheld!)).toEqual({ + title: "Request changes unavailable", + detail: "↩ Implement · automation stopped", + }); + // Once the repeat is exhausted its own At limit is the authored route, so it is offered. + const exhausted = { ...run, repeats: { "review:implement": 1 } } as State; + expect(allowedActions(exhausted)).toEqual(["cancel", "approve", "request-changes"]); + }); +}); diff --git a/packages/plugin-workflows/src/graph.ts b/packages/plugin-workflows/src/graph.ts index bf932db4e747..ad71b092e1a3 100644 --- a/packages/plugin-workflows/src/graph.ts +++ b/packages/plugin-workflows/src/graph.ts @@ -8,6 +8,8 @@ import { type Predicate, type Value, type Node, + type StopKind, + type AttentionItem, } from "./contracts.ts"; import { dataProblems, evaluate } from "./definition.ts"; import { canonical } from "./encoding.ts"; @@ -97,25 +99,54 @@ export function recoveryNode(run: Run): string | undefined { } return undefined; } +export type RouteOutcome = "admitted" | "limit" | "visit-limit" | "automation-stopped"; +/** + * Where an authored route leads now, without changing the run. Transition follows it, and + * allowed gate decisions are exactly those it would admit as authored (including a repeat's + * own At limit), so the client is never offered a decision the server would divert. + */ +export function settleRoute( + run: Pick, + nodeId: string, + route: Route, +): { readonly target: string; readonly outcome: RouteOutcome; readonly count: number | null } { + const count = route.repeat ? (run.repeats[`${nodeId}:${route.to}`] ?? 0) : null; + const limited = route.repeat !== undefined && count! >= route.repeat.max; + const terminal = (id: string) => + ["human", "end"].includes(run.definition.nodes.find((node) => node.id === id)!.kind); + const target = limited ? route.repeat!.atLimit : route.to; + if (terminal(target)) return { target, outcome: limited ? "limit" : "admitted", count }; + if (run.visits >= (run.definition.maxVisits ?? 100)) + return { target: run.definition.atLimit, outcome: "visit-limit", count }; + if (run.automationStopped) + return { target: run.definition.atLimit, outcome: "automation-stopped", count }; + return { target, outcome: "admitted", count }; +} +const gateRoutes = (node: Extract) => + [ + ["approve", node.approve], + ["request-changes", node.changes], + ] as const; +/** Gate decisions withheld because their authored route would be diverted, and why. */ +export function withheldDecisions(run: Run) { + const node = run.definition.nodes.find((node) => node.id === run.currentNode); + if (run.state !== "awaiting-review" || node?.kind !== "human") return []; + return gateRoutes(node).flatMap(([action, route]) => { + const { outcome } = settleRoute(run, node.id, route); + return outcome === "visit-limit" || outcome === "automation-stopped" + ? [{ action, to: route.to, repeat: route.repeat !== undefined, cause: outcome }] + : []; + }); +} export function allowedActions(run: Run): State["allowedActions"] { if (["completed", "failed", "canceled"].includes(run.state)) return []; if (run.state === "awaiting-review") { const node = run.definition.nodes.find((node) => node.id === run.currentNode)!; - if (!run.automationStopped || node.kind !== "human") - return ["cancel", "approve", "request-changes"]; - const permitted = (route: Route) => { - const target = - route.repeat && (run.repeats[`${node.id}:${route.to}`] ?? 0) >= route.repeat.max - ? route.repeat.atLimit - : route.to; - return ["human", "end"].includes( - run.definition.nodes.find((node) => node.id === target)!.kind, - ); - }; + if (node.kind !== "human") return ["cancel", "approve", "request-changes"]; + const withheld = new Set(withheldDecisions(run).map((item) => item.action)); return [ "cancel", - ...(permitted(node.approve) ? ["approve" as const] : []), - ...(permitted(node.changes) ? ["request-changes" as const] : []), + ...gateRoutes(node).flatMap(([action]) => (withheld.has(action) ? [] : [action])), ]; } if (run.state === "unresolved") { @@ -135,9 +166,23 @@ export function allowedActions(run: Run): State["allowedActions"] { } return ["cancel"]; } -export function unresolved(run: State, reason: string) { +export function unresolved( + run: State, + reason: string, + kind: StopKind, + attemptId: string | null = null, +) { run.state = "unresolved"; run.reason = reason; + run.stop = { kind, attemptId }; +} +/** Why a route into an unresolved end stopped, from the review aggregate it consumed. */ +function endStop(run: State): StopKind { + const review = run.reviews.find((review) => run.trace.at(-1)?.sourceIds.includes(review.id)); + if (review?.cause === "head-unverifiable") return "review-unverifiable"; + if (review?.cause === "head-changed" || review?.result === "stale") return "review-stale"; + if (review?.result === "failed") return "failed"; + return "unresolved"; } export function admit(run: State, id: string, now: number) { let node = run.definition.nodes.find((node) => node.id === id)!; @@ -153,9 +198,11 @@ export function admit(run: State, id: string, now: number) { run.currentNode = node.id; run.state = "running"; run.gate = null; + run.stop = null; run.visits++; if (node.kind === "end") { run.state = node.outcome; + if (node.outcome === "unresolved") run.stop = { kind: endStop(run), attemptId: null }; return; } if (node.kind === "human") { @@ -220,7 +267,7 @@ export function reserveAttempt( bindingReason = `Resolved input exceeds ${limits.fields} fields.`; input = { ...run.input }; } - if (bindingReason && !branch) unresolved(run, bindingReason); + if (bindingReason && !branch) unresolved(run, bindingReason, "unresolved", attemptId); const timeoutMs = agent?.timeoutMs ?? (node.kind === "check" ? node.timeoutMs : undefined) ?? @@ -275,37 +322,25 @@ export function transition( sourceIds?: ReadonlyArray; considered?: ReadonlyArray<{ predicate: Predicate; matched: boolean }>; reason?: string; + /** Inspector control of the authored route, recorded so evidence never infers it. */ + control?: string; } = {}, ) { const identity = `${nodeId}:${route.to}`; - const repeatCount = route.repeat ? (run.repeats[identity] ?? 0) : null; - let target = route.to; - let reason = input.reason ?? "Unconditional route."; - if (route.repeat) { - if (repeatCount! >= route.repeat.max) { - run.automationStopped = true; - target = route.repeat.atLimit; - reason = "The repeat limit was reached."; - } else { - run.repeats[identity] = repeatCount! + 1; - reason = "Admitted a bounded repeat."; - } - } - if ( - run.visits >= (run.definition.maxVisits ?? 100) && - !["human", "end"].includes(run.definition.nodes.find((node) => node.id === target)!.kind) - ) { - run.automationStopped = true; - target = run.definition.atLimit; - reason = "The whole-run visit limit was reached."; - } - if ( - run.automationStopped && - !["human", "end"].includes(run.definition.nodes.find((node) => node.id === target)!.kind) - ) { - target = run.definition.atLimit; - reason = "The exhausted automation bound permits only human gates or an end."; - } + const { target, outcome, count } = settleRoute(run, nodeId, route); + if (outcome === "limit" || outcome === "visit-limit") run.automationStopped = true; + const reason = + outcome === "limit" + ? "The repeat limit was reached." + : outcome === "visit-limit" + ? "The whole-run visit limit was reached." + : outcome === "automation-stopped" + ? "The exhausted automation bound permits only human gates or an end." + : route.repeat + ? "Admitted a bounded repeat." + : (input.reason ?? "Unconditional route."); + // A repeat is spent only when it is admitted; a diverted repeat did not happen. + if (route.repeat && outcome === "admitted") run.repeats[identity] = count! + 1; run.trace.push({ id: `${run.id}:edge:${run.trace.length}`, nodeId, @@ -315,6 +350,17 @@ export function transition( chosen: target, reason, repeatCount: route.repeat ? (run.repeats[identity] ?? 0) : null, + ...(input.control === undefined ? {} : { route: input.control }), + ...(route.repeat + ? { + repeat: { + max: route.repeat.max, + atLimit: route.repeat.atLimit, + exhausted: outcome === "limit", + outcome, + }, + } + : {}), at: now, }); admit(run, target, now); @@ -325,12 +371,18 @@ export function choose( values: Record, ) { const considered: { predicate: Predicate; matched: boolean }[] = []; - for (const rule of rules) { + for (const [index, rule] of rules.entries()) { const matched = evaluate(rule.when, values); considered.push({ predicate: rule.when, matched }); - if (matched) return { route: rule.route, considered, reason: "First matching rule." }; + if (matched) + return { + route: rule.route, + considered, + reason: "First matching rule.", + control: `rules.${index}`, + }; } - return { route: otherwise, considered, reason: "Otherwise route." }; + return { route: otherwise, considered, reason: "Otherwise route.", control: "otherwise" }; } export function launchInstruction(agent: Agent, attempt: Attempt, head?: string): string { const invocation = attempt.skill @@ -340,3 +392,73 @@ export function launchInstruction(agent: Agent, attempt: Attempt, head?: string) } export const commandId = (attempt: Attempt, kind: string, revision = 0) => CommandId.make(`${attempt.id}:${kind}:${revision}`); + +/** + * What a person must do for this run, newest-relevant first: a human gate, every pending + * native request of every waiting attempt in its thread's queue order, or the stop of an + * unresolved run. Running work without a request is progress and yields nothing. + */ +export function attentionItems(run: Run): Omit[] { + const items: Omit[] = []; + if (run.state === "awaiting-review" && run.gate) + items.push({ + id: `${run.id}:gate:${run.gate.nodeId}:${run.visits}`, + kind: "needs-review", + nodeId: run.gate.nodeId, + branchId: null, + attemptId: null, + threadId: null, + gateRevision: run.gate.revision, + request: null, + }); + if (run.state === "running" || run.state === "unresolved") + for (const attempt of run.attempts) { + if (attempt.phase !== "waiting-input") continue; + const base = { + kind: "needs-input" as const, + nodeId: attempt.nodeId, + branchId: attempt.branchId, + attemptId: attempt.id, + threadId: attempt.threadId, + gateRevision: null, + }; + const requests = attempt.requests ?? []; + if (requests.length === 0) + items.push({ ...base, id: `${run.id}:input:${attempt.id}`, request: null }); + requests.forEach((request, index) => + items.push({ + ...base, + id: `${run.id}:request:${attempt.id}:${request.id}`, + request: { + ...request, + position: index + 1, + pending: Math.max(attempt.pendingRequests ?? 0, requests.length), + }, + }), + ); + } + if (run.state === "unresolved") { + const attempt = run.stop?.attemptId + ? run.attempts.find((item) => item.id === run.stop!.attemptId) + : undefined; + items.push({ + id: `${run.id}:stop:${run.visits}`, + kind: run.stop?.kind ?? "unresolved", + nodeId: attempt?.nodeId ?? run.currentNode, + branchId: attempt?.branchId ?? null, + attemptId: attempt?.id ?? null, + threadId: attempt?.threadId ?? null, + gateRevision: null, + request: null, + }); + } + return items; +} + +/** Whether a run currently has, or could be listed with, something a person must do. */ +export const attentionRelevant = (run: Run) => + run.state === "awaiting-review" || + run.state === "unresolved" || + run.attempts.some( + (attempt) => attempt.phase === "waiting-input" || (attempt.requests ?? []).length > 0, + ); diff --git a/packages/plugin-workflows/src/server.ts b/packages/plugin-workflows/src/server.ts index 640339112a6e..0a07b189c055 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, @@ -64,6 +69,8 @@ export const plugin: ServerPlugin = { retry: workflows.retry, resume: workflows.resume, gate: workflows.gate, + attention: workflows.watchAttention, + attentionPage: workflows.readAttention, schedule: workflows.schedule, }; return { diff --git a/packages/plugin-workflows/src/web/Attention.tsx b/packages/plugin-workflows/src/web/Attention.tsx new file mode 100644 index 000000000000..26920be3a80f --- /dev/null +++ b/packages/plugin-workflows/src/web/Attention.tsx @@ -0,0 +1,335 @@ +import type { PluginBreadcrumbItem } from "@t3tools/plugin-host-contract/web"; +import type { ProjectId } from "@t3tools/plugin-host-contract/schema"; +import { + CircleAlertIcon, + GavelIcon, + InboxIcon, + MessageCircleQuestionIcon, + MessageSquareIcon, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import { limits, type AttentionItem, type AttentionPage, type AttentionRun } from "../contracts.ts"; +import { ProjectsAlert, useProjects, type PageProps } from "./common.tsx"; +import { + attentionLabels, + attentionText, + formatTime, + relativeTime, + runStateLabels, + runStateVariant, +} from "./run.ts"; + +const PAGE = limits.attentionRuns; +/** The server's bound on one attention read. */ +const MAX = limits.attentionRunsMax; + +export function AttentionPageView(props: PageProps) { + return ; +} + +/** + * Runs that need a person, grouped by run, newest first. The server owns every item and its + * total; viewing, reading or snoozing a thread here or elsewhere clears nothing. + */ +function Attention(props: PageProps) { + const { client, connection, projectId, Button, Badge, Alert, Empty, PageHeader } = props; + const offline = connection === "disconnected"; + const projects = useProjects(props); + const [latest, setLatest] = useState(null); + const [error, setError] = useState(null); + const [attempt, setAttempt] = useState(0); + // How many newest runs to follow. Loading older runs widens the one live read, so every + // listed run, older ones included, is what the server sent in a single snapshot. + const [limit, setLimit] = useState(PAGE); + // The limit of the snapshot on screen; it stays visible until the wider one arrives. + const [shown, setShown] = useState(null); + useEffect(() => { + if (offline) return; + setError(null); + return client.subscribeAttention( + { projectId, limit }, + (page) => { + setError(null); + setLatest(page); + setShown(limit); + }, + setError, + ); + }, [client, projectId, limit, offline, attempt]); + const runs = latest?.runs ?? []; + const live = !offline && error === null; + const loading = live && latest !== null && shown !== limit; + // Judged on the list on screen, so the bound is never claimed before the wider read lands. + const capped = latest !== null && runs.length < latest.total && shown === MAX; + const projectTitle = (id: string) => + projects.projects?.find((project) => project.id === id)?.title ?? id; + const scope = (id: ProjectId | null) => + props.navigate( + id === null + ? { pageId: "workflows.attention" } + : { pageId: "workflows.attention", projectId: id }, + ); + const crumbs: ReadonlyArray = [ + { + label: projectId === null ? "All projects" : projectTitle(projectId), + ariaLabel: "Project", + value: projectId ?? "", + disabled: offline || projects.projects === null, + options: [ + { value: "", label: "All projects" }, + ...(projects.projects ?? []).map((project) => ({ + value: project.id, + label: project.title, + })), + ], + onChange: (value) => scope(value === "" ? null : (value as ProjectId)), + }, + { + label: "Workflows", + onSelect: () => + props.navigate( + projectId === null + ? { pageId: "workflows.library" } + : { pageId: "workflows.library", projectId }, + ), + }, + { label: "Attention" }, + ]; + const stale = latest !== null && (offline || error !== null); + return ( + <> + + + {latest === null || latest.total === 0 ? null : ( + + {latest.total} {latest.total === 1 ? "run" : "runs"} + + )} + {stale ? Last loaded : null} + + +
+
+ + {error === null ? null : ( + setAttempt((value) => value + 1)} + > + Retry + + } + > + {error} + + )} + {latest === null ? ( + offline ? ( + + ) : error === null ? ( +

+ Loading attention… +

+ ) : null + ) : latest.total === 0 ? ( + } /> + ) : null} + {runs.map((run) => ( + scope(run.projectId)} + /> + ))} + {capped ? ( +

+ Newest {runs.length} of {latest.total} + {projectId === null ? " · open a run's project to see the rest" : ""} +

+ ) : latest !== null && (runs.length < latest.total || loading) ? ( +
+ + Newest {runs.length} of {latest.total} + + +
+ ) : null} +
+
+ + ); +} + +/** One run's items, each with the exact place to resolve it. */ +function RunGroup({ + props, + run, + projectTitle, + onProject, +}: { + readonly props: PageProps; + readonly run: AttentionRun; + /** Shown when the page spans every project. */ + readonly projectTitle: string | null; + readonly onProject: () => void; +}) { + const { Button, Badge, ListGroup, ListRow, Tooltip } = props; + const title = run.workflowTitle || run.runId; + return ( + + {runStateLabels[run.state]} + {projectTitle === null ? null : ( + + )} + + {relativeTime(run.createdAt)} + + + + } + > + {run.items.map((item) => ( + + ))} + {run.itemTotal > run.items.length ? ( + + props.navigate({ + pageId: "workflows.runs", + projectId: run.projectId, + state: { run: run.runId }, + }) + } + /> + ) : null} + + ); +} + +function Item({ + props, + run, + item, +}: { + readonly props: PageProps; + readonly run: AttentionRun; + readonly item: AttentionItem; +}) { + const { Button, Badge, ListRow } = props; + const open = (state: Record) => + props.navigate({ + pageId: "workflows.runs", + projectId: run.projectId, + state: { run: run.runId, ...state }, + }); + const recovery = [ + run.allowedActions.includes("resume") ? "Resume" : null, + run.allowedActions.includes("retry") ? "Retry" : null, + ].filter((action) => action !== null); + const stopped = item.kind !== "needs-review" && item.kind !== "needs-input"; + return ( + + ) : item.kind === "needs-input" ? ( + + ) : ( + + ) + } + badges={{attentionLabels[item.kind]}} + description={[ + attentionText(item), + stopped + ? recovery.length + ? `${recovery.join(" or ")} available` + : "Cancel to close" + : null, + ] + .filter((part) => part !== null) + .join(" · ")} + actions={ + item.kind === "needs-review" ? ( + + ) : item.kind === "needs-input" && item.threadId !== null ? ( + <> + {item.attemptId === null ? null : ( + + )} + + + ) : ( + + ) + } + /> + ); +} diff --git a/packages/plugin-workflows/src/web/Editor.tsx b/packages/plugin-workflows/src/web/Editor.tsx index 5cfacfe77dff..319647b82636 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, @@ -98,10 +99,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(), ); @@ -292,6 +290,14 @@ function Editor(props: PageProps & { readonly workflow: string | null }) { }, [narrow], ); + // Choosing a route on the graph opens its step and focuses that route's control. + const selectRoute = useCallback( + (stepId: string, control: string) => { + select(stepId); + setFocus(controlId(stepId, control)); + }, + [select], + ); const discard = () => { forget(); setDraft(server?.definition ?? null); @@ -550,6 +556,8 @@ function Editor(props: PageProps & { readonly workflow: string | null }) { setCapabilitiesAttempt((value) => value + 1)} disabled={readOnly} onAdd={(kind: EditableKind) => { const added = addStep(draft, kind, selected, capabilities); @@ -966,12 +974,14 @@ function Editor(props: PageProps & { readonly workflow: string | null }) { ) : view === "graph" && draft !== null ? (
) : view === "routes" && draft !== null ? ( diff --git a/packages/plugin-workflows/src/web/Flow.tsx b/packages/plugin-workflows/src/web/Flow.tsx index a4c4ed871e20..9c2d7ee7bcf7 100644 --- a/packages/plugin-workflows/src/web/Flow.tsx +++ b/packages/plugin-workflows/src/web/Flow.tsx @@ -1,8 +1,22 @@ -import { ArrowDownIcon, ArrowUpIcon, CornerDownRightIcon, Trash2Icon } from "lucide-react"; -import type { KeyboardEvent } from "react"; +import { + ArrowDownIcon, + ArrowUpIcon, + CornerDownRightIcon, + RefreshCwIcon, + Trash2Icon, + Undo2Icon, +} from "lucide-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"; +import { + editableKinds, + kindLabels, + predicateSummary, + repeatBadge, + routeList, + type EditableKind, +} from "./editing.ts"; import { KindIcon } from "./kinds.tsx"; /** @@ -14,18 +28,28 @@ export const stepButtonId = (nodeId: string) => char === "_" ? "__" : `_x${char.charCodeAt(0).toString(16).padStart(4, "0")}`, )}`; const title = (definition: Definition, id: string) => - definition.nodes.find((node) => node.id === id)?.title ?? `${id} (missing)`; + definition.nodes.find((node) => node.id === id)?.title || `${id} (missing)`; +const exists = (definition: Definition, id: string) => + definition.nodes.some((node) => node.id === id); const editable = (node: Node) => (editableKinds as ReadonlyArray).includes(node.kind); -/** Add a step: kinds this client authors are enabled; the rest stay visible but unavailable. */ +/** + * Add a step: only kinds this client authors and the environment advertises are enabled; the + * rest stay visible with the reason on hover. Until capabilities load, only agent steps and + * ends (which every backend runs) are offered. + */ export function Palette({ props, capabilities, + capabilitiesError, + onRetryCapabilities, disabled, onAdd, }: { readonly props: PageProps; readonly capabilities: Capabilities | null; + readonly capabilitiesError: string | null; + readonly onRetryCapabilities: () => void; readonly disabled: boolean; readonly onAdd: (kind: EditableKind) => void; }) { @@ -34,14 +58,30 @@ export function Palette({ return ( ); } /** * 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 +123,7 @@ export function RouteList({ onSelect, onMove, onRemove, + renderStatus, }: { readonly props: PageProps; readonly definition: Definition; @@ -75,6 +133,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); @@ -124,6 +183,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 ? ( @@ -159,19 +219,57 @@ export function RouteList({ )}
    + {node.kind === "parallel" || node.kind === "join" ? ( +
  • + + {node.kind === "parallel" + ? `${node.branches.map((branch) => branch.title || branch.id).join(", ")} · ${node.pullRequest.repository || "repository not set"}#${node.pullRequest.number}` + : `Wait for all · ${title(definition, node.fork)}`} + +
  • + ) : null} {routes .filter((route) => route.from.id === node.id) - .map((route) => ( -
  • - - - {route.label} → {title(definition, route.to)} - {route.repeat - ? ` · repeat ×${route.repeat.max}, at limit → ${title(definition, route.repeat.atLimit)}` - : ""} - -
  • - ))} + .map((route) => { + const rule = + route.control.startsWith("rules.") && + (node.kind === "decision" || node.kind === "join") + ? node.rules[Number(route.control.split(".")[1])] + : undefined; + const missing = !exists(definition, route.to); + const Icon = route.repeat ? Undo2Icon : CornerDownRightIcon; + return ( +
  • + + {route.label} + {rule === undefined || route.condition === null ? null : ( + <> + {" "} + + + {predicateSummary(rule.when)} + + + + )}{" "} + + {route.repeat ? "↩" : "→"} {title(definition, route.to)} + + {route.repeat ? ( + <> + {repeatBadge(route.repeat)} + + at limit → {title(definition, route.repeat.atLimit)} + + + ) : null} +
  • + ); + })}
); diff --git a/packages/plugin-workflows/src/web/Graph.tsx b/packages/plugin-workflows/src/web/Graph.tsx index b6ac8cff8266..286b322bd114 100644 --- a/packages/plugin-workflows/src/web/Graph.tsx +++ b/packages/plugin-workflows/src/web/Graph.tsx @@ -22,6 +22,7 @@ import { } from "@xyflow/react"; import { CircleAlertIcon } from "lucide-react"; import { createContext, use, useEffect, useMemo, useState, type CSSProperties } from "react"; +import type { PluginDesign } from "@t3tools/plugin-host-contract/web"; import type { Definition, Problem } from "../contracts.ts"; import { editableKinds, kindLabels } from "./editing.ts"; import { @@ -37,13 +38,31 @@ import { type Point, } 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; + /** Selecting a route's edge or label selects its owning step and route control. */ + readonly onSelectRoute: (stepId: string, control: string) => void; + readonly Tooltip: PluginDesign["Tooltip"]; } const GraphActionsContext = createContext(null); @@ -52,6 +71,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 @@ -64,6 +90,8 @@ type RouteData = { readonly label: Point | null; /** For repeat back edges: the x of the loop's vertical run, clear of every box it passes. */ readonly loopX: number | null; + /** On a run's graph, whether a recorded routing decision took this edge. */ + readonly taken: boolean | null; }; type RouteEdge = Edge; @@ -81,6 +109,14 @@ const tileTone: Record = { missing: "bg-transparent text-destructive", }; const hiddenHandle: CSSProperties = { opacity: 0, pointerEvents: "none" }; +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, @@ -112,21 +148,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(" ")} > - {node.entry || node.runLimit ? ( - + {node.entry || node.runLimit || run?.current ? ( + + {run?.current ? ( + + Current + + ) : null} {node.entry ? ( Start @@ -160,7 +208,11 @@ function StepCard({
)} @@ -291,7 +387,8 @@ function RouteLine({ const nodeTypes = { step: StepCard, bounds: () => null }; const edgeTypes = { route: RouteLine }; -const fitOptions = { padding: 0.16, maxZoom: 1 }; +// Fitting never shrinks text below ~90%: a larger workflow pans instead (zoom out stays manual). +const fitOptions = { padding: 0.16, maxZoom: 1, minZoom: 0.9 }; // App tokens drive React Flow's own chrome so light and dark themes follow the app. const theme = { "--xy-background-color": "transparent", @@ -315,19 +412,56 @@ const laidOut = (graph: FlowGraph, key: string) => { * is resized (narrow layouts, the inspector opening). */ function Refit({ signature }: { readonly signature: string }) { - const { fitView } = useReactFlow(); + const { fitView, getNodes, getNodesBounds, getViewport, setViewport } = useReactFlow(); const initialized = useNodesInitialized(); const width = useStore((state) => state.width); const height = useStore((state) => state.height); useEffect(() => { - if (initialized && width > 0 && height > 0) void fitView(fitOptions); - }, [signature, initialized, width, height, fitView]); + if (!initialized || width <= 0 || height <= 0) return; + void fitView(fitOptions).then(() => { + // A workflow taller than the canvas at the minimum fit zoom starts at its entry. + const bounds = getNodesBounds(getNodes()); + const { x, zoom } = getViewport(); + if (bounds.height * zoom + 48 > height) + void setViewport({ x, y: 24 - bounds.y * zoom, zoom }); + }); + }, [ + signature, + initialized, + width, + height, + fitView, + getNodes, + getNodesBounds, + getViewport, + setViewport, + ]); + return null; +} + +/** 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, @@ -336,13 +470,26 @@ export function Graph({ readOnly, onSelect, onRemove, + onSelectRoute, + 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; + /** A route's edge was chosen; without it, choosing an edge selects its owning step. */ + readonly onSelectRoute?: (stepId: string, control: string) => void; + /** The host tooltip, for the full text behind a clipped edge label. */ + 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 @@ -383,9 +530,18 @@ export function Graph({ measured: size, data: { node, - selected: selected === node.stepId, + selected: selected === node.stepId || selected === node.id, 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, + }, }, }; }), @@ -401,12 +557,28 @@ export function Graph({ data: {}, })), ], - [graph, layout, loops, counts, selected], + [graph, layout, loops, counts, selected, run], ); const edges = useMemo(() => { + 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 + : edge.dangling + ? danglingStroke + : (markerColors[edge.kind] ?? "var(--muted-foreground)"); return { id: edge.id, type: "route", @@ -420,10 +592,11 @@ export function Graph({ points: route?.points ?? null, label: route?.label ?? null, loopX: loops.get(edge.id)?.x ?? null, + taken: isTaken, }, }; }); - }, [graph, layout, loops]); + }, [graph, layout, loops, run]); const removableIds = useMemo( () => new Set( @@ -441,8 +614,10 @@ export function Graph({ removable: (id) => removableIds.has(id), onSelect, onRemove: (id) => onRemove?.(id), + onSelectRoute: onSelectRoute ?? ((stepId) => onSelect(stepId)), + Tooltip, }), - [readOnly, removableIds, onSelect, onRemove], + [readOnly, removableIds, onSelect, onRemove, onSelectRoute, Tooltip], ); const signature = useMemo(() => graph.nodes.map((node) => node.id).join("\u0000"), [graph]); return ( @@ -471,13 +646,20 @@ 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); + }} + onEdgeClick={(_, edge) => { + const owner = edge.data?.edge.route; + if (owner) actions.onSelectRoute(owner.stepId, owner.control); }} attributionPosition="bottom-right" > + {focus === null || onFocused === undefined ? null : ( + + )} diff --git a/packages/plugin-workflows/src/web/Inspector.tsx b/packages/plugin-workflows/src/web/Inspector.tsx index 432e3c8cf0ce..467731ca2278 100644 --- a/packages/plugin-workflows/src/web/Inspector.tsx +++ b/packages/plugin-workflows/src/web/Inspector.tsx @@ -1,12 +1,36 @@ -import { Trash2Icon } from "lucide-react"; +import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { useEffect, useState } from "react"; import type { ProviderInstanceId, RuntimeMode } from "@t3tools/plugin-host-contract/schema"; -import type { Capabilities, Definition, Field, Node, Problem, Route, Skill } from "../contracts.ts"; -import { Labeled, errorMessage, type PageProps } from "./common.tsx"; -import { isProtected, kindLabels, routeList, updateNode, upstreamFields } from "./editing.ts"; +import type { Agent, Field, Node, Skill } from "../contracts.ts"; +import { Labeled, errorMessage } from "./common.tsx"; +import { + addBranch, + isProtected, + joinOf, + kindLabels, + moveBranch, + removeBranch, + updateNode, + upstreamFields, + type Branch, +} from "./editing.ts"; import { KindIcon } from "./kinds.tsx"; +import { + Problems, + RouteEditor, + controlId, + matches, + minutes, + nodeOptions, + withCurrent, + type InspectorProps, +} from "./Routes.tsx"; +import { CheckInspector, DecisionInspector, HumanInspector } from "./Steps.tsx"; + +export { controlId }; type AgentNode = Extract; +type ParallelNode = Extract; const runtimeModes: ReadonlyArray = [ "approval-required", "auto-accept-edits", @@ -19,30 +43,6 @@ const runtimeLabels: Record = { auto: "Automatic", "full-access": "Full access", }; -export const controlId = (nodeId: string | null, control: string) => - `wf-${nodeId ?? "workflow"}-${control.replace(/[^a-zA-Z0-9_-]/g, "-")}`; -/** An empty control matches the node's own problems that name no specific control. */ -const matches = (problem: Problem, nodeId: string | null, control: string) => - (problem.nodeId ?? null) === nodeId && - (control === "" - ? problem.control === undefined - : problem.control !== undefined && - (problem.control === control || problem.control.startsWith(`${control}.`))); -const minutes = (value: number | undefined) => - value === undefined ? "" : String(Math.round(value / 60_000)); - -interface InspectorProps { - readonly props: PageProps; - readonly definition: Definition; - readonly selected: string | null; - readonly capabilities: Capabilities | null; - readonly capabilitiesError: string | null; - readonly onRetryCapabilities: () => void; - readonly problems: ReadonlyArray; - readonly readOnly: boolean; - readonly identityEditable: boolean; - readonly onChange: (definition: Definition) => void; -} export function Inspector(input: InspectorProps) { const node = input.definition.nodes.find((item) => item.id === input.selected); @@ -60,54 +60,21 @@ export function Inspector(input: InspectorProps) { ) : node.kind === "end" ? ( + ) : node.kind === "check" ? ( + + ) : node.kind === "decision" ? ( + + ) : node.kind === "human" ? ( + + ) : node.kind === "parallel" ? ( + ) : ( - + )} ); } -function Problems({ - problems, - nodeId, - control, -}: { - readonly problems: ReadonlyArray; - readonly nodeId: string | null; - readonly control: string; -}) { - const found = problems.filter((problem) => matches(problem, nodeId, control)); - if (found.length === 0) return null; - return ( -
    - {found.map((problem, index) => ( -
  • - {problem.message} -
  • - ))} -
- ); -} - -function nodeOptions(definition: Definition, filter: (node: Node) => boolean = () => true) { - return definition.nodes.filter(filter).map((node) => ({ - value: node.id, - label: `${node.title || node.id} (${kindLabels[node.kind]})`, - })); -} -function withCurrent( - options: ReadonlyArray<{ value: string; label: string; disabled?: boolean }>, - current: string, - label: string, -) { - return current === "" || options.some((option) => option.value === current) - ? options - : [{ value: current, label }, ...options]; -} - function WorkflowSettings({ props, definition, @@ -185,60 +152,6 @@ function WorkflowSettings({ ); } -function RouteSelect({ - props, - definition, - node, - control, - label, - route, - optional, - readOnly, - problems, - onRoute, -}: { - readonly props: PageProps; - readonly definition: Definition; - readonly node: Node; - readonly control: string; - readonly label: string; - readonly route: Route | undefined; - readonly optional: boolean; - readonly readOnly: boolean; - readonly problems: ReadonlyArray; - readonly onRoute: (route: Route | undefined) => void; -}) { - const id = controlId(node.id, control); - const target = (to: string) => definition.nodes.find((item) => item.id === to)?.title ?? to; - return ( - <> - - matches(problem, node.id, control))} - options={withCurrent( - [...(optional ? [{ value: "", label: "Not set" }] : []), ...nodeOptions(definition)], - route?.to ?? "", - `${route?.to ?? ""} (missing)`, - )} - onChange={(to) => onRoute(to === "" ? undefined : { to })} - /> - - - - ); -} - function EndInspector({ props, definition, @@ -278,60 +191,102 @@ function EndInspector({ ); } -/** Kinds authored in later updates are shown with their routes and kept exactly on save. */ -function ReadOnlyStep({ - props, - definition, - node, - problems, -}: InspectorProps & { readonly node: Node }) { - const target = (id: string) => definition.nodes.find((item) => item.id === id)?.title ?? id; +function AgentInspector(input: InspectorProps & { readonly node: AgentNode }) { + const { props, definition, node, problems, readOnly, onChange } = input; + const set = (update: (current: AgentNode) => AgentNode) => + onChange(updateNode(definition, node.id, (current) => update(current as AgentNode))); + const invalid = (control: string) => + problems.some((problem) => matches(problem, node.id, control)); return ( -
-
- {node.title} - Read-only +
+ + set((current) => ({ ...current, title }))} + /> + + + set((current) => update(current) as AgentNode)} + /> +
+ route && set((current) => ({ ...current, next: route }))} + /> + + set((current) => { + const { onUnresolved: _previous, ...rest } = current; + return route === undefined ? rest : { ...rest, onUnresolved: route }; + }) + } + />
-
    - {routeList(definition) - .filter((route) => route.from.id === node.id) - .map((route) => ( -
  • - {route.label} → {target(route.to)} - {route.repeat - ? ` · repeat ×${route.repeat.max}, at limit → ${target(route.repeat.atLimit)}` - : ""} -
  • - ))} -
); } -function AgentInspector({ +function AgentFields({ props, definition, - node, capabilities, capabilitiesError, onRetryCapabilities, problems, readOnly, - onChange, -}: InspectorProps & { readonly node: AgentNode }) { + nodeId, + prefix, + agent, + reviewer, + set, +}: InspectorProps & { + readonly nodeId: string; + /** Control prefix of this agent inside its node (`branches.0.` for a reviewer). */ + readonly prefix: string; + readonly agent: Agent; + /** Reviewers keep the canonical review permission policy. */ + readonly reviewer: boolean; + /** Updates keep every property of the owning node or branch they do not change. */ + readonly set: (update: (current: Agent) => Agent) => void; +}) { const { Input, Select, Textarea, Button, client, projectId, connection } = props; - const set = (update: (current: AgentNode) => AgentNode) => - onChange(updateNode(definition, node.id, (current) => update(current as AgentNode))); + const cid = (control: string) => controlId(nodeId, `${prefix}${control}`); const invalid = (control: string) => - problems.some((problem) => matches(problem, node.id, control)); + problems.some((problem) => matches(problem, nodeId, `${prefix}${control}`)); const providers = capabilities?.providers ?? []; - const provider = providers.find((item) => item.instanceId === node.modelSelection.instanceId); - const model = provider?.models.find((item) => item.slug === node.modelSelection.model); + const provider = providers.find((item) => item.instanceId === agent.modelSelection.instanceId); + const model = provider?.models.find((item) => item.slug === agent.modelSelection.model); const [skills, setSkills] = useState | null>(null); const [skillsError, setSkillsError] = useState(null); const [skillsAttempt, setSkillsAttempt] = useState(0); - const instanceId = node.modelSelection.instanceId; + const instanceId = agent.modelSelection.instanceId; useEffect(() => { if (projectId === null || connection === "disconnected") return; let active = true; @@ -349,7 +304,7 @@ function AgentInspector({ active = false; }; }, [client, projectId, connection, instanceId, skillsAttempt]); - const upstream = upstreamFields(definition, node.id); + const upstream = upstreamFields(definition, nodeId); const field = (index: number, update: Partial) => set((current) => ({ ...current, @@ -367,7 +322,7 @@ function AgentInspector({ }, })); const optionValue = (id: string) => - node.modelSelection.options?.find((option) => option.id === id)?.value; + agent.modelSelection.options?.find((option) => option.id === id)?.value; const setOption = (id: string, value: string | boolean | undefined) => set((current) => { const options = (current.modelSelection.options ?? []).filter((option) => option.id !== id); @@ -396,16 +351,6 @@ function AgentInspector({ ]; return (
- - set((current) => ({ ...current, title }))} - /> - - {capabilitiesError !== null || capabilities?.discoveryError ? (
@@ -422,7 +367,7 @@ function AgentInspector({
) : null} ({ value: item.slug, label: `${item.name}${item.isCustom ? " (custom)" : ""}`, })), - node.modelSelection.model, - `${node.modelSelection.model} (not listed)`, + agent.modelSelection.model, + `${agent.modelSelection.model} (not listed)`, )} onChange={(value) => set((current) => ({ @@ -491,7 +436,7 @@ function AgentInspector({ /> {(model?.optionDescriptors ?? []).map((descriptor) => { - const id = controlId(node.id, `option-${descriptor.id}`); + const id = cid(`option-${descriptor.id}`); const value = optionValue(descriptor.id); return ( @@ -518,11 +463,35 @@ function AgentInspector({ ); })} - + {reviewer && + (agent.interactionMode !== "plan" || agent.runtimeMode !== "approval-required") ? ( +
+ Not the review permission policy + +
+ ) : null} + skill.name === node.skill && skill.enabled) - ? `${node.skill} is not installed for this provider. Choose an installed skill.` + !skills.some((skill) => skill.name === agent.skill && skill.enabled) + ? `${agent.skill} is not installed for this provider. Choose an installed skill.` : skills === null && connection === "connected" ? "Loading skills…" : null } >