@getpipher/armory-fleet 0.11.0 → 0.12.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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/engine/concurrency-lock.ts +20 -15
  3. package/src/engine/spawnSubagent.ts +8 -2
  4. package/src/index.ts +112 -8
  5. package/src/panel/fleet-panel.ts +165 -11
  6. package/src/runtime/async-runner.ts +79 -25
  7. package/src/runtime/reconcile.ts +12 -9
  8. package/src/runtime/resume.ts +21 -15
  9. package/src/runtime/run-journal.ts +2 -2
  10. package/src/scheduling/scheduler.ts +4 -0
  11. package/src/tools/fleet.ts +179 -0
  12. package/src/tools/subagent.ts +8 -2
  13. package/src/workflows/builtin/adversarial-review.js +19 -0
  14. package/src/workflows/builtin/code-review.js +13 -0
  15. package/src/workflows/builtin/codebase-audit.js +16 -0
  16. package/src/workflows/builtin/deep-research.js +12 -0
  17. package/src/workflows/builtin/multi-perspective.js +17 -0
  18. package/src/workflows/helpers/checkpoint.ts +15 -0
  19. package/src/workflows/helpers/completeness-check.ts +18 -0
  20. package/src/workflows/helpers/gate.ts +22 -0
  21. package/src/workflows/helpers/index.ts +8 -0
  22. package/src/workflows/helpers/judge-panel.ts +33 -0
  23. package/src/workflows/helpers/loop-until-dry.ts +21 -0
  24. package/src/workflows/helpers/retry.ts +17 -0
  25. package/src/workflows/helpers/types.ts +19 -0
  26. package/src/workflows/helpers/verify.ts +27 -0
  27. package/src/workflows/journal.ts +76 -0
  28. package/src/workflows/keyword.ts +22 -0
  29. package/src/workflows/panel/workflows-items.ts +150 -0
  30. package/src/workflows/panel/workflows-rows.ts +3 -0
  31. package/src/workflows/panel-host.ts +179 -0
  32. package/src/workflows/registry.ts +68 -0
  33. package/src/workflows/runner.ts +507 -0
  34. package/src/workflows/runtime/adapters.ts +182 -0
  35. package/src/workflows/runtime/controller.ts +493 -0
  36. package/src/workflows/runtime/hydrate.ts +116 -0
  37. package/src/workflows/runtime/pause-gate.ts +41 -0
  38. package/src/workflows/runtime/run-store.ts +31 -0
  39. package/src/workflows/runtime/save.ts +111 -0
  40. package/src/workflows/runtime/types.ts +78 -0
  41. package/src/workflows/source.ts +156 -0
  42. package/src/workflows/vm-realm.ts +106 -0
  43. package/src/worktree/worktree-service.ts +10 -0
@@ -0,0 +1,116 @@
1
+ // SPEC-6-3 §8 — journal hydration. On session start, reconstruct terminal + interrupted
2
+ // run rows from journal files so the store reflects historical state after a restart.
3
+ import { readdirSync, existsSync } from "node:fs"
4
+ import { join } from "node:path"
5
+
6
+ import type { WorkflowJournal, WorkflowJournalEvent } from "../journal.ts"
7
+ import type { WorkflowRunStore } from "./run-store.ts"
8
+ import type { WorkflowRunState } from "./types.ts"
9
+
10
+ export function hydrateWorkflowRuns(
11
+ journal: WorkflowJournal,
12
+ store: WorkflowRunStore,
13
+ ): void {
14
+ const dir = (journal as unknown as { dir: string }).dir
15
+ if (!existsSync(dir)) return
16
+
17
+ for (const f of readdirSync(dir)) {
18
+ if (!f.endsWith(".jsonl")) continue
19
+ const runId = f.slice(0, -".jsonl".length)
20
+ const events = journal.replay(runId)
21
+ if (events.length === 0) continue
22
+
23
+ const state = buildStateFromEvents(runId, events)
24
+ if (state) store.set(runId, state)
25
+ }
26
+ }
27
+
28
+ function buildStateFromEvents(
29
+ runId: string,
30
+ events: WorkflowJournalEvent[],
31
+ ): WorkflowRunState | undefined {
32
+ let startedEvent: Extract<WorkflowJournalEvent, { type: "wf:started" }> | undefined
33
+ let lastProgress: Extract<WorkflowJournalEvent, { type: "wf:progress" }> | undefined
34
+ let terminalEvent:
35
+ | Extract<WorkflowJournalEvent, { type: "wf:completed" }>
36
+ | Extract<WorkflowJournalEvent, { type: "wf:aborted" }>
37
+ | undefined
38
+
39
+ for (const e of events) {
40
+ if (e.type === "wf:started") {
41
+ startedEvent = e
42
+ } else if (e.type === "wf:progress") {
43
+ lastProgress = e
44
+ } else if (e.type === "wf:completed" || e.type === "wf:aborted") {
45
+ terminalEvent = e
46
+ }
47
+ }
48
+
49
+ if (!startedEvent) return undefined
50
+
51
+ const script = startedEvent.script
52
+ const mode = startedEvent.mode
53
+ const startedAt = startedEvent.ts
54
+
55
+ // Base fields from the latest progress snapshot (if any).
56
+ const currentPhase = lastProgress?.currentPhase ?? "default"
57
+ const phases = lastProgress?.phases ?? []
58
+ const childRunIds = lastProgress?.childRunIds ?? []
59
+ const logs = lastProgress?.logs ?? []
60
+ const tokenTotal = lastProgress?.tokenTotal ?? 0
61
+ const costTotal = lastProgress?.costTotal ?? 0
62
+
63
+ if (terminalEvent) {
64
+ if (terminalEvent.type === "wf:completed") {
65
+ return {
66
+ runId,
67
+ name: runId,
68
+ script,
69
+ mode,
70
+ status: "completed",
71
+ startedAt,
72
+ endedAt: terminalEvent.ts,
73
+ currentPhase,
74
+ phases,
75
+ childRunIds,
76
+ logs,
77
+ tokenTotal: terminalEvent.tokenTotal ?? tokenTotal,
78
+ costTotal: terminalEvent.costTotal ?? costTotal,
79
+ ...(terminalEvent.result !== undefined ? { result: terminalEvent.result } : {}),
80
+ }
81
+ }
82
+ // wf:aborted
83
+ return {
84
+ runId,
85
+ name: runId,
86
+ script,
87
+ mode,
88
+ status: "aborted",
89
+ startedAt,
90
+ endedAt: terminalEvent.ts,
91
+ currentPhase,
92
+ phases,
93
+ childRunIds,
94
+ logs,
95
+ tokenTotal,
96
+ costTotal,
97
+ error: terminalEvent.reason,
98
+ }
99
+ }
100
+
101
+ // Non-terminal: interrupted.
102
+ return {
103
+ runId,
104
+ name: runId,
105
+ script,
106
+ mode,
107
+ status: "interrupted",
108
+ startedAt,
109
+ currentPhase,
110
+ phases,
111
+ childRunIds,
112
+ logs,
113
+ tokenTotal,
114
+ costTotal,
115
+ }
116
+ }
@@ -0,0 +1,41 @@
1
+ // SPEC-6-3 cooperative pause gate — blocks new work when paused, releases all waiters on resume,
2
+ // rejects waiters on abort signal.
3
+
4
+ export class PauseGate {
5
+ private paused = false;
6
+ private readonly waiters = new Set<() => void>();
7
+
8
+ pause(): void {
9
+ this.paused = true;
10
+ }
11
+
12
+ resume(): void {
13
+ this.paused = false;
14
+ const toRelease = [...this.waiters];
15
+ this.waiters.clear();
16
+ for (const resolve of toRelease) resolve();
17
+ }
18
+
19
+ isPaused(): boolean {
20
+ return this.paused;
21
+ }
22
+
23
+ wait(signal: AbortSignal): Promise<void> {
24
+ if (!this.paused) return Promise.resolve();
25
+
26
+ return new Promise<void>((resolve, reject) => {
27
+ const onAbort = () => {
28
+ this.waiters.delete(resolve);
29
+ reject(signal.reason);
30
+ };
31
+
32
+ const resolveFn = () => {
33
+ signal.removeEventListener("abort", onAbort, { once: true } as EventListenerOptions);
34
+ resolve();
35
+ };
36
+
37
+ this.waiters.add(resolveFn);
38
+ signal.addEventListener("abort", onAbort, { once: true });
39
+ });
40
+ }
41
+ }
@@ -0,0 +1,31 @@
1
+ // SPEC-6-3 reactive run store — mirrors BgRunsStore pattern (private Map + listener Set)
2
+ // but values() returns newest-first by startedAt for panel ordering.
3
+
4
+ import type { WorkflowRunState } from "./types.ts";
5
+
6
+ export type WorkflowRunChangeListener = (runId: string) => void;
7
+
8
+ export class WorkflowRunStore {
9
+ private readonly runs = new Map<string, WorkflowRunState>();
10
+ private readonly listeners = new Set<WorkflowRunChangeListener>();
11
+
12
+ set(runId: string, state: WorkflowRunState): void {
13
+ this.runs.set(runId, state);
14
+ for (const fn of this.listeners) fn(runId);
15
+ }
16
+
17
+ get(runId: string): WorkflowRunState | undefined {
18
+ return this.runs.get(runId);
19
+ }
20
+
21
+ values(): WorkflowRunState[] {
22
+ return [...this.runs.values()].sort((a, b) => b.startedAt - a.startedAt);
23
+ }
24
+
25
+ subscribe(listener: WorkflowRunChangeListener): () => void {
26
+ this.listeners.add(listener);
27
+ return () => {
28
+ this.listeners.delete(listener);
29
+ };
30
+ }
31
+ }
@@ -0,0 +1,111 @@
1
+ // SPEC-6-3 §6 — atomic workflow save with an injectable fs port for deterministic failure tests.
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ renameSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ writeSync,
13
+ } from "node:fs"
14
+ import { join } from "node:path"
15
+ import { randomBytes } from "node:crypto"
16
+
17
+ import { parseWorkflowSource, validateWorkflowName } from "../source.ts"
18
+ import type { WorkflowDef, WorkflowSource } from "../registry.ts"
19
+
20
+ export interface SaveFs {
21
+ existsSync(path: string): boolean
22
+ mkdirSync(path: string, opts: { recursive: boolean }): void
23
+ openSync(path: string, flags: string): number
24
+ writeSync(fd: number, data: string): number
25
+ closeSync(fd: number): void
26
+ renameSync(oldPath: string, newPath: string): void
27
+ unlinkSync(path: string): void
28
+ readdirSync(path: string): string[]
29
+ readFileSync(path: string, encoding: string): string
30
+ writeFileSync(path: string, data: string, encoding: string): void
31
+ }
32
+
33
+ export const NODE_SAVE_FS: SaveFs = {
34
+ existsSync,
35
+ mkdirSync: (p, o) => mkdirSync(p, o),
36
+ openSync,
37
+ writeSync,
38
+ closeSync,
39
+ renameSync,
40
+ unlinkSync,
41
+ readdirSync,
42
+ readFileSync: (p, enc) => readFileSync(p, enc as BufferEncoding),
43
+ writeFileSync: (p, data, enc) => writeFileSync(p, data, enc as BufferEncoding),
44
+ }
45
+
46
+ export interface SaveInput {
47
+ name: string
48
+ source: string
49
+ overwrite?: boolean
50
+ /** Target directory for the workflow file. Defaults to process.cwd(). */
51
+ dir?: string
52
+ }
53
+
54
+ export function saveWorkflowAtomic(input: SaveInput, fsOps: SaveFs = NODE_SAVE_FS): WorkflowDef {
55
+ validateWorkflowName(input.name)
56
+
57
+ const filePath = `${input.name}.js`
58
+ const parsed = parseWorkflowSource(input.source, { filePath, requireMeta: true })
59
+ if (!parsed.meta) throw new Error(`${filePath}: missing meta`)
60
+ if (parsed.meta.name !== input.name) {
61
+ throw new Error(`meta.name '${parsed.meta.name}' does not match save name '${input.name}'`)
62
+ }
63
+
64
+ const targetDir = input.dir ?? process.cwd()
65
+ const targetPath = join(targetDir, filePath)
66
+
67
+ fsOps.mkdirSync(targetDir, { recursive: true })
68
+
69
+ if (fsOps.existsSync(targetPath) && !input.overwrite) {
70
+ throw new Error(`workflow '${input.name}' already exists; set overwrite:true to replace`)
71
+ }
72
+
73
+ const nonce = randomBytes(6).toString("hex")
74
+ const tempName = `.${input.name}.tmp-${process.pid}-${nonce}`
75
+ const tempPath = join(targetDir, tempName)
76
+
77
+ let fd: number | undefined
78
+ try {
79
+ fd = fsOps.openSync(tempPath, "w")
80
+ fsOps.writeSync(fd, input.source)
81
+ fsOps.closeSync(fd)
82
+ fd = undefined
83
+ fsOps.renameSync(tempPath, targetPath)
84
+ } catch (e) {
85
+ if (fd !== undefined) {
86
+ try {
87
+ fsOps.closeSync(fd)
88
+ } catch {
89
+ // best-effort
90
+ }
91
+ }
92
+ try {
93
+ fsOps.unlinkSync(tempPath)
94
+ } catch {
95
+ // best-effort — never mask the original error
96
+ }
97
+ throw e
98
+ }
99
+
100
+ const source: WorkflowSource = "project"
101
+ return {
102
+ name: parsed.meta.name,
103
+ description: parsed.meta.description,
104
+ phases: parsed.meta.phases,
105
+ sourceText: parsed.source,
106
+ body: parsed.body,
107
+ executable: parsed.executable,
108
+ source,
109
+ filePath: targetPath,
110
+ }
111
+ }
@@ -0,0 +1,78 @@
1
+ // SPEC-6-3 runtime types — public state shapes consumed by the runner, store, panel, and later tasks.
2
+
3
+ export type WorkflowStatus =
4
+ | "queued"
5
+ | "running"
6
+ | "paused"
7
+ | "checkpoint"
8
+ | "completed"
9
+ | "failed"
10
+ | "aborted"
11
+ | "interrupted";
12
+
13
+ export interface WorkflowRunState {
14
+ runId: string;
15
+ name: string;
16
+ script: string;
17
+ args?: unknown;
18
+ mode: "auto" | "checkpointed";
19
+ status: WorkflowStatus;
20
+ startedAt: number;
21
+ endedAt?: number;
22
+ currentPhase: string;
23
+ phases: Array<{ title: string; agents: number; cached: number; reRun: number }>;
24
+ childRunIds: string[];
25
+ logs: string[];
26
+ tokenTotal: number;
27
+ costTotal: number;
28
+ result?: unknown;
29
+ error?: string;
30
+ resumeFromRunId?: string;
31
+ checkpoint?: { prompt: string; opts: Record<string, unknown> };
32
+ }
33
+
34
+ export interface WorkflowProgressEvent {
35
+ kind:
36
+ | "started"
37
+ | "phase"
38
+ | "child-started"
39
+ | "child-completed"
40
+ | "child-failed"
41
+ | "helper-started"
42
+ | "helper-completed"
43
+ | "log"
44
+ | "checkpoint"
45
+ | "checkpoint-resolved"
46
+ | "completed"
47
+ | "failed"
48
+ | "aborted";
49
+ runId: string;
50
+ snapshot: WorkflowRunState;
51
+ }
52
+
53
+ export interface WorkflowStartInput {
54
+ script?: string;
55
+ workflowName?: string;
56
+ name?: string;
57
+ overwrite?: boolean;
58
+ args?: unknown;
59
+ mode: "auto" | "checkpointed";
60
+ background?: boolean;
61
+ resumeFromRunId?: string;
62
+ maxAgents?: number;
63
+ concurrency?: number;
64
+ agentRetries?: number;
65
+ agentTimeoutMs?: number;
66
+ tokenBudget?: number;
67
+ }
68
+
69
+ export interface WorkflowStartReceipt {
70
+ runId: string;
71
+ status: "background";
72
+ }
73
+
74
+ export interface WorkflowSaveInput {
75
+ name: string;
76
+ source: string;
77
+ overwrite?: boolean;
78
+ }
@@ -0,0 +1,156 @@
1
+ // SPEC-6-3 §3.1 — canonical workflow source parser. Extracts `export const meta = {…}`
2
+ // via a balanced-brace scanner (the old non-greedy regex broke on nested braces / multi-line),
3
+ // retains the editable sourceText + stripped body, and normalizes an executable string
4
+ // that the vm realm can compile as CommonJS.
5
+
6
+ export interface WorkflowMeta {
7
+ name: string;
8
+ description: string;
9
+ phases: { title: string }[];
10
+ }
11
+
12
+ export interface ParsedWorkflowSource {
13
+ meta?: WorkflowMeta;
14
+ source: string;
15
+ body: string;
16
+ executable: string;
17
+ }
18
+
19
+ const META_MARKER = "export const meta =";
20
+
21
+ const NAME_RE = /^[a-z][a-z0-9-]{0,63}$/;
22
+
23
+ const WINDOWS_RESERVED = new Set<string>([
24
+ "con", "prn", "aux", "nul",
25
+ ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
26
+ ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
27
+ ]);
28
+
29
+ /**
30
+ * Validate a workflow save name. Must be kebab-case (`^[a-z][a-z0-9-]{0,63}$`).
31
+ * Rejects Windows device names (con, prn, aux, nul, com1–9, lpt1–9) and any path traversal.
32
+ */
33
+ export function validateWorkflowName(name: string): void {
34
+ if (!NAME_RE.test(name) || WINDOWS_RESERVED.has(name.toLowerCase())) {
35
+ throw new Error(`invalid workflow name: '${name}'`);
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Scan a balanced `{…}` object starting at `start` (which must point at the opening brace).
41
+ * Ignores braces inside single/double/backtick quoted strings. Returns the raw text including
42
+ * the outer braces, or `null` if unbalanced.
43
+ */
44
+ function extractBalancedBraces(source: string, start: number): string | null {
45
+ let depth = 0;
46
+ let inSingle = false;
47
+ let inDouble = false;
48
+ let inBacktick = false;
49
+
50
+ for (let i = start; i < source.length; i++) {
51
+ const ch = source[i]!;
52
+
53
+ if (inSingle) {
54
+ if (ch === "\\") { i++; continue; }
55
+ if (ch === "'") inSingle = false;
56
+ continue;
57
+ }
58
+ if (inDouble) {
59
+ if (ch === "\\") { i++; continue; }
60
+ if (ch === '"') inDouble = false;
61
+ continue;
62
+ }
63
+ if (inBacktick) {
64
+ if (ch === "\\") { i++; continue; }
65
+ if (ch === "`") inBacktick = false;
66
+ continue;
67
+ }
68
+
69
+ if (ch === "'") { inSingle = true; continue; }
70
+ if (ch === '"') { inDouble = true; continue; }
71
+ if (ch === "`") { inBacktick = true; continue; }
72
+
73
+ if (ch === "{") depth++;
74
+ else if (ch === "}") {
75
+ depth--;
76
+ if (depth === 0) return source.slice(start, i + 1);
77
+ }
78
+ }
79
+
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * Parse a workflow source file into its meta, body, and executable form.
85
+ *
86
+ * - Extracts `export const meta = {…}` via balanced-brace scanning (ignoring braces in
87
+ * quoted strings). Evaluates the extracted object under the existing trusted-dev posture.
88
+ * - `body` is the source after the meta declaration is removed (trimmed).
89
+ * - `executable` wraps the body as `module.exports = (async () => { … })()` unless the body
90
+ * already starts with `module.exports =` (legacy CommonJS).
91
+ *
92
+ * When `requireMeta` is true and no meta declaration is found, throws.
93
+ */
94
+ export function parseWorkflowSource(
95
+ source: string,
96
+ opts: { filePath: string; requireMeta: boolean },
97
+ ): ParsedWorkflowSource {
98
+ const metaStart = source.indexOf(META_MARKER);
99
+
100
+ if (metaStart === -1) {
101
+ if (opts.requireMeta) {
102
+ throw new Error(`${opts.filePath}: missing \`export const meta = {…}\``);
103
+ }
104
+ const body = source.trim();
105
+ const executable = /^\s*module\.exports\s*=/.test(body)
106
+ ? body
107
+ : `module.exports = (async () => {\n${body}\n})()`;
108
+ return { source, body, executable };
109
+ }
110
+
111
+ // Skip whitespace to find the opening brace
112
+ let braceStart = metaStart + META_MARKER.length;
113
+ while (braceStart < source.length && source[braceStart] !== "{") braceStart++;
114
+ if (braceStart >= source.length) {
115
+ throw new Error(`${opts.filePath}: meta declaration has no opening brace`);
116
+ }
117
+
118
+ const metaText = extractBalancedBraces(source, braceStart);
119
+ if (metaText === null) {
120
+ throw new Error(`${opts.filePath}: meta declaration has unbalanced braces`);
121
+ }
122
+
123
+ // Evaluate the extracted object (trusted-dev-environment; meta is small + author-controlled)
124
+ let meta: WorkflowMeta;
125
+ try {
126
+ // eslint-disable-next-line no-new-func
127
+ const fn = new Function(`"use strict"; return (${metaText})`);
128
+ const raw = fn() as Record<string, unknown>;
129
+ if (typeof raw.name !== "string" || !raw.name.trim()) {
130
+ throw new Error(`${opts.filePath}: meta.name missing`);
131
+ }
132
+ if (typeof raw.description !== "string") {
133
+ throw new Error(`${opts.filePath}: meta.description missing`);
134
+ }
135
+ const phases = Array.isArray(raw.phases) ? raw.phases as { title: string }[] : [];
136
+ meta = {
137
+ name: raw.name.trim(),
138
+ description: raw.description.trim(),
139
+ phases,
140
+ };
141
+ } catch (e) {
142
+ const msg = (e as Error).message;
143
+ if (msg.startsWith(opts.filePath)) throw e;
144
+ throw new Error(`${opts.filePath}: meta parse failed: ${msg}`);
145
+ }
146
+
147
+ // Body is everything after the complete meta declaration
148
+ const declEnd = braceStart + metaText.length;
149
+ const body = source.slice(declEnd).trim();
150
+
151
+ const executable = /^\s*module\.exports\s*=/.test(body)
152
+ ? body
153
+ : `module.exports = (async () => {\n${body}\n})()`;
154
+
155
+ return { meta, source, body, executable };
156
+ }
@@ -0,0 +1,106 @@
1
+ // SPEC-6-3 §3.1 — the vm sandbox realm. Determinism, NOT security (PRD §6: trusted-dev-environment).
2
+ // JS-only workflow scripts (no .ts → no transpile). Strips Date/Math.random/require/import/fs/net/timers/eval;
3
+ // injects the 5 orchestration globals + 7 helpers + realm globals (log/args/cwd/process.cwd/budget).
4
+ import vm from "node:vm";
5
+
6
+ export interface RealmDeps {
7
+ agent: (prompt: string, opts?: Record<string, unknown>) => Promise<unknown>;
8
+ parallel: (thunks: Array<() => Promise<unknown>>) => Promise<unknown[]>;
9
+ pipeline: (items: unknown[], ...stages: Array<(item: unknown) => Promise<unknown>>) => Promise<unknown[]>;
10
+ phase: (title: string, opts?: { budget?: number }) => void;
11
+ workflow: (name: string, args?: unknown) => Promise<unknown>;
12
+ verify: (item: unknown, opts?: Record<string, unknown>) => Promise<unknown>;
13
+ judgePanel: (attempts: unknown[], opts?: Record<string, unknown>) => Promise<unknown>;
14
+ loopUntilDry: (opts: Record<string, unknown>) => Promise<unknown[]>;
15
+ completenessCheck: (taskArgs: unknown, results: unknown) => Promise<unknown>;
16
+ gate: (thunk: (feedback: string | undefined, attempt: number) => unknown, validator: (v: unknown) => { ok: boolean; feedback?: string }, opts?: Record<string, unknown>) => Promise<unknown>;
17
+ retry: (thunk: (attempt: number) => unknown, opts?: Record<string, unknown>) => Promise<unknown>;
18
+ checkpoint: (prompt: string, opts?: Record<string, unknown>) => Promise<unknown>;
19
+ log: (message: unknown) => void;
20
+ args: unknown;
21
+ cwd: string;
22
+ budget: { total: number; spent: () => number; remaining: () => number };
23
+ }
24
+
25
+ export const REALM_GLOBAL_NAMES = [
26
+ "agent", "parallel", "pipeline", "phase", "workflow",
27
+ "verify", "judgePanel", "loopUntilDry", "completenessCheck", "gate", "retry", "checkpoint",
28
+ "log", "args", "cwd", "process", "budget",
29
+ ] as const;
30
+
31
+ /** Build a vm context with the injected globals + stripped non-determinism. */
32
+ export function buildRealm(deps: RealmDeps): vm.Context {
33
+ const sandbox: Record<string, unknown> = {
34
+ // 5 orchestration globals
35
+ agent: deps.agent,
36
+ parallel: deps.parallel,
37
+ pipeline: deps.pipeline,
38
+ phase: deps.phase,
39
+ workflow: deps.workflow,
40
+ // 7 helpers
41
+ verify: deps.verify,
42
+ judgePanel: deps.judgePanel,
43
+ loopUntilDry: deps.loopUntilDry,
44
+ completenessCheck: deps.completenessCheck,
45
+ gate: deps.gate,
46
+ retry: deps.retry,
47
+ checkpoint: deps.checkpoint,
48
+ // realm globals
49
+ log: deps.log,
50
+ args: deps.args,
51
+ cwd: deps.cwd,
52
+ budget: deps.budget,
53
+ // process.cwd() returns the session cwd (deterministic); no process.env.
54
+ process: { cwd: () => deps.cwd },
55
+ // CommonJS module surface so scripts can `module.exports = …`
56
+ module: { exports: {} as unknown },
57
+ exports: undefined as unknown,
58
+ // async + Promise must be present for the script to use await
59
+ Promise,
60
+ // console absent (log() is the channel) — but provide a noop for accidental use
61
+ console: { log: deps.log, error: deps.log, warn: deps.log, info: deps.log },
62
+ };
63
+ // Fix: extract typed locals so we can alias `exports` without `unknown` errors.
64
+ const mod = sandbox.module as { exports: unknown };
65
+ sandbox.exports = mod.exports;
66
+ // JSON + the bare operators need nothing extra; but `JSON` is a global — provide it
67
+ sandbox.JSON = JSON;
68
+ // Symbol/Array/Object/Number/String/Boolean/Math (for JSON.stringify/Array.from etc.) —
69
+ // but we STRIP Math.random + Date by NOT providing them. Provide the safe builtins:
70
+ sandbox.Array = Array;
71
+ sandbox.Object = Object;
72
+ sandbox.String = String;
73
+ sandbox.Number = Number;
74
+ sandbox.Boolean = Boolean;
75
+ sandbox.Symbol = Symbol;
76
+ sandbox.Map = Map;
77
+ sandbox.Set = Set;
78
+ sandbox.Error = Error;
79
+ sandbox.RegExp = RegExp;
80
+ sandbox.parseInt = parseInt;
81
+ sandbox.parseFloat = parseFloat;
82
+ sandbox.isNaN = isNaN;
83
+ sandbox.isFinite = isFinite;
84
+ // Deliberately NOT provided (determinism contract): Date, Math, require, import,
85
+ // setTimeout, setInterval, setImmediate, fetch, globalThis, process.env, eval, Function.
86
+ // vm.createContext provides builtins (Date, Math, timers, eval, etc.) regardless of
87
+ // sandbox contents — shadow them with throwing getters so any access throws ReferenceError.
88
+ // process is NOT here: we inject `process: { cwd: () => deps.cwd }` above, and the spec
89
+ // strips process.env (not process.cwd). Exclude process from the strip set.
90
+ const STRIPPED = ["Date", "Math", "setTimeout", "setInterval", "setImmediate", "clearTimeout", "clearInterval", "clearImmediate", "eval", "Function", "globalThis", "fetch", "require"] as const;
91
+ for (const name of STRIPPED) {
92
+ Object.defineProperty(sandbox, name, {
93
+ get() { throw new ReferenceError(`${name} is not defined`); },
94
+ set() { throw new ReferenceError(`${name} is not defined`); },
95
+ configurable: false,
96
+ enumerable: false,
97
+ });
98
+ }
99
+ return vm.createContext(sandbox, { name: "armory-fleet-workflow" });
100
+ }
101
+
102
+ /** Compile a JS workflow script. JS-only — a .ts script is rejected upstream. */
103
+ export function compileWorkflowScript(script: string): vm.Script {
104
+ // Wrap so the script can use `module.exports = …` (CommonJS) OR an async IIFE.
105
+ return new vm.Script(script, { filename: "workflow.js" });
106
+ }
@@ -40,6 +40,16 @@ export class WorktreeService {
40
40
  return existsSync(this.pathFor(runId));
41
41
  }
42
42
 
43
+ /** v0.11.1: is `rootDir` (or `dir`) inside a git repo? Cheap sync pre-flight for isolation routing. */
44
+ isGitRepo(dir: string = this.rootDir): boolean {
45
+ try {
46
+ sh("git rev-parse --show-toplevel", dir);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
43
53
  create(runId: string, baseRef = "HEAD"): WorktreeRef {
44
54
  if (this.exists(runId)) {
45
55
  throw new Error(`worktree for run ${runId} already exists at ${this.pathFor(runId)}`);