@parcha/agentrun-pi 0.1.0-beta.13 → 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.
- package/README.md +44 -6
- package/dist/durable/index.d.ts +2 -1
- package/dist/durable/index.js +1 -0
- package/dist/durable/node.js +27 -10
- package/dist/durable/readers.d.ts +4 -1
- package/dist/durable/readers.js +44 -5
- package/dist/durable/receipts.d.ts +4 -2
- package/dist/durable/receipts.js +3 -2
- package/dist/durable/store.js +57 -9
- package/dist/durable/task.d.ts +6 -2
- package/dist/durable/task.js +18 -2
- package/dist/durable/workflow-tool.d.ts +32 -0
- package/dist/durable/workflow-tool.js +188 -0
- package/dist/extension.js +64 -32
- package/dist/host-addendum.js +3 -2
- package/dist/pi-host.d.ts +7 -0
- package/dist/pi-host.js +22 -0
- 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 +3 -2
- package/dist/skills/author/references/language.md +4 -3
- package/dist/skills/author/references/sdk-authoring.md +2 -0
- package/dist/workflow-inspector.d.ts +1 -1
- package/dist/workflow-inspector.js +2 -2
- package/dist/workflow-store.d.ts +6 -0
- package/dist/workflow-store.js +6 -0
- package/dist/workflow-tool.d.ts +81 -0
- package/dist/workflow-tool.js +94 -0
- package/dist/workflow-view.js +5 -1
- package/dist/workflows.d.ts +1 -0
- package/dist/workflows.js +3 -0
- package/package.json +18 -6
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AuthOperationOptions, Credential, CredentialInfo, CredentialStore } from "@earendil-works/pi-ai";
|
|
2
|
+
/** Where the sign-in lives: `home`, else $AGENTRUN_HOME, else ~/.config/agentrun. */
|
|
3
|
+
export declare function agentrunHome(home?: string, env?: NodeJS.ProcessEnv): string;
|
|
4
|
+
export declare class AuthStoreError extends Error {
|
|
5
|
+
readonly code: "HOME_MODE" | "LOCK_TIMEOUT" | "CORRUPT";
|
|
6
|
+
constructor(code: "HOME_MODE" | "LOCK_TIMEOUT" | "CORRUPT", message: string, options?: {
|
|
7
|
+
cause?: unknown;
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export type FileCredentialStoreOptions = {
|
|
11
|
+
/** How long a writer waits for another process's lock before failing. Default 60 s; pi-ai bounds a refresh at 15 s. */
|
|
12
|
+
lockTimeoutMs?: number;
|
|
13
|
+
};
|
|
14
|
+
export declare class FileCredentialStore implements CredentialStore {
|
|
15
|
+
readonly home: string;
|
|
16
|
+
readonly file: string;
|
|
17
|
+
private readonly lockFile;
|
|
18
|
+
private readonly lockTimeoutMs;
|
|
19
|
+
constructor(home: string, options?: FileCredentialStoreOptions);
|
|
20
|
+
read(providerId: string, options?: AuthOperationOptions): Promise<Credential | undefined>;
|
|
21
|
+
list(options?: AuthOperationOptions): Promise<readonly CredentialInfo[]>;
|
|
22
|
+
modify(providerId: string, fn: (current: Credential | undefined) => Promise<Credential | undefined>, options?: AuthOperationOptions): Promise<Credential | undefined>;
|
|
23
|
+
delete(providerId: string, options?: AuthOperationOptions): Promise<void>;
|
|
24
|
+
/** This install's host id, a UUID made once and kept in auth.json: the `getDeviceId` of every sign-in from here. */
|
|
25
|
+
hostId(options?: AuthOperationOptions): Promise<string>;
|
|
26
|
+
/** Run `fn` on the file as it is under the lock. `signal` cancels only the wait: once `fn` starts, a refresh may have
|
|
27
|
+
* rotated the token at the server, so it and its write run to the end. */
|
|
28
|
+
private exclusive;
|
|
29
|
+
private load;
|
|
30
|
+
/** The whole file, written to a new file in the same directory, flushed, then renamed over auth.json. */
|
|
31
|
+
private save;
|
|
32
|
+
/** The directory, made 0700 when missing. An existing one that others can reach is refused, not changed. */
|
|
33
|
+
private ensureHome;
|
|
34
|
+
/** The cross-process lock: an exclusive transaction on the lock database, taken without blocking the event loop (a
|
|
35
|
+
* busy database answers at once and the wait is a timer). Closing the connection ends the transaction and frees it.
|
|
36
|
+
* The lock file is created with O_EXCL and never opened or closed by hand afterwards: closing any descriptor of a file
|
|
37
|
+
* drops every lock this process holds on it. */
|
|
38
|
+
private lock;
|
|
39
|
+
}
|
|
40
|
+
export declare function fileCredentialStore(home: string, options?: FileCredentialStoreOptions): FileCredentialStore;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// The sign-in's token store: $AGENTRUN_HOME/auth.json, as pi-ai's `CredentialStore`.
|
|
2
|
+
//
|
|
3
|
+
// The directory is 0700 and the file 0600. Every write is the whole file, written beside it and renamed into place, so a
|
|
4
|
+
// reader without the lock always sees one complete version. Writes are serialized inside a process by one chain per file,
|
|
5
|
+
// and across processes by an operating-system lock: an exclusive SQLite transaction on auth.json.lock, an empty
|
|
6
|
+
// database. The system drops that lock when its holder exits or is killed, so there is no abandoned lock to detect or
|
|
7
|
+
// break, and a lock another writer holds is never moved or removed. `modify` re-reads the file under the lock, so
|
|
8
|
+
// pi-ai's refresh, which runs inside `modify` and re-checks expiry there, refreshes once: a process that waited sees the
|
|
9
|
+
// rotated token and leaves it. A token is never written anywhere else, logged or printed.
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeSync } from "node:fs";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { join, resolve } from "node:path";
|
|
15
|
+
/** Where the sign-in lives: `home`, else $AGENTRUN_HOME, else ~/.config/agentrun. */
|
|
16
|
+
export function agentrunHome(home, env = process.env) {
|
|
17
|
+
return resolve(home ?? env.AGENTRUN_HOME ?? join(homedir(), ".config", "agentrun"));
|
|
18
|
+
}
|
|
19
|
+
export class AuthStoreError extends Error {
|
|
20
|
+
code;
|
|
21
|
+
constructor(code, message, options) {
|
|
22
|
+
super(message, options);
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.name = "AuthStoreError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** One chain per auth file in this process: every instance on the same file queues on it, so the lock file only ever
|
|
28
|
+
* arbitrates between processes. */
|
|
29
|
+
const chains = new Map();
|
|
30
|
+
const sleep = (ms, signal) => new Promise((done, fail) => {
|
|
31
|
+
const timer = setTimeout(() => { signal?.removeEventListener("abort", onAbort); done(); }, ms);
|
|
32
|
+
const onAbort = () => { clearTimeout(timer); fail(signal?.reason); };
|
|
33
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
34
|
+
});
|
|
35
|
+
/** node:sqlite, loaded on the first write: Node 22 marks it experimental and warns once when it loads. */
|
|
36
|
+
let sqlite;
|
|
37
|
+
const SQLITE_BUSY = 5;
|
|
38
|
+
/** Every lock connection this process holds, until its writer releases it. A connection nothing references is closed
|
|
39
|
+
* by the garbage collector, and closing it frees the lock: a writer whose own code no longer references its wait would
|
|
40
|
+
* otherwise lose the lock while still inside. */
|
|
41
|
+
const held = new Set();
|
|
42
|
+
export class FileCredentialStore {
|
|
43
|
+
home;
|
|
44
|
+
file;
|
|
45
|
+
lockFile;
|
|
46
|
+
lockTimeoutMs;
|
|
47
|
+
constructor(home, options = {}) {
|
|
48
|
+
this.home = home;
|
|
49
|
+
this.file = join(home, "auth.json");
|
|
50
|
+
this.lockFile = `${this.file}.lock`;
|
|
51
|
+
this.lockTimeoutMs = options.lockTimeoutMs ?? 60_000;
|
|
52
|
+
}
|
|
53
|
+
async read(providerId, options) {
|
|
54
|
+
options?.signal?.throwIfAborted();
|
|
55
|
+
return this.load().credentials[providerId];
|
|
56
|
+
}
|
|
57
|
+
async list(options) {
|
|
58
|
+
options?.signal?.throwIfAborted();
|
|
59
|
+
return Object.entries(this.load().credentials).map(([providerId, credential]) => ({ providerId, type: credential.type }));
|
|
60
|
+
}
|
|
61
|
+
modify(providerId, fn, options) {
|
|
62
|
+
return this.exclusive(async (file) => {
|
|
63
|
+
const current = file.credentials[providerId];
|
|
64
|
+
const next = await fn(current);
|
|
65
|
+
if (next === undefined)
|
|
66
|
+
return current;
|
|
67
|
+
file.credentials[providerId] = next;
|
|
68
|
+
this.save(file);
|
|
69
|
+
return next;
|
|
70
|
+
}, options?.signal);
|
|
71
|
+
}
|
|
72
|
+
delete(providerId, options) {
|
|
73
|
+
return this.exclusive(async (file) => {
|
|
74
|
+
if (!(providerId in file.credentials))
|
|
75
|
+
return;
|
|
76
|
+
delete file.credentials[providerId];
|
|
77
|
+
this.save(file);
|
|
78
|
+
}, options?.signal);
|
|
79
|
+
}
|
|
80
|
+
/** This install's host id, a UUID made once and kept in auth.json: the `getDeviceId` of every sign-in from here. */
|
|
81
|
+
hostId(options) {
|
|
82
|
+
return this.exclusive(async (file) => {
|
|
83
|
+
if (file.hostId)
|
|
84
|
+
return file.hostId;
|
|
85
|
+
file.hostId = randomUUID();
|
|
86
|
+
this.save(file);
|
|
87
|
+
return file.hostId;
|
|
88
|
+
}, options?.signal);
|
|
89
|
+
}
|
|
90
|
+
/** Run `fn` on the file as it is under the lock. `signal` cancels only the wait: once `fn` starts, a refresh may have
|
|
91
|
+
* rotated the token at the server, so it and its write run to the end. */
|
|
92
|
+
exclusive(fn, signal) {
|
|
93
|
+
const previous = chains.get(this.file) ?? Promise.resolve();
|
|
94
|
+
const run = previous.catch(() => { }).then(async () => {
|
|
95
|
+
signal?.throwIfAborted();
|
|
96
|
+
const lock = await this.lock(signal);
|
|
97
|
+
try {
|
|
98
|
+
return await fn(this.load());
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
held.delete(lock);
|
|
102
|
+
lock.close();
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
const tail = run.catch(() => { });
|
|
106
|
+
chains.set(this.file, tail);
|
|
107
|
+
void tail.then(() => { if (chains.get(this.file) === tail)
|
|
108
|
+
chains.delete(this.file); });
|
|
109
|
+
return run;
|
|
110
|
+
}
|
|
111
|
+
load() {
|
|
112
|
+
let text;
|
|
113
|
+
try {
|
|
114
|
+
text = readFileSync(this.file, "utf8");
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (error.code === "ENOENT")
|
|
118
|
+
return { version: 1, credentials: {} };
|
|
119
|
+
throw error;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(text);
|
|
123
|
+
if (parsed?.version !== 1 || typeof parsed.credentials !== "object" || parsed.credentials === null)
|
|
124
|
+
throw new Error("not an auth file");
|
|
125
|
+
return parsed;
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
throw new AuthStoreError("CORRUPT", `${this.file} is not a sign-in file this version reads; move it aside and sign in again`, { cause: error });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** The whole file, written to a new file in the same directory, flushed, then renamed over auth.json. */
|
|
132
|
+
save(file) {
|
|
133
|
+
this.ensureHome();
|
|
134
|
+
const temp = join(this.home, `.auth.json.${process.pid}.${randomUUID()}.tmp`);
|
|
135
|
+
const fd = openSync(temp, "wx", 0o600);
|
|
136
|
+
try {
|
|
137
|
+
try {
|
|
138
|
+
writeSync(fd, `${JSON.stringify(file, null, 2)}\n`);
|
|
139
|
+
fsyncSync(fd);
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
closeSync(fd);
|
|
143
|
+
}
|
|
144
|
+
renameSync(temp, this.file);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
try {
|
|
148
|
+
unlinkSync(temp);
|
|
149
|
+
}
|
|
150
|
+
catch { /* already renamed or never written */ }
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** The directory, made 0700 when missing. An existing one that others can reach is refused, not changed. */
|
|
155
|
+
ensureHome() {
|
|
156
|
+
mkdirSync(this.home, { recursive: true, mode: 0o700 });
|
|
157
|
+
if (process.platform === "win32")
|
|
158
|
+
return;
|
|
159
|
+
const stat = statSync(this.home);
|
|
160
|
+
if ((stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid())) {
|
|
161
|
+
throw new AuthStoreError("HOME_MODE", `${this.home} must be a directory only you can read (mode 0700, owned by you); it is ${(stat.mode & 0o777).toString(8)}. Run chmod 700 on it, or set AGENTRUN_HOME to a new directory.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/** The cross-process lock: an exclusive transaction on the lock database, taken without blocking the event loop (a
|
|
165
|
+
* busy database answers at once and the wait is a timer). Closing the connection ends the transaction and frees it.
|
|
166
|
+
* The lock file is created with O_EXCL and never opened or closed by hand afterwards: closing any descriptor of a file
|
|
167
|
+
* drops every lock this process holds on it. */
|
|
168
|
+
async lock(signal) {
|
|
169
|
+
this.ensureHome();
|
|
170
|
+
try {
|
|
171
|
+
closeSync(openSync(this.lockFile, "wx", 0o600));
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (error.code !== "EEXIST")
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
sqlite ??= createRequire(import.meta.url)("node:sqlite");
|
|
178
|
+
const db = new sqlite.DatabaseSync(this.lockFile);
|
|
179
|
+
const deadline = Date.now() + this.lockTimeoutMs;
|
|
180
|
+
try {
|
|
181
|
+
for (let delay = 5;; delay = Math.min(delay * 2, 100)) {
|
|
182
|
+
signal?.throwIfAborted();
|
|
183
|
+
try {
|
|
184
|
+
db.exec("BEGIN EXCLUSIVE");
|
|
185
|
+
held.add(db);
|
|
186
|
+
return db;
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (error.errcode !== SQLITE_BUSY)
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
if (Date.now() > deadline) {
|
|
193
|
+
throw new AuthStoreError("LOCK_TIMEOUT", `${this.lockFile} is held by another process; it was not released within ${Math.round(this.lockTimeoutMs / 1000)} s`);
|
|
194
|
+
}
|
|
195
|
+
await sleep(delay + Math.random() * delay, signal);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
db.close();
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
export function fileCredentialStore(home, options) {
|
|
205
|
+
return new FileCredentialStore(home, options);
|
|
206
|
+
}
|
|
@@ -123,9 +123,10 @@ Read [the SDK authoring path](references/sdk-authoring.md) only when a host call
|
|
|
123
123
|
|
|
124
124
|
These host rules add to the language above; they never change it.
|
|
125
125
|
- This host runs only these node kinds: `chain`, `code`, `agent`, `decide`, `extract`, `report`, `map`, `parallel`, `loop`, `escalate`, `call`, `workflow`, `judge`, `pick`, `sift`, `route`. Author no other kind.
|
|
126
|
-
- Call agentrun with action describe first, then inspect the complete workflow with its input, then run without a workflow argument.
|
|
126
|
+
- Call agentrun with action describe first, then inspect the complete workflow with its input, then dry-run it (action dry-run, without a workflow argument), then run without a workflow argument.
|
|
127
|
+
- A dry run runs the inspected workflow on synthetic values, or the input you give it, with no model, Jev or tool call. Its status is passed, not_run (a check could not run; skipped says why, and it proves nothing) or failed (problems say why). Report not_run as not run, never as passed.
|
|
127
128
|
- Use exactly the tools describe returns. Without host-configured tools these are the active read-only built-ins (read, grep, find, ls), and bash, edit and write exist only in a trusted run; a host that configures its tools offers only those, including in trusted runs. The search tool reads fictional demo sources only.
|
|
128
|
-
- Code nodes and mutating tools run only after the user issues /agentrun run --trusted for that run. Trusted execution is local and unsandboxed.
|
|
129
|
+
- Code nodes and mutating tools run only after the user issues /agentrun run --trusted for that run. Trusted execution is local and unsandboxed. A dry run of a workflow with code nodes is not_run until the user issues /agentrun dry-run --trusted.
|
|
129
130
|
- A call uses via tool with a tool describe returns. Shell and executor calls and artifact delivery are unavailable.
|
|
130
131
|
- This extension supplies no SOP text: do not run a workflow with sopSection here; it needs an SDK host that supplies the authoritative SOP.
|
|
131
132
|
- Custom extension tools and outer permission hooks are not inherited, including in trusted runs.
|
|
@@ -76,7 +76,7 @@ Typed questions answered by the host's judge in one request each: no tools, no s
|
|
|
76
76
|
### Control nodes
|
|
77
77
|
|
|
78
78
|
- `chain`: non-empty `steps`, run in order.
|
|
79
|
-
- `map`: `itemsPath` (an upstream list), `body` and `as`. The body sees `item` and `item_index`; `maxConcurrency` is a positive integer (default 4); `resultPath` selects one path from each completed item's state.
|
|
79
|
+
- `map`: `itemsPath` (an upstream list), `body` and `as`. The body sees `item` and `item_index`; `maxConcurrency` is a positive integer (default 4); `resultPath` selects one path from each completed item's state. Without `resultPath`, and with no `as` in the body, each item's result is its whole state, the mapped list included: set `resultPath` (or an `as` in the body) for a large map; the validator warns.
|
|
80
80
|
- `parallel`: at least two `branches`, each starting from the state before the parallel node. Branches write disjoint keys and never read a sibling's writes.
|
|
81
81
|
- `loop`: `body`, `until` (a predicate) and an integer `maxIters` of at least 1 (no maximum), optionally an integer `expect_iters` of at least 1 (priced passes). At the bound the state passes through with `until` unmet; follow the loop with an escalate or gate on that condition.
|
|
82
82
|
- `escalate`: `when` (a predicate), and non-empty `kind`, `stage` and `summary`. When the predicate holds the run stops without output and returns the escalation with its interpolated summary.
|
|
@@ -121,9 +121,10 @@ A predicate is one JSON object that names itself in its `predicate` field beside
|
|
|
121
121
|
|
|
122
122
|
These host rules add to the language above; they never change it.
|
|
123
123
|
- This host runs only these node kinds: `chain`, `code`, `agent`, `decide`, `extract`, `report`, `map`, `parallel`, `loop`, `escalate`, `call`, `workflow`, `judge`, `pick`, `sift`, `route`. Author no other kind.
|
|
124
|
-
- Call agentrun with action describe first, then inspect the complete workflow with its input, then run without a workflow argument.
|
|
124
|
+
- Call agentrun with action describe first, then inspect the complete workflow with its input, then dry-run it (action dry-run, without a workflow argument), then run without a workflow argument.
|
|
125
|
+
- A dry run runs the inspected workflow on synthetic values, or the input you give it, with no model, Jev or tool call. Its status is passed, not_run (a check could not run; skipped says why, and it proves nothing) or failed (problems say why). Report not_run as not run, never as passed.
|
|
125
126
|
- Use exactly the tools describe returns. Without host-configured tools these are the active read-only built-ins (read, grep, find, ls), and bash, edit and write exist only in a trusted run; a host that configures its tools offers only those, including in trusted runs. The search tool reads fictional demo sources only.
|
|
126
|
-
- Code nodes and mutating tools run only after the user issues /agentrun run --trusted for that run. Trusted execution is local and unsandboxed.
|
|
127
|
+
- Code nodes and mutating tools run only after the user issues /agentrun run --trusted for that run. Trusted execution is local and unsandboxed. A dry run of a workflow with code nodes is not_run until the user issues /agentrun dry-run --trusted.
|
|
127
128
|
- A call uses via tool with a tool describe returns. Shell and executor calls and artifact delivery are unavailable.
|
|
128
129
|
- This extension supplies no SOP text: do not run a workflow with sopSection here; it needs an SDK host that supplies the authoritative SOP.
|
|
129
130
|
- Custom extension tools and outer permission hooks are not inherited, including in trusted runs.
|
|
@@ -7,3 +7,5 @@ The session's system text is the [language reference](language.md) followed by t
|
|
|
7
7
|
With `rubricSections`, every generated LLM node must reference every supplied section, including child workflows. This conservative author policy rejects Jev nodes, semantic `ask` predicates, and `verify` clauses until the host has a separately reviewed question contract. Execution must receive the same authoritative SOP text. A schema field named `node`, `verify`, or `predicate` is ordinary data, not a judgment clause.
|
|
8
8
|
|
|
9
9
|
Host-owned acceptance callbacks and fixtures remain outside generated workflows. Do not edit them to obtain a pass. Structural acceptance alone does not establish behavior. Keep request text, rubric text, candidates, and reviews in storage appropriate for their contents.
|
|
10
|
+
|
|
11
|
+
A host that ships a workflow with the input it runs on exports a `Job` (`{ workflow, input, files? }`, files as bytes by path); `defineJob(job)` returns its argument and keeps the workflow's literal type, so `export const workflow = {...} as const satisfies Workflow` and `export default defineJob({ workflow, input })` compile together.
|
|
@@ -17,7 +17,7 @@ export interface InspectorView {
|
|
|
17
17
|
output?: string[];
|
|
18
18
|
readOnly?: boolean;
|
|
19
19
|
}
|
|
20
|
-
export type InspectorAction = 'run' | 'save' | 'load' | 'input' | 'history' | 'stop' | 'edit';
|
|
20
|
+
export type InspectorAction = 'run' | 'dry-run' | 'save' | 'load' | 'input' | 'history' | 'stop' | 'edit';
|
|
21
21
|
/** Exported for deterministic rendering/navigation tests, without a terminal or model. */
|
|
22
22
|
export declare class WorkflowInspector implements Component {
|
|
23
23
|
private readonly getView;
|
|
@@ -49,7 +49,7 @@ export class WorkflowInspector {
|
|
|
49
49
|
actions(view) {
|
|
50
50
|
if (view.readOnly)
|
|
51
51
|
return [];
|
|
52
|
-
return view.status === 'running' ? ['stop', 'history'] : ['run', 'input', 'edit', 'save', 'load', 'history'];
|
|
52
|
+
return view.status === 'running' ? ['stop', 'history'] : ['run', 'dry-run', 'input', 'edit', 'save', 'load', 'history'];
|
|
53
53
|
}
|
|
54
54
|
fingerprint() {
|
|
55
55
|
const view = this.view();
|
|
@@ -190,7 +190,7 @@ export class WorkflowInspector {
|
|
|
190
190
|
else if (this.page === 'actions') {
|
|
191
191
|
const actions = this.actions(view);
|
|
192
192
|
this.actionIndex = Math.min(this.actionIndex, actions.length - 1);
|
|
193
|
-
const labels = { run: 'Run this workflow', save: 'Save procedure', load: 'Load procedure', input: 'Change input', history: 'Inspect run history', stop: 'Stop running workflow', edit: 'Edit procedure' };
|
|
193
|
+
const labels = { run: 'Run this workflow', 'dry-run': 'Dry-run on synthetic values (no model calls)', save: 'Save procedure', load: 'Load procedure', input: 'Change input', history: 'Inspect run history', stop: 'Stop running workflow', edit: 'Edit procedure' };
|
|
194
194
|
const first = Math.max(0, this.actionIndex - this.viewport + 1);
|
|
195
195
|
body = actions.slice(first, first + this.viewport).map((action, index) => cut(`${first + index === this.actionIndex ? '›' : ' '} ${labels[action]}`));
|
|
196
196
|
position = 'Choose an action; execution stays with Pi.';
|
package/dist/workflow-store.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export declare class WorkflowStoreError extends Error {
|
|
|
15
15
|
/** Local procedure revisions, not execution state or authority. The host still
|
|
16
16
|
* preflights loaded workflows against current capabilities and approves each run.
|
|
17
17
|
* This is not a sandbox against a process concurrently replacing project directories.
|
|
18
|
+
*
|
|
19
|
+
* Public through `@parcha/agentrun-pi/workflows`. The constructor takes a project directory, never the
|
|
20
|
+
* workflows directory itself: records live in `<projectDirectory>/.pi/agentrun/workflows/<name>/`, the same
|
|
21
|
+
* place the extension keeps them for its `ctx.cwd`, so the extension and a host that passes that directory
|
|
22
|
+
* read each other's records. The directory is resolved once when it is used; everything under it must be
|
|
23
|
+
* real directories and regular files.
|
|
18
24
|
*/
|
|
19
25
|
export declare class WorkflowStore {
|
|
20
26
|
private readonly projectDirectory;
|
package/dist/workflow-store.js
CHANGED
|
@@ -38,6 +38,12 @@ function checkedWorkflow(value) {
|
|
|
38
38
|
/** Local procedure revisions, not execution state or authority. The host still
|
|
39
39
|
* preflights loaded workflows against current capabilities and approves each run.
|
|
40
40
|
* This is not a sandbox against a process concurrently replacing project directories.
|
|
41
|
+
*
|
|
42
|
+
* Public through `@parcha/agentrun-pi/workflows`. The constructor takes a project directory, never the
|
|
43
|
+
* workflows directory itself: records live in `<projectDirectory>/.pi/agentrun/workflows/<name>/`, the same
|
|
44
|
+
* place the extension keeps them for its `ctx.cwd`, so the extension and a host that passes that directory
|
|
45
|
+
* read each other's records. The directory is resolved once when it is used; everything under it must be
|
|
46
|
+
* real directories and regular files.
|
|
41
47
|
*/
|
|
42
48
|
export class WorkflowStore {
|
|
43
49
|
projectDirectory;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import { type AuthorHostAddendum, type DryRunResult, type WorkflowDeps } from '@parcha/agentrun-dsl';
|
|
3
|
+
import type { WorkflowExtensionService } from './extension-service.js';
|
|
4
|
+
export declare const WORKFLOW_TOOL_ACTIONS: readonly ["describe", "inspect", "dry-run", "run", "save", "load", "list"];
|
|
5
|
+
/** The tool's fields: one action, and the fields an action reads. A host's tool may add its own. */
|
|
6
|
+
export declare const workflowToolProperties: () => {
|
|
7
|
+
action: Type.TUnion<Type.TLiteral<"run" | "dry-run" | "save" | "load" | "describe" | "inspect" | "list">[]>;
|
|
8
|
+
name: Type.TOptional<Type.TString>;
|
|
9
|
+
digest: Type.TOptional<Type.TString>;
|
|
10
|
+
workflow: Type.TOptional<Type.TRecord<"^.*$", Type.TUnknown>>;
|
|
11
|
+
input: Type.TOptional<Type.TRecord<"^.*$", Type.TUnknown>>;
|
|
12
|
+
};
|
|
13
|
+
/** The tool's parameters. */
|
|
14
|
+
export declare const workflowToolParameters: () => Type.TObject<{
|
|
15
|
+
action: Type.TUnion<Type.TLiteral<"run" | "dry-run" | "save" | "load" | "describe" | "inspect" | "list">[]>;
|
|
16
|
+
name: Type.TOptional<Type.TString>;
|
|
17
|
+
digest: Type.TOptional<Type.TString>;
|
|
18
|
+
workflow: Type.TOptional<Type.TRecord<"^.*$", Type.TUnknown>>;
|
|
19
|
+
input: Type.TOptional<Type.TRecord<"^.*$", Type.TUnknown>>;
|
|
20
|
+
}>;
|
|
21
|
+
/** What describe says of each tool a workflow may call: its schemas, and the result contract it is called under. */
|
|
22
|
+
export declare const describeTools: (tools: readonly {
|
|
23
|
+
name: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
parameters: unknown;
|
|
26
|
+
resultSchema?: unknown;
|
|
27
|
+
}[]) => {
|
|
28
|
+
name: string;
|
|
29
|
+
description: string | undefined;
|
|
30
|
+
parameters: unknown;
|
|
31
|
+
resultSchema: {};
|
|
32
|
+
resultContract: string;
|
|
33
|
+
}[];
|
|
34
|
+
/** The author references describe returns, with the host's addendum (the coding-agent extension's by default). */
|
|
35
|
+
export declare const authoringReferences: (host?: AuthorHostAddendum) => {
|
|
36
|
+
language: string;
|
|
37
|
+
workflow: string;
|
|
38
|
+
jev: string;
|
|
39
|
+
host: string;
|
|
40
|
+
};
|
|
41
|
+
/** The checks that run no code (admission by `checker`, structure, a supplied input), and the workflow's tree. A
|
|
42
|
+
* refusal throws, naming why. */
|
|
43
|
+
export declare function inspectCandidate(checker: WorkflowExtensionService, candidate: unknown, input?: Record<string, unknown>): {
|
|
44
|
+
inspection: import("@parcha/agentrun-dsl").WorkflowInspection;
|
|
45
|
+
tree: string;
|
|
46
|
+
};
|
|
47
|
+
/** A dry run's answer. `status` says whether every check ran: `passed`, `not_run` (a check could not run; `skipped`
|
|
48
|
+
* says which, so it is no proof the workflow works) or `failed` (`problems` say why). */
|
|
49
|
+
export type WorkflowDryRun = {
|
|
50
|
+
status: 'passed' | 'not_run';
|
|
51
|
+
ok: true;
|
|
52
|
+
skipped?: string[];
|
|
53
|
+
} | {
|
|
54
|
+
status: 'failed';
|
|
55
|
+
ok: false;
|
|
56
|
+
problems: string[];
|
|
57
|
+
stage?: string;
|
|
58
|
+
};
|
|
59
|
+
export type DryRunDraftOptions = {
|
|
60
|
+
/** The input to run on; without one the dry run synthesizes it from the workflow's input schema. */
|
|
61
|
+
input?: Record<string, unknown>;
|
|
62
|
+
/** The SOP text for a workflow that names sections. */
|
|
63
|
+
sop?: string;
|
|
64
|
+
/** Why no SOP text is available, said when the workflow names sections. */
|
|
65
|
+
sopUnavailable?: string;
|
|
66
|
+
/** The operator authorized code for this dry run: code nodes run in this process. A host's `runCode` runs them instead. */
|
|
67
|
+
trusted?: boolean;
|
|
68
|
+
runCode?: WorkflowDeps['runCode'];
|
|
69
|
+
/** How code is authorized in this host, named when a code node does not run. */
|
|
70
|
+
trustedCommand?: string;
|
|
71
|
+
/** Ends the dry run at its next step, as not run. */
|
|
72
|
+
signal?: AbortSignal;
|
|
73
|
+
};
|
|
74
|
+
/** The status of a dry run's result: its own once the DSL gives one, else read from `ok` and `skipped`. */
|
|
75
|
+
export declare function dryRunStatus(result: DryRunResult): WorkflowDryRun['status'];
|
|
76
|
+
/** Dry-runs an inspected draft on synthetic values, or `options.input`: model, Jev and tool calls are synthesized, so
|
|
77
|
+
* nothing is paid and nothing outside this process is touched. Code runs only when authorized, and a code failure is
|
|
78
|
+
* reported without what it threw. */
|
|
79
|
+
export declare function dryRunDraft(draft: unknown, options?: DryRunDraftOptions): Promise<WorkflowDryRun>;
|
|
80
|
+
/** A dry run as a person reads it. */
|
|
81
|
+
export declare function formatDryRun(report: WorkflowDryRun): string;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The agentrun tool's own work, for any host that offers it to an agent: its actions and parameters, describe, inspect
|
|
2
|
+
// and the dry run. Nothing here imports pi's coding agent, its agent core or its TUI, so a pi-durable host can use it.
|
|
3
|
+
import { Type } from 'typebox';
|
|
4
|
+
import { compileTransform, dryRunWorkflow, formatWorkflowTree, inspectWorkflow, loadAuthorReference, renderAuthorHostAddendum, } from '@parcha/agentrun-dsl';
|
|
5
|
+
import { PI_HOST_ADDENDUM } from './host-addendum.js';
|
|
6
|
+
export const WORKFLOW_TOOL_ACTIONS = ['describe', 'inspect', 'dry-run', 'run', 'save', 'load', 'list'];
|
|
7
|
+
/** The tool's fields: one action, and the fields an action reads. A host's tool may add its own. */
|
|
8
|
+
export const workflowToolProperties = () => ({ action: Type.Union(WORKFLOW_TOOL_ACTIONS.map(value => Type.Literal(value))),
|
|
9
|
+
name: Type.Optional(Type.String({ description: 'Saved procedure name: lowercase letters, digits, hyphens or underscores.' })),
|
|
10
|
+
digest: Type.Optional(Type.String({ description: 'Optional exact saved SHA-256 revision to load.' })),
|
|
11
|
+
workflow: Type.Optional(Type.Record(Type.String(), Type.Unknown(), {
|
|
12
|
+
description: 'The complete AgentRun workflow JSON object. Pass the object directly, not a JSON-encoded string. Inspection validates its DSL structure.',
|
|
13
|
+
})), input: Type.Optional(Type.Record(Type.String(), Type.Unknown())) });
|
|
14
|
+
/** The tool's parameters. */
|
|
15
|
+
export const workflowToolParameters = () => Type.Object(workflowToolProperties());
|
|
16
|
+
/** What describe says of each tool a workflow may call: its schemas, and the result contract it is called under. */
|
|
17
|
+
export const describeTools = (tools) => tools.map(t => ({
|
|
18
|
+
name: t.name, description: t.description, parameters: t.parameters,
|
|
19
|
+
resultSchema: t.resultSchema ?? { type: 'object', properties: { content: { type: 'array', items: { type: 'object' } }, details: {} }, required: ['content'] },
|
|
20
|
+
resultContract: t.resultSchema ? 'Host-declared direct workflow call result.' : 'Pi result envelope; tool-specific details are not declared. Do not invent fields inside details.',
|
|
21
|
+
}));
|
|
22
|
+
/** The author references describe returns, with the host's addendum (the coding-agent extension's by default). */
|
|
23
|
+
export const authoringReferences = (host = PI_HOST_ADDENDUM) => ({
|
|
24
|
+
language: loadAuthorReference('language'), workflow: loadAuthorReference('workflow-format'), jev: loadAuthorReference('jev-decisions'),
|
|
25
|
+
host: renderAuthorHostAddendum(host),
|
|
26
|
+
});
|
|
27
|
+
/** The checks that run no code (admission by `checker`, structure, a supplied input), and the workflow's tree. A
|
|
28
|
+
* refusal throws, naming why. */
|
|
29
|
+
export function inspectCandidate(checker, candidate, input) {
|
|
30
|
+
if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
31
|
+
throw new Error('Cannot inspect workflow: workflow must be a JSON object, not a JSON-encoded string or array. Pass the complete object directly.');
|
|
32
|
+
}
|
|
33
|
+
let inspection;
|
|
34
|
+
try {
|
|
35
|
+
inspection = checker.preflight(candidate, input);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new Error(`Cannot inspect workflow: ${error instanceof Error ? error.message : 'Invalid workflow'}`);
|
|
39
|
+
}
|
|
40
|
+
return { inspection, tree: formatWorkflowTree(inspection) };
|
|
41
|
+
}
|
|
42
|
+
/** The status of a dry run's result: its own once the DSL gives one, else read from `ok` and `skipped`. */
|
|
43
|
+
export function dryRunStatus(result) {
|
|
44
|
+
const given = result.status;
|
|
45
|
+
if (given === 'passed' || given === 'not_run' || given === 'failed')
|
|
46
|
+
return given;
|
|
47
|
+
return !result.ok ? 'failed' : result.skipped?.length ? 'not_run' : 'passed';
|
|
48
|
+
}
|
|
49
|
+
/** A code node run in this process, as the interpreter runs one; a failure names the node and not what it threw. */
|
|
50
|
+
const inProcessCode = async (node, state, ctx) => {
|
|
51
|
+
let transform;
|
|
52
|
+
try {
|
|
53
|
+
transform = compileTransform(node.code);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
throw new Error(`code node "${ctx.label}" did not compile`);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return transform(state, { sop: ctx.sop });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error(`code node "${ctx.label}" threw; what it threw is not shown`);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
/** Never reached: a draft with code runs it only when authorized, and this keeps validation from running any. */
|
|
66
|
+
const noCode = async (_node, _state, ctx) => { throw new Error(`code node "${ctx.label}" did not run`); };
|
|
67
|
+
/** Dry-runs an inspected draft on synthetic values, or `options.input`: model, Jev and tool calls are synthesized, so
|
|
68
|
+
* nothing is paid and nothing outside this process is touched. Code runs only when authorized, and a code failure is
|
|
69
|
+
* reported without what it threw. */
|
|
70
|
+
export async function dryRunDraft(draft, options = {}) {
|
|
71
|
+
const inspection = inspectWorkflow(draft);
|
|
72
|
+
const code = inspection.nodes.filter(node => node.kind === 'code').map(node => JSON.stringify(node.label));
|
|
73
|
+
if (code.length && !options.trusted && !options.runCode) {
|
|
74
|
+
return { status: 'not_run', ok: true, skipped: [`Code node${code.length === 1 ? '' : 's'} ${code.join(', ')} did not run, and nothing else ran: code runs only ${options.trustedCommand ? `with the user command ${options.trustedCommand}` : 'with the host\'s code executor'}.`] };
|
|
75
|
+
}
|
|
76
|
+
const sections = inspection.requires.sopSections;
|
|
77
|
+
if (sections.length && options.sop === undefined) {
|
|
78
|
+
return { status: 'not_run', ok: true, skipped: [options.sopUnavailable ?? `The workflow needs SOP text for ${sections.map(name => JSON.stringify(name)).join(', ')}, and none was supplied.`] };
|
|
79
|
+
}
|
|
80
|
+
const result = await dryRunWorkflow(draft, {
|
|
81
|
+
...(options.input === undefined ? {} : { input: options.input }), ...(options.sop === undefined ? {} : { sop: options.sop }),
|
|
82
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
83
|
+
runCode: options.runCode ?? (code.length ? inProcessCode : noCode),
|
|
84
|
+
});
|
|
85
|
+
return { ...result, status: dryRunStatus(result) };
|
|
86
|
+
}
|
|
87
|
+
/** A dry run as a person reads it. */
|
|
88
|
+
export function formatDryRun(report) {
|
|
89
|
+
const head = !report.ok ? `Dry run failed${report.stage ? ` at "${report.stage}"` : ''}.`
|
|
90
|
+
: report.status === 'passed' ? 'Dry run passed: every step ran on synthetic values. It checks the wiring, not the answers.'
|
|
91
|
+
: 'Dry run not run: a check could not run, so this is not proof the workflow works.';
|
|
92
|
+
const lines = report.ok ? report.skipped ?? [] : report.problems;
|
|
93
|
+
return [`${head} No model, Jev or tool call was made.`, ...lines.map(line => `- ${line}`)].join('\n');
|
|
94
|
+
}
|
package/dist/workflow-view.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { inspectWorkflow } from '@parcha/agentrun-dsl';
|
|
2
|
+
import { workflowGraph } from '@parcha/agentrun-dsl/graph';
|
|
2
3
|
import { cleanText } from './presentation.js';
|
|
3
4
|
import { types as utilTypes } from 'node:util';
|
|
4
5
|
/** Display limits never admit, cancel, or modify execution. */
|
|
@@ -188,7 +189,10 @@ export function workflowView(workflow, options = {}) {
|
|
|
188
189
|
const missing = required.filter((key) => !Object.hasOwn(options.input ?? {}, key));
|
|
189
190
|
const tools = inspection.requires.tools;
|
|
190
191
|
const unavailable = options.availableTools ? tools.filter(name => !options.availableTools.includes(name)) : [];
|
|
191
|
-
|
|
192
|
+
// The nodes are the graph's (`@parcha/agentrun-dsl/graph`, the one the viewer's graph panel draws); inspection only adds what a step writes.
|
|
193
|
+
const writes = new Map(inspection.nodes.map(node => [node.path, node.writes]));
|
|
194
|
+
const nodes = workflowGraph(workflow).nodes.map(graphNode => {
|
|
195
|
+
const node = { ...graphNode, writes: writes.get(graphNode.path) };
|
|
192
196
|
const source = at(definition, node.path) ?? {};
|
|
193
197
|
const childRoot = node.path.lastIndexOf('/workflow/root');
|
|
194
198
|
const owner = childRoot < 0 ? definition : at(definition, node.path.slice(0, childRoot + '/workflow'.length));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { WorkflowStore, WorkflowStoreError, type StoredWorkflow, type WorkflowRevision } from './workflow-store.js';
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
// Saved workflows for a host: the store the /agentrun extension keeps for its project directory, against a directory the host passes.
|
|
2
|
+
// It loads nothing from pi, so a host that never runs the extension can import it.
|
|
3
|
+
export { WorkflowStore, WorkflowStoreError } from './workflow-store.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parcha/agentrun-pi",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.15",
|
|
4
4
|
"description": "Pi extension for building, running and evaluating AgentRun workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
"./durable": {
|
|
20
20
|
"types": "./dist/durable/index.d.ts",
|
|
21
21
|
"import": "./dist/durable/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./signin": {
|
|
24
|
+
"types": "./dist/signin/index.d.ts",
|
|
25
|
+
"import": "./dist/signin/index.js"
|
|
26
|
+
},
|
|
27
|
+
"./workflows": {
|
|
28
|
+
"types": "./dist/workflows.d.ts",
|
|
29
|
+
"import": "./dist/workflows.js"
|
|
22
30
|
}
|
|
23
31
|
},
|
|
24
32
|
"bin": {
|
|
@@ -42,25 +50,29 @@
|
|
|
42
50
|
"@earendil-works/pi-coding-agent": "1.1.0",
|
|
43
51
|
"@earendil-works/pi-durable": "1.1.0",
|
|
44
52
|
"@earendil-works/pi-tui": "1.1.0",
|
|
45
|
-
"@parcha/pi-durable-disk": "0.1.0-beta.
|
|
53
|
+
"@parcha/pi-durable-disk": "0.1.0-beta.15",
|
|
46
54
|
"typebox": "1.3.7"
|
|
47
55
|
},
|
|
48
56
|
"dependencies": {
|
|
49
|
-
"@parcha/agentrun-dsl": "0.1.0-beta.
|
|
50
|
-
"@parcha/agentrun-jev": "0.1.0-beta.
|
|
57
|
+
"@parcha/agentrun-dsl": "0.1.0-beta.15",
|
|
58
|
+
"@parcha/agentrun-jev": "0.1.0-beta.15"
|
|
51
59
|
},
|
|
52
60
|
"peerDependencies": {
|
|
53
61
|
"@earendil-works/pi-agent-core": "*",
|
|
62
|
+
"@earendil-works/pi-ai": "*",
|
|
54
63
|
"@earendil-works/pi-coding-agent": "*",
|
|
55
64
|
"@earendil-works/pi-tui": "*",
|
|
56
65
|
"typebox": "*",
|
|
57
|
-
"@earendil-works/pi-durable": ">=1.0.4 <1.
|
|
58
|
-
"@earendil-works/chord": ">=1.0.4 <1.
|
|
66
|
+
"@earendil-works/pi-durable": ">=1.0.4 <1.4.0",
|
|
67
|
+
"@earendil-works/chord": ">=1.0.4 <1.4.0"
|
|
59
68
|
},
|
|
60
69
|
"peerDependenciesMeta": {
|
|
61
70
|
"@earendil-works/pi-agent-core": {
|
|
62
71
|
"optional": true
|
|
63
72
|
},
|
|
73
|
+
"@earendil-works/pi-ai": {
|
|
74
|
+
"optional": true
|
|
75
|
+
},
|
|
64
76
|
"@earendil-works/pi-coding-agent": {
|
|
65
77
|
"optional": true
|
|
66
78
|
},
|