@parcha/agentrun-pi 0.1.0-beta.1 → 0.1.0-beta.3
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 +69 -27
- package/dist/cli.js +5 -3
- package/dist/demo.js +1 -0
- package/dist/extension-service.js +12 -4
- package/dist/extension.js +329 -57
- package/dist/host-addendum.d.ts +4 -0
- package/dist/host-addendum.js +19 -0
- package/dist/index.d.ts +1 -2
- package/dist/index.js +1 -2
- package/dist/presentation.js +2 -2
- package/dist/skills/author/SKILL.md +135 -0
- package/{skills → dist/skills}/author/references/jev-decisions.md +9 -8
- package/dist/skills/author/references/language.md +128 -0
- package/dist/skills/author/references/sdk-authoring.md +9 -0
- package/{skills → dist/skills}/author/references/workflow-format.md +7 -15
- package/dist/triage-demo.d.ts +62 -0
- package/dist/triage-demo.js +153 -0
- package/dist/types.d.ts +3 -0
- package/dist/workflow-inspector.d.ts +47 -0
- package/dist/workflow-inspector.js +235 -0
- package/dist/workflow-session.d.ts +19 -0
- package/dist/workflow-session.js +167 -0
- package/dist/workflow-store.d.ts +30 -0
- package/dist/workflow-store.js +249 -0
- package/dist/workflow-view.d.ts +64 -0
- package/dist/workflow-view.js +268 -0
- package/package.json +5 -6
- 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/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,249 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { link, lstat, mkdir, open, readdir, realpath, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { inspectWorkflow, validateWorkflow } from '@parcha/agentrun-dsl';
|
|
6
|
+
export class WorkflowStoreError extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.name = 'WorkflowStoreError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const validName = (name) => {
|
|
15
|
+
if (typeof name !== 'string' || !/^[a-z0-9][a-z0-9_-]{0,63}$/.test(name))
|
|
16
|
+
throw new WorkflowStoreError('invalid_name', 'Use a workflow name of 1–64 lowercase letters, digits, hyphens or underscores, starting with a letter or digit.');
|
|
17
|
+
};
|
|
18
|
+
const validDigest = (digest) => {
|
|
19
|
+
if (typeof digest !== 'string' || !/^[a-f0-9]{64}$/.test(digest))
|
|
20
|
+
throw new WorkflowStoreError('invalid_revision', 'A workflow revision must be its complete SHA-256 digest.');
|
|
21
|
+
};
|
|
22
|
+
const missing = (error) => error?.code === 'ENOENT';
|
|
23
|
+
const unsafe = () => new WorkflowStoreError('unsafe_path', 'Workflow storage requires real directories and regular files, without symbolic links.');
|
|
24
|
+
function checkedWorkflow(value) {
|
|
25
|
+
try {
|
|
26
|
+
// Inspection rejects non-JSON values before cloning. Neither check executes code.
|
|
27
|
+
const inspection = inspectWorkflow(value);
|
|
28
|
+
const workflow = structuredClone(value);
|
|
29
|
+
const validation = validateWorkflow(workflow, { executeCode: false });
|
|
30
|
+
if (!validation.ok)
|
|
31
|
+
throw new Error('invalid workflow');
|
|
32
|
+
return { workflow, digest: inspection.sha256 };
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new WorkflowStoreError('invalid_workflow', 'Workflow failed nonexecuting structure, syntax or schema validation. Inspect it before saving.');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Local procedure revisions, not execution state or authority. The host still
|
|
39
|
+
* preflights loaded workflows against current capabilities and approves each run.
|
|
40
|
+
* This is not a sandbox against a process concurrently replacing project directories.
|
|
41
|
+
*/
|
|
42
|
+
export class WorkflowStore {
|
|
43
|
+
projectDirectory;
|
|
44
|
+
constructor(projectDirectory) { this.projectDirectory = resolve(projectDirectory); }
|
|
45
|
+
async directory(name, create = false) {
|
|
46
|
+
if (name !== undefined)
|
|
47
|
+
validName(name);
|
|
48
|
+
let directory = await realpath(this.projectDirectory);
|
|
49
|
+
for (const component of ['.pi', 'agentrun', 'workflows', ...(name === undefined ? [] : [name])]) {
|
|
50
|
+
directory = join(directory, component);
|
|
51
|
+
if (create) {
|
|
52
|
+
try {
|
|
53
|
+
await mkdir(directory, { mode: 0o700 });
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code !== 'EEXIST')
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
let stat;
|
|
61
|
+
try {
|
|
62
|
+
stat = await lstat(directory);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (missing(error) && !create)
|
|
66
|
+
return undefined;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || await realpath(directory) !== directory)
|
|
70
|
+
throw unsafe();
|
|
71
|
+
}
|
|
72
|
+
return directory;
|
|
73
|
+
}
|
|
74
|
+
async read(name, digest) {
|
|
75
|
+
validName(name);
|
|
76
|
+
validDigest(digest);
|
|
77
|
+
const directory = await this.directory(name);
|
|
78
|
+
if (!directory)
|
|
79
|
+
throw new WorkflowStoreError('not_found', 'Saved workflow revision not found.');
|
|
80
|
+
const path = join(directory, `${digest}.json`);
|
|
81
|
+
let handle;
|
|
82
|
+
try {
|
|
83
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
if (missing(error))
|
|
87
|
+
throw new WorkflowStoreError('not_found', 'Saved workflow revision not found.');
|
|
88
|
+
if (error.code === 'ELOOP')
|
|
89
|
+
throw unsafe();
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
let value;
|
|
93
|
+
try {
|
|
94
|
+
if (!(await handle.stat()).isFile() || await realpath(path) !== path)
|
|
95
|
+
throw unsafe();
|
|
96
|
+
try {
|
|
97
|
+
value = JSON.parse(await handle.readFile('utf8'));
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new WorkflowStoreError('invalid_record', 'Saved workflow record is not valid JSON.');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
await handle.close();
|
|
105
|
+
}
|
|
106
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
107
|
+
|| Object.keys(value).sort().join(',') !== 'createdAt,digest,name,version,workflow'
|
|
108
|
+
|| value.version !== 1 || value.name !== name || value.digest !== digest
|
|
109
|
+
|| typeof value.createdAt !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value.createdAt)
|
|
110
|
+
|| !Number.isFinite(Date.parse(value.createdAt)) || new Date(value.createdAt).toISOString() !== value.createdAt)
|
|
111
|
+
throw new WorkflowStoreError('invalid_record', 'Saved workflow metadata does not match its revision.');
|
|
112
|
+
const checked = checkedWorkflow(value.workflow);
|
|
113
|
+
if (checked.digest !== digest)
|
|
114
|
+
throw new WorkflowStoreError('invalid_record', 'Saved workflow content does not match its digest.');
|
|
115
|
+
return { name, digest, createdAt: value.createdAt, workflowName: checked.workflow.name, workflow: checked.workflow };
|
|
116
|
+
}
|
|
117
|
+
async latest(name) {
|
|
118
|
+
const directory = await this.directory(name);
|
|
119
|
+
if (!directory)
|
|
120
|
+
return undefined;
|
|
121
|
+
const path = join(directory, 'latest');
|
|
122
|
+
let handle;
|
|
123
|
+
try {
|
|
124
|
+
handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (missing(error))
|
|
128
|
+
return undefined;
|
|
129
|
+
if (error.code === 'ELOOP')
|
|
130
|
+
throw unsafe();
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const stat = await handle.stat();
|
|
135
|
+
if (!stat.isFile() || await realpath(path) !== path)
|
|
136
|
+
throw unsafe();
|
|
137
|
+
if (stat.size !== 65)
|
|
138
|
+
throw new WorkflowStoreError('invalid_record', 'Saved workflow latest revision is invalid.');
|
|
139
|
+
const value = await handle.readFile('utf8');
|
|
140
|
+
if (!/^[a-f0-9]{64}\n$/.test(value))
|
|
141
|
+
throw new WorkflowStoreError('invalid_record', 'Saved workflow latest revision is invalid.');
|
|
142
|
+
return value.slice(0, -1);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
await handle.close();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** All revisions, newest first; the active revision leads within a timestamp tie. */
|
|
149
|
+
async list() {
|
|
150
|
+
const directory = await this.directory();
|
|
151
|
+
if (!directory)
|
|
152
|
+
return [];
|
|
153
|
+
const revisions = [];
|
|
154
|
+
const latest = new Map();
|
|
155
|
+
for (const name of await readdir(directory)) {
|
|
156
|
+
validName(name);
|
|
157
|
+
const child = await this.directory(name);
|
|
158
|
+
if (!child)
|
|
159
|
+
continue;
|
|
160
|
+
const active = await this.latest(name);
|
|
161
|
+
if (active)
|
|
162
|
+
latest.set(name, active);
|
|
163
|
+
for (const file of await readdir(child)) {
|
|
164
|
+
// Unpublished files survive an interrupted write and never become revisions.
|
|
165
|
+
if (/^\.pending-[a-f0-9-]+$/.test(file) || file === 'latest')
|
|
166
|
+
continue;
|
|
167
|
+
if (!/^[a-f0-9]{64}\.json$/.test(file))
|
|
168
|
+
throw new WorkflowStoreError('invalid_record', 'Unexpected file in workflow revision storage.');
|
|
169
|
+
const { workflow: _workflow, ...revision } = await this.read(name, file.slice(0, -5));
|
|
170
|
+
revisions.push(revision);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return revisions.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || a.name.localeCompare(b.name)
|
|
174
|
+
|| Number(b.digest === latest.get(b.name)) - Number(a.digest === latest.get(a.name)) || a.digest.localeCompare(b.digest));
|
|
175
|
+
}
|
|
176
|
+
async load(name, digest) {
|
|
177
|
+
validName(name);
|
|
178
|
+
if (digest !== undefined)
|
|
179
|
+
return this.read(name, digest);
|
|
180
|
+
const latest = await this.latest(name);
|
|
181
|
+
if (latest)
|
|
182
|
+
return this.read(name, latest);
|
|
183
|
+
// Revisions saved before the latest pointer was introduced remain readable.
|
|
184
|
+
const revisions = (await this.list()).filter(item => item.name === name);
|
|
185
|
+
const revision = revisions[0];
|
|
186
|
+
if (!revision)
|
|
187
|
+
throw new WorkflowStoreError('not_found', 'Saved workflow not found.');
|
|
188
|
+
if (revisions[1]?.createdAt === revision.createdAt)
|
|
189
|
+
throw new WorkflowStoreError('ambiguous_revision', 'Saved workflow revisions share a timestamp. Load by complete digest or save the intended revision again.');
|
|
190
|
+
return this.read(name, revision.digest);
|
|
191
|
+
}
|
|
192
|
+
/** Publish a complete immutable revision atomically; identical saves are idempotent. */
|
|
193
|
+
async save(name, value) {
|
|
194
|
+
validName(name);
|
|
195
|
+
const { workflow, digest } = checkedWorkflow(value);
|
|
196
|
+
const directory = (await this.directory(name, true));
|
|
197
|
+
const temporary = join(directory, `.pending-${randomUUID()}`);
|
|
198
|
+
const target = join(directory, `${digest}.json`);
|
|
199
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
200
|
+
try {
|
|
201
|
+
await handle.writeFile(JSON.stringify({ version: 1, name, digest, createdAt: new Date().toISOString(), workflow }, null, 2) + '\n');
|
|
202
|
+
await handle.sync();
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
await handle.close();
|
|
206
|
+
}
|
|
207
|
+
// Hard-link publication is atomic and, unlike rename, never overwrites a revision.
|
|
208
|
+
if (await this.directory(name) !== directory)
|
|
209
|
+
throw unsafe();
|
|
210
|
+
try {
|
|
211
|
+
await link(temporary, target);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (error.code !== 'EEXIST')
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
const saved = await this.read(name, digest);
|
|
218
|
+
await unlink(temporary);
|
|
219
|
+
const parent = await open(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
220
|
+
try {
|
|
221
|
+
await parent.sync();
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
await parent.close();
|
|
225
|
+
}
|
|
226
|
+
// This rename is the linearization point for "load by name", even when
|
|
227
|
+
// different revisions have identical millisecond timestamps.
|
|
228
|
+
const pointerTemporary = join(directory, `.pending-${randomUUID()}`);
|
|
229
|
+
const pointer = await open(pointerTemporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
230
|
+
try {
|
|
231
|
+
await pointer.writeFile(`${digest}\n`);
|
|
232
|
+
await pointer.sync();
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
await pointer.close();
|
|
236
|
+
}
|
|
237
|
+
if (await this.directory(name) !== directory)
|
|
238
|
+
throw unsafe();
|
|
239
|
+
await rename(pointerTemporary, join(directory, 'latest'));
|
|
240
|
+
const updatedParent = await open(directory, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
241
|
+
try {
|
|
242
|
+
await updatedParent.sync();
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
await updatedParent.close();
|
|
246
|
+
}
|
|
247
|
+
return saved;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type WorkflowDeps } from '@parcha/agentrun-dsl';
|
|
2
|
+
import type { ExtensionRunReport } from './extension-service.js';
|
|
3
|
+
type Event = Parameters<NonNullable<WorkflowDeps['onEvent']>>[0];
|
|
4
|
+
type JudgeRequest = Parameters<NonNullable<WorkflowDeps['runJudge']>>[0];
|
|
5
|
+
export interface WorkflowViewNode {
|
|
6
|
+
path: string;
|
|
7
|
+
label: string;
|
|
8
|
+
kind: string;
|
|
9
|
+
status: string;
|
|
10
|
+
summary: string;
|
|
11
|
+
details: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface WorkflowView {
|
|
14
|
+
title: string;
|
|
15
|
+
digest: string;
|
|
16
|
+
status: string;
|
|
17
|
+
summary: string[];
|
|
18
|
+
nodes: WorkflowViewNode[];
|
|
19
|
+
output?: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface DecisionEvidence {
|
|
22
|
+
path: string;
|
|
23
|
+
label: string;
|
|
24
|
+
questions: string;
|
|
25
|
+
evidence: string;
|
|
26
|
+
answer?: string;
|
|
27
|
+
accepted?: boolean;
|
|
28
|
+
}
|
|
29
|
+
export interface RunObservation {
|
|
30
|
+
steps: Record<string, {
|
|
31
|
+
label: string;
|
|
32
|
+
kind: string;
|
|
33
|
+
status: string;
|
|
34
|
+
}>;
|
|
35
|
+
decisions: DecisionEvidence[];
|
|
36
|
+
notes: Record<string, string>;
|
|
37
|
+
omitted: number;
|
|
38
|
+
routes?: Record<string, string>;
|
|
39
|
+
}
|
|
40
|
+
/** Display limits never admit, cancel, or modify execution. */
|
|
41
|
+
export declare function readable(value: unknown, max?: number): string;
|
|
42
|
+
export declare class WorkflowObservation {
|
|
43
|
+
readonly data: RunObservation;
|
|
44
|
+
private decisionBytes;
|
|
45
|
+
record(event: Event): void;
|
|
46
|
+
private recordSafe;
|
|
47
|
+
/** Capture the exact supplied questions/state, not an explanation invented afterward. */
|
|
48
|
+
requested(request: JudgeRequest): DecisionEvidence | undefined;
|
|
49
|
+
observe(deps: WorkflowDeps): WorkflowDeps;
|
|
50
|
+
}
|
|
51
|
+
export declare function progressLines(observation: RunObservation, terminalStatus?: string): string[];
|
|
52
|
+
export declare function workflowView(workflow: unknown, options?: {
|
|
53
|
+
input?: Record<string, unknown>;
|
|
54
|
+
observation?: RunObservation;
|
|
55
|
+
report?: ExtensionRunReport;
|
|
56
|
+
running?: boolean;
|
|
57
|
+
mode?: string;
|
|
58
|
+
model?: string;
|
|
59
|
+
limits?: Record<string, number | null>;
|
|
60
|
+
availableTools?: string[];
|
|
61
|
+
savedName?: string;
|
|
62
|
+
}): WorkflowView;
|
|
63
|
+
export declare function formatWorkflowView(view: WorkflowView): string;
|
|
64
|
+
export {};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { inspectWorkflow } from '@parcha/agentrun-dsl';
|
|
2
|
+
import { cleanText } from './presentation.js';
|
|
3
|
+
import { types as utilTypes } from 'node:util';
|
|
4
|
+
/** Display limits never admit, cancel, or modify execution. */
|
|
5
|
+
export function readable(value, max = 12_000) {
|
|
6
|
+
let text;
|
|
7
|
+
let excerpt = false;
|
|
8
|
+
try {
|
|
9
|
+
if (typeof value === 'string')
|
|
10
|
+
text = value;
|
|
11
|
+
// The controller explicitly requests complete expanded output; its structured
|
|
12
|
+
// report remains authoritative. Normal observation never uses this path.
|
|
13
|
+
else if (max === Number.MAX_SAFE_INTEGER)
|
|
14
|
+
text = JSON.stringify(value, null, 2) ?? '';
|
|
15
|
+
else {
|
|
16
|
+
let remaining = max, values = 0;
|
|
17
|
+
const seen = new Set();
|
|
18
|
+
const visit = (item, depth) => {
|
|
19
|
+
if (++values > 2_000 || remaining <= 0 || depth > 16) {
|
|
20
|
+
excerpt = true;
|
|
21
|
+
return '[Display excerpt]';
|
|
22
|
+
}
|
|
23
|
+
if (typeof item === 'string') {
|
|
24
|
+
const part = item.slice(0, remaining);
|
|
25
|
+
remaining -= part.length;
|
|
26
|
+
if (part.length < item.length)
|
|
27
|
+
excerpt = true;
|
|
28
|
+
return part;
|
|
29
|
+
}
|
|
30
|
+
remaining -= 8;
|
|
31
|
+
if (item === null || typeof item === 'boolean' || typeof item === 'number')
|
|
32
|
+
return item;
|
|
33
|
+
if (!item || typeof item !== 'object' || utilTypes.isProxy(item))
|
|
34
|
+
return '[Non-JSON value]';
|
|
35
|
+
if (seen.has(item))
|
|
36
|
+
return '[Repeated reference]';
|
|
37
|
+
const proto = Object.getPrototypeOf(item);
|
|
38
|
+
if (proto !== null && proto !== Object.prototype && proto !== Array.prototype)
|
|
39
|
+
return '[Non-JSON value]';
|
|
40
|
+
seen.add(item);
|
|
41
|
+
const result = Array.isArray(item) ? [] : Object.create(null);
|
|
42
|
+
if (Array.isArray(item)) {
|
|
43
|
+
const length = Math.min(item.length, 2_000);
|
|
44
|
+
for (let index = 0; index < length; index++) {
|
|
45
|
+
if (remaining <= 0 || values >= 2_000) {
|
|
46
|
+
excerpt = true;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
const descriptor = Object.getOwnPropertyDescriptor(item, String(index));
|
|
50
|
+
result.push(descriptor && 'value' in descriptor ? visit(descriptor.value, depth + 1) : '[Unavailable element]');
|
|
51
|
+
}
|
|
52
|
+
if (result.length < item.length)
|
|
53
|
+
excerpt = true;
|
|
54
|
+
seen.delete(item);
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
for (const key in item) {
|
|
58
|
+
if (!Object.hasOwn(item, key))
|
|
59
|
+
continue;
|
|
60
|
+
if (remaining <= 0 || values >= 2_000) {
|
|
61
|
+
excerpt = true;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
const descriptor = Object.getOwnPropertyDescriptor(item, key);
|
|
65
|
+
remaining -= key.length;
|
|
66
|
+
Object.defineProperty(result, key, { value: 'value' in descriptor ? visit(descriptor.value, depth + 1) : '[Accessor not read]', enumerable: true, configurable: true });
|
|
67
|
+
}
|
|
68
|
+
seen.delete(item);
|
|
69
|
+
return result;
|
|
70
|
+
};
|
|
71
|
+
text = JSON.stringify(visit(value, 0), null, 2) ?? '';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return '[Not available as JSON]';
|
|
76
|
+
}
|
|
77
|
+
const clipped = text.length > max || excerpt;
|
|
78
|
+
return cleanText(text.slice(0, max)) + (clipped ? '\n… Display excerpt; additional content omitted.' : '');
|
|
79
|
+
}
|
|
80
|
+
const definitionPath = (path) => path.replace(/\/items\/\d+\/body/g, '/body').replace(/\/iterations\/\d+(?=\/|$)/g, '');
|
|
81
|
+
const at = (value, path) => path.split('/').slice(1).reduce((item, key) => item && Object.hasOwn(item, key.replace(/~1/g, '/').replace(/~0/g, '~')) ? item[key.replace(/~1/g, '/').replace(/~0/g, '~')] : undefined, value);
|
|
82
|
+
export class WorkflowObservation {
|
|
83
|
+
data = { steps: {}, decisions: [], notes: {}, routes: {}, omitted: 0 };
|
|
84
|
+
decisionBytes = 0;
|
|
85
|
+
record(event) {
|
|
86
|
+
try {
|
|
87
|
+
this.recordSafe(event);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
this.data.omitted++;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
recordSafe(event) {
|
|
94
|
+
const path = event.executionPath ?? `/label/${event.label}`;
|
|
95
|
+
const detail = event.detail;
|
|
96
|
+
if (event.type === 'node.start' || event.type === 'node.end') {
|
|
97
|
+
if (!Object.hasOwn(this.data.steps, path) && Object.keys(this.data.steps).length >= 2_000) {
|
|
98
|
+
this.data.omitted++;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
this.data.steps[path] = { label: cleanText(event.label), kind: String(detail?.kind ?? 'step'),
|
|
102
|
+
status: event.type === 'node.start' ? 'running' : detail?.status === 'ok' ? 'succeeded' : detail?.status === 'escalated' ? 'needs attention' : 'failed' };
|
|
103
|
+
}
|
|
104
|
+
// Escalation emits an evaluation event, not node.start/node.end. Project that
|
|
105
|
+
// existing interpreter evidence so the selected branch cannot look untouched.
|
|
106
|
+
if (event.type === 'escalate.evaluated') {
|
|
107
|
+
if (!Object.hasOwn(this.data.steps, path) && Object.keys(this.data.steps).length >= 2_000) {
|
|
108
|
+
this.data.omitted++;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
this.data.steps[path] = { label: cleanText(event.label), kind: 'escalate',
|
|
112
|
+
status: detail?.fired === true ? 'needs attention' : 'condition not met' };
|
|
113
|
+
}
|
|
114
|
+
if (event.type === 'judge.answered' || event.type === 'route.chosen' || event.type === 'ask.evaluated') {
|
|
115
|
+
const decision = this.data.decisions.findLast(item => item.path === path && item.accepted === undefined);
|
|
116
|
+
if (decision)
|
|
117
|
+
decision.accepted = true;
|
|
118
|
+
}
|
|
119
|
+
if (event.type === 'route.chosen' || event.type === 'loop.exited' || event.type === 'map.failed' || event.type === 'map.escalated') {
|
|
120
|
+
if (!Object.hasOwn(this.data.notes, path) && Object.keys(this.data.notes).length >= 200) {
|
|
121
|
+
this.data.omitted++;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
this.data.notes[path] = event.type === 'route.chosen' ? `Selected branch: ${readable(detail?.value, 500)}`
|
|
125
|
+
: event.type === 'loop.exited' ? `${detail?.iterations} iterations · ${detail?.reason === 'condition_met' ? 'stop condition met' : 'iteration bound reached; not proof of success'}`
|
|
126
|
+
: `${detail?.completed} of ${detail?.of} items completed · ${event.type === 'map.failed' ? 'failed' : 'needs attention'}`;
|
|
127
|
+
if (event.type === 'route.chosen' && typeof detail?.value?.taken === 'string')
|
|
128
|
+
this.data.routes[path] = detail.value.taken;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** Capture the exact supplied questions/state, not an explanation invented afterward. */
|
|
132
|
+
requested(request) {
|
|
133
|
+
if (this.data.decisions.length >= 32 || this.decisionBytes >= 128_000) {
|
|
134
|
+
this.data.omitted++;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const item = { path: request.executionPath ?? `/label/${request.label}`, label: cleanText(request.label),
|
|
138
|
+
questions: readable(request.questions, 8_000), evidence: readable(request.state, 8_000) };
|
|
139
|
+
this.decisionBytes += item.questions.length + item.evidence.length;
|
|
140
|
+
this.data.decisions.push(item);
|
|
141
|
+
return item;
|
|
142
|
+
}
|
|
143
|
+
observe(deps) {
|
|
144
|
+
if (!deps.runJudge)
|
|
145
|
+
return deps;
|
|
146
|
+
const runJudge = deps.runJudge;
|
|
147
|
+
return { ...deps, runJudge: async (request) => {
|
|
148
|
+
let record;
|
|
149
|
+
try {
|
|
150
|
+
record = this.requested(request);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
this.data.omitted++;
|
|
154
|
+
}
|
|
155
|
+
const result = await runJudge(request);
|
|
156
|
+
// A transport response is not acceptance; interpreter events confirm it later.
|
|
157
|
+
try {
|
|
158
|
+
if (record)
|
|
159
|
+
record.answer = readable(result.answers, 8_000);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
this.data.omitted++;
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
} };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export function progressLines(observation, terminalStatus) {
|
|
169
|
+
const steps = Object.values(observation.steps).map(step => terminalStatus && step.status === 'running'
|
|
170
|
+
? { ...step, status: ['interrupted', 'cancelled'].includes(terminalStatus) ? 'interrupted' : 'completion not observed' } : step);
|
|
171
|
+
const count = (status) => steps.filter(step => step.status === status).length;
|
|
172
|
+
const active = steps.filter(step => step.status === 'running');
|
|
173
|
+
return [
|
|
174
|
+
`${count('succeeded')} succeeded · ${active.length} active · ${count('failed')} failed · ${count('needs attention')} need attention`,
|
|
175
|
+
...(count('interrupted') ? [`${count('interrupted')} interrupted; completion not observed.`] : []),
|
|
176
|
+
...(count('completion not observed') ? [`${count('completion not observed')} steps have no observed completion.`] : []),
|
|
177
|
+
...active.slice(0, 3).map(step => `Working: ${step.label}`),
|
|
178
|
+
...(active.length > 3 ? [`… ${active.length - 3} more active steps`] : []),
|
|
179
|
+
...(observation.omitted ? ['Observation is partial; omitted details do not stop execution.'] : []),
|
|
180
|
+
];
|
|
181
|
+
}
|
|
182
|
+
export function workflowView(workflow, options = {}) {
|
|
183
|
+
const inspection = inspectWorkflow(workflow);
|
|
184
|
+
const definition = workflow;
|
|
185
|
+
const observed = options.observation;
|
|
186
|
+
const status = options.running ? 'running' : options.report?.status ?? 'draft';
|
|
187
|
+
const required = definition.input ? definition.schemas[definition.input.schemaId]?.required ?? [] : [];
|
|
188
|
+
const missing = required.filter((key) => !Object.hasOwn(options.input ?? {}, key));
|
|
189
|
+
const tools = inspection.requires.tools;
|
|
190
|
+
const unavailable = options.availableTools ? tools.filter(name => !options.availableTools.includes(name)) : [];
|
|
191
|
+
const nodes = inspection.nodes.map(node => {
|
|
192
|
+
const source = at(definition, node.path) ?? {};
|
|
193
|
+
const childRoot = node.path.lastIndexOf('/workflow/root');
|
|
194
|
+
const owner = childRoot < 0 ? definition : at(definition, node.path.slice(0, childRoot + '/workflow'.length));
|
|
195
|
+
const instances = Object.entries(observed?.steps ?? {}).filter(([path]) => definitionPath(path) === node.path);
|
|
196
|
+
const events = Object.entries(observed?.notes ?? {}).filter(([path]) => definitionPath(path) === node.path);
|
|
197
|
+
const decisions = observed?.decisions.filter(item => definitionPath(item.path) === node.path) ?? [];
|
|
198
|
+
const descendants = Object.entries(observed?.steps ?? {}).filter(([path]) => definitionPath(path).startsWith(`${node.path}/`));
|
|
199
|
+
const structural = ['chain', 'map', 'parallel', 'loop'].includes(node.kind);
|
|
200
|
+
const statuses = (instances.length ? instances : structural ? descendants : []).map(([, item]) => item.status);
|
|
201
|
+
const final = !!options.report && !options.running;
|
|
202
|
+
const skipped = final && !observed?.omitted && Object.entries(observed?.routes ?? {}).some(([routePath]) => {
|
|
203
|
+
const parent = definitionPath(routePath);
|
|
204
|
+
if (!node.path.startsWith(`${parent}/branches/`))
|
|
205
|
+
return false;
|
|
206
|
+
const branch = node.path.slice(`${parent}/branches/`.length).split('/')[0];
|
|
207
|
+
const choices = Object.entries(observed?.routes ?? {}).filter(([path]) => definitionPath(path) === parent);
|
|
208
|
+
return choices.length > 0 && choices.every(([, taken]) => taken.replace(/~/g, '~0').replace(/\//g, '~1') !== branch);
|
|
209
|
+
});
|
|
210
|
+
const nodeStatus = statuses.includes('running') ? final ? ['interrupted', 'cancelled'].includes(status) ? 'interrupted' : 'completion not observed' : 'running' : statuses.includes('failed') ? 'failed'
|
|
211
|
+
: statuses.some(value => ['interrupted', 'cancelled'].includes(value)) ? 'interrupted'
|
|
212
|
+
: statuses.includes('needs attention') ? 'needs attention'
|
|
213
|
+
: structural && !instances.length && observed?.omitted ? 'completion not observed'
|
|
214
|
+
: statuses.length && statuses.every(value => value === 'condition not met') ? 'condition not met'
|
|
215
|
+
: statuses.length && statuses.every(value => value === 'succeeded' || value === 'condition not met') ? 'succeeded'
|
|
216
|
+
: statuses.length ? 'completion not observed'
|
|
217
|
+
: events.length ? 'observed' : status === 'draft' ? 'planned' : skipped ? 'skipped (branch not selected)' : 'not observed';
|
|
218
|
+
const displayedStatus = structural && !instances.length && nodeStatus === 'succeeded' ? 'observed work succeeded' : nodeStatus;
|
|
219
|
+
const counts = instances.length > 1 ? `${statuses.filter(s => s === 'succeeded').length}/${instances.length} observed executions succeeded` : '';
|
|
220
|
+
const detail = [
|
|
221
|
+
`Step: ${node.label} [${node.kind}]`, `Definition: ${node.path}`, `State: ${displayedStatus}`,
|
|
222
|
+
...(source.requires?.length ? [`Requires: ${source.requires.join(', ')}`] : []),
|
|
223
|
+
...(source.state !== undefined ? ['Input binding:', readable(source.state)] : []),
|
|
224
|
+
...(source.instructions ? ['Instructions:', readable(source.instructions)] : []),
|
|
225
|
+
...(source.tools?.length ? [`Tools: ${source.tools.join(', ')}`] : []),
|
|
226
|
+
...(source.tool ? [`Tool: ${source.tool}`] : []),
|
|
227
|
+
...(source.args ? ['Tool arguments:', readable(source.args)] : []),
|
|
228
|
+
...(source.code ? ['Code (not executed by inspection):', readable(source.code)] : []),
|
|
229
|
+
...(source.until ? ['Stop condition:', readable(source.until), `Maximum iterations: ${source.maxIters ?? 'none configured'}`] : []),
|
|
230
|
+
...(source.when ? ['Condition:', readable(source.when)] : []),
|
|
231
|
+
...(source.out ? [`Output schema: ${source.out}`, readable(owner?.schemas?.[source.out])] : []),
|
|
232
|
+
...(node.writes ? [`Writes: ${node.writes}`] : []),
|
|
233
|
+
...(source.branches && !Array.isArray(source.branches) ? ['Branch rules:', readable(Object.fromEntries(Object.entries(source.branches).map(([name, value]) => [name, value.when ?? value.criteria ?? value.description ?? name])))] : []),
|
|
234
|
+
...events.map(([path, note]) => `${path}: ${note}`),
|
|
235
|
+
...decisions.flatMap((item, index) => [`Decision ${index + 1} · ${item.path}`, 'Actual questions and criteria:', item.questions,
|
|
236
|
+
'Actual evidence supplied:', item.evidence, item.accepted ? 'Interpreter accepted the answer:' : 'Response (acceptance not observed):', item.answer ?? 'No answer observed.',
|
|
237
|
+
'Confidence describes this judgment, not whole-workflow correctness.']),
|
|
238
|
+
];
|
|
239
|
+
return { path: node.path, label: cleanText(node.label), kind: node.kind, status: displayedStatus,
|
|
240
|
+
summary: [counts, ...events.map(([, note]) => note)].filter(Boolean).join(' · '), details: detail.map(cleanText) };
|
|
241
|
+
});
|
|
242
|
+
return {
|
|
243
|
+
title: cleanText(inspection.name), digest: inspection.sha256, status,
|
|
244
|
+
summary: [
|
|
245
|
+
`${options.savedName ? `Saved: ${cleanText(options.savedName)} · ` : ''}Revision: ${inspection.sha256.slice(0, 12)} · ${options.mode ?? 'live adapters'}`,
|
|
246
|
+
...(options.savedName ? [`Load this saved revision: /agentrun load ${cleanText(options.savedName)} ${inspection.sha256}`] : []),
|
|
247
|
+
`Input: ${inspection.inputSchema ?? 'object'} → Output: ${inspection.outputSchema}${inspection.outputPath ? ` (${inspection.outputPath})` : ''}`,
|
|
248
|
+
...(missing.length ? [`Missing input: ${missing.join(', ')}`] : []),
|
|
249
|
+
`Tools: ${tools.join(', ') || 'none'}`,
|
|
250
|
+
...(unavailable.length ? [`Unavailable without host configuration or approval: ${unavailable.join(', ')}`] : []),
|
|
251
|
+
...(inspection.requires.executableCode ? ['Approval: code requires /agentrun run --trusted for each run (unsandboxed).'] : []),
|
|
252
|
+
...(options.model && !options.mode?.startsWith('scripted') && inspection.requires.adapters.includes('runNode') ? [`Agent model: ${options.model}`] : []),
|
|
253
|
+
...(options.limits ? [`Host limits: ${Object.entries(options.limits).map(([key, value]) => `${key}=${value ?? 'disabled'}`).join(', ')}`] : []),
|
|
254
|
+
...(observed ? progressLines(observed, options.report && !options.running ? status : undefined) : ['Preflight checks syntax and admission; it does not prove task correctness.']),
|
|
255
|
+
...(options.report?.error ? [`Failure: ${options.report.error.stage ?? 'workflow'} · ${options.report.error.message}`] : []),
|
|
256
|
+
...(options.report?.uncertainEffects?.length ? ['Unknown tool outcomes: reconcile before restarting.'] : []),
|
|
257
|
+
...(options.report?.traceTruncated ? ['Retained event trace is partial.'] : []),
|
|
258
|
+
].map(cleanText), nodes,
|
|
259
|
+
...(options.report?.output !== undefined ? { output: ['Output:', readable(options.report.output)] } : {}),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
export function formatWorkflowView(view) {
|
|
263
|
+
return cleanText([`${view.title} · ${view.status}`, ...view.summary, '',
|
|
264
|
+
...view.nodes.map(node => `${' '.repeat(Math.min(5, node.path.split('/').length - 2))}${node.label} [${node.kind}] · ${node.status}${node.summary ? ` · ${node.summary}` : ''}`),
|
|
265
|
+
...(view.output ? ['', ...view.output] : []),
|
|
266
|
+
'', 'Inspect a step for criteria, evidence, and observed decisions. Restart reruns the whole workflow; it is not resume.',
|
|
267
|
+
].join('\n'));
|
|
268
|
+
}
|
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.3",
|
|
4
4
|
"description": "Pi extension for building, running and evaluating AgentRun workflows",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,13 +22,12 @@
|
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
24
|
"dist",
|
|
25
|
-
"skills",
|
|
26
25
|
"README.md",
|
|
27
26
|
"LICENSE",
|
|
28
27
|
"NOTICE"
|
|
29
28
|
],
|
|
30
29
|
"scripts": {
|
|
31
|
-
"build": "node ../../scripts/build-pi-demo.mjs && tsc -p tsconfig.json",
|
|
30
|
+
"build": "node ../../scripts/build-pi-demo.mjs && tsc -p tsconfig.json && node ../../scripts/render-pi-skill.mjs",
|
|
32
31
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
33
32
|
"test": "node --test test/*.test.mjs"
|
|
34
33
|
},
|
|
@@ -36,8 +35,8 @@
|
|
|
36
35
|
"@earendil-works/pi-ai": "0.87.0"
|
|
37
36
|
},
|
|
38
37
|
"dependencies": {
|
|
39
|
-
"@parcha/agentrun-dsl": "0.1.0-beta.
|
|
40
|
-
"@parcha/agentrun-jev": "0.1.0-beta.
|
|
38
|
+
"@parcha/agentrun-dsl": "0.1.0-beta.3",
|
|
39
|
+
"@parcha/agentrun-jev": "0.1.0-beta.3",
|
|
41
40
|
"@earendil-works/pi-agent-core": "0.87.0",
|
|
42
41
|
"@earendil-works/pi-coding-agent": "0.87.0",
|
|
43
42
|
"@earendil-works/pi-tui": "0.87.0",
|
|
@@ -67,7 +66,7 @@
|
|
|
67
66
|
"./dist/extension.js"
|
|
68
67
|
],
|
|
69
68
|
"skills": [
|
|
70
|
-
"./skills"
|
|
69
|
+
"./dist/skills"
|
|
71
70
|
]
|
|
72
71
|
}
|
|
73
72
|
}
|
package/dist/admission.d.ts
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
export interface WorkflowAdmissionOptions {
|
|
2
|
-
rubricSections?: Record<string, string>;
|
|
3
|
-
allowExecutableCandidates?: boolean;
|
|
4
|
-
allowedEffectTools?: readonly string[];
|
|
5
|
-
}
|
|
6
|
-
export declare function workflowPolicyErrors(candidate: unknown, options: WorkflowAdmissionOptions): string[];
|