@parcha/agentrun-pi 0.1.0-beta.2 → 0.1.0-beta.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -32
- package/dist/cli.js +15 -4
- package/dist/demo-cli.d.ts +2 -0
- package/dist/demo-cli.js +25 -0
- package/dist/demo.js +1 -0
- package/dist/durable/decision-recovery.d.ts +23 -0
- package/dist/durable/decision-recovery.js +57 -0
- package/dist/durable/decisions.d.ts +113 -0
- package/dist/durable/decisions.js +71 -0
- package/dist/durable/index.d.ts +20 -0
- package/dist/durable/index.js +14 -0
- package/dist/durable/node-runner.d.ts +106 -0
- package/dist/durable/node-runner.js +253 -0
- package/dist/durable/node.d.ts +88 -0
- package/dist/durable/node.js +98 -0
- package/dist/durable/pi-versions.d.ts +34 -0
- package/dist/durable/pi-versions.js +66 -0
- package/dist/durable/readers.d.ts +28 -0
- package/dist/durable/readers.js +103 -0
- package/dist/durable/receipts.d.ts +54 -0
- package/dist/durable/receipts.js +92 -0
- package/dist/durable/record-file.d.ts +10 -0
- package/dist/durable/record-file.js +56 -0
- package/dist/durable/record-tool.d.ts +76 -0
- package/dist/durable/record-tool.js +123 -0
- package/dist/durable/record.d.ts +157 -0
- package/dist/durable/record.js +402 -0
- package/dist/durable/recording.d.ts +90 -0
- package/dist/durable/recording.js +292 -0
- package/dist/durable/store.d.ts +23 -0
- package/dist/durable/store.js +242 -0
- package/dist/durable/task.d.ts +133 -0
- package/dist/durable/task.js +411 -0
- package/dist/durable/workflow-tool.d.ts +32 -0
- package/dist/durable/workflow-tool.js +188 -0
- package/dist/extension-service.js +63 -22
- package/dist/extension.d.ts +2 -0
- package/dist/extension.js +463 -80
- package/dist/host-addendum.d.ts +4 -0
- package/dist/host-addendum.js +20 -0
- package/dist/host-session.js +5 -2
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/pi-host.d.ts +7 -0
- package/dist/pi-host.js +22 -0
- package/dist/presentation.js +2 -2
- package/dist/run-error.d.ts +6 -0
- package/dist/run-error.js +13 -0
- package/dist/runner.d.ts +1 -6
- package/dist/runner.js +7 -13
- package/dist/signin/index.d.ts +9 -0
- package/dist/signin/index.js +7 -0
- package/dist/signin/limit.d.ts +10 -0
- package/dist/signin/limit.js +23 -0
- package/dist/signin/resolve.d.ts +47 -0
- package/dist/signin/resolve.js +163 -0
- package/dist/signin/signin.d.ts +44 -0
- package/dist/signin/signin.js +126 -0
- package/dist/signin/spec.d.ts +116 -0
- package/dist/signin/spec.js +181 -0
- package/dist/signin/store.d.ts +40 -0
- package/dist/signin/store.js +206 -0
- package/dist/skills/author/SKILL.md +138 -0
- package/{skills → dist/skills}/author/references/jev-decisions.md +27 -14
- package/dist/skills/author/references/language.md +134 -0
- package/dist/skills/author/references/sdk-authoring.md +11 -0
- package/{skills → dist/skills}/author/references/workflow-format.md +7 -15
- package/dist/sop.d.ts +12 -0
- package/dist/sop.js +29 -0
- package/dist/thinking.d.ts +3 -0
- package/dist/thinking.js +7 -0
- package/dist/tool-context.d.ts +21 -0
- package/dist/tool-context.js +13 -0
- package/dist/triage-demo.d.ts +62 -0
- package/dist/triage-demo.js +153 -0
- package/dist/types.d.ts +7 -0
- package/dist/workflow-inspector.d.ts +47 -0
- package/dist/workflow-inspector.js +235 -0
- package/dist/workflow-session.d.ts +23 -0
- package/dist/workflow-session.js +168 -0
- package/dist/workflow-store.d.ts +36 -0
- package/dist/workflow-store.js +255 -0
- package/dist/workflow-tool.d.ts +81 -0
- package/dist/workflow-tool.js +94 -0
- package/dist/workflow-view.d.ts +64 -0
- package/dist/workflow-view.js +273 -0
- package/dist/workflows.d.ts +1 -0
- package/dist/workflows.js +3 -0
- package/package.json +57 -11
- package/dist/admission.d.ts +0 -6
- package/dist/admission.js +0 -51
- package/dist/author.d.ts +0 -21
- package/dist/author.js +0 -72
- package/dist/benchmark-contract.d.ts +0 -88
- package/dist/benchmark-contract.js +0 -50
- package/dist/benchmark-freeze.d.ts +0 -9
- package/dist/benchmark-freeze.js +0 -9
- package/dist/benchmark.d.ts +0 -127
- package/dist/benchmark.js +0 -260
- package/dist/comparison.d.ts +0 -69
- package/dist/comparison.js +0 -229
- package/dist/skill-bundle.d.ts +0 -5
- package/dist/skill-bundle.js +0 -15
- package/skills/author/SKILL.md +0 -102
- package/skills/author/references/sdk-authoring.md +0 -16
- /package/{skills → dist/skills}/author/examples/evidence-gate.input.json +0 -0
- /package/{skills → dist/skills}/author/examples/evidence-gate.json +0 -0
- /package/{skills → dist/skills}/author/examples/read-file.input.json +0 -0
- /package/{skills → dist/skills}/author/examples/read-file.json +0 -0
- /package/{skills → dist/skills}/author/examples/read-source-decision.input.json +0 -0
- /package/{skills → dist/skills}/author/examples/read-source-decision.json +0 -0
- /package/{skills → dist/skills}/author/examples/release-note.txt +0 -0
- /package/{skills → dist/skills}/author/examples/review-release-notes.input.json +0 -0
- /package/{skills → dist/skills}/author/examples/review-release-notes.json +0 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { Extension, Harness, TaskId, TaskRuntime } from "@earendil-works/pi-durable";
|
|
2
|
+
import { runWorkflow, type Workflow, type WorkflowDeps } from "@parcha/agentrun-dsl";
|
|
3
|
+
import { type EscalationRow, type RecoveryAdapters, type RecoveryDriver, type RecoveryOptions } from "@parcha/agentrun-dsl/recovery";
|
|
4
|
+
type Context = Parameters<Harness["commit"]>[1];
|
|
5
|
+
/** What one run is: its key (the same key reaches the same task and journal), the workflow as authored, its input, and
|
|
6
|
+
* what else must not change between two opens of it. */
|
|
7
|
+
export type WorkflowRequest = {
|
|
8
|
+
key: string;
|
|
9
|
+
workflow: Workflow;
|
|
10
|
+
input: Record<string, unknown>;
|
|
11
|
+
bind?: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
/** How a run ended, as the host reads it. A pause is not an outcome: a paused run's task stays pending. */
|
|
14
|
+
export type WorkflowOutcome = {
|
|
15
|
+
status: "complete";
|
|
16
|
+
output: unknown;
|
|
17
|
+
state: unknown;
|
|
18
|
+
} | {
|
|
19
|
+
status: "escalated";
|
|
20
|
+
escalation: unknown;
|
|
21
|
+
state: unknown;
|
|
22
|
+
continuation?: unknown;
|
|
23
|
+
} | {
|
|
24
|
+
status: "refused";
|
|
25
|
+
code: string;
|
|
26
|
+
message: string;
|
|
27
|
+
detail?: unknown;
|
|
28
|
+
} | {
|
|
29
|
+
status: "stopped";
|
|
30
|
+
stop: "cancelled";
|
|
31
|
+
source?: string;
|
|
32
|
+
};
|
|
33
|
+
/** One invocation of a run's phase, as the host's adapters see it: a node runner that opens conversations owns them by
|
|
34
|
+
* `runtime.taskId`, so a cancel aborts them before the task. */
|
|
35
|
+
export type WorkflowInvocation = {
|
|
36
|
+
key: string;
|
|
37
|
+
taskId: TaskId;
|
|
38
|
+
runtime: TaskRuntime<WorkflowRequest, TaskCheckpoint, WorkflowResult, object>;
|
|
39
|
+
context: Context;
|
|
40
|
+
};
|
|
41
|
+
/** The host's part, asked once per invocation. */
|
|
42
|
+
export type WorkflowTaskHost = {
|
|
43
|
+
/** The driver's host options, beside the run's key and binding: asked before the driver opens, by the run's phase and
|
|
44
|
+
* by its abort handler. A host settles an effect a killed process left unknown with `reconcile`: the driver calls it
|
|
45
|
+
* once, through its own open of the run, with the unknown effects and a `complete` that records one's result. An
|
|
46
|
+
* effect it completes is answered from that result and not dispatched again; one it leaves is refused at resume. Do not
|
|
47
|
+
* open the run's journal here: that open is a generation of its own. The task's abort handler opens the driver too, to
|
|
48
|
+
* record a cancel, so `reconcile` also runs then; `phase` is `"abort"` there and `"run"` for the run's own open, and a
|
|
49
|
+
* host that wants no reconcile during a cancel returns none for `"abort"`. */
|
|
50
|
+
options?(invocation: WorkflowInvocation & {
|
|
51
|
+
phase: "run" | "abort";
|
|
52
|
+
}): Omit<RecoveryOptions, "key" | "bind"> | Promise<Omit<RecoveryOptions, "key" | "bind">>;
|
|
53
|
+
/** What the interpreter runs with, asked once the driver is open, so a host that composes its own couplings over the
|
|
54
|
+
* driver (its spend, its sessions, its escalation rows) builds them here. */
|
|
55
|
+
run(invocation: WorkflowInvocation & {
|
|
56
|
+
driver: RecoveryDriver;
|
|
57
|
+
}): WorkflowHostParts | Promise<WorkflowHostParts>;
|
|
58
|
+
/** The host's ladder: a run that did not complete and was not refused continues here, inside the task's invocation,
|
|
59
|
+
* so what the host opens (its continuation) is owned by the task, and a cancel aborts it before the task. The host
|
|
60
|
+
* commits the escalation row through `invocation.driver.escalate` before anything acts on it; a run reopened after
|
|
61
|
+
* that commit comes here again with the row, and the interpreter does not run. `invocation.signal` ends with the
|
|
62
|
+
* run's stops: a pause leaves the task pending, and the next open comes here again with the row. What it returns
|
|
63
|
+
* rides on the escalated outcome as `continuation`; a throw is the run's failure. Absent, an escalated run ends with
|
|
64
|
+
* the interpreter's escalation and any failure is the run's. */
|
|
65
|
+
escalated?(ending: WorkflowEnding, invocation: WorkflowInvocation & {
|
|
66
|
+
driver: RecoveryDriver;
|
|
67
|
+
signal: AbortSignal;
|
|
68
|
+
}): Promise<unknown>;
|
|
69
|
+
};
|
|
70
|
+
/** Why a run continues on the host's ladder: a gate that fired (`escalation`), a failure that is not a refusal
|
|
71
|
+
* (`error`), a refusal found before any step (`refusal`), or an escalation an earlier invocation committed (`row`).
|
|
72
|
+
* `state` is the run's state at that point. */
|
|
73
|
+
export type WorkflowEnding = {
|
|
74
|
+
escalation?: unknown;
|
|
75
|
+
error?: unknown;
|
|
76
|
+
refusal?: NonNullable<RecoveryDriver["preStepRefusal"]>;
|
|
77
|
+
row?: EscalationRow;
|
|
78
|
+
state: Record<string, unknown>;
|
|
79
|
+
};
|
|
80
|
+
/** The host's adapters, which the task wraps with `withRecovery` (`durableNodes`: the node runner answers a delivered step
|
|
81
|
+
* again from its own record), or the dependencies the host built over `invocation.driver` itself, which the task runs as
|
|
82
|
+
* they are, stopped by the run's signal too, with the host's own `interpret` when it runs its documents under its own
|
|
83
|
+
* policy (its view of the document, its lint): it is called as `runWorkflow` would be, and answers as it does. */
|
|
84
|
+
export type WorkflowHostParts = {
|
|
85
|
+
adapters: RecoveryAdapters;
|
|
86
|
+
durableNodes?: boolean;
|
|
87
|
+
} | {
|
|
88
|
+
deps: WorkflowDeps;
|
|
89
|
+
interpret?: (workflow: Workflow, input: Record<string, unknown>, deps: WorkflowDeps) => ReturnType<typeof runWorkflow>;
|
|
90
|
+
};
|
|
91
|
+
/** What starting and reading a run asks of its caller: a Harness, or a tool's own `api` inside its invocation, whose
|
|
92
|
+
* commits create the run's task in the calling conversation. A caller with no `root` names the conversation in
|
|
93
|
+
* `start`'s options; one with no `abortTask` cannot start a run with a cancel. */
|
|
94
|
+
export type WorkflowCaller = Pick<Harness, "commit" | "snapshot" | "getTask" | "waitForTask"> & Partial<Pick<Harness, "root" | "resume" | "abortTask">>;
|
|
95
|
+
/** A started run. `wait` resolves with the run's outcome once its task is terminal, and rejects with the storage's error
|
|
96
|
+
* when the Session failed at one. */
|
|
97
|
+
export type WorkflowRun = {
|
|
98
|
+
key: string;
|
|
99
|
+
taskId: TaskId<WorkflowResult>;
|
|
100
|
+
/** `created`: this start made the task; `attached`: it was live; `settled`: it was terminal and is returned as it is. */
|
|
101
|
+
started: "created" | "attached" | "settled";
|
|
102
|
+
wait(context: Context): Promise<WorkflowOutcome>;
|
|
103
|
+
};
|
|
104
|
+
/** What a stop reached. A pause reaches a run only where it is live, in this process: `paused` once the run's work has
|
|
105
|
+
* stopped with the pause in its journal, `ended` when the run ended otherwise, `not-live` when it is not running here. A cancel is `abortTask`'s
|
|
106
|
+
* answer: `marked` (the task ends aborted later) or `terminal` (it already was). */
|
|
107
|
+
export type WorkflowStop = "paused" | "ended" | "not-live" | "marked" | "terminal";
|
|
108
|
+
type TaskCheckpoint = {
|
|
109
|
+
phase: "run";
|
|
110
|
+
};
|
|
111
|
+
/** A completed task's result: the outcomes that end a run without a refusal. */
|
|
112
|
+
type WorkflowResult = Extract<WorkflowOutcome, {
|
|
113
|
+
status: "complete" | "escalated";
|
|
114
|
+
}>;
|
|
115
|
+
/** The task definition, the extension that carries it, and the host's three operations on a run. */
|
|
116
|
+
export declare function createWorkflowExtension(host: WorkflowTaskHost): {
|
|
117
|
+
extension: Extension;
|
|
118
|
+
start(harness: WorkflowCaller, request: WorkflowRequest, context: Context, options?: {
|
|
119
|
+
conversationId?: number;
|
|
120
|
+
background?: boolean;
|
|
121
|
+
stop?: {
|
|
122
|
+
action: "pause" | "cancel";
|
|
123
|
+
source?: string;
|
|
124
|
+
};
|
|
125
|
+
}): Promise<WorkflowRun>;
|
|
126
|
+
read(harness: WorkflowCaller, key: string, context: Context): Promise<{
|
|
127
|
+
taskId: TaskId<WorkflowResult>;
|
|
128
|
+
status: string;
|
|
129
|
+
outcome?: WorkflowOutcome;
|
|
130
|
+
} | undefined>;
|
|
131
|
+
stop(harness: Harness, key: string, action: "pause" | "cancel", context: Context, source?: string): Promise<WorkflowStop>;
|
|
132
|
+
};
|
|
133
|
+
export {};
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
// A workflow run as one pi-durable task: `agentrun.workflow`, version 1, one phase `run`. The phase opens the recovery
|
|
2
|
+
// driver on the document store, runs the interpreter to its end inside one invocation, and makes one terminal commit;
|
|
3
|
+
// every journal commit in between leaves the task's own state unchanged. The journal is what resumes: a process that
|
|
4
|
+
// dies leaves the task pending, and the next process's `resume()` runs the phase again, which answers what committed
|
|
5
|
+
// from the journal. Three rules from the task spike hold here:
|
|
6
|
+
// - a typed failure is a committed outcome (`failed`, with the code in `error.detail`), never a throw, so pi records
|
|
7
|
+
// it whole instead of faulting the task with a message;
|
|
8
|
+
// - a pause is taken at a seam: the driver stops admitting, an effect in flight completes and commits, the journal
|
|
9
|
+
// commits `paused`, and the task stays pending until the host closes the Harness; reopening continues it;
|
|
10
|
+
// - a cancel is pi's `abortTask`, which returns once the task is marked, before it is terminal: pi ends the run
|
|
11
|
+
// invocation and the conversations the task owns first, then the abort handler commits `cancelled` in the journal
|
|
12
|
+
// and an `aborted` outcome. A cancelled run never resumes.
|
|
13
|
+
// A host that continues an escalated run (its ladder) does so inside the same invocation, through `escalated`, so the
|
|
14
|
+
// continuation it opens is a conversation the task owns: the run's stops reach it the way they reach the run's steps.
|
|
15
|
+
import { defineDocFamily, defineExtension, defineTask } from "@earendil-works/pi-durable";
|
|
16
|
+
import { runWorkflow } from "@parcha/agentrun-dsl";
|
|
17
|
+
import { bindingDigests, bindingMismatch, canonicalHash, isRecoveryError, openRecovery, recoveryBound, RecoveryError, withRecovery } from "@parcha/agentrun-dsl/recovery";
|
|
18
|
+
import { storageError } from "./pi-versions.js";
|
|
19
|
+
import { documentStore } from "./store.js";
|
|
20
|
+
const RunDoc = defineDocFamily({ kind: "agentrun.task", version: 1, scope: "session", family: true, initial: () => ({ task: null, stop: null }) });
|
|
21
|
+
/** Records a stop on a run's entry. A recorded cancel stands, so the first cancel names the run's source; a recorded
|
|
22
|
+
* pause is replaced by the stop that follows it. */
|
|
23
|
+
const recordStop = (run, stop) => { if (run.stop?.action !== "cancel")
|
|
24
|
+
run.stop = stop; };
|
|
25
|
+
/** The source of the cancel a run's entry records. */
|
|
26
|
+
const cancelSource = (run) => (run?.stop?.action === "cancel" ? run.stop.source ?? undefined : undefined);
|
|
27
|
+
const cancelled = (source) => ({ status: "aborted", reason: source ? `cancelled by ${source}` : "cancelled" });
|
|
28
|
+
const json = (value) => (value === undefined ? null : JSON.parse(JSON.stringify(value)));
|
|
29
|
+
/** Why a request is not the run a task holds, or undefined when it is: the same binding (the workflow as authored and
|
|
30
|
+
* what the host binds beside it, as the driver binds its journal) and the same input, each by its canonical digest, so
|
|
31
|
+
* key order does not matter. The refusal is the driver's binding mismatch and names what moved: the workflow's parts,
|
|
32
|
+
* what the host binds, or the input's fields (`input.<field>`). Each part is compared over its own digests, so a bound
|
|
33
|
+
* key named like the input (`input`) hides neither; when both moved, the refusal holds both parts' messages and names. */
|
|
34
|
+
const requestMoved = (held, asked) => {
|
|
35
|
+
const was = recoveryBound(held.workflow, held.bind), is = recoveryBound(asked.workflow, asked.bind);
|
|
36
|
+
const parts = [
|
|
37
|
+
was.binding !== is.binding && bindingMismatch(JSON.stringify(was.inputs), is.inputs),
|
|
38
|
+
canonicalHash(held.input) !== canonicalHash(asked.input) && bindingMismatch(JSON.stringify(bindingDigests({ input: held.input })), bindingDigests({ input: asked.input })),
|
|
39
|
+
].filter((part) => Boolean(part));
|
|
40
|
+
if (parts.length < 2)
|
|
41
|
+
return parts[0];
|
|
42
|
+
return Object.assign(new RecoveryError(parts.map((part) => part.message).join("; "), "RUN_STORE_BINDING_MISMATCH"), { moved: parts.flatMap((part) => part.moved ?? []) });
|
|
43
|
+
};
|
|
44
|
+
const codeOf = (error) => {
|
|
45
|
+
const code = error?.code;
|
|
46
|
+
return typeof code === "string" && code ? code : "WORKFLOW_FAILED";
|
|
47
|
+
};
|
|
48
|
+
const messageOf = (error) => String(error?.message ?? error);
|
|
49
|
+
/** A failure's own fields that are plain JSON (a host's typed failure: its stage, its node, its cost), so the host can
|
|
50
|
+
* read back the failure it raised. The stack and the cause stay out. */
|
|
51
|
+
const ownFields = (error) => {
|
|
52
|
+
const out = {};
|
|
53
|
+
if (!error || typeof error !== "object")
|
|
54
|
+
return out;
|
|
55
|
+
for (const [key, value] of Object.entries(error)) {
|
|
56
|
+
if (key === "stack" || key === "cause" || value === undefined)
|
|
57
|
+
continue;
|
|
58
|
+
try {
|
|
59
|
+
out[key] = json(value);
|
|
60
|
+
}
|
|
61
|
+
catch { /* not plain JSON: left out */ }
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
};
|
|
65
|
+
/** The document store over one invocation's commits: a commit after the task was aborted or the Harness closed is
|
|
66
|
+
* refused by pi, so the driver can never write past its run's end. The task is the journal's one owner across its
|
|
67
|
+
* invocations: its abort handler takes the journal over from a run invocation that never closed it. */
|
|
68
|
+
function invocationStore(runtime, context, key) {
|
|
69
|
+
const host = {
|
|
70
|
+
commit: async (change) => {
|
|
71
|
+
let result;
|
|
72
|
+
await runtime.commit(async (tx) => { result = await change(tx); return undefined; }, context);
|
|
73
|
+
return result;
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
return documentStore(host, key, { owner: `agentrun.workflow:${runtime.taskId}`, context });
|
|
77
|
+
}
|
|
78
|
+
const uncommitted = new Map();
|
|
79
|
+
const uncommittedId = (key, taskId) => JSON.stringify([key, taskId]);
|
|
80
|
+
const uncommittedOf = (key, taskId) => {
|
|
81
|
+
const id = uncommittedId(key, taskId);
|
|
82
|
+
let entry = uncommitted.get(id);
|
|
83
|
+
if (!entry) {
|
|
84
|
+
let reject;
|
|
85
|
+
const promise = new Promise((_, fail) => { reject = fail; });
|
|
86
|
+
promise.catch(() => undefined);
|
|
87
|
+
const made = { promise, failed: false, waits: 0, fail: (error) => { made.failed = true; reject(error); } };
|
|
88
|
+
entry = made;
|
|
89
|
+
uncommitted.set(id, entry);
|
|
90
|
+
}
|
|
91
|
+
return entry;
|
|
92
|
+
};
|
|
93
|
+
/** Runs where this process holds them, by key: the driver a pause is handed to, and when the run ended. */
|
|
94
|
+
const live = new Map();
|
|
95
|
+
/** The task definition, the extension that carries it, and the host's three operations on a run. */
|
|
96
|
+
export function createWorkflowExtension(host) {
|
|
97
|
+
/** The run's driver over its journal, with the host's options. */
|
|
98
|
+
const openDriver = async (task, runtime, context, phase) => {
|
|
99
|
+
const { key, workflow, bind } = task.input;
|
|
100
|
+
const options = await host.options?.({ key, taskId: task.id, runtime, context, phase });
|
|
101
|
+
return openRecovery(invocationStore(runtime, context, key), workflow, { ...options, key, ...(bind ? { bind } : {}) });
|
|
102
|
+
};
|
|
103
|
+
/** The driver, then what the interpreter runs with: a host that fails to give it lets the driver go. */
|
|
104
|
+
const open = async (task, runtime, context) => {
|
|
105
|
+
const driver = await openDriver(task, runtime, context, "run");
|
|
106
|
+
try {
|
|
107
|
+
const parts = await host.run({ key: task.input.key, taskId: task.id, runtime, context, driver });
|
|
108
|
+
const deps = "deps" in parts
|
|
109
|
+
? { ...parts.deps, signal: AbortSignal.any([driver.signal, runtime.signal, ...(parts.deps.signal ? [parts.deps.signal] : [])]) }
|
|
110
|
+
: withRecovery(driver, { ...parts.adapters, signal: runtime.signal }, { durableNodes: parts.durableNodes });
|
|
111
|
+
return { deps, driver, interpret: ("deps" in parts && parts.interpret) || runWorkflow };
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
await driver.close().catch(() => undefined);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
const failed = (error) => ({ status: "failed", error: { message: messageOf(error), detail: { ...ownFields(error), code: codeOf(error) } } });
|
|
119
|
+
const Workflow = defineTask({
|
|
120
|
+
name: "agentrun.workflow", version: 1, initial: () => ({ phase: "run" }),
|
|
121
|
+
phases: {
|
|
122
|
+
run: async (task, runtime, context) => {
|
|
123
|
+
const { key, workflow, input } = task.input;
|
|
124
|
+
/** The run's one terminal commit. One the Session refuses (not an abort's) hands this process's waiters the run's
|
|
125
|
+
* own failure, or the refusal. */
|
|
126
|
+
const commitEnd = async (outcome, cause) => {
|
|
127
|
+
try {
|
|
128
|
+
await runtime.commit(() => ({ status: "terminal", outcome }), context);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
if (!runtime.signal.aborted)
|
|
132
|
+
uncommittedOf(key, task.id).fail(cause ?? error);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
/** The invocation ends when the host closes the Harness, and the task stays pending. */
|
|
137
|
+
const untilClosed = () => new Promise((resolve) => { if (runtime.signal.aborted)
|
|
138
|
+
resolve();
|
|
139
|
+
else
|
|
140
|
+
runtime.signal.addEventListener("abort", () => resolve(), { once: true }); });
|
|
141
|
+
let opened;
|
|
142
|
+
try {
|
|
143
|
+
opened = await open(task, runtime, context);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (runtime.signal.aborted)
|
|
147
|
+
throw error;
|
|
148
|
+
// A run a later build stored in a schema this build does not know is left for that build: the task stays
|
|
149
|
+
// pending, and this process's waiters get the refusal. Any other failure to open ends the run.
|
|
150
|
+
if (codeOf(error) === "RECOVERY_SCHEMA_NEWER") {
|
|
151
|
+
uncommittedOf(key, task.id).fail(error);
|
|
152
|
+
await untilClosed();
|
|
153
|
+
throw runtime.signal.reason;
|
|
154
|
+
}
|
|
155
|
+
await commitEnd(failed(error), error);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const { deps, driver, interpret } = opened;
|
|
159
|
+
// A stop the run's entry records is the driver's before anything runs. The entry lets go of a pause once the
|
|
160
|
+
// journal holds it, so the next start runs again. A failure here lets the driver go and is the run's failure,
|
|
161
|
+
// as a failure to open is.
|
|
162
|
+
let early;
|
|
163
|
+
try {
|
|
164
|
+
early = (await runtime.snapshot(RunDoc, key, context))?.stop ?? null;
|
|
165
|
+
if (early?.action === "pause") {
|
|
166
|
+
driver.stop({ action: "pause", ...(early.source ? { source: early.source } : {}) });
|
|
167
|
+
await driver.flush().catch(() => undefined);
|
|
168
|
+
await runtime.commit(async (tx) => { const run = await tx.doc(RunDoc, key, null); if (run.stop?.action === "pause")
|
|
169
|
+
run.stop = null; return undefined; }, context);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
await driver.close().catch(() => undefined);
|
|
174
|
+
if (runtime.signal.aborted)
|
|
175
|
+
throw error;
|
|
176
|
+
await commitEnd(failed(error), error);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
let end;
|
|
180
|
+
live.set(key, { driver, ended: new Promise((resolve) => { end = resolve; }) });
|
|
181
|
+
let outcome;
|
|
182
|
+
let cause;
|
|
183
|
+
const ladder = host.escalated;
|
|
184
|
+
/** The run continues on the host's ladder; the row the host committed rides on the outcome. */
|
|
185
|
+
const handOff = async (ending) => {
|
|
186
|
+
const continuation = await ladder(ending, { key, taskId: task.id, runtime, context, driver, signal: AbortSignal.any([runtime.signal, driver.signal]) });
|
|
187
|
+
await driver.flush();
|
|
188
|
+
const row = driver.escalation();
|
|
189
|
+
if (!row)
|
|
190
|
+
throw Object.assign(new Error("The host continued the run without committing its escalation"), { code: "ESCALATION_NOT_COMMITTED" });
|
|
191
|
+
return { status: "completed", result: { status: "escalated", escalation: json(row), state: json(ending.state), continuation: json(continuation) } };
|
|
192
|
+
};
|
|
193
|
+
/** A pause the driver took: the task stays pending until the host closes the Harness. */
|
|
194
|
+
const paused = () => driver.signal.aborted && driver.signal.reason?.code === "FROZEN_PAUSED";
|
|
195
|
+
try {
|
|
196
|
+
const prior = ladder ? driver.escalation() : undefined;
|
|
197
|
+
if (early?.action === "cancel") {
|
|
198
|
+
// A cancel recorded before pi marked the task (the process died between the two) is taken before any step
|
|
199
|
+
// and before the host's ladder on a run reopened after its escalation: this invocation ends the run as the
|
|
200
|
+
// abort handler would have.
|
|
201
|
+
driver.stop({ action: "cancel", ...(early.source ? { source: early.source } : {}) });
|
|
202
|
+
await driver.flush();
|
|
203
|
+
outcome = cancelled(early.source ?? undefined);
|
|
204
|
+
}
|
|
205
|
+
else if (prior) {
|
|
206
|
+
driver.checkStop();
|
|
207
|
+
outcome = await handOff({ row: prior, state: prior.state });
|
|
208
|
+
}
|
|
209
|
+
else if (driver.preStepRefusal) {
|
|
210
|
+
// A refusal found before any step: nothing runs. With a ladder, the host continues the run from it.
|
|
211
|
+
if (!ladder)
|
|
212
|
+
throw driver.preStepRefusal;
|
|
213
|
+
outcome = await handOff({ refusal: driver.preStepRefusal, state: { ...input } });
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
let run;
|
|
217
|
+
try {
|
|
218
|
+
run = await interpret(workflow, input, deps);
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
// A refusal, a stop or an abort never ladders.
|
|
222
|
+
if (!ladder || runtime.signal.aborted || driver.signal.aborted || isRecoveryError(error))
|
|
223
|
+
throw error;
|
|
224
|
+
outcome = await handOff({ error, state: driver.lastState() });
|
|
225
|
+
}
|
|
226
|
+
if (run)
|
|
227
|
+
outcome = run.status === "complete"
|
|
228
|
+
? { status: "completed", result: { status: "complete", output: json(run.output), state: json(run.state) } }
|
|
229
|
+
: ladder ? await handOff({ escalation: run.escalation, state: run.state })
|
|
230
|
+
: { status: "completed", result: { status: "escalated", escalation: json(run.escalation), state: json(run.state) } };
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
// An abort or a close: this invocation may not decide the outcome (its commits are refused), so it only ends.
|
|
235
|
+
if (runtime.signal.aborted)
|
|
236
|
+
throw error;
|
|
237
|
+
if (!paused()) {
|
|
238
|
+
outcome = failed(error);
|
|
239
|
+
cause = error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
// A paused run stays live here until its invocation ends, so a pause handed over meanwhile is answered.
|
|
244
|
+
if (!paused())
|
|
245
|
+
live.delete(key);
|
|
246
|
+
await driver.close().catch(() => undefined);
|
|
247
|
+
end();
|
|
248
|
+
}
|
|
249
|
+
if (paused() || !outcome) {
|
|
250
|
+
// The pause is in the journal once the driver's commits have landed; the task stays pending, and its
|
|
251
|
+
// invocation ends when the host closes the Harness.
|
|
252
|
+
await new Promise((resolve) => { if (runtime.signal.aborted)
|
|
253
|
+
resolve();
|
|
254
|
+
else
|
|
255
|
+
runtime.signal.addEventListener("abort", () => resolve(), { once: true }); });
|
|
256
|
+
if (live.get(key)?.driver === driver)
|
|
257
|
+
live.delete(key);
|
|
258
|
+
throw runtime.signal.reason;
|
|
259
|
+
}
|
|
260
|
+
await commitEnd(outcome, cause);
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
abort: async (task, runtime, context) => {
|
|
264
|
+
const { key } = task.input;
|
|
265
|
+
const source = cancelSource(await runtime.snapshot(RunDoc, key, context).catch(() => undefined));
|
|
266
|
+
// The journal records the cancel, so no later open of it resumes. A journal the driver refuses (a binding that
|
|
267
|
+
// moved, a checkpoint it cannot read) is left as it is; the task still ends aborted.
|
|
268
|
+
try {
|
|
269
|
+
const driver = await openDriver(task, runtime, context, "abort");
|
|
270
|
+
driver.stop({ action: "cancel", ...(source ? { source } : {}) });
|
|
271
|
+
await driver.close();
|
|
272
|
+
}
|
|
273
|
+
catch { /* the outcome below is committed whatever the journal said */ }
|
|
274
|
+
await runtime.commit(() => ({ status: "terminal", outcome: cancelled(source) }), context);
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
const outcomeOf = async (harness, settled, key, context) => {
|
|
278
|
+
if (settled.state.status !== "terminal")
|
|
279
|
+
return undefined;
|
|
280
|
+
const outcome = settled.state.outcome;
|
|
281
|
+
if (outcome.status === "completed")
|
|
282
|
+
return outcome.result;
|
|
283
|
+
if (outcome.status === "aborted") {
|
|
284
|
+
const source = cancelSource(await harness.snapshot(RunDoc, key, context));
|
|
285
|
+
return { status: "stopped", stop: "cancelled", ...(source ? { source } : {}) };
|
|
286
|
+
}
|
|
287
|
+
const error = outcome.error;
|
|
288
|
+
return { status: "refused", code: error?.detail?.code ?? String(outcome.status).toUpperCase(), message: error?.message ?? String(outcome.reason ?? outcome.status), ...(error?.detail ? { detail: error.detail } : {}) };
|
|
289
|
+
};
|
|
290
|
+
const handle = (harness, key, taskId, started) => ({
|
|
291
|
+
key, taskId, started,
|
|
292
|
+
wait: async (context) => {
|
|
293
|
+
const failure = uncommittedOf(key, taskId);
|
|
294
|
+
failure.waits += 1;
|
|
295
|
+
try {
|
|
296
|
+
const settled = await Promise.race([harness.waitForTask(taskId, context), failure.promise]);
|
|
297
|
+
return (await outcomeOf(harness, settled, key, context));
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
// A Session that failed at a storage error rejects its waits with `SessionFailed` (pi-durable 1.2): the
|
|
301
|
+
// waiters get the storage's error, as 1.1 gave it.
|
|
302
|
+
throw storageError(error);
|
|
303
|
+
}
|
|
304
|
+
finally {
|
|
305
|
+
// The last wait to end releases the entry, whether the task ended or the wait did; a failure keeps it.
|
|
306
|
+
failure.waits -= 1;
|
|
307
|
+
const id = uncommittedId(key, taskId);
|
|
308
|
+
if (failure.waits === 0 && !failure.failed && uncommitted.get(id) === failure)
|
|
309
|
+
uncommitted.delete(id);
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
return {
|
|
314
|
+
extension: defineExtension({ name: "agentrun.workflow", tasks: [Workflow] }),
|
|
315
|
+
/** Start the run `request.key` names, or reach the task that already runs it. The run's entry and its task are
|
|
316
|
+
* created in one commit, so two starts of one key make one task. A start that reaches a task asks for the run it
|
|
317
|
+
* holds: its workflow, what the host binds and its input are the task's, by canonical digest, so key order does not
|
|
318
|
+
* matter. One that asks for another run is refused with the driver's binding mismatch (`RUN_STORE_BINDING_MISMATCH`,
|
|
319
|
+
* its `moved` naming the workflow's parts, what the host binds, or the input's fields as `input.<field>`) and
|
|
320
|
+
* commits nothing: no task, no stop, no cancel. Another run takes another key. A live task is attached to; a task
|
|
321
|
+
* that ended complete, escalated or cancelled is returned as it is; one that ended refused, failed or faulted is
|
|
322
|
+
* replaced by a new task over the same journal, which refuses again until the host reconciles it. A journal an
|
|
323
|
+
* older build started, with no task, gets one here, and its driver checks the binding as it opens. A stop the host
|
|
324
|
+
* gave before the run started (`options.stop`) is recorded in the commit that creates or reaches the task, and is
|
|
325
|
+
* taken before anything runs. A cancel aborts the task before the scheduler runs it, so its abort handler commits
|
|
326
|
+
* it; one a process recorded and died before pi marked the task is taken by the run's next invocation, before any
|
|
327
|
+
* step and before the host's ladder. A pause is handed to the driver by the run's phase as it opens, before any
|
|
328
|
+
* step. So a pause given with a start that attaches to a run already running in this process is not taken now: it
|
|
329
|
+
* is recorded, and taken when the run's next invocation opens. `stop` pauses a run that is running here. */
|
|
330
|
+
async start(harness, request, context, options = {}) {
|
|
331
|
+
if (options.stop?.action === "cancel" && !harness.abortTask)
|
|
332
|
+
throw new Error("A start with a cancel needs a caller that can abort a task: the Harness");
|
|
333
|
+
const conversationId = options.conversationId ?? (harness.root ? (await harness.root(context)).id : undefined);
|
|
334
|
+
if (conversationId === undefined)
|
|
335
|
+
throw new Error("A caller with no root conversation names the conversation the run belongs to: options.conversationId");
|
|
336
|
+
const stop = options.stop ? { action: options.stop.action, source: options.stop.source ?? null } : null;
|
|
337
|
+
const asked = json(request);
|
|
338
|
+
const [taskId, started] = await harness.commit(async (tx) => {
|
|
339
|
+
const run = await tx.doc(RunDoc, request.key, null);
|
|
340
|
+
if (run.task !== null) {
|
|
341
|
+
const prior = await tx.task(run.task);
|
|
342
|
+
// A request for another run than the task holds is refused, and the throw discards this commit.
|
|
343
|
+
const moved = prior && requestMoved(prior.input, asked);
|
|
344
|
+
if (moved)
|
|
345
|
+
throw moved;
|
|
346
|
+
if (prior && prior.state.status !== "terminal") {
|
|
347
|
+
if (stop)
|
|
348
|
+
recordStop(run, stop);
|
|
349
|
+
return [run.task, "attached"];
|
|
350
|
+
}
|
|
351
|
+
const kept = prior?.state.status === "terminal" && (prior.state.outcome.status === "completed" || prior.state.outcome.status === "aborted");
|
|
352
|
+
if (kept)
|
|
353
|
+
return [run.task, "settled"];
|
|
354
|
+
}
|
|
355
|
+
const id = await tx.createTask(Workflow, asked, { ownership: { kind: "conversation" }, conversationId: conversationId, ...(options.background ? { background: true } : {}) });
|
|
356
|
+
run.task = id;
|
|
357
|
+
run.stop = stop;
|
|
358
|
+
return [id, "created"];
|
|
359
|
+
}, context);
|
|
360
|
+
// A failure an earlier invocation of this task left in this process is not this one's; one still awaited is kept,
|
|
361
|
+
// so the waiters it has and the invocation that may yet fail share it.
|
|
362
|
+
const earlier = uncommittedId(request.key, taskId);
|
|
363
|
+
if (uncommitted.get(earlier)?.failed)
|
|
364
|
+
uncommitted.delete(earlier);
|
|
365
|
+
if (stop?.action === "cancel" && started !== "settled")
|
|
366
|
+
await harness.abortTask(taskId, context);
|
|
367
|
+
harness.resume?.();
|
|
368
|
+
return handle(harness, request.key, taskId, started);
|
|
369
|
+
},
|
|
370
|
+
/** The run's task and, once it is terminal, its outcome; undefined when no start named the key. */
|
|
371
|
+
async read(harness, key, context) {
|
|
372
|
+
const run = await harness.snapshot(RunDoc, key, context);
|
|
373
|
+
if (!run || run.task === null)
|
|
374
|
+
return undefined;
|
|
375
|
+
const record = await harness.getTask(run.task, context);
|
|
376
|
+
if (!record)
|
|
377
|
+
return undefined;
|
|
378
|
+
const outcome = await outcomeOf(harness, record, key, context);
|
|
379
|
+
return { taskId: record.id, status: record.state.status, ...(outcome ? { outcome } : {}) };
|
|
380
|
+
},
|
|
381
|
+
/** Pause or cancel the run. A pause resolves once the journal holds it (an effect in flight completes and commits
|
|
382
|
+
* first) or the run ended; the host then closes the Harness, and the task stays pending. A cancel resolves when pi
|
|
383
|
+
* has marked the task, before it is terminal; `wait` gives the aborted outcome. */
|
|
384
|
+
async stop(harness, key, action, context, source) {
|
|
385
|
+
if (action === "pause") {
|
|
386
|
+
const running = live.get(key);
|
|
387
|
+
if (!running)
|
|
388
|
+
return "not-live";
|
|
389
|
+
running.driver.stop({ action: "pause", ...(source ? { source } : {}) });
|
|
390
|
+
// The run is paused once its work has stopped, not when the journal first holds the pause: a continuation the
|
|
391
|
+
// host's ladder runs stops through the run's signal and may take a while, and the host lets go of the run only
|
|
392
|
+
// after it has. Its driver's commits have landed by then.
|
|
393
|
+
await running.ended;
|
|
394
|
+
await running.driver.flush().catch(() => undefined);
|
|
395
|
+
return running.driver.signal.aborted ? "paused" : "ended";
|
|
396
|
+
}
|
|
397
|
+
const taskId = await harness.commit(async (tx) => {
|
|
398
|
+
const run = await tx.doc(RunDoc, key, null);
|
|
399
|
+
if (run.task === null)
|
|
400
|
+
return null;
|
|
401
|
+
// A finished run keeps what ended it, and the first cancel of a live one names its source.
|
|
402
|
+
if ((await tx.task(run.task))?.state.status !== "terminal")
|
|
403
|
+
recordStop(run, { action: "cancel", source: source ?? null });
|
|
404
|
+
return run.task;
|
|
405
|
+
}, context);
|
|
406
|
+
if (taskId === null)
|
|
407
|
+
return "not-live";
|
|
408
|
+
return harness.abortTask(taskId, context);
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Extension } from "@earendil-works/pi-durable";
|
|
2
|
+
import { type AuthorHostAddendum, type WorkflowDeps } from "@parcha/agentrun-dsl";
|
|
3
|
+
import { createWorkflowExtension, type WorkflowTaskHost } from "./task.js";
|
|
4
|
+
/** What a pi-durable session adds to the workflow language, rendered for describe's `authoring.host`. */
|
|
5
|
+
export declare const DURABLE_HOST_ADDENDUM: AuthorHostAddendum;
|
|
6
|
+
export type WorkflowToolHost = {
|
|
7
|
+
/** The project whose saved workflows save, load and list keep: `<projectDir>/.pi/agentrun/workflows`, the store the
|
|
8
|
+
* coding-agent extension keeps for its working directory. */
|
|
9
|
+
projectDir: string;
|
|
10
|
+
/** The tools a workflow may call, as describe lists them. Inspect admits only these names. */
|
|
11
|
+
tools?: readonly {
|
|
12
|
+
name: string;
|
|
13
|
+
description?: string;
|
|
14
|
+
parameters: unknown;
|
|
15
|
+
resultSchema?: unknown;
|
|
16
|
+
}[];
|
|
17
|
+
/** What a run's task runs with: `createWorkflowExtension`'s host. */
|
|
18
|
+
task: WorkflowTaskHost;
|
|
19
|
+
/** The host's code executor. Without it no code node runs: a dry run of a draft with code is not_run, and a run of
|
|
20
|
+
* one is refused before it starts. Given, a run's task runs its code nodes through it. */
|
|
21
|
+
runCode?: WorkflowDeps["runCode"];
|
|
22
|
+
/** The SOP text for workflows that name sections. */
|
|
23
|
+
sop?: string;
|
|
24
|
+
/** The tool's name, `agentrun` by default: the agent definition lists it. */
|
|
25
|
+
name?: string;
|
|
26
|
+
};
|
|
27
|
+
/** The tool, and the workflow task its runs are: install `extension` (it carries both) and list the tool's name in the
|
|
28
|
+
* agent definition. `workflow` reads and stops runs from the host's side. */
|
|
29
|
+
export declare function createWorkflowTool(host: WorkflowToolHost): {
|
|
30
|
+
extension: Extension;
|
|
31
|
+
workflow: ReturnType<typeof createWorkflowExtension>;
|
|
32
|
+
};
|