oh-my-workflow 0.1.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/LICENSE +21 -0
- package/README.md +178 -0
- package/examples/deep-research/workflow.ts +82 -0
- package/package.json +60 -0
- package/skill/SKILL.md +491 -0
- package/src/adapters/claude.ts +146 -0
- package/src/adapters/codex.ts +149 -0
- package/src/adapters/fake.ts +70 -0
- package/src/adapters/types.ts +43 -0
- package/src/cli/omw.ts +37 -0
- package/src/cli/replay.ts +98 -0
- package/src/cli/run.ts +371 -0
- package/src/cli/validate.ts +110 -0
- package/src/journal.ts +138 -0
- package/src/resume.ts +48 -0
- package/src/runtime.ts +235 -0
- package/src/schema-gate.ts +164 -0
package/src/cli/run.ts
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// `omw run <wf> --agent <a> [--args JSON] [--concurrency N] [--pretty]`.
|
|
2
|
+
// Parsing is a pure function so the input contract is testable without touching
|
|
3
|
+
// the filesystem, a clock, or a subprocess.
|
|
4
|
+
|
|
5
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import type { AgentPort } from "../adapters/types";
|
|
9
|
+
import { makeFakeAdapter, type FakeAdapterOptions } from "../adapters/fake";
|
|
10
|
+
import { makeClaudeAdapter } from "../adapters/claude";
|
|
11
|
+
import { makeCodexAdapter } from "../adapters/codex";
|
|
12
|
+
import type { Runtime } from "../runtime";
|
|
13
|
+
import { makeRuntime } from "../runtime";
|
|
14
|
+
import { makeJournal, parseJournalLines, type JournalEvent } from "../journal";
|
|
15
|
+
import type { ResumeIndex } from "../resume";
|
|
16
|
+
import { makeResumeIndexFromLines } from "../resume";
|
|
17
|
+
|
|
18
|
+
export type RunOptions = {
|
|
19
|
+
wfPath: string;
|
|
20
|
+
agent: string;
|
|
21
|
+
args: unknown;
|
|
22
|
+
concurrency?: number;
|
|
23
|
+
pretty: boolean;
|
|
24
|
+
/** Path to a prior run's journal to resume from (longest-unchanged-prefix). */
|
|
25
|
+
resume?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ParseResult =
|
|
29
|
+
| { ok: true; value: RunOptions }
|
|
30
|
+
| { ok: false; error: string };
|
|
31
|
+
|
|
32
|
+
export function parseRunArgs(argv: string[]): ParseResult {
|
|
33
|
+
let wfPath: string | undefined;
|
|
34
|
+
let agent: string | undefined;
|
|
35
|
+
let args: unknown;
|
|
36
|
+
let concurrency: number | undefined;
|
|
37
|
+
let pretty = false;
|
|
38
|
+
let resume: string | undefined;
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
const tok = argv[i]!;
|
|
42
|
+
switch (tok) {
|
|
43
|
+
case "--agent":
|
|
44
|
+
agent = argv[++i];
|
|
45
|
+
break;
|
|
46
|
+
case "--args": {
|
|
47
|
+
const raw = argv[++i];
|
|
48
|
+
try {
|
|
49
|
+
args = JSON.parse(raw!);
|
|
50
|
+
} catch {
|
|
51
|
+
return { ok: false, error: `--args must be valid JSON, got: ${raw}` };
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
case "--concurrency": {
|
|
56
|
+
const raw = argv[++i];
|
|
57
|
+
const n = Number(raw);
|
|
58
|
+
if (raw === undefined || !Number.isInteger(n) || n < 1) {
|
|
59
|
+
return { ok: false, error: `--concurrency must be a positive integer, got: ${raw}` };
|
|
60
|
+
}
|
|
61
|
+
concurrency = n;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case "--pretty":
|
|
65
|
+
pretty = true;
|
|
66
|
+
break;
|
|
67
|
+
case "--resume":
|
|
68
|
+
resume = argv[++i];
|
|
69
|
+
if (!resume) return { ok: false, error: "--resume requires a journal path" };
|
|
70
|
+
break;
|
|
71
|
+
default:
|
|
72
|
+
if (wfPath === undefined) wfPath = tok;
|
|
73
|
+
else return { ok: false, error: `unexpected argument: ${tok}` };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (wfPath === undefined) return { ok: false, error: "missing workflow path" };
|
|
78
|
+
if (agent === undefined) return { ok: false, error: "missing --agent <name>" };
|
|
79
|
+
|
|
80
|
+
return { ok: true, value: { wfPath, agent, args, concurrency, pretty, resume } };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── workflow execution ──────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/** A loaded workflow module. The orchestration script the host agent authors is
|
|
86
|
+
* the default export; `fake` is optional fixtures used only by `--agent fake`
|
|
87
|
+
* so the example is deterministic green with no API key. */
|
|
88
|
+
export type LoadedWorkflow = {
|
|
89
|
+
workflow: (rt: Runtime, args: unknown) => unknown | Promise<unknown>;
|
|
90
|
+
fake?: FakeAdapterOptions;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Either a ready adapter, or a structured "not installed" signal (exit 3). */
|
|
94
|
+
export type AdapterResolution =
|
|
95
|
+
| { adapter: AgentPort }
|
|
96
|
+
| { missing: string; installHint: string };
|
|
97
|
+
|
|
98
|
+
/** Entry filenames tried, in order, when a workflow path is a directory. */
|
|
99
|
+
const ENTRY_NAMES = ["workflow.ts", "workflow.js", "index.ts", "index.js"];
|
|
100
|
+
|
|
101
|
+
/** Package root, so bundled paths (e.g. `examples/…`) resolve when omw is
|
|
102
|
+
* installed and invoked from an arbitrary cwd, not only from inside a clone. */
|
|
103
|
+
const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
104
|
+
|
|
105
|
+
/** Resolve a workflow path: an absolute path as-is; otherwise cwd-relative
|
|
106
|
+
* (the user's own workflows) first, then package-relative as a fallback so the
|
|
107
|
+
* shipped `examples/…` demo runs post-install. The cwd path is kept for the
|
|
108
|
+
* not-found error so the message points where the user actually looked. */
|
|
109
|
+
export function resolveWorkflowPath(wfPath: string): string {
|
|
110
|
+
if (isAbsolute(wfPath)) return wfPath;
|
|
111
|
+
const fromCwd = resolve(process.cwd(), wfPath);
|
|
112
|
+
if (existsSync(fromCwd)) return fromCwd;
|
|
113
|
+
// Fall back to the package-bundled demo ONLY for the `examples/` namespace, so
|
|
114
|
+
// a user's mistyped workflow path can't silently resolve to a shipped file.
|
|
115
|
+
if (wfPath === "examples" || wfPath.startsWith("examples/")) {
|
|
116
|
+
const fromPkg = resolve(PKG_ROOT, wfPath);
|
|
117
|
+
if (existsSync(fromPkg)) return fromPkg;
|
|
118
|
+
}
|
|
119
|
+
return fromCwd;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Load a workflow module from a file path or a directory (resolved to its
|
|
123
|
+
* conventional entry). The default export is the orchestration fn; a missing
|
|
124
|
+
* default is an authoring bug surfaced as a load error, not a silent no-op. */
|
|
125
|
+
export async function loadWorkflow(wfPath: string): Promise<LoadedWorkflow> {
|
|
126
|
+
const abs = resolveWorkflowPath(wfPath);
|
|
127
|
+
let entry = abs;
|
|
128
|
+
let isDir = false;
|
|
129
|
+
try {
|
|
130
|
+
isDir = statSync(abs).isDirectory();
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error(`workflow path not found: ${wfPath}`);
|
|
133
|
+
}
|
|
134
|
+
if (isDir) {
|
|
135
|
+
const found = ENTRY_NAMES.map((n) => join(abs, n)).find((p) => {
|
|
136
|
+
try {
|
|
137
|
+
return statSync(p).isFile();
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
if (!found) throw new Error(`no workflow entry (${ENTRY_NAMES.join(", ")}) in directory: ${wfPath}`);
|
|
143
|
+
entry = found;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const mod = await import(entry);
|
|
147
|
+
if (typeof mod.default !== "function") {
|
|
148
|
+
throw new Error(`workflow ${wfPath} must default-export a function (rt, args) => result`);
|
|
149
|
+
}
|
|
150
|
+
return { workflow: mod.default, fake: mod.fake };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type RunDeps = {
|
|
154
|
+
loadWorkflow: (wfPath: string) => Promise<LoadedWorkflow>;
|
|
155
|
+
resolveAdapter: (name: string, wf: LoadedWorkflow) => AdapterResolution;
|
|
156
|
+
journalSink: (line: string) => void;
|
|
157
|
+
now: () => number;
|
|
158
|
+
runId: () => string;
|
|
159
|
+
/** A prior run's journal as a lookup; when present, nodes whose key hits are
|
|
160
|
+
* served from it and the adapter is skipped. Built by runCommand from the
|
|
161
|
+
* --resume file so runWorkflow stays fs-free. */
|
|
162
|
+
resume?: ResumeIndex;
|
|
163
|
+
/** Optional human-facing tree (--pretty). Pure side-channel; never stdout. */
|
|
164
|
+
stderr?: (line: string) => void;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
export type RunOutcome = {
|
|
168
|
+
exitCode: number;
|
|
169
|
+
/** The result JSON — a single blob, stdout only. Present on exit 0 and on
|
|
170
|
+
* exit 4 (completed, but a node hit internal_error). */
|
|
171
|
+
stdout?: string;
|
|
172
|
+
/** Structured error for stderr on a non-zero exit. */
|
|
173
|
+
error?: object;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
177
|
+
|
|
178
|
+
export async function runWorkflow(opts: RunOptions, deps: RunDeps): Promise<RunOutcome> {
|
|
179
|
+
let loaded: LoadedWorkflow;
|
|
180
|
+
try {
|
|
181
|
+
loaded = await deps.loadWorkflow(opts.wfPath);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
return { exitCode: 1, error: { error: "load_failed", message: errMsg(e), wf: opts.wfPath } };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const resolved = deps.resolveAdapter(opts.agent, loaded);
|
|
187
|
+
if ("missing" in resolved) {
|
|
188
|
+
return {
|
|
189
|
+
exitCode: 3,
|
|
190
|
+
error: { error: "adapter_missing", adapter: resolved.missing, install_hint: resolved.installHint },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const runId = deps.runId();
|
|
195
|
+
const journal = makeJournal({ sink: deps.journalSink, now: deps.now });
|
|
196
|
+
const rt = makeRuntime({ adapter: resolved.adapter, journal, concurrency: opts.concurrency, resume: deps.resume });
|
|
197
|
+
|
|
198
|
+
journal.runStart({ run: runId, wf: opts.wfPath });
|
|
199
|
+
try {
|
|
200
|
+
const result = await loaded.workflow(rt, opts.args);
|
|
201
|
+
// internal_error is an AUTHOR bug (e.g. a JSON Schema that won't compile),
|
|
202
|
+
// not a flaky node — the null-contract absorbs it so the run completes, but
|
|
203
|
+
// we escalate to exit 4 so a caller (or authoring agent) doesn't read the
|
|
204
|
+
// null as a legitimate abstention. The partial result still goes to stdout.
|
|
205
|
+
const internalErrors = journal
|
|
206
|
+
.events()
|
|
207
|
+
.filter((e): e is Extract<JournalEvent, { ev: "agent_end" }> => e.ev === "agent_end" && !e.ok && e.kind === "internal_error")
|
|
208
|
+
.map((e) => e.call);
|
|
209
|
+
journal.runEnd({ ok: internalErrors.length === 0 });
|
|
210
|
+
if (internalErrors.length > 0) {
|
|
211
|
+
return {
|
|
212
|
+
exitCode: 4,
|
|
213
|
+
stdout: JSON.stringify(result),
|
|
214
|
+
error: {
|
|
215
|
+
error: "internal_error_nodes",
|
|
216
|
+
calls: internalErrors,
|
|
217
|
+
hint: "a node failed to compile/execute (likely an invalid JSON Schema) — see the journal's internal_error entries",
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
return { exitCode: 0, stdout: JSON.stringify(result) };
|
|
222
|
+
} catch (e) {
|
|
223
|
+
// A throw escaping the workflow body is a SCRIPT error (the authored JS), not
|
|
224
|
+
// a node failure — node failures are swallowed by the null-contract. Exit 1.
|
|
225
|
+
journal.runEnd({ ok: false });
|
|
226
|
+
return { exitCode: 1, error: { error: "script_error", message: errMsg(e), wf: opts.wfPath } };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── adapter resolution ──────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/** Install hints surfaced (exit 3) when an adapter's CLI isn't on PATH. The
|
|
233
|
+
* `fake` adapter is always available — it is the free, no-key demo engine. */
|
|
234
|
+
const INSTALL_HINTS: Record<string, string> = {
|
|
235
|
+
claude: "npm i -g @anthropic-ai/claude-code (then `claude login`)",
|
|
236
|
+
codex: "npm i -g @openai/codex (experimental adapter)",
|
|
237
|
+
pi: "see https://github.com/parallel-ai/pi (experimental adapter)",
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/** PATH probe — injected so the missing→installed branch is testable. */
|
|
241
|
+
const defaultBinExists = (bin: string): boolean => Bun.which(bin) != null;
|
|
242
|
+
|
|
243
|
+
export function resolveAdapter(
|
|
244
|
+
name: string,
|
|
245
|
+
wf: LoadedWorkflow,
|
|
246
|
+
binExists: (bin: string) => boolean = defaultBinExists,
|
|
247
|
+
): AdapterResolution {
|
|
248
|
+
if (name === "fake") return { adapter: makeFakeAdapter(wf.fake) };
|
|
249
|
+
if (name === "claude") {
|
|
250
|
+
// A real adapter exists, but exit 3 (adapter_missing) if the CLI isn't on
|
|
251
|
+
// PATH — tell the user what to install rather than failing mid-run.
|
|
252
|
+
if (!binExists("claude")) return { missing: "claude", installHint: INSTALL_HINTS.claude! };
|
|
253
|
+
return { adapter: makeClaudeAdapter() };
|
|
254
|
+
}
|
|
255
|
+
if (name === "codex") {
|
|
256
|
+
if (!binExists("codex")) return { missing: "codex", installHint: INSTALL_HINTS.codex! };
|
|
257
|
+
return { adapter: makeCodexAdapter() };
|
|
258
|
+
}
|
|
259
|
+
// pi lands here as it is built; until then, fail actionably.
|
|
260
|
+
return {
|
|
261
|
+
missing: name,
|
|
262
|
+
installHint: INSTALL_HINTS[name] ?? `unknown adapter "${name}". Try --agent fake for the free demo.`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ── process wiring (the bin entry calls runCommand) ─────────────────────────
|
|
267
|
+
|
|
268
|
+
export type Io = {
|
|
269
|
+
stdout: (s: string) => void;
|
|
270
|
+
stderr: (s: string) => void;
|
|
271
|
+
/** Directory for journal files; defaults to ".omw". */
|
|
272
|
+
omwDir?: string;
|
|
273
|
+
runId?: () => string;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
const defaultRunId = (): string => "r-" + Date.now().toString(36);
|
|
277
|
+
|
|
278
|
+
/** Wire real fs/import deps and run. Returns the exit code; writes the result
|
|
279
|
+
* JSON to stdout, the journal to <omwDir>/<runId>.jsonl, and any error JSON to
|
|
280
|
+
* stderr. Usage errors (parse failures) are exit 2. */
|
|
281
|
+
export async function runCommand(argv: string[], io: Io): Promise<number> {
|
|
282
|
+
const parsed = parseRunArgs(argv);
|
|
283
|
+
if (!parsed.ok) {
|
|
284
|
+
io.stderr(JSON.stringify({ error: "usage", message: parsed.error }));
|
|
285
|
+
io.stderr(
|
|
286
|
+
"\nusage: omw run <workflow> --agent <fake|claude|codex|pi> [--args JSON] [--concurrency N] [--resume <journal.jsonl>] [--pretty]",
|
|
287
|
+
);
|
|
288
|
+
return 2;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const omwDir = io.omwDir ?? ".omw";
|
|
292
|
+
const runId = (io.runId ?? defaultRunId)();
|
|
293
|
+
const journalPath = join(omwDir, `${runId}.jsonl`);
|
|
294
|
+
|
|
295
|
+
// Resume: load the prior journal into an index. A read failure is exit 1 (an
|
|
296
|
+
// unreadable --resume path is a user error, not a reason to silently run live).
|
|
297
|
+
let resume: ResumeIndex | undefined;
|
|
298
|
+
if (parsed.value.resume) {
|
|
299
|
+
let lines: string[];
|
|
300
|
+
try {
|
|
301
|
+
lines = readFileSync(parsed.value.resume, "utf8").split("\n");
|
|
302
|
+
} catch {
|
|
303
|
+
io.stderr(JSON.stringify({ error: "resume_read_failed", path: parsed.value.resume }) + "\n");
|
|
304
|
+
return 1;
|
|
305
|
+
}
|
|
306
|
+
resume = makeResumeIndexFromLines(lines);
|
|
307
|
+
if (resume.size === 0) {
|
|
308
|
+
// Readable but no cached nodes (empty/truncated/wrong file). Warn instead
|
|
309
|
+
// of silently re-running every node live — which the user would read as a
|
|
310
|
+
// free resume while paying full adapter cost.
|
|
311
|
+
io.stderr(JSON.stringify({ warning: "resume_empty", path: parsed.value.resume }) + "\n");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const events: string[] = [];
|
|
316
|
+
const outcome = await runWorkflow(parsed.value, {
|
|
317
|
+
loadWorkflow,
|
|
318
|
+
resolveAdapter,
|
|
319
|
+
journalSink: (line) => {
|
|
320
|
+
events.push(line);
|
|
321
|
+
// Create .omw/ lazily on the first journal line, so a failed load/adapter
|
|
322
|
+
// resolution (which records nothing) doesn't litter an empty directory.
|
|
323
|
+
if (events.length === 1) mkdirSync(omwDir, { recursive: true });
|
|
324
|
+
appendFileSync(journalPath, line + "\n");
|
|
325
|
+
},
|
|
326
|
+
now: () => Date.now(),
|
|
327
|
+
runId: () => runId,
|
|
328
|
+
resume,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
if (outcome.stdout !== undefined) io.stdout(outcome.stdout + "\n");
|
|
332
|
+
if (outcome.error) io.stderr(JSON.stringify(outcome.error) + "\n");
|
|
333
|
+
|
|
334
|
+
// Only surface the journal when a run actually recorded one — a load/adapter
|
|
335
|
+
// failure (exit 1/3) writes no events, so pointing at an empty file misleads.
|
|
336
|
+
if (events.length > 0) {
|
|
337
|
+
if (parsed.value.pretty) io.stderr(renderTree(events) + "\n");
|
|
338
|
+
io.stderr(`journal: ${journalPath}\n`);
|
|
339
|
+
}
|
|
340
|
+
return outcome.exitCode;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** A phase/fan-out tree from journal JSONL lines — the --pretty side-channel and
|
|
344
|
+
* the shared renderer reused by `omw replay`. Pure: events in, string out. */
|
|
345
|
+
export function renderTree(lines: string[]): string {
|
|
346
|
+
const out: string[] = [];
|
|
347
|
+
let ok = 0;
|
|
348
|
+
let failed = 0;
|
|
349
|
+
for (const e of parseJournalLines(lines)) {
|
|
350
|
+
switch (e.ev) {
|
|
351
|
+
case "run_start":
|
|
352
|
+
out.push(`run ${e.run}${e.wf ? ` (${e.wf})` : ""}`);
|
|
353
|
+
break;
|
|
354
|
+
case "phase":
|
|
355
|
+
out.push(` ▸ ${e.title}`);
|
|
356
|
+
break;
|
|
357
|
+
case "agent_start":
|
|
358
|
+
out.push(` • ${e.label ?? `call#${e.call}`} [${e.adapter}]`);
|
|
359
|
+
break;
|
|
360
|
+
case "agent_end":
|
|
361
|
+
if (e.ok) ok++;
|
|
362
|
+
else failed++;
|
|
363
|
+
out.push(` ${e.ok ? "✓" : `✗ ${e.kind ?? "fail"}`} call#${e.call}`);
|
|
364
|
+
break;
|
|
365
|
+
case "run_end":
|
|
366
|
+
out.push(`run_end ok=${e.ok} · ${ok} ok / ${failed} failed`);
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return out.join("\n");
|
|
371
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// `omw validate <wf> [--json]` — pre-flight check that spawns NO agents and does
|
|
2
|
+
// NOT run the workflow body. omw workflows are imperative JS, so a node's schema
|
|
3
|
+
// isn't knowable statically; what IS cheap to check is that the module loads and
|
|
4
|
+
// default-exports a function, and that a `fake` fixture is well-formed — the
|
|
5
|
+
// silent-degradation traps the SKILL warns about (top-level `responses`, a string
|
|
6
|
+
// `match`, or no rules+default), each of which makes `--agent fake` match nothing
|
|
7
|
+
// and quietly return `{}`. Runtime schema bugs surface separately as the exit-4
|
|
8
|
+
// internal_error escalation in `omw run`.
|
|
9
|
+
|
|
10
|
+
import type { Io } from "./run";
|
|
11
|
+
import { loadWorkflow, type LoadedWorkflow } from "./run";
|
|
12
|
+
|
|
13
|
+
export type ValidateReport = {
|
|
14
|
+
wf: string;
|
|
15
|
+
ok: boolean;
|
|
16
|
+
errors: string[];
|
|
17
|
+
warnings: string[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
|
|
21
|
+
|
|
22
|
+
/** Lint a `fake` fixture for shapes that silently match nothing. Returns
|
|
23
|
+
* human-readable warnings (empty = clean, or no fixture at all). */
|
|
24
|
+
export function lintFake(fake: unknown): string[] {
|
|
25
|
+
const warnings: string[] = [];
|
|
26
|
+
if (fake == null) return warnings;
|
|
27
|
+
if (typeof fake !== "object") {
|
|
28
|
+
warnings.push(`fake must be an object { rules, default }, got ${typeof fake}`);
|
|
29
|
+
return warnings;
|
|
30
|
+
}
|
|
31
|
+
const f = fake as Record<string, unknown>;
|
|
32
|
+
if ("responses" in f) {
|
|
33
|
+
warnings.push("`fake.responses` at the top level is ignored — responses belong inside `fake.rules[].responses`");
|
|
34
|
+
}
|
|
35
|
+
const rules = f.rules;
|
|
36
|
+
if (rules !== undefined && !Array.isArray(rules)) {
|
|
37
|
+
warnings.push("`fake.rules` must be an array");
|
|
38
|
+
} else if (Array.isArray(rules)) {
|
|
39
|
+
rules.forEach((r, i) => {
|
|
40
|
+
if (r == null || typeof r !== "object") {
|
|
41
|
+
warnings.push(`fake.rules[${i}] must be an object { match, responses }`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const rule = r as Record<string, unknown>;
|
|
45
|
+
if (typeof rule.match !== "function") {
|
|
46
|
+
warnings.push(`fake.rules[${i}].match must be a predicate function (prompt) => boolean, got ${typeof rule.match}`);
|
|
47
|
+
}
|
|
48
|
+
if (!Array.isArray(rule.responses)) {
|
|
49
|
+
warnings.push(`fake.rules[${i}].responses must be an array`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const hasRules = Array.isArray(rules) && rules.length > 0;
|
|
54
|
+
if (!hasRules && f.default === undefined) {
|
|
55
|
+
warnings.push("`fake` has no `rules` and no `default` — every node falls back to `{}` and will likely fail its schema");
|
|
56
|
+
}
|
|
57
|
+
return warnings;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Load the workflow (no run) and lint its fixture. `load` is injectable so the
|
|
61
|
+
* check is testable without the filesystem. */
|
|
62
|
+
export async function validateWorkflow(
|
|
63
|
+
wfPath: string,
|
|
64
|
+
load: (p: string) => Promise<LoadedWorkflow> = loadWorkflow,
|
|
65
|
+
): Promise<ValidateReport> {
|
|
66
|
+
let loaded: LoadedWorkflow;
|
|
67
|
+
try {
|
|
68
|
+
loaded = await load(wfPath);
|
|
69
|
+
} catch (e) {
|
|
70
|
+
return { wf: wfPath, ok: false, errors: [errMsg(e)], warnings: [] };
|
|
71
|
+
}
|
|
72
|
+
return { wf: wfPath, ok: true, errors: [], warnings: lintFake(loaded.fake) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type ValidateParse =
|
|
76
|
+
| { ok: true; value: { wfPath: string; json: boolean } }
|
|
77
|
+
| { ok: false; error: string };
|
|
78
|
+
|
|
79
|
+
export function parseValidateArgs(argv: string[]): ValidateParse {
|
|
80
|
+
let wfPath: string | undefined;
|
|
81
|
+
let json = false;
|
|
82
|
+
for (const tok of argv) {
|
|
83
|
+
if (tok === "--json") json = true;
|
|
84
|
+
else if (wfPath === undefined) wfPath = tok;
|
|
85
|
+
else return { ok: false, error: `unexpected argument: ${tok}` };
|
|
86
|
+
}
|
|
87
|
+
if (wfPath === undefined) return { ok: false, error: "missing workflow path" };
|
|
88
|
+
return { ok: true, value: { wfPath, json } };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Exit 0 only when the workflow loads AND any fake fixture is well-formed; exit
|
|
92
|
+
* 1 on a load error or a fixture problem; exit 2 on a usage error. */
|
|
93
|
+
export async function validateCommand(argv: string[], io: Io): Promise<number> {
|
|
94
|
+
const parsed = parseValidateArgs(argv);
|
|
95
|
+
if (!parsed.ok) {
|
|
96
|
+
io.stderr(JSON.stringify({ error: "usage", message: parsed.error }) + "\n");
|
|
97
|
+
io.stderr("usage: omw validate <workflow> [--json]\n");
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const report = await validateWorkflow(parsed.value.wfPath);
|
|
102
|
+
if (parsed.value.json) {
|
|
103
|
+
io.stdout(JSON.stringify(report) + "\n");
|
|
104
|
+
} else {
|
|
105
|
+
for (const e of report.errors) io.stderr(`✗ ${e}\n`);
|
|
106
|
+
for (const w of report.warnings) io.stderr(`⚠ ${w}\n`);
|
|
107
|
+
if (report.ok && report.warnings.length === 0) io.stdout(`✓ ${report.wf} — ok\n`);
|
|
108
|
+
}
|
|
109
|
+
return report.ok && report.warnings.length === 0 ? 0 : 1;
|
|
110
|
+
}
|
package/src/journal.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// The journal is the product's spine: a JSONL record of every step, so the
|
|
2
|
+
// authoring agent can read its own failure and repair its own script. The format
|
|
3
|
+
// is resume-compatible — the resume key (callIndex, promptHash, optsHash) is the
|
|
4
|
+
// same longest-unchanged-prefix idea Claude Code uses, so v2 live-resume layers
|
|
5
|
+
// on without a format change. Hashes exclude wall-clock time on purpose.
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
const sha256 = (s: string): string => "sha256:" + createHash("sha256").update(s).digest("hex");
|
|
10
|
+
|
|
11
|
+
/** Stable JSON: object keys sorted recursively so hashing is order-independent.
|
|
12
|
+
* undefined-valued keys are dropped (matching JSON.stringify) so that an
|
|
13
|
+
* explicitly-undefined optional field hashes identically to an absent one —
|
|
14
|
+
* otherwise the resume key drifts between behaviorally-identical opts. */
|
|
15
|
+
function stableStringify(value: unknown): string {
|
|
16
|
+
if (value === undefined) return "null";
|
|
17
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
return "[" + value.map((v) => (v === undefined ? "null" : stableStringify(v))).join(",") + "]";
|
|
20
|
+
}
|
|
21
|
+
const obj = value as Record<string, unknown>;
|
|
22
|
+
const keys = Object.keys(obj)
|
|
23
|
+
.filter((k) => obj[k] !== undefined)
|
|
24
|
+
.sort();
|
|
25
|
+
const body = keys.map((k) => JSON.stringify(k) + ":" + stableStringify(obj[k])).join(",");
|
|
26
|
+
return "{" + body + "}";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const promptHash = (prompt: string): string => sha256(prompt);
|
|
30
|
+
export const optsHash = (opts: unknown): string => sha256(stableStringify(opts ?? null));
|
|
31
|
+
|
|
32
|
+
export const resumeKey = (k: { call: number; promptHash: string; optsHash: string }): string =>
|
|
33
|
+
`${k.call}:${k.promptHash}:${k.optsHash}`;
|
|
34
|
+
|
|
35
|
+
// ── events ────────────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export type JournalEvent =
|
|
38
|
+
| { ev: "run_start"; run: string; wf?: string; args?: string; ts: number }
|
|
39
|
+
| { ev: "phase"; title: string }
|
|
40
|
+
| {
|
|
41
|
+
ev: "agent_start";
|
|
42
|
+
call: number;
|
|
43
|
+
label?: string;
|
|
44
|
+
phase?: string;
|
|
45
|
+
adapter: string;
|
|
46
|
+
promptHash: string;
|
|
47
|
+
optsHash: string;
|
|
48
|
+
ts: number;
|
|
49
|
+
}
|
|
50
|
+
| { ev: "attempt"; call: number; n: number; kind: string; errors?: string[]; stderr?: string; rawText?: string }
|
|
51
|
+
| {
|
|
52
|
+
ev: "agent_end";
|
|
53
|
+
call: number;
|
|
54
|
+
ok: boolean;
|
|
55
|
+
kind?: string;
|
|
56
|
+
result?: unknown;
|
|
57
|
+
durationMs?: number;
|
|
58
|
+
// Incremental v2 field: true when this end was served from a resume index
|
|
59
|
+
// (the adapter was skipped). Absent on a live run — the 7-event STRUCTURE is
|
|
60
|
+
// unchanged, so old journals stay readable.
|
|
61
|
+
cached?: boolean;
|
|
62
|
+
// Diagnostic payload so the authoring agent can read WHY a node failed:
|
|
63
|
+
// adapter stderr, the node's raw non-conforming text, or an internal error.
|
|
64
|
+
stderr?: string;
|
|
65
|
+
rawText?: string;
|
|
66
|
+
error?: string;
|
|
67
|
+
}
|
|
68
|
+
| { ev: "log"; msg: string }
|
|
69
|
+
| { ev: "run_end"; ok: boolean; stats?: Record<string, unknown> };
|
|
70
|
+
|
|
71
|
+
export type Sink = (line: string) => void;
|
|
72
|
+
|
|
73
|
+
export type Journal = {
|
|
74
|
+
runStart(e: { run: string; wf?: string; args?: string }): void;
|
|
75
|
+
phase(title: string): void;
|
|
76
|
+
agentStart(e: {
|
|
77
|
+
call: number;
|
|
78
|
+
label?: string;
|
|
79
|
+
phase?: string;
|
|
80
|
+
adapter: string;
|
|
81
|
+
promptHash: string;
|
|
82
|
+
optsHash: string;
|
|
83
|
+
}): void;
|
|
84
|
+
attempt(e: { call: number; n: number; kind: string; errors?: string[]; stderr?: string; rawText?: string }): void;
|
|
85
|
+
agentEnd(e: {
|
|
86
|
+
call: number;
|
|
87
|
+
ok: boolean;
|
|
88
|
+
kind?: string;
|
|
89
|
+
result?: unknown;
|
|
90
|
+
durationMs?: number;
|
|
91
|
+
cached?: boolean;
|
|
92
|
+
stderr?: string;
|
|
93
|
+
rawText?: string;
|
|
94
|
+
error?: string;
|
|
95
|
+
}): void;
|
|
96
|
+
log(msg: string): void;
|
|
97
|
+
runEnd(e: { ok: boolean; stats?: Record<string, unknown> }): void;
|
|
98
|
+
events(): JournalEvent[];
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Parse recorded journal JSONL into events, tolerating blank and malformed
|
|
102
|
+
* lines (a partially-flushed journal still yields its valid prefix). The single
|
|
103
|
+
* reader shared by resume-index build, `omw replay`, and the --pretty tree, so
|
|
104
|
+
* those three never disagree about what a journal file means. */
|
|
105
|
+
export function parseJournalLines(lines: string[]): JournalEvent[] {
|
|
106
|
+
const events: JournalEvent[] = [];
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
if (!line.trim()) continue;
|
|
109
|
+
try {
|
|
110
|
+
events.push(JSON.parse(line) as JournalEvent);
|
|
111
|
+
} catch {
|
|
112
|
+
// skip malformed line
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return events;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function makeJournal(opts?: { sink?: Sink; now?: () => number }): Journal {
|
|
119
|
+
const now = opts?.now ?? (() => Date.now());
|
|
120
|
+
const sink = opts?.sink;
|
|
121
|
+
const events: JournalEvent[] = [];
|
|
122
|
+
|
|
123
|
+
const emit = (e: JournalEvent): void => {
|
|
124
|
+
events.push(e);
|
|
125
|
+
sink?.(JSON.stringify(e));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
runStart: (e) => emit({ ev: "run_start", ts: now(), ...e }),
|
|
130
|
+
phase: (title) => emit({ ev: "phase", title }),
|
|
131
|
+
agentStart: (e) => emit({ ev: "agent_start", ts: now(), ...e }),
|
|
132
|
+
attempt: (e) => emit({ ev: "attempt", ...e }),
|
|
133
|
+
agentEnd: (e) => emit({ ev: "agent_end", ...e }),
|
|
134
|
+
log: (msg) => emit({ ev: "log", msg }),
|
|
135
|
+
runEnd: (e) => emit({ ev: "run_end", ...e }),
|
|
136
|
+
events: () => events,
|
|
137
|
+
};
|
|
138
|
+
}
|
package/src/resume.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The resume index turns a PRIOR run's journal into a lookup: (call, promptHash,
|
|
2
|
+
// optsHash) -> result. It is the read side of the frozen resume contract — the
|
|
3
|
+
// same longest-unchanged-prefix key the journal records — so v2 live-resume
|
|
4
|
+
// layers on without a format change. Only ok:true ends are cached; a failed node
|
|
5
|
+
// has no entry, so resume re-runs it live (partial-failure recompute).
|
|
6
|
+
|
|
7
|
+
import { type JournalEvent, parseJournalLines, resumeKey } from "./journal";
|
|
8
|
+
|
|
9
|
+
export type ResumeKey = { call: number; promptHash: string; optsHash: string };
|
|
10
|
+
|
|
11
|
+
export type ResumeHit = { found: false } | { found: true; value: unknown };
|
|
12
|
+
|
|
13
|
+
export type ResumeIndex = {
|
|
14
|
+
lookup(key: ResumeKey): ResumeHit;
|
|
15
|
+
/** Count of cached (ok) nodes available to resume. 0 means an empty/truncated/
|
|
16
|
+
* wrong journal, so the caller can warn instead of silently re-running live. */
|
|
17
|
+
size: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function makeResumeIndex(events: JournalEvent[]): ResumeIndex {
|
|
21
|
+
const byCall = new Map<number, ResumeKey>();
|
|
22
|
+
const results = new Map<string, unknown>();
|
|
23
|
+
|
|
24
|
+
for (const e of events) {
|
|
25
|
+
if (e.ev === "agent_start") {
|
|
26
|
+
byCall.set(e.call, { call: e.call, promptHash: e.promptHash, optsHash: e.optsHash });
|
|
27
|
+
} else if (e.ev === "agent_end" && e.ok) {
|
|
28
|
+
const k = byCall.get(e.call);
|
|
29
|
+
if (k) results.set(resumeKey(k), e.result);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
lookup(key) {
|
|
35
|
+
const rk = resumeKey(key);
|
|
36
|
+
if (!results.has(rk)) return { found: false };
|
|
37
|
+
return { found: true, value: results.get(rk) };
|
|
38
|
+
},
|
|
39
|
+
size: results.size,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Build a resume index from raw journal JSONL lines (a recorded run file).
|
|
44
|
+
* Tolerates blank/malformed lines the same way the replay summarizer does, so a
|
|
45
|
+
* partially-flushed journal still resumes its valid prefix. */
|
|
46
|
+
export function makeResumeIndexFromLines(lines: string[]): ResumeIndex {
|
|
47
|
+
return makeResumeIndex(parseJournalLines(lines));
|
|
48
|
+
}
|