@parcha/agentrun-pi 0.1.0-beta.14 → 0.1.0-beta.15

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.
Files changed (42) hide show
  1. package/README.md +40 -2
  2. package/dist/durable/index.d.ts +2 -1
  3. package/dist/durable/index.js +1 -0
  4. package/dist/durable/node.js +27 -10
  5. package/dist/durable/readers.d.ts +4 -1
  6. package/dist/durable/readers.js +44 -5
  7. package/dist/durable/receipts.d.ts +4 -2
  8. package/dist/durable/receipts.js +3 -2
  9. package/dist/durable/store.js +57 -9
  10. package/dist/durable/task.d.ts +6 -2
  11. package/dist/durable/task.js +18 -2
  12. package/dist/durable/workflow-tool.d.ts +32 -0
  13. package/dist/durable/workflow-tool.js +188 -0
  14. package/dist/extension.js +64 -32
  15. package/dist/host-addendum.js +3 -2
  16. package/dist/pi-host.d.ts +7 -0
  17. package/dist/pi-host.js +22 -0
  18. package/dist/signin/index.d.ts +9 -0
  19. package/dist/signin/index.js +7 -0
  20. package/dist/signin/limit.d.ts +10 -0
  21. package/dist/signin/limit.js +23 -0
  22. package/dist/signin/resolve.d.ts +47 -0
  23. package/dist/signin/resolve.js +163 -0
  24. package/dist/signin/signin.d.ts +44 -0
  25. package/dist/signin/signin.js +126 -0
  26. package/dist/signin/spec.d.ts +116 -0
  27. package/dist/signin/spec.js +181 -0
  28. package/dist/signin/store.d.ts +40 -0
  29. package/dist/signin/store.js +206 -0
  30. package/dist/skills/author/SKILL.md +3 -2
  31. package/dist/skills/author/references/language.md +4 -3
  32. package/dist/skills/author/references/sdk-authoring.md +2 -0
  33. package/dist/workflow-inspector.d.ts +1 -1
  34. package/dist/workflow-inspector.js +2 -2
  35. package/dist/workflow-store.d.ts +6 -0
  36. package/dist/workflow-store.js +6 -0
  37. package/dist/workflow-tool.d.ts +81 -0
  38. package/dist/workflow-tool.js +94 -0
  39. package/dist/workflow-view.js +5 -1
  40. package/dist/workflows.d.ts +1 -0
  41. package/dist/workflows.js +3 -0
  42. package/package.json +16 -4
package/README.md CHANGED
@@ -74,6 +74,7 @@ Loading starts with empty input and does not execute. A save retains the definit
74
74
  | `/agentrun` | Inspect the current workflow, or show help if there is none. |
75
75
  | `/agentrun input <case or JSON>` | Set new input without changing the definition or running it. |
76
76
  | `/agentrun run` | Repeat the current workflow with its last supplied input. Scripted demos stay scripted; authored workflows use real adapters. |
77
+ | `/agentrun dry-run` | Run the current workflow on synthetic values, with no model, Jev or tool call. |
77
78
  | `/agentrun save <name>` | Save an immutable, project-local definition revision. |
78
79
  | `/agentrun load <name> [digest]` | Load the newest or exact revision; input starts empty. |
79
80
  | `/agentrun list` | List saved revisions. |
@@ -89,6 +90,19 @@ A live run is durable. Its journal is kept in `.pi/agentrun/runs/<run id>/journa
89
90
 
90
91
  One workflow runs at a time. Named definitions are durable across sessions in the same project. Pi run receipts belong to the current branch and may remain in memory until Pi persists its session; saving a definition does not save a receipt.
91
92
 
93
+ A host that does not run the extension can save, load and list the same definitions with `@parcha/agentrun-pi/workflows`, which loads nothing from pi:
94
+
95
+ ```ts
96
+ import { WorkflowStore } from '@parcha/agentrun-pi/workflows';
97
+
98
+ const store = new WorkflowStore(projectDirectory); // records go to <projectDirectory>/.pi/agentrun/workflows/<name>/
99
+ const saved = await store.save('release-review', workflow); // an immutable revision, named by its SHA-256
100
+ const again = await store.load('release-review'); // the last one saved; pass a digest for an exact revision
101
+ const revisions = await store.list(); // every revision, newest first
102
+ ```
103
+
104
+ The argument is a project directory, not the workflows directory: the extension keeps its records for `ctx.cwd` in the same place, so a host that passes that directory and the extension read each other's records. Storage needs real directories and regular files (a symbolic link in the path or among the records fails with `WorkflowStoreError` code `unsafe_path`), and every load checks the record's metadata and the SHA-256 of its workflow. A `<name>/` directory copied into the workflows directory loads. A saved workflow is a procedure, not authority: the host still checks it against its current capabilities before it runs.
105
+
92
106
  ### Three demo modes
93
107
 
94
108
  | Command | What it demonstrates |
@@ -105,6 +119,8 @@ By default, the extension exposes currently active Pi built-in `read`, `grep`, `
105
119
 
106
120
  The user must issue `/agentrun run --trusted` for each run that needs code nodes or active built-in `bash`, `edit`, or `write` tools. This permits local, unsandboxed execution; authorization does not carry into the next run. A model cannot grant itself that permission with a tool argument.
107
121
 
122
+ A dry run (`/agentrun dry-run`, the inspector's Dry-run action, or the tool's `dry-run` action, which also takes an `input`) runs the inspected workflow on synthetic values: agent and judge answers and tool results are synthesized from their schemas, so it makes no model, Jev or tool call, writes no run journal and leaves the last run's receipt as it was. Its `status` is `passed`, `failed` (`problems` say why), or `not_run`: a check could not run, for example a schema the generator cannot satisfy (`pattern`, `format`), a synthetic answer that escalated, or SOP text that was not supplied, and `skipped` says which. A `not_run` dry run proves nothing. A workflow with code nodes is `not_run` unless the user issues `/agentrun dry-run --trusted`, which runs its code in this process for that dry run only; a code node that throws is reported by its label, without what it threw. A dry run stops after 10,000 synthetic steps, or at `/agentrun stop`, as `not_run`.
123
+
108
124
  A workflow that names `sopSection`s needs SOP text. `/agentrun sop <file>` reads a Markdown file inside the project (up to 512 KiB) and checks that it has a `## <section>` heading for each section the workflow names; `/agentrun sop` shows what is set. The file's digest is kept with the session, and a run is refused if the file changed since it was supplied. A host that passes `sop` to `createAgentRunExtension` supplies the text itself and `/agentrun sop` is not used.
109
125
 
110
126
  Custom extension tools and the outer Pi session's permission hooks are **not inherited**, including in trusted runs. A configured host can explicitly supply its own tool definitions as described below. Shell/executor effect transports and artifact delivery are unavailable in this extension; declared tool effects must use an available tool.
@@ -230,6 +246,28 @@ With `rubricSections`, every supplied section must appear on every generated LLM
230
246
 
231
247
  The standalone CLI remains available as `node packages/pi/dist/cli.js --help` from source. It imports a trusted caller-owned `--config` module for `author` and `run`; importing that module executes its code. `validate` can evaluate code probes. Review executable workflows before using it.
232
248
 
249
+ ## Sign in, pick a model, check keys
250
+
251
+ `@parcha/agentrun-pi/signin` is what a demo, or your own app, runs before its first model call. It is the only entry of this package that loads `@earendil-works/pi-ai`, an optional peer: install it beside the package (`npm install @earendil-works/pi-ai@~1.2.0`) to use this entry. No other entry imports it.
252
+
253
+ ```js
254
+ import { builtinModels } from '@earendil-works/pi-ai/providers/all';
255
+ import { checkKeys, resolveModel, usageLimitError } from '@parcha/agentrun-pi/signin';
256
+
257
+ const report = checkKeys(spec, { live: true, dir: demoDir });
258
+ if (!report.ok) { console.error(report.text); process.exit(report.exitCode); } // 2: nothing was sent
259
+ const { model, getApiKey, source } = await resolveModel(spec, { provider, model: modelId });
260
+ const message = await builtinModels().completeSimple(model, context, { apiKey: await getApiKey() });
261
+ if (usageLimitError(message)) throw usageLimitError(message);
262
+ ```
263
+
264
+ - `checkKeys(spec, options)` lists what this run needs and what is missing: each key by its env name and where to create it, each program on `PATH` (refused outside its `platforms`, or below its `min` version as the first x.y.z that `<cmd> --version` prints), and each package as the demo's directory resolves it, with the exact `npm install --no-save <name>@<version>` line when it is missing. An item with `for: 'live'` or `for: 'kill'` counts only in that mode. A `oneOf` choice is met by any member, and an array member only by all of its items. A Jev judge asks for `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY`. The check sends no request and prompts for nothing; a key's value is never printed.
265
+ - `checkSetupPrompt(spec, text)` checks a demo's setup prompt against its spec: every key env name, program command and package name in the spec appears in it, and it names no credential env name (`*_API_KEY`, `*_KEY`, `*_TOKEN*`, `*_SECRET*`, `*_PROJECT_ID`) the spec does not list. `node scripts/check-setup-prompts.mjs` runs it for every `examples/*/demo.json` in CI.
266
+ - `resolveModel(spec, options)` picks the model and its key. An explicit `provider` (and `model`) with that provider's env key wins, passed on as an explicit key; Anthropic takes `ANTHROPIC_API_KEY` only. Otherwise a stored ChatGPT sign-in is used, and the model is chosen from the account's own `GET /v1/models` by what the spec needs (text, tools, images): the spec's default when the account lists it, else the newest one pi-ai knows. With neither, it runs the sign-in. A sign-in's model takes only streaming Responses calls with `store: false` (`responsesOnly`, `unsupported`). The spec's eyes model resolves the same way: an explicit `eyes` model with its key, else the lead model when it meets the eyes' needs, else a `ResolveError` naming the gap; the ChatGPT plan takes no video input. The result serializes without its key.
267
+ - `signIn(options)` runs pi-ai's own Sign in with ChatGPT once per install, with this app's name and a host id kept for the install. pi-ai's callback port is fixed at 1455; when another login holds it, the sign-in fails at once and says to finish or cancel that login. A pi-ai that ignores the app name (before 1.1) is refused before the page opens.
268
+ - The sign-in lives in `$AGENTRUN_HOME/auth.json` (default `~/.config/agentrun/`): the directory is 0700 and the file 0600, written whole and renamed into place. An existing directory others can read is refused, not changed. Refreshes are serialized across processes by an operating-system lock, an exclusive SQLite transaction on `auth.json.lock` (an empty database beside it, through `node:sqlite`). The system frees it when its holder exits or is killed, so a crashed run leaves no lock to clear, the rotating refresh token is never sent twice, and a process that waited reads the new token. On Node 22, `node:sqlite` prints one experimental warning when the first write loads it. A token never reaches a log, a URL or a recording.
269
+ - The plan's usage limit (`subscription_sharing_usage_limit_exceeded`) maps to `UsageLimitError`: "Your ChatGPT plan's usage limit is reached; wait, or set a provider key." It is never retried.
270
+
233
271
  ## Develop from source
234
272
 
235
273
  To change the extension itself, clone and build the repository:
@@ -250,9 +288,9 @@ These commands create a separate `agentrun-pi-project` beside the checkout. You
250
288
 
251
289
  ## Dependencies and retained data
252
290
 
253
- Pi is optional for `@parcha/agentrun-dsl`. This package depends on the DSL and the Jev adapter only. The Pi packages it uses (`@earendil-works/pi-coding-agent`, `@earendil-works/pi-agent-core`, `@earendil-works/pi-tui`) and `typebox` are optional peer dependencies at `*`, as Pi's package guidance requires, so an installed copy never shadows the host's. The release inventory records the locked versions.
291
+ Pi is optional for `@parcha/agentrun-dsl`. This package depends on the DSL and the Jev adapter only. The Pi packages it uses (`@earendil-works/pi-coding-agent`, `@earendil-works/pi-agent-core`, `@earendil-works/pi-tui`, and `@earendil-works/pi-ai` for the sign-in entry only) and `typebox` are optional peer dependencies at `*`, as Pi's package guidance requires, so an installed copy never shadows the host's. The release inventory records the locked versions.
254
292
 
255
- When loaded as an extension, Pi resolves its core packages and TypeBox through the host loader, and `pi install` adds nothing else. A standalone application or the `agentrun-pi` CLI has no host: install the peers yourself (`npm install @earendil-works/pi-coding-agent@~1.2.0 @earendil-works/pi-agent-core@~1.2.0 @earendil-works/pi-tui@~1.2.0 typebox`). The pi packages are pinned to 1.2: pi 1.3.0 pins its own packages to exact versions, and this release's ranges stop below 1.3 until the next one. The tested host is Pi 1.2.0; peer ranges at `*` do not make an older or newer host compatible. The offline loader regression checks competing local dependencies, a native file read, and the scripted workflow. Pi's [package guidance](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md#dependencies) is the rule this follows.
293
+ When loaded as an extension, Pi resolves its core packages and TypeBox through the host loader, and `pi install` adds nothing else. A standalone application or the `agentrun-pi` CLI has no host: install the peers yourself (`npm install @earendil-works/pi-coding-agent@~1.2.0 @earendil-works/pi-agent-core@~1.2.0 @earendil-works/pi-tui@~1.2.0 typebox`). The pi packages are pinned to 1.2: pi 1.3.0 pins its own packages to exact versions, and this release's ranges stop below 1.3 until the next one. The tested host is Pi 1.2.0; peer ranges at `*` do not make an older or newer host compatible. The sign-in entry needs pi-ai 1.1.0 or later: pi-ai 1.0.x ignores the app name and would put pi's name on ChatGPT's sign-in page, so `signIn` refuses it with `SignInError` (`APP_NAME_IGNORED`) before the page is shown. The offline loader regression checks competing local dependencies, a native file read, and the scripted workflow. Pi's [package guidance](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md#dependencies) is the rule this follows.
256
294
 
257
295
  The durable entry (`@parcha/agentrun-pi/durable`) takes pi-durable and chord as optional peers from 1.0.4 to below 1.4; CI runs it on 1.0.4, 1.1.0, 1.2.0 and 1.3.0. A run that pi-durable 1.1 left (killed, paused, or with a tool call in flight) continues on 1.2. The move does not go back. A run that pi 1.2 left with a tool call in flight cannot be continued by pi 1.1: 1.1 waits on the newer task, and the only thing it writes is that task's mark from running back to pending, as pi does for any task a dead process left running. Continue such a run on 1.2, and do not downgrade pi under a live run. A host can call `refuseNewerTasks(harness, context)` from the durable entry after opening its Harness. On pi before 1.2, it then fails such a store with `NewerTaskError` (code `PI_TASK_NEWER`, naming the task's kind and version) instead of waiting.
258
296
 
@@ -16,4 +16,5 @@ export { closeNodeAttempt, hostScope, NodeBindingMismatch, NodeIndex, runNodeAtt
16
16
  export type { NodeAttempt, NodeOutcome, NodeScope, NodeSettlement } from "./node.js";
17
17
  export { NodeDoc, NodeFailure, nodeRunner } from "./node-runner.js";
18
18
  export type { NodeHost, NodeRecord, NodeRequest } from "./node-runner.js";
19
- export { createWorkflowExtension, type WorkflowEnding, type WorkflowHostParts, type WorkflowInvocation, type WorkflowOutcome, type WorkflowRequest, type WorkflowRun, type WorkflowStop, type WorkflowTaskHost } from "./task.js";
19
+ export { createWorkflowExtension, type WorkflowCaller, type WorkflowEnding, type WorkflowHostParts, type WorkflowInvocation, type WorkflowOutcome, type WorkflowRequest, type WorkflowRun, type WorkflowStop, type WorkflowTaskHost } from "./task.js";
20
+ export { createWorkflowTool, DURABLE_HOST_ADDENDUM, type WorkflowToolHost } from "./workflow-tool.js";
@@ -11,3 +11,4 @@ export { deliveredRecord, GateDoc, RecordDoc, recordNudge, recordStore, recordTo
11
11
  export { closeNodeAttempt, hostScope, NodeBindingMismatch, NodeIndex, runNodeAttempt, taskScope } from "./node.js";
12
12
  export { NodeDoc, NodeFailure, nodeRunner } from "./node-runner.js";
13
13
  export { createWorkflowExtension } from "./task.js";
14
+ export { createWorkflowTool, DURABLE_HOST_ADDENDUM } from "./workflow-tool.js";
@@ -39,6 +39,19 @@ export class NodeBindingMismatch extends Error {
39
39
  /** Run one attempt. */
40
40
  export async function runNodeAttempt(scope, attempt, context) {
41
41
  let found;
42
+ /** The attempt's conversation when another task owns it: that task ended, and this one replaced it. */
43
+ let orphaned;
44
+ /** A new conversation for the attempt, owned by the scope's task, recorded under the attempt's session. */
45
+ const create = async (tx, entry) => {
46
+ const conversation = await tx.createConversation({ ownership: scope.owner !== undefined ? { kind: "task", taskId: scope.owner } : { kind: "ownerless" } });
47
+ await configure(tx, conversation.id, attempt.agent);
48
+ await attempt.init?.(tx, conversation.id);
49
+ entry.conversation = Number(conversation.id);
50
+ entry.digest = attempt.digest;
51
+ entry.startedMs = Date.now();
52
+ entry.closed = false;
53
+ found = { conversationId: conversation.id, digest: attempt.digest, resumed: false, startedMs: entry.startedMs };
54
+ };
42
55
  await scope.commit(async (tx) => {
43
56
  const entry = await tx.doc(NodeIndex, attempt.session, null);
44
57
  if (entry.conversation !== null) {
@@ -46,19 +59,23 @@ export async function runNodeAttempt(scope, attempt, context) {
46
59
  // An attempt under another binding is refused below, untouched.
47
60
  if (entry.digest === attempt.digest)
48
61
  await attempt.adopt?.(tx, found.conversationId, entry);
62
+ if (scope.owner !== undefined && entry.digest === attempt.digest) {
63
+ const owner = (await tx.conversation(found.conversationId))?.owner?.taskId;
64
+ if (owner !== undefined && owner !== scope.owner)
65
+ orphaned = found.conversationId;
66
+ }
49
67
  return;
50
68
  }
51
- const conversation = await tx.createConversation({ ownership: scope.owner !== undefined ? { kind: "task", taskId: scope.owner } : { kind: "ownerless" } });
52
- await configure(tx, conversation.id, attempt.agent);
53
- await attempt.init?.(tx, conversation.id);
54
- entry.conversation = Number(conversation.id);
55
- entry.digest = attempt.digest;
56
- entry.startedMs = Date.now();
57
- found = { conversationId: conversation.id, digest: attempt.digest, resumed: false, startedMs: entry.startedMs };
69
+ await create(tx, entry);
58
70
  }, context);
59
- const { conversationId, digest, resumed, startedMs } = found;
60
- if (digest !== attempt.digest)
61
- throw new NodeBindingMismatch(attempt.session, digest, attempt.digest);
71
+ if (found.digest !== attempt.digest)
72
+ throw new NodeBindingMismatch(attempt.session, found.digest, attempt.digest);
73
+ // The replaced task's conversation: its record, when it delivered one, is the node's; otherwise the attempt runs
74
+ // again in a conversation of this task, never on the request the old task's end aborted.
75
+ if (orphaned !== undefined && !(await deliveredRecord(scope, orphaned, context))) {
76
+ await scope.commit(async (tx) => { await create(tx, await tx.doc(NodeIndex, attempt.session, null)); }, context);
77
+ }
78
+ const { conversationId, resumed, startedMs } = found;
62
79
  await attempt.onOpen?.({ conversationId, resumed, startedMs });
63
80
  // A record the attempt already delivered is the node's record: nothing is requested for it.
64
81
  const already = await deliveredRecord(scope, conversationId, context);
@@ -17,7 +17,10 @@ export declare function withDurableFile<T>(file: string, read: (db: Database) =>
17
17
  /** A Session document of the run's store `file`, read-only; undefined when the file or the document is absent. */
18
18
  export declare function durableDoc<T = Record<string, unknown>>(file: string, kind: string, key?: string): T | undefined;
19
19
  /** The journal `key` holds in the run's store `file`, as the last commit left it; undefined when the file holds no
20
- * such journal or none has bound it. */
20
+ * such journal or none has bound it. `effects` are every effect the journal admitted, once each, in the order they were
21
+ * admitted, in either layout: in the keyed one (`agentrun.frozen_run.v4`) the effects a map item owns, nested maps
22
+ * included, are listed by that item's record, not the driver's, and are read from there. `state` is the driver's own
23
+ * record: in the keyed layout what lies in each map item stays in the item's record. */
21
24
  export declare function readJournal(file: string, key: string): JournalView | undefined;
22
25
  /** Every committed route decision a frozen snapshot holds (each judge's answer, whole), by execution path. Empty for a
23
26
  * state that is no snapshot. */
@@ -32,24 +32,63 @@ function revisionsOf(db, id) {
32
32
  }
33
33
  return value;
34
34
  }
35
- /** A Session document's current value. `key` names a family member; a singleton has none. */
36
- function sessionDoc(db, kind, key) {
35
+ /** The row of a Session document. `key` names a family member; a singleton has none. */
36
+ function sessionDocId(db, kind, key) {
37
37
  const row = db.prepare("SELECT id FROM documents WHERE kind = ? AND scope_kind = 'session' AND owner_id = 0 AND family = ? AND key_value = ? AND retired_at IS NULL ORDER BY created_at DESC LIMIT 1")
38
38
  .get(JSON.stringify(kind), key === undefined ? 0 : 1, JSON.stringify(key ?? ""));
39
- return row?.id === undefined ? undefined : revisionsOf(db, row.id);
39
+ return row?.id;
40
+ }
41
+ /** A Session document's current value. `key` names a family member; a singleton has none. */
42
+ function sessionDoc(db, kind, key) {
43
+ const id = sessionDocId(db, kind, key);
44
+ return id === undefined ? undefined : revisionsOf(db, id);
45
+ }
46
+ /** The ids of the effects the map items of `table` own (the keyed layout's `maps`: `{ <map path>: { items, ... } }`), each
47
+ * item's own nested maps included, read as the driver reads them back: item `i` of a map is its record at
48
+ * `<map path>/items/<i>`, for every item the map started. */
49
+ function itemEffects(db, key, table, into) {
50
+ if (!table || typeof table !== "object" || Array.isArray(table))
51
+ return;
52
+ for (const [mapPath, entry] of Object.entries(table)) {
53
+ const started = entry?.items;
54
+ if (typeof started !== "number" || !Number.isSafeInteger(started))
55
+ continue;
56
+ for (let index = 0; index < started; index += 1) {
57
+ const item = sessionDoc(db, "agentrun.items", effectKey(key, `${mapPath}/items/${index}`));
58
+ if (!item)
59
+ continue;
60
+ if (Array.isArray(item.effects))
61
+ for (const id of item.effects)
62
+ if (typeof id === "string")
63
+ into.push(id);
64
+ itemEffects(db, key, item.maps, into);
65
+ }
66
+ }
40
67
  }
41
68
  /** A Session document of the run's store `file`, read-only; undefined when the file or the document is absent. */
42
69
  export function durableDoc(file, kind, key) {
43
70
  return (withDurableFile(file, (db) => sessionDoc(db, kind, key)) ?? undefined);
44
71
  }
45
72
  /** The journal `key` holds in the run's store `file`, as the last commit left it; undefined when the file holds no
46
- * such journal or none has bound it. */
73
+ * such journal or none has bound it. `effects` are every effect the journal admitted, once each, in the order they were
74
+ * admitted, in either layout: in the keyed one (`agentrun.frozen_run.v4`) the effects a map item owns, nested maps
75
+ * included, are listed by that item's record, not the driver's, and are read from there. `state` is the driver's own
76
+ * record: in the keyed layout what lies in each map item stays in the item's record. */
47
77
  export function readJournal(file, key) {
48
78
  return withDurableFile(file, (db) => {
49
79
  const doc = sessionDoc(db, "agentrun.driver", key);
50
80
  if (!doc || doc.binding === null)
51
81
  return undefined;
52
- const effects = doc.effects.map((id) => {
82
+ // The driver's record lists the effects no map item owns; in the keyed layout each item's record lists its own. All of
83
+ // them, once each, in the order they were admitted: an effect's document is created by its admission, and a document's
84
+ // row is numbered when it is created (a later checkpoint folds its revisions, never its row).
85
+ const ids = [...doc.effects];
86
+ itemEffects(db, key, doc.state?.maps, ids);
87
+ const admitted = new Map();
88
+ for (const id of new Set(ids))
89
+ admitted.set(id, sessionDocId(db, "agentrun.effects", effectKey(key, id)) ?? Number.MAX_SAFE_INTEGER);
90
+ // A stable sort: an id whose document is missing keeps its place in the listing, after every admitted one.
91
+ const effects = [...admitted.keys()].sort((a, b) => admitted.get(a) - admitted.get(b)).map((id) => {
53
92
  const e = sessionDoc(db, "agentrun.effects", effectKey(key, id));
54
93
  return { id, name: e?.name ?? "", argsHash: e?.argsHash ?? "", status: e?.status ?? "unknown", session: e?.session ?? null, result: e?.result ?? null, ...(e?.intent ? { intent: e.intent } : {}), ...(e?.args !== undefined ? { args: e.args } : {}), ...(Array.isArray(e?.calls) ? { calls: e.calls } : {}) };
55
94
  });
@@ -25,8 +25,10 @@ export type ReusableTool = {
25
25
  export type ReuseReceiptsOptions = {
26
26
  /** The tools whose calls are paid and keep a receipt: a tool whose answer is its own session's state keeps none. */
27
27
  tools: readonly string[];
28
- /** The session this wrapper runs in: it never answers a call from a receipt its own session wrote. */
29
- sessionId: string;
28
+ /** The session a call belongs to: a call is never answered from a receipt its own session wrote. Absent, each call's
29
+ * own conversation, so a node run again in another conversation (its task replaced after it failed) is answered from
30
+ * the receipt the conversation it replaces paid for, and does not pay again. */
31
+ sessionId?: string;
30
32
  /** Whether a repeat of another session's paid call is answered from its receipt (a continuation's sessions do). Receipts
31
33
  * are recorded either way. */
32
34
  answer: boolean;
@@ -62,9 +62,10 @@ export function reuseReceipts(options) {
62
62
  return toolResult([{ type: "text", text: `${tool.name} is not called again: an identical earlier call was cut while it ran, so what it did outside is unknown. Work from what you have, or make a different call.` }], { isError: true, details: { effect_status: "unknown", refused: true } });
63
63
  }
64
64
  const key = `${intent.tool}:${intent.argsHash}`;
65
+ const session = options.sessionId ?? String(api?.conversationId);
65
66
  if (options.answer) {
66
67
  const receipt = await api.snapshot(ReceiptIndex, key, context);
67
- if (receipt?.result != null && receipt.session !== options.sessionId) {
68
+ if (receipt?.result != null && receipt.session !== session) {
68
69
  stats.reused += 1;
69
70
  const text = typeof receipt.result === "string" ? receipt.result : JSON.stringify(receipt.result);
70
71
  return toolResult([{ type: "text", text: `${text}\n\n(answered from the paid receipt of ${receipt.session}; the call was not dispatched again)` }], { details: { effect_status: "reused", source_session: receipt.session } });
@@ -80,7 +81,7 @@ export function reuseReceipts(options) {
80
81
  receipt.tool = intent.tool;
81
82
  receipt.argsHash = intent.argsHash;
82
83
  receipt.result = value;
83
- receipt.session = options.sessionId;
84
+ receipt.session = session;
84
85
  }
85
86
  }, context);
86
87
  }
@@ -25,6 +25,19 @@ const DriverDoc = defineDocFamily({ kind: "agentrun.driver", version: 1, scope:
25
25
  initial: () => ({ binding: null, inputs: null, generation: 0, revision: 0, state: null, notes: [], effects: [] }) });
26
26
  const EffectDoc = defineDocFamily({ kind: "agentrun.effects", version: 1, scope: "session", family: true,
27
27
  initial: () => ({ driver: "", name: "", argsHash: "", status: "unknown", session: null, result: null }) });
28
+ const ItemDoc = defineDocFamily({ kind: "agentrun.items", version: 1, scope: "session", family: true,
29
+ initial: () => ({ driver: "", effects: [] }) });
30
+ /** A state by its digest; `sha256` is empty only for one never written. */
31
+ const StateDoc = defineDocFamily({ kind: "agentrun.states", version: 1, scope: "session", family: true,
32
+ initial: () => ({ driver: "", sha256: "", state: null }) });
33
+ /** A draft made to hold `value` whole: every field set, every other field removed. */
34
+ function replaceWhole(draft, value) {
35
+ for (const field of Object.keys(draft))
36
+ if (!Object.hasOwn(value, field))
37
+ delete draft[field];
38
+ for (const [field, item] of Object.entries(value))
39
+ draft[field] = item;
40
+ }
28
41
  /** A value as the journal stores it: plain JSON, every number finite. */
29
42
  function json(value) {
30
43
  return value === undefined ? null : JSON.parse(JSON.stringify(value, (_key, item) => {
@@ -107,23 +120,36 @@ async function openDocumentJournal(host, key, bound, options) {
107
120
  return done;
108
121
  };
109
122
  /** One Session commit on the journal's record: it checks this open's generation is the record's, advances the
110
- * revision, and lets `write` change the record (its state computed with that revision) and the effect documents. */
111
- const commit = async (write) => host.commit(async (tx) => {
123
+ * revision, and lets `write` change the record (its state computed with that revision) and the effect documents,
124
+ * then writes the parts computed with that revision. */
125
+ const commit = async (write, parts) => host.commit(async (tx) => {
112
126
  const doc = await tx.doc(DriverDoc, key, null);
113
127
  if (doc.generation !== opened.generation)
114
128
  throw new RecoveryError("Run owner generation is not acquired");
115
129
  const next = doc.revision + 1;
116
130
  const value = await write(doc, next, tx);
131
+ const laid = parts?.(next);
132
+ for (const [path, item] of Object.entries(laid?.items ?? {})) {
133
+ replaceWhole(await tx.doc(ItemDoc, effectKey(key, path), null), json(item));
134
+ }
135
+ for (const [sha256, state] of Object.entries(laid?.states ?? {})) {
136
+ const held = await tx.doc(StateDoc, effectKey(key, sha256), null);
137
+ if (held.sha256 === "")
138
+ Object.assign(held, { driver: key, sha256, state: json(state) });
139
+ }
117
140
  doc.revision = next;
118
141
  return { revision: next, value };
119
142
  }, context).then((done) => { revision = done.revision; return done; });
143
+ /** A read in a commit of its own: it writes nothing the caller did not ask for. */
144
+ const read = (body) => inOrder(() => host.commit(body, context));
145
+ const storedEffect = (id, e) => ({ id, name: e.name, argsHash: e.argsHash, status: e.status, session: e.session, result: json(e.result), ...(e.intent ? { intent: json(e.intent) } : {}), ...(e.args !== undefined ? { args: json(e.args) } : {}), ...(Array.isArray(e.calls) ? { calls: json(e.calls) } : {}) });
120
146
  return {
121
147
  existing: opened.existing, generation: opened.generation, state: opened.state,
122
148
  get revision() { return revision; },
123
149
  effects: () => [...effects.values()],
124
150
  effect: (id) => effects.get(id),
125
151
  notes: () => [...notes],
126
- save: (state, note) => inOrder(async () => {
152
+ save: (state, note, parts) => inOrder(async () => {
127
153
  let entry;
128
154
  const done = await commit((doc, next) => {
129
155
  doc.state = json(typeof state === "function" ? state(next) : state);
@@ -131,7 +157,7 @@ async function openDocumentJournal(host, key, bound, options) {
131
157
  entry = { revision: next, kind: note.kind, detail: json(note.detail), at: new Date().toISOString() };
132
158
  doc.notes.push(entry);
133
159
  }
134
- });
160
+ }, parts);
135
161
  if (entry)
136
162
  notes.push(entry);
137
163
  return done.revision;
@@ -142,7 +168,7 @@ async function openDocumentJournal(host, key, bound, options) {
142
168
  notes.push({ revision: done.revision, ...entry });
143
169
  return done.revision;
144
170
  }),
145
- admit: (id, name, argsHash, state, session = null, intent, args) => inOrder(async () => {
171
+ admit: (id, name, argsHash, state, session = null, intent, args, parts, owner) => inOrder(async () => {
146
172
  effectKey(key, id);
147
173
  const known = effects.get(id);
148
174
  if (known)
@@ -152,13 +178,15 @@ async function openDocumentJournal(host, key, bound, options) {
152
178
  const keptArgs = args === undefined ? undefined : json(args);
153
179
  await commit(async (doc, _next, tx) => {
154
180
  Object.assign(await tx.doc(EffectDoc, effectKey(key, id), null), { driver: key, name, argsHash, status: "unknown", session, result: null, ...(kept ? { intent: kept } : {}), ...(keptArgs !== undefined ? { args: keptArgs } : {}) });
155
- doc.effects.push(id);
181
+ // An effect a map item owns is listed by that item's record, written in the same commit.
182
+ if (owner === undefined || !parts)
183
+ doc.effects.push(id);
156
184
  doc.state = stored;
157
- });
185
+ }, parts);
158
186
  effects.set(id, { id, name, argsHash, status: "unknown", session, result: null, ...(kept ? { intent: kept } : {}), ...(keptArgs !== undefined ? { args: keptArgs } : {}) });
159
187
  return "new";
160
188
  }),
161
- complete: (id, result, state = opened.state) => inOrder(async () => {
189
+ complete: (id, result, state = opened.state, parts) => inOrder(async () => {
162
190
  const known = effects.get(id);
163
191
  if (!known)
164
192
  throw new RecoveryError(`Effect ${id} completes without an admission`);
@@ -171,7 +199,7 @@ async function openDocumentJournal(host, key, bound, options) {
171
199
  effect.status = "completed";
172
200
  effect.result = value;
173
201
  doc.state = next;
174
- });
202
+ }, parts);
175
203
  effects.set(id, { ...known, status: "completed", result: value });
176
204
  }),
177
205
  called: (id, calls) => inOrder(async () => {
@@ -188,5 +216,25 @@ async function openDocumentJournal(host, key, bound, options) {
188
216
  await queue;
189
217
  held.release();
190
218
  },
219
+ // Only paths and digests the driver wrote are asked for: a read of a document never written would create it.
220
+ items: (paths) => read(async (tx) => {
221
+ const found = [];
222
+ for (const path of paths) {
223
+ const item = await tx.doc(ItemDoc, effectKey(key, path), null);
224
+ if (item.driver === "") {
225
+ found.push(undefined);
226
+ continue;
227
+ }
228
+ found.push(json(item));
229
+ for (const id of (item.effects ?? []))
230
+ if (!effects.has(id))
231
+ effects.set(id, storedEffect(id, await tx.doc(EffectDoc, effectKey(key, id), null)));
232
+ }
233
+ return found;
234
+ }),
235
+ stateOf: (sha256) => read(async (tx) => {
236
+ const held = await tx.doc(StateDoc, effectKey(key, sha256), null);
237
+ return held.sha256 === "" ? undefined : json(held.state);
238
+ }),
191
239
  };
192
240
  }
@@ -88,6 +88,10 @@ export type WorkflowHostParts = {
88
88
  deps: WorkflowDeps;
89
89
  interpret?: (workflow: Workflow, input: Record<string, unknown>, deps: WorkflowDeps) => ReturnType<typeof runWorkflow>;
90
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">>;
91
95
  /** A started run. `wait` resolves with the run's outcome once its task is terminal, and rejects with the storage's error
92
96
  * when the Session failed at one. */
93
97
  export type WorkflowRun = {
@@ -111,7 +115,7 @@ type WorkflowResult = Extract<WorkflowOutcome, {
111
115
  /** The task definition, the extension that carries it, and the host's three operations on a run. */
112
116
  export declare function createWorkflowExtension(host: WorkflowTaskHost): {
113
117
  extension: Extension;
114
- start(harness: Harness, request: WorkflowRequest, context: Context, options?: {
118
+ start(harness: WorkflowCaller, request: WorkflowRequest, context: Context, options?: {
115
119
  conversationId?: number;
116
120
  background?: boolean;
117
121
  stop?: {
@@ -119,7 +123,7 @@ export declare function createWorkflowExtension(host: WorkflowTaskHost): {
119
123
  source?: string;
120
124
  };
121
125
  }): Promise<WorkflowRun>;
122
- read(harness: Harness, key: string, context: Context): Promise<{
126
+ read(harness: WorkflowCaller, key: string, context: Context): Promise<{
123
127
  taskId: TaskId<WorkflowResult>;
124
128
  status: string;
125
129
  outcome?: WorkflowOutcome;
@@ -133,6 +133,11 @@ export function createWorkflowExtension(host) {
133
133
  throw error;
134
134
  }
135
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 }); });
136
141
  let opened;
137
142
  try {
138
143
  opened = await open(task, runtime, context);
@@ -140,6 +145,13 @@ export function createWorkflowExtension(host) {
140
145
  catch (error) {
141
146
  if (runtime.signal.aborted)
142
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
+ }
143
155
  await commitEnd(failed(error), error);
144
156
  return;
145
157
  }
@@ -316,7 +328,11 @@ export function createWorkflowExtension(host) {
316
328
  * step. So a pause given with a start that attaches to a run already running in this process is not taken now: it
317
329
  * is recorded, and taken when the run's next invocation opens. `stop` pauses a run that is running here. */
318
330
  async start(harness, request, context, options = {}) {
319
- const conversationId = options.conversationId ?? (await harness.root(context)).id;
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");
320
336
  const stop = options.stop ? { action: options.stop.action, source: options.stop.source ?? null } : null;
321
337
  const asked = json(request);
322
338
  const [taskId, started] = await harness.commit(async (tx) => {
@@ -348,7 +364,7 @@ export function createWorkflowExtension(host) {
348
364
  uncommitted.delete(earlier);
349
365
  if (stop?.action === "cancel" && started !== "settled")
350
366
  await harness.abortTask(taskId, context);
351
- harness.resume();
367
+ harness.resume?.();
352
368
  return handle(harness, request.key, taskId, started);
353
369
  },
354
370
  /** The run's task and, once it is terminal, its outcome; undefined when no start named the key. */
@@ -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
+ };