@pify/workflow 0.1.1 → 0.2.0
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/extensions/workflow.ts +53 -5
- package/package.json +1 -1
- package/src/isolate.ts +81 -0
- package/src/sandbox.ts +4 -0
package/extensions/workflow.ts
CHANGED
|
@@ -29,7 +29,10 @@ import { Type } from "typebox";
|
|
|
29
29
|
import { readFileSync, readdirSync } from "node:fs";
|
|
30
30
|
import { basename, join } from "node:path";
|
|
31
31
|
|
|
32
|
+
import { spawnSync } from "node:child_process";
|
|
33
|
+
|
|
32
34
|
import { BUILTIN_AGENTS } from "../src/builtin.ts";
|
|
35
|
+
import { createIsolationWorktree, isolationNote, type Isolation } from "../src/isolate.ts";
|
|
33
36
|
import { parseAgentFile } from "../src/frontmatter.ts";
|
|
34
37
|
import { buildWidgetLines, formatResult, formatStatus } from "../src/report.ts";
|
|
35
38
|
import { runScript, type AgentOptions } from "../src/sandbox.ts";
|
|
@@ -44,6 +47,28 @@ import {
|
|
|
44
47
|
|
|
45
48
|
const RUN_ENTRY = "workflow-run";
|
|
46
49
|
const FALLBACK_AGENT = "scout";
|
|
50
|
+
const GATE_TIMEOUT_MS = 120_000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run a gate command with the shell in the child's working directory.
|
|
54
|
+
* Gate commands come from the workflow script — the same trust level as the
|
|
55
|
+
* bash tool in this session.
|
|
56
|
+
*/
|
|
57
|
+
function runGate(command: string, cwd: string): { ok: boolean; output: string } {
|
|
58
|
+
try {
|
|
59
|
+
const result = spawnSync(command, {
|
|
60
|
+
shell: true,
|
|
61
|
+
cwd,
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
timeout: GATE_TIMEOUT_MS,
|
|
64
|
+
windowsHide: true,
|
|
65
|
+
});
|
|
66
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
|
67
|
+
return { ok: result.status === 0, output };
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return { ok: false, output: err instanceof Error ? err.message : String(err) };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
47
72
|
|
|
48
73
|
type UiContext = ExtensionContext;
|
|
49
74
|
|
|
@@ -154,18 +179,25 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
154
179
|
}
|
|
155
180
|
if (!model) throw new Error("No model available");
|
|
156
181
|
|
|
182
|
+
// v0.2: worktree isolation for mutating steps.
|
|
183
|
+
let isolation: Isolation | null = null;
|
|
184
|
+
if (opts?.isolation === "worktree") {
|
|
185
|
+
isolation = createIsolationWorktree(ctx.cwd, `${run.runId}-${call.label}`);
|
|
186
|
+
}
|
|
187
|
+
const workDir = isolation?.path ?? ctx.cwd;
|
|
188
|
+
|
|
157
189
|
const promptHost = ctx as unknown as {
|
|
158
190
|
getSystemPromptOptions?: () => { customPrompt?: string; appendSystemPrompt?: string };
|
|
159
191
|
};
|
|
160
192
|
const promptOptions = promptHost.getSystemPromptOptions?.() ?? {};
|
|
161
193
|
|
|
162
194
|
const created = await createAgentSession({
|
|
163
|
-
sessionManager: SessionManager.inMemory(
|
|
195
|
+
sessionManager: SessionManager.inMemory(workDir),
|
|
164
196
|
model,
|
|
165
197
|
thinkingLevel: (def.thinking ?? pi.getThinkingLevel()) as never,
|
|
166
198
|
tools: def.tools,
|
|
167
199
|
resourceLoader: new DefaultResourceLoader({
|
|
168
|
-
cwd:
|
|
200
|
+
cwd: workDir,
|
|
169
201
|
agentDir: getAgentDir(),
|
|
170
202
|
noExtensions: true,
|
|
171
203
|
noPromptTemplates: true,
|
|
@@ -212,8 +244,22 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
212
244
|
call.status = "error";
|
|
213
245
|
return null;
|
|
214
246
|
}
|
|
247
|
+
|
|
248
|
+
// v0.2 gate: verify the child's work by running a command instead of
|
|
249
|
+
// asking another model (tintinweb). Non-zero exit fails the call.
|
|
250
|
+
if (opts?.gate) {
|
|
251
|
+
const gate = runGate(opts.gate, workDir);
|
|
252
|
+
if (!gate.ok) {
|
|
253
|
+
call.status = "error";
|
|
254
|
+
run.logs.push(`gate failed for ${call.label}: ${gate.output.slice(0, 200)}`);
|
|
255
|
+
renderWidget();
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
run.logs.push(`gate passed for ${call.label}`);
|
|
259
|
+
}
|
|
260
|
+
|
|
215
261
|
call.status = "done";
|
|
216
|
-
return text;
|
|
262
|
+
return isolation ? `${text}\n\n${isolationNote(isolation)}` : text;
|
|
217
263
|
} catch {
|
|
218
264
|
call.status = "error";
|
|
219
265
|
return null;
|
|
@@ -285,12 +331,14 @@ export default function workflow(pi: ExtensionAPI) {
|
|
|
285
331
|
label: "Run workflow",
|
|
286
332
|
description:
|
|
287
333
|
"Run a deterministic JavaScript orchestration script that fans work out across child agents. " +
|
|
288
|
-
"Globals: agent(prompt, {agent?, label?, phase?}) -> Promise<string|null> (agent types: " +
|
|
334
|
+
"Globals: agent(prompt, {agent?, label?, phase?, gate?, isolation?}) -> Promise<string|null> (agent types: " +
|
|
289
335
|
"reviewer/scout/worker + .pi/agents custom; write prompts as self-contained briefs); " +
|
|
290
336
|
"parallel(thunks) (barrier, failures resolve null); pipeline(items, ...stages) (no barrier " +
|
|
291
337
|
"between stages); phase(title); log(msg); args. The script's return value is the tool result. " +
|
|
292
338
|
"Date.now()/Math.random()/eval throw (determinism). Provide script XOR name " +
|
|
293
|
-
"(name loads .pi/workflows/<name>.js). background=true returns a runId for workflow_status."
|
|
339
|
+
"(name loads .pi/workflows/<name>.js). background=true returns a runId for workflow_status. " +
|
|
340
|
+
"agent() extras: gate=shell command run after the child (non-zero exit fails the call); " +
|
|
341
|
+
"isolation=worktree runs the child in its own git worktree for mutating steps.",
|
|
294
342
|
parameters: Type.Object({
|
|
295
343
|
script: Type.Optional(Type.String({ description: "JavaScript orchestration script body" })),
|
|
296
344
|
name: Type.Optional(Type.String({ description: "Saved workflow name in .pi/workflows/" })),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pify/workflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Deterministic multi-step agent orchestration for pi: a Claude Code-style workflow tool with agent()/parallel()/pipeline() scripts over the shared agent catalog",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/isolate.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Worktree isolation for child agents (v0.2 integration with the suite's
|
|
8
|
+
* worktree conventions): a mutating child gets its own git worktree on an
|
|
9
|
+
* agent/<slug> branch under ~/.worktrees/<repo>/, so parallel edits can
|
|
10
|
+
* never collide with the main checkout. All git calls are execFile argv —
|
|
11
|
+
* no shell, no interpolation. The worktree is NOT auto-removed: the result
|
|
12
|
+
* reports it so the user merges (worktree_merge from @pify/worktree, or
|
|
13
|
+
* plain git) or discards deliberately.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface Isolation {
|
|
17
|
+
path: string;
|
|
18
|
+
branch: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function git(cwd: string, args: string[]): string {
|
|
22
|
+
return execFileSync("git", args, {
|
|
23
|
+
cwd,
|
|
24
|
+
encoding: "utf8",
|
|
25
|
+
timeout: 30_000,
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
28
|
+
}).trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sanitizeSlug(raw: string): string {
|
|
32
|
+
const slug = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
33
|
+
return slug || "run";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createIsolationWorktree(cwd: string, rawSlug: string): Isolation {
|
|
37
|
+
let toplevel: string;
|
|
38
|
+
try {
|
|
39
|
+
toplevel = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
40
|
+
} catch {
|
|
41
|
+
throw new Error("Worktree isolation requires a git repository.");
|
|
42
|
+
}
|
|
43
|
+
const repo = basename(toplevel);
|
|
44
|
+
const slug = sanitizeSlug(rawSlug);
|
|
45
|
+
|
|
46
|
+
let branch = `agent/${slug}`;
|
|
47
|
+
let path = join(homedir(), ".worktrees", repo, slug);
|
|
48
|
+
let counter = 2;
|
|
49
|
+
while (existsSync(path) || branchExists(cwd, branch)) {
|
|
50
|
+
branch = `agent/${slug}-${counter}`;
|
|
51
|
+
path = join(homedir(), ".worktrees", repo, `${slug}-${counter}`);
|
|
52
|
+
counter++;
|
|
53
|
+
if (counter > 50) throw new Error("Could not find a free worktree slot.");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
git(cwd, ["worktree", "add", "-b", branch, path, "HEAD"]);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const e = err as { stderr?: string; message?: string };
|
|
60
|
+
throw new Error(`git worktree add failed: ${(e.stderr ?? e.message ?? "unknown").toString().trim()}`);
|
|
61
|
+
}
|
|
62
|
+
return { path, branch };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function branchExists(cwd: string, branch: string): boolean {
|
|
66
|
+
try {
|
|
67
|
+
git(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Note appended to a child's report when it ran isolated. */
|
|
75
|
+
export function isolationNote(isolation: Isolation): string {
|
|
76
|
+
return [
|
|
77
|
+
`Ran isolated in worktree ${isolation.path} (branch ${isolation.branch}).`,
|
|
78
|
+
`The main checkout is untouched. Merge with @pify/worktree's worktree_merge branch="${isolation.branch}",`,
|
|
79
|
+
`or inspect: cd "${isolation.path}" && git log --stat`,
|
|
80
|
+
].join("\n");
|
|
81
|
+
}
|
package/src/sandbox.ts
CHANGED
|
@@ -15,6 +15,10 @@ export interface AgentOptions {
|
|
|
15
15
|
agent?: string;
|
|
16
16
|
label?: string;
|
|
17
17
|
phase?: string;
|
|
18
|
+
/** Shell command run after the child finishes; non-zero exit → result null (v0.2). */
|
|
19
|
+
gate?: string;
|
|
20
|
+
/** "worktree": run the child in an isolated git worktree (v0.2). */
|
|
21
|
+
isolation?: string;
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export interface SandboxHooks {
|