oh-my-workflow 0.2.0 → 0.4.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/src/runtime.ts CHANGED
@@ -11,6 +11,18 @@ import type { Journal } from "./journal";
11
11
  import { promptHash, optsHash } from "./journal";
12
12
  import type { ResumeIndex } from "./resume";
13
13
  import { schemaGate, makeValidator, type GateCall, type GateFeedback } from "./schema-gate";
14
+ import { withWorktree as defaultWithWorktree } from "./worktree";
15
+
16
+ /** Optional `export const meta` a workflow can declare to describe itself and
17
+ * its phases. Mirrors native dynamic-workflow's meta block: a pure literal the
18
+ * loader reads for naming, phase titles, and per-phase/default model hints. */
19
+ export type WorkflowMeta = {
20
+ name?: string;
21
+ description?: string;
22
+ whenToUse?: string;
23
+ model?: string;
24
+ phases?: Array<{ title: string; model?: string; detail?: string }>;
25
+ };
14
26
 
15
27
  export type AgentOpts = {
16
28
  label?: string;
@@ -20,6 +32,16 @@ export type AgentOpts = {
20
32
  cwd?: string;
21
33
  timeoutMs?: number;
22
34
  maxRetries?: number;
35
+ /** Inherit the host's MCP servers in this node (default false → isolated, fast). */
36
+ inheritMcp?: boolean;
37
+ /** Reasoning-effort hint for this node (adapter maps it where supported). */
38
+ effort?: "low" | "medium" | "high" | "xhigh" | "max";
39
+ /** Cross-vendor node profile (named agent persona) for this node. */
40
+ agentType?: string;
41
+ /** Run this node in a fresh ephemeral git worktree (cwd = the worktree), so
42
+ * parallel file-mutating nodes don't clobber each other. Best-effort: a
43
+ * non-git cwd runs in place with a warning. */
44
+ isolation?: "worktree";
23
45
  };
24
46
 
25
47
  // `prev`/`item` are intentionally `any`: orchestration scripts are plain JS the
@@ -27,12 +49,22 @@ export type AgentOpts = {
27
49
  // without fighting the type system. The runtime treats every value opaquely.
28
50
  export type Stage = (prev: any, item: any, index: number) => unknown | Promise<unknown>;
29
51
 
52
+ /** Shared, mutable token-spend accumulator. Lives outside makeRuntime so a
53
+ * parent and any nested workflow() child can point at the SAME counter — the
54
+ * token pool is shared across the whole run, not per-runtime. */
55
+ export type BudgetState = { spent: number };
56
+
30
57
  export type Runtime = {
31
58
  agent(prompt: string, opts?: AgentOpts): Promise<unknown | null>;
32
59
  pipeline(items: unknown[], ...stages: Stage[]): Promise<unknown[]>;
33
60
  parallel(thunks: Array<() => Promise<unknown>>): Promise<unknown[]>;
61
+ workflow(ref: string | { scriptPath: string }, args?: unknown): Promise<unknown>;
34
62
  phase(title: string): void;
35
63
  log(msg: string): void;
64
+ /** Token budget view. `total` is the ceiling (null = unbounded); `spent()`
65
+ * reads the shared accumulator; `remaining()` is `total - spent` (Infinity
66
+ * when unbounded). The ceiling is enforced in agent() (BudgetExceededError). */
67
+ budget: { total: number | null; spent(): number; remaining(): number };
36
68
  };
37
69
 
38
70
  /** Bounded-concurrency gate: at most `max` bodies run at once; the rest queue.
@@ -61,12 +93,57 @@ export function makeLimiter(max: number) {
61
93
 
62
94
  const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e));
63
95
 
96
+ /** The semantic subset of a node's options — everything that changes WHAT the
97
+ * node computes, and nothing cosmetic. The resume key hashes only this, so a
98
+ * display-only change (label/phase) or a retry-policy tweak (timeoutMs/
99
+ * maxRetries) re-uses the cached result instead of needlessly re-running. The
100
+ * resolved model (after the opts>phase>meta chain) is passed in so a meta/phase
101
+ * model change still busts the cache even when opts.model is unset. */
102
+ function pickSemantic(opts: AgentOpts, model: string | undefined) {
103
+ return {
104
+ model,
105
+ schema: opts.schema,
106
+ effort: opts.effort,
107
+ isolation: opts.isolation,
108
+ agentType: opts.agentType,
109
+ cwd: opts.cwd,
110
+ inheritMcp: opts.inheritMcp,
111
+ };
112
+ }
113
+
114
+ /** The ONE documented exception to the null-contract: when a token budget is set
115
+ * and already exhausted, agent() throws this instead of returning null, so a
116
+ * budget-bounded loop terminates instead of silently spinning out null nodes.
117
+ * It is thrown OUTSIDE the per-node try, so it propagates; a throw that lands
118
+ * inside parallel()/pipeline() is still swallowed to null (matches native). */
119
+ export class BudgetExceededError extends Error {
120
+ constructor(message: string) {
121
+ super(message);
122
+ this.name = "BudgetExceededError";
123
+ }
124
+ }
125
+
126
+ // Cap the echoed prior output so a huge malformed dump can't blow the fresh prompt.
127
+ const RETRY_RAWTEXT_CAP = 4000;
128
+
64
129
  function retryPrompt(original: string, feedback: GateFeedback, fresh: boolean): string {
65
130
  const note =
66
131
  "Your previous output failed validation:\n" +
67
132
  feedback.errors.map((e) => `- ${e}`).join("\n") +
68
133
  "\nReturn ONLY corrected JSON, no prose.";
69
- return fresh ? `${original}\n\n${note}` : note;
134
+ // In-session followUp (fresh=false): the prior attempt is still in the live
135
+ // transcript, so the errors alone are enough. Fresh invoke (fresh=true): a
136
+ // brand-new subprocess has NO memory of what it produced, so hand its own
137
+ // non-conforming output back (capped) to repair against — otherwise it repairs
138
+ // blind and tends to regress on a different field (the B6 whack-a-mole).
139
+ if (!fresh) return note;
140
+ const prior = feedback.rawText.trim();
141
+ const echo = prior
142
+ ? "\nYour previous output (fix THIS, do not start over):\n```\n" +
143
+ (prior.length > RETRY_RAWTEXT_CAP ? prior.slice(0, RETRY_RAWTEXT_CAP) + "\n…(truncated)" : prior) +
144
+ "\n```\n"
145
+ : "";
146
+ return `${original}${echo}\n${note}`;
70
147
  }
71
148
 
72
149
  export function makeRuntime(deps: {
@@ -78,17 +155,45 @@ export function makeRuntime(deps: {
78
155
  * the longest-unchanged-prefix resume model. A miss (incl. a prior failure)
79
156
  * runs live, so resume only re-executes failed/changed nodes. */
80
157
  resume?: ResumeIndex;
158
+ /** Token ceiling for the run (null/undefined = unbounded). */
159
+ budget?: number | null;
160
+ /** Shared spend accumulator. When omitted, a local one is created; a nested
161
+ * workflow() passes the parent's so the pool is shared across the run. */
162
+ budgetState?: BudgetState;
163
+ /** The workflow's meta, used to resolve the effective model per node along the
164
+ * `opts.model > phase model > meta.model` chain. */
165
+ meta?: WorkflowMeta;
166
+ /** Injected for isolation:'worktree'; defaults to the real git-backed helper.
167
+ * Overridable so the runtime is testable without a git subprocess. */
168
+ withWorktree?: typeof defaultWithWorktree;
81
169
  }): Runtime {
82
170
  const { adapter, journal, resume } = deps;
171
+ const withWorktree = deps.withWorktree ?? defaultWithWorktree;
83
172
  const limit = makeLimiter(deps.concurrency ?? 4);
84
173
  let callCounter = 0;
85
174
  let currentPhase: string | undefined;
175
+ const budgetTotal = deps.budget ?? null;
176
+ const budgetState: BudgetState = deps.budgetState ?? { spent: 0 };
177
+
178
+ // Effective model along the precedence chain opts > phase > meta default.
179
+ // Resolved per node so a phase or meta default applies without the script
180
+ // repeating `model` on every agent() call.
181
+ const resolveModel = (opts: AgentOpts, phase: string | undefined): string | undefined => {
182
+ if (opts.model !== undefined) return opts.model;
183
+ // `?.` guards null/undefined but NOT a wrong type — an author typo like
184
+ // `phases: "scan"` would make `.find` throw. Array.isArray closes that gap so
185
+ // a malformed meta degrades to the default model instead of killing the run.
186
+ const phases = deps.meta?.phases;
187
+ const phaseModel = phase && Array.isArray(phases) ? phases.find((p) => p.title === phase)?.model : undefined;
188
+ return phaseModel ?? deps.meta?.model;
189
+ };
86
190
 
87
191
  async function agent(prompt: string, opts: AgentOpts = {}): Promise<unknown | null> {
88
192
  const call = ++callCounter;
89
193
  const phase = opts.phase ?? currentPhase;
194
+ const model = resolveModel(opts, phase);
90
195
  const pHash = promptHash(prompt);
91
- const oHash = optsHash(opts);
196
+ const oHash = optsHash(pickSemantic(opts, model));
92
197
  journal.agentStart({
93
198
  call,
94
199
  label: opts.label,
@@ -108,23 +213,48 @@ export function makeRuntime(deps: {
108
213
  }
109
214
  }
110
215
 
216
+ // Budget ceiling: checked AFTER the resume short-circuit (a cached hit costs
217
+ // nothing) and OUTSIDE limit()'s try, so it propagates as the one sanctioned
218
+ // null-contract exception rather than being swallowed to null.
219
+ if (budgetTotal != null && budgetState.spent >= budgetTotal) {
220
+ throw new BudgetExceededError(`budget exhausted: ${budgetState.spent}/${budgetTotal} tokens`);
221
+ }
222
+
111
223
  return limit(async () => {
224
+ // The node body, parameterized by the effective working directory so an
225
+ // isolation:'worktree' node runs the SAME logic with cwd = the worktree.
226
+ const body = async (effCwd: string | undefined): Promise<unknown | null> => {
112
227
  let durationMs = 0;
113
228
  const account = (r: AgentResult) => {
114
229
  durationMs += r.ok ? r.meta.durationMs : (r.meta?.durationMs ?? 0);
230
+ // Count tokens whether the node succeeded or failed: a failed node that
231
+ // still reported `usage` (error/refusal envelope) consumed real budget,
232
+ // so a loop on a failing node trips the ceiling instead of spinning.
233
+ // Guard the value: a buggy/custom adapter (the pluggable boundary) could
234
+ // hand back NaN, a negative, or a non-number — any of which would corrupt
235
+ // `spent` and silently disable the ceiling. Coerce junk to 0.
236
+ const tokens = r.meta?.outputTokens;
237
+ budgetState.spent += typeof tokens === "number" && Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
115
238
  };
239
+ // A fresh node invocation carrying this call's options. Built in one place
240
+ // so the next InvokeRequest field is threaded once, not per call site.
241
+ const invokeFresh = (p: string) =>
242
+ adapter.invoke({
243
+ prompt: p,
244
+ model,
245
+ cwd: effCwd,
246
+ timeoutMs: opts.timeoutMs,
247
+ inheritMcp: opts.inheritMcp,
248
+ effort: opts.effort,
249
+ agentType: opts.agentType,
250
+ });
116
251
 
117
252
  try {
118
253
  // No schema: one shot, raw text out (or null).
119
254
  if (!opts.schema) {
120
255
  let r: AgentResult;
121
256
  try {
122
- r = await adapter.invoke({
123
- prompt,
124
- model: opts.model,
125
- cwd: opts.cwd,
126
- timeoutMs: opts.timeoutMs,
127
- });
257
+ r = await invokeFresh(prompt);
128
258
  } catch (e) {
129
259
  // A throw at the adapter boundary IS an adapter failure.
130
260
  journal.agentEnd({ call, ok: false, kind: "spawn_failure", stderr: errMsg(e), durationMs });
@@ -146,10 +276,24 @@ export function makeRuntime(deps: {
146
276
  const gateCall: GateCall = async (_n, feedback) => {
147
277
  let r: AgentResult;
148
278
  if (feedback && lastSessionId && adapter.followUp) {
149
- r = await adapter.followUp(lastSessionId, retryPrompt(prompt, feedback, false));
279
+ // Resume in the original cwd and with the same MCP choice, so the
280
+ // repair turn runs in the same environment as the turn it continues.
281
+ r = await adapter.followUp(lastSessionId, retryPrompt(prompt, feedback, false), {
282
+ cwd: effCwd,
283
+ inheritMcp: opts.inheritMcp,
284
+ timeoutMs: opts.timeoutMs,
285
+ });
286
+ // Resume can fail even when the format hiccup was recoverable (e.g. a
287
+ // killed/expired session). Don't let a broken resume be terminal —
288
+ // fall back to a fresh invoke with the error appended (the contract
289
+ // AgentPort documents for the no-followUp case). Account the failed
290
+ // resume too: it spawned a real subprocess, so its time is real cost.
291
+ if (!r.ok) {
292
+ account(r);
293
+ r = await invokeFresh(retryPrompt(prompt, feedback, true));
294
+ }
150
295
  } else {
151
- const p = feedback ? retryPrompt(prompt, feedback, true) : prompt;
152
- r = await adapter.invoke({ prompt: p, model: opts.model, cwd: opts.cwd, timeoutMs: opts.timeoutMs });
296
+ r = await invokeFresh(feedback ? retryPrompt(prompt, feedback, true) : prompt);
153
297
  }
154
298
  account(r);
155
299
  if (r.ok && r.meta.sessionId) lastSessionId = r.meta.sessionId;
@@ -185,6 +329,14 @@ export function makeRuntime(deps: {
185
329
  journal.agentEnd({ call, ok: false, kind: "internal_error", error: errMsg(e), durationMs });
186
330
  return null;
187
331
  }
332
+ };
333
+
334
+ // isolation:'worktree' gives the node its own ephemeral checkout as cwd;
335
+ // otherwise it runs in the caller-provided cwd (or the process cwd).
336
+ if (opts.isolation === "worktree") {
337
+ return withWorktree(opts.cwd ?? process.cwd(), (wt) => body(wt));
338
+ }
339
+ return body(opts.cwd);
188
340
  });
189
341
  }
190
342
 
@@ -226,10 +378,18 @@ export function makeRuntime(deps: {
226
378
  agent,
227
379
  parallel,
228
380
  pipeline,
381
+ workflow: async () => {
382
+ throw new Error("workflow() hook is only available through runWorkflow");
383
+ },
229
384
  phase: (title: string) => {
230
385
  currentPhase = title;
231
386
  journal.phase(title);
232
387
  },
233
388
  log: (msg: string) => journal.log(msg),
389
+ budget: {
390
+ total: budgetTotal,
391
+ spent: () => budgetState.spent,
392
+ remaining: () => (budgetTotal == null ? Infinity : budgetTotal - budgetState.spent),
393
+ },
234
394
  };
235
395
  }
@@ -0,0 +1,72 @@
1
+ // Ephemeral git worktree per node, for `agent(prompt, { isolation: 'worktree' })`.
2
+ // When several nodes mutate files in parallel they would clobber each other in a
3
+ // shared checkout; giving each its own `git worktree` isolates them. The worktree
4
+ // is auto-removed when the node left it unchanged, and LEFT IN PLACE (with a warn)
5
+ // when it has changes, so a caller can inspect/merge them. A non-git cwd has no
6
+ // worktree to make — we run in place and warn rather than fail (honest-scope:
7
+ // isolation is best-effort, the null-contract still holds).
8
+
9
+ import { join } from "node:path";
10
+
11
+ export type GitSpawnResult = { code: number; stdout: string; stderr: string };
12
+ export type GitSpawn = (args: string[], cwd: string) => Promise<GitSpawnResult>;
13
+
14
+ export type WorktreeDeps = {
15
+ /** Injected so the unit under test drives git without a subprocess; defaults
16
+ * to a real `git` over Bun.spawn. */
17
+ spawn?: GitSpawn;
18
+ warn?: (msg: string) => void;
19
+ };
20
+
21
+ function defaultGitSpawn(): GitSpawn {
22
+ return async (args, cwd) => {
23
+ const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
24
+ const [stdout, stderr] = await Promise.all([
25
+ new Response(proc.stdout).text(),
26
+ new Response(proc.stderr).text(),
27
+ ]);
28
+ const code = await proc.exited;
29
+ return { code, stdout, stderr };
30
+ };
31
+ }
32
+
33
+ // Per-process counter so concurrent worktrees get distinct dirs WITHOUT Date.now
34
+ // or Math.random (kept deterministic-friendly, mirroring the rest of the engine).
35
+ let wtCounter = 0;
36
+
37
+ /** Run `fn` with an ephemeral detached git worktree as its working directory,
38
+ * then clean up. Returns whatever `fn` returns. */
39
+ export async function withWorktree<T>(
40
+ repoCwd: string,
41
+ fn: (worktreeDir: string) => Promise<T>,
42
+ deps: WorktreeDeps = {},
43
+ ): Promise<T> {
44
+ const spawn = deps.spawn ?? defaultGitSpawn();
45
+ const warn = deps.warn ?? ((m: string) => console.error(m));
46
+
47
+ const top = await spawn(["rev-parse", "--show-toplevel"], repoCwd);
48
+ if (top.code !== 0) {
49
+ warn(`omw(worktree): ${repoCwd} is not a git repo; running the node in place.`);
50
+ return fn(repoCwd);
51
+ }
52
+
53
+ const dir = join(repoCwd, ".omw-worktrees", `wt-${process.pid}-${++wtCounter}`);
54
+ const add = await spawn(["worktree", "add", "--detach", dir], repoCwd);
55
+ if (add.code !== 0) {
56
+ warn(`omw(worktree): \`git worktree add\` failed (${add.stderr.trim()}); running in place.`);
57
+ return fn(repoCwd);
58
+ }
59
+
60
+ try {
61
+ return await fn(dir);
62
+ } finally {
63
+ // Auto-remove only when the node left the worktree clean; otherwise keep it
64
+ // so the changes aren't silently discarded.
65
+ const status = await spawn(["status", "--porcelain"], dir);
66
+ if (status.code === 0 && status.stdout.trim() === "") {
67
+ await spawn(["worktree", "remove", "--force", dir], repoCwd);
68
+ } else {
69
+ warn(`omw(worktree): ${dir} has uncommitted changes; leaving it for inspection.`);
70
+ }
71
+ }
72
+ }
package/vercel.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "buildCommand": "bun run docs:build",
3
+ "outputDirectory": "dist/docs",
4
+ "installCommand": "bun install --frozen-lockfile"
5
+ }