pi-taskflow 0.0.24 → 0.0.26
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/CHANGELOG.md +110 -0
- package/extensions/cache.ts +6 -1
- package/extensions/flowir/hash.ts +97 -0
- package/extensions/flowir/index.ts +73 -0
- package/extensions/flowir/meta.ts +126 -0
- package/extensions/flowir/translate.ts +163 -0
- package/extensions/index.ts +292 -5
- package/extensions/interpolate.ts +17 -0
- package/extensions/runtime.ts +417 -49
- package/extensions/schema.ts +3 -1
- package/extensions/stale.ts +193 -0
- package/extensions/store.ts +25 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,116 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-taskflow are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
|
|
4
4
|
|
|
5
|
+
## [0.0.26] — 2026-06-25
|
|
6
|
+
|
|
7
|
+
> Foundation release: **the convergence roadmap's H1 lands** — a real FlowIR
|
|
8
|
+
> compile seam (M1), a declared dependency plane (M2), and a
|
|
9
|
+
> backward-compatible cache-key migration. v0.0.25 made incremental recompute
|
|
10
|
+
> *trustworthy*; this release makes the contract underneath it *real*: the
|
|
11
|
+
> recompute frontier now reasons over **observed ∪ declared** dependencies, the
|
|
12
|
+
> flow definition compiles through a typed IR surface instead of an inlined
|
|
13
|
+
> hash, and folding the definition into the cache key no longer evicts every
|
|
14
|
+
> pre-existing cross-run entry.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **FlowIR compile seam (M1).** New `extensions/flowir/{index,translate,meta}.ts`
|
|
18
|
+
exposes `compileTaskflowToIR(def) → { ir, meta, hash, usedFallbackHash,
|
|
19
|
+
warnings, errors }` — a typed, never-throwing projection of a desugared flow
|
|
20
|
+
into a content-addressed IR. The runtime now routes `flowDefHash` through this
|
|
21
|
+
seam instead of inlining it. `translate` is currently a 1:1 stub projection
|
|
22
|
+
(so `usedFallbackHash` is `true` and the hash equals the vendored
|
|
23
|
+
`flowDefHash`); it becomes the genuine overstory compiler once that kernel is
|
|
24
|
+
vendored, at which point the cache-key version advances `v2: → v3:`.
|
|
25
|
+
- **`/tf ir <flow>` command + `ir` tool action.** Renders the compiled IR plus
|
|
26
|
+
its hash and any structured `CompileError[]` — zero tokens, no LLM.
|
|
27
|
+
- **Declared dependency plane (M2).** `compileTaskflowToIR` synthesizes per-phase
|
|
28
|
+
`DeclaredDeps { reads, writes }` from interpolation refs
|
|
29
|
+
(`task`/`over`/`when`/`until`/`eval`/`branches`/`with`/`context`) and
|
|
30
|
+
`dependsOn`, attaches them to `ir.meta.declaredDeps`, and persists them to
|
|
31
|
+
`RunState`. `/tf recompute` now computes its stale frontier over
|
|
32
|
+
**union(observed ∪ declared)** rather than observed-only — a dependency that
|
|
33
|
+
was declared but never interpolated at runtime is no longer missed.
|
|
34
|
+
- **Tests: 753 → 802** (+49) across new suites: `flowir.test.ts`,
|
|
35
|
+
`flowir-declared.test.ts`, `stale-union.test.ts` (incl. a 500-iteration
|
|
36
|
+
property test proving the union frontier is never narrower than observed-only),
|
|
37
|
+
`recompute-union.test.ts`, `cache-migration.test.ts`, plus `e2e-flowir.mts`
|
|
38
|
+
and `e2e-cache-migration.mts`.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
- **Cache-key migration no longer evicts existing cross-run entries.** Folding
|
|
42
|
+
`flowdef:` into the key previously invalidated every pre-existing cross-run
|
|
43
|
+
cache entry on upgrade (a one-time miss-storm). `cacheKey` is now versioned
|
|
44
|
+
(`v2:flowdef:`) with a **3-tier lookup**: new key → bare `flowdef:` key →
|
|
45
|
+
legacy (no-flowdef) key. Old entries still hit for one release cycle; there is
|
|
46
|
+
no write-through on a fallback hit (legacy entries age out naturally), and
|
|
47
|
+
every tier still includes `flow:${name}` so two different flows can never
|
|
48
|
+
collide.
|
|
49
|
+
- **Declared plane and recompute guard now see `loop.until` and `gate.eval`.**
|
|
50
|
+
`collectRefs` skipped `until` (loop convergence) and `eval[]` (gate zero-token
|
|
51
|
+
checks), so a dependency expressed only in those fields was absent from the
|
|
52
|
+
declared plane and from the `dryRun:false` unobserved-dependency guard. Both
|
|
53
|
+
are now scanned. (Closes the two MEDIUM findings from the H1 risk review.)
|
|
54
|
+
|
|
55
|
+
### Compatibility
|
|
56
|
+
- **Backward compatible.** `RunState.flowDefHash` and `RunState.declaredDeps`
|
|
57
|
+
are optional — pre-0.0.26 run states load unchanged. A compile/hash failure
|
|
58
|
+
fails open: `usedFallbackHash` stays set, cross-run cache is disabled for that
|
|
59
|
+
run, and the key degrades to a flow-scoped (collision-free) form. The one
|
|
60
|
+
observable change on upgrade is a single re-execution of in-flight phases
|
|
61
|
+
whose stored `inputHash` predates the `v2:` prefix.
|
|
62
|
+
|
|
63
|
+
## [0.0.25] — 2026-06-24
|
|
64
|
+
|
|
65
|
+
> Correctness release: **incremental recompute is now trustworthy.** `/tf
|
|
66
|
+
> recompute` shipped in the prior line as a promising idea — force-rerun a seed,
|
|
67
|
+
> walk its stale frontier, let the cache cut off untouched downstreams. But the
|
|
68
|
+
> dependency graph it walked was a half-truth: reads observed only inside a
|
|
69
|
+
> `when` guard or an `eval` gate were never recorded, a loop that read its own
|
|
70
|
+
> output **deadlocked the scheduler**, and a `{previous.output}` chain could be
|
|
71
|
+
> silently skipped — each one a path where "only rerun what changed" quietly
|
|
72
|
+
> reused **stale** upstream state and returned a wrong answer that *looked*
|
|
73
|
+
> incrementally correct. This release closes all of them: the observed readSet
|
|
74
|
+
> is now complete, the recompute order unions declared **and** observed edges,
|
|
75
|
+
> and real (`dryRun:false`) recomputation refuses to run when it cannot prove
|
|
76
|
+
> the frontier is sound. The headline feature finally earns its safety claim —
|
|
77
|
+
> the difference between *looks* incremental and *provably* incremental.
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
- **Safety guard for real recomputation.** `recomputeTaskflow` with `dryRun:false`
|
|
81
|
+
now refuses to run flows whose dependencies cannot be fully observed through
|
|
82
|
+
the captured readSet: Shared Context Tree (`shareContext` / `contextSharing`),
|
|
83
|
+
`flow` phases, `context:` file pre-reads, and interpolation placeholders such
|
|
84
|
+
as `{previous.output}`, `{args.X}`, or `{item.X}`. This prevents silently
|
|
85
|
+
reusing stale upstream state.
|
|
86
|
+
- **Regression tests** in `test/recompute.test.ts`:
|
|
87
|
+
- observed-read edges still order recomputation even without an explicit
|
|
88
|
+
`dependsOn` declaration;
|
|
89
|
+
- `{previous.output}` chains are rejected for real recomputation;
|
|
90
|
+
- `recomputeTaskflow` returns a fresh `RunState` and does not mutate the
|
|
91
|
+
caller's state.
|
|
92
|
+
|
|
93
|
+
### Fixed
|
|
94
|
+
- **Loop self-read no longer deadlocks recompute.** A loop whose `until`
|
|
95
|
+
condition references its own prior output (e.g. `{steps.refine.output}`)
|
|
96
|
+
produced a self-edge in the observed-dependency graph, causing `topoLayers` to
|
|
97
|
+
schedule the phase with a permanently non-zero indegree. `observedDeps()` now
|
|
98
|
+
filters self-references so scheduling remains sound.
|
|
99
|
+
- **`when` condition upstream reads are captured.** Conditions are now evaluated
|
|
100
|
+
inside `executePhaseInner` with the same `onRead` hook used by the phase task,
|
|
101
|
+
so upstream refs observed only in a `when` guard are recorded in
|
|
102
|
+
`PhaseState.reads`.
|
|
103
|
+
- **Gate `eval` upstream reads are captured.** The machine-check `eval` branch
|
|
104
|
+
now receives the shared `onRead` hook, and the resulting readSet is persisted
|
|
105
|
+
when an eval-only gate skips the LLM call.
|
|
106
|
+
- **Recompute topo-order now unions declared and observed edges.** Previously
|
|
107
|
+
the recompute order only respected declared `dependsOn`, which could place a
|
|
108
|
+
downstream phase before its observed-but-not-declared upstream refreshed and
|
|
109
|
+
cause false early-cutoff. The scheduling graph now merges both edge sets.
|
|
110
|
+
- **Recompute no longer mutates the caller's RunState.** `recomputeTaskflow`
|
|
111
|
+
clones the input state via `structuredClone` before modifying it.
|
|
112
|
+
- **Help text accuracy.** `/tf` command and tool-action descriptions updated to
|
|
113
|
+
match the new `recompute` and provenance behavior.
|
|
114
|
+
|
|
5
115
|
## [0.0.24] — 2026-06-23
|
|
6
116
|
|
|
7
117
|
> Feature release: **`/tf compile`** — turn the declared DAG into a Mermaid
|
package/extensions/cache.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { execFileSync } from "node:child_process";
|
|
|
17
17
|
import * as crypto from "node:crypto";
|
|
18
18
|
import * as fs from "node:fs";
|
|
19
19
|
import * as path from "node:path";
|
|
20
|
-
import { cacheDir, withLock, writeFileAtomic } from "./store.ts";
|
|
20
|
+
import { cacheDir, withLock, writeFileAtomic, type PhaseState } from "./store.ts";
|
|
21
21
|
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
23
|
// Fingerprint resolution
|
|
@@ -144,6 +144,11 @@ export interface CacheEntry {
|
|
|
144
144
|
output?: string;
|
|
145
145
|
json?: unknown;
|
|
146
146
|
model?: string;
|
|
147
|
+
/** Full PhaseState payload preserved so cross-run reuse is semantically
|
|
148
|
+
* equivalent to within-run resume. Storing only output/json would drop
|
|
149
|
+
* `gate`, `approval`, `reads`, `loop`, `tournament`, `warnings`, etc.,
|
|
150
|
+
* breaking recompute soundness and gate-block detection. */
|
|
151
|
+
state?: PhaseState;
|
|
147
152
|
/** Provenance for audit / cleanup. */
|
|
148
153
|
flowName?: string;
|
|
149
154
|
phaseId?: string;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-addressed hashing for flow definitions.
|
|
3
|
+
*
|
|
4
|
+
* The canonical-JSON + SHA-256-truncation algorithm here is **vendored from
|
|
5
|
+
* overstory `packages/core/src/ir/hash.ts`** (pinned commit) so that
|
|
6
|
+
* pi-taskflow and overstory share one byte-identical hashing contract. This is
|
|
7
|
+
* the `M1` slice of the overstory-convergence roadmap: we are *not* compiling
|
|
8
|
+
* to overstory FlowIR yet (the IR compiler expects an explicit inject/emits
|
|
9
|
+
* model pi-taskflow doesn't have), but we share the **hash algorithm** now —
|
|
10
|
+
* the cheapest, lowest-risk piece of the contract — and put it to immediate
|
|
11
|
+
* work folding the flow *definition* into the cross-run cache key (M2).
|
|
12
|
+
*
|
|
13
|
+
* Why this matters: previously the cache key folded only the flow **name**
|
|
14
|
+
* (`flow:${flowName}`), so two structurally-different flows that happened to
|
|
15
|
+
* share a name + phase id + task could collide in the cross-run cache, and a
|
|
16
|
+
* flow that changed structure (but not name) could serve a stale hit. Folding
|
|
17
|
+
* `flowDefHash` (a content fingerprint of the desugared definition) closes
|
|
18
|
+
* that hole and is the foundation of "identical re-run is free ($0.00)".
|
|
19
|
+
*
|
|
20
|
+
* Pure module: no IO. Uses Web Crypto (`globalThis.crypto.subtle`) — therefore
|
|
21
|
+
* async — exactly like overstory's `hashIR`, so the contract is identical.
|
|
22
|
+
*
|
|
23
|
+
* @see docs/internal/overstory-convergence-roadmap.md §3 (M1, "cut B")
|
|
24
|
+
* @see docs/internal/rfc-flowir-compilation.md
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { Taskflow } from "../schema.ts";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Canonical JSON (vendored from overstory ir/hash.ts — byte-identical)
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Deterministic JSON: recursively key-sorted (UTF-16 code units), no
|
|
35
|
+
* whitespace, `undefined` values dropped. Arrays keep their order (the
|
|
36
|
+
* desugared Taskflow is already in a canonical shape). Byte-identical to
|
|
37
|
+
* overstory's `canonicalJson` — do not diverge without bumping the contract
|
|
38
|
+
* and updating the parity test.
|
|
39
|
+
*/
|
|
40
|
+
export function canonicalJson(value: unknown): string {
|
|
41
|
+
if (value === null || typeof value === "number" || typeof value === "boolean") {
|
|
42
|
+
return JSON.stringify(value);
|
|
43
|
+
}
|
|
44
|
+
if (typeof value === "string") {
|
|
45
|
+
return JSON.stringify(value);
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
return `[${value.map((item) => canonicalJson(item === undefined ? null : item)).join(",")}]`;
|
|
49
|
+
}
|
|
50
|
+
if (typeof value === "object") {
|
|
51
|
+
const record = value as Record<string, unknown>;
|
|
52
|
+
const keys = Object.keys(record)
|
|
53
|
+
.filter((key) => record[key] !== undefined)
|
|
54
|
+
.sort();
|
|
55
|
+
const body = keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`);
|
|
56
|
+
return `{${body.join(",")}}`;
|
|
57
|
+
}
|
|
58
|
+
// undefined / function / symbol at the top level — not representable.
|
|
59
|
+
return "null";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Hashing (vendored from overstory ir/hash.ts — byte-identical)
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
/** SHA-256 of the canonical serialization, first 16 bytes, lowercase hex.
|
|
67
|
+
* Same shape as overstory's `hashCanonical` / RFC-001 content hashes. */
|
|
68
|
+
export async function hashCanonical(canonical: string): Promise<string> {
|
|
69
|
+
const bytes = new TextEncoder().encode(canonical);
|
|
70
|
+
const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
|
|
71
|
+
const view = new Uint8Array(digest).slice(0, 16);
|
|
72
|
+
let hex = "";
|
|
73
|
+
for (const byte of view) {
|
|
74
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
75
|
+
}
|
|
76
|
+
return hex;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Flow-definition fingerprint
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Content fingerprint of a desugared `Taskflow` definition.
|
|
85
|
+
*
|
|
86
|
+
* Hashes the **definition** (structure + task text + declared deps), NOT the
|
|
87
|
+
* runtime `args` values — args vary per invocation and are already folded into
|
|
88
|
+
* each phase's `inputHash` via the interpolated task. `flowDefHash` answers a
|
|
89
|
+
* different question: "did the flow *itself* change?" Two flows are
|
|
90
|
+
* definitionally identical ⟺ this hash matches (key order / whitespace /
|
|
91
|
+
* optional-field presence do not affect it).
|
|
92
|
+
*
|
|
93
|
+
* Deterministic and async (Web Crypto), matching overstory's `hashIR` shape.
|
|
94
|
+
*/
|
|
95
|
+
export async function flowDefHash(def: Taskflow): Promise<string> {
|
|
96
|
+
return hashCanonical(canonicalJson(def));
|
|
97
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public entry point for the FlowIR compile seam.
|
|
3
|
+
*
|
|
4
|
+
* `compileTaskflowToIR` is the read-only, content-addressed IR projection used
|
|
5
|
+
* by:
|
|
6
|
+
* - `/tf ir <flow>` / `action=ir` — render the compiled IR + hash (0 tokens)
|
|
7
|
+
* - the runtime (`runTaskflowLayers`) — fold `ir.hash` into the cache key
|
|
8
|
+
* (== `flowDefHash` in the stub; the overstory-canonical hash once the
|
|
9
|
+
* genuine compiler is vendored) and persist `ir.meta.declaredDeps` to
|
|
10
|
+
* `RunState` (M2 declared plane).
|
|
11
|
+
*
|
|
12
|
+
* The stub hash reuses the already-vendored overstory `flowDefHash` algorithm
|
|
13
|
+
* (./hash.ts) so pi-taskflow and overstory share one byte-identical hashing
|
|
14
|
+
* contract today. `usedFallbackHash` is `true` in the stub (the genuine
|
|
15
|
+
* overstory `hashIR` is not yet wired); it flips to `false` once the compiler
|
|
16
|
+
* is vendored, at which point the cache key's `v2:` prefix advances to `v3:`
|
|
17
|
+
* (see docs/internal/cache-migration.md).
|
|
18
|
+
*
|
|
19
|
+
* Pure + async (Web Crypto). Never throws — a hash failure leaves `hash`
|
|
20
|
+
* unset and `usedFallbackHash` true; the runtime degrades to the safe
|
|
21
|
+
* flowName-only cache key (cross-run disabled for that run).
|
|
22
|
+
*
|
|
23
|
+
* @see docs/internal/overstory-convergence-roadmap.md §3 (M1)
|
|
24
|
+
* @see docs/internal/rfc-flowir-compilation.md
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { Taskflow } from "../schema.ts";
|
|
28
|
+
import { flowDefHash } from "./hash.ts";
|
|
29
|
+
import { translateTaskflow } from "./translate.ts";
|
|
30
|
+
import type { TaskflowIR } from "./meta.ts";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compile a (desugared) `Taskflow` into its content-addressed IR.
|
|
34
|
+
*
|
|
35
|
+
* The returned `hash` is, in the stub, exactly `flowDefHash(def)` — the
|
|
36
|
+
* overstory-vendored canonical-JSON + SHA-256-truncation contract. The
|
|
37
|
+
* `usedFallbackHash` flag records that this is the *fallback* hash (non-IR-
|
|
38
|
+
* canonical): it is `true` whenever the stub cannot guarantee IR-canonicity
|
|
39
|
+
* (any phase with a `when`, or any hash-compute failure).
|
|
40
|
+
*
|
|
41
|
+
* Never throws. Returns structured diagnostics so `/tf ir` on a broken flow
|
|
42
|
+
* yields a clean error table instead of crashing.
|
|
43
|
+
*/
|
|
44
|
+
export async function compileTaskflowToIR(def: Taskflow): Promise<TaskflowIR> {
|
|
45
|
+
const t = translateTaskflow(def);
|
|
46
|
+
let hash: string | undefined;
|
|
47
|
+
try {
|
|
48
|
+
hash = await flowDefHash(def);
|
|
49
|
+
} catch {
|
|
50
|
+
hash = undefined;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
ir: t.ir,
|
|
54
|
+
meta: t.meta,
|
|
55
|
+
hash,
|
|
56
|
+
warnings: t.warnings,
|
|
57
|
+
errors: t.errors,
|
|
58
|
+
// Stub: the fallback hash is used whenever (a) any phase has a `when`
|
|
59
|
+
// (translateTaskflow flags it) OR (b) the hash computation itself failed.
|
|
60
|
+
// Once the genuine overstory compiler is vendored, condition (a) drops.
|
|
61
|
+
usedFallbackHash: t.usedFallbackHash || hash === undefined,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export type {
|
|
66
|
+
CompileError,
|
|
67
|
+
CompileWarning,
|
|
68
|
+
DeclaredDeps,
|
|
69
|
+
FlowIR,
|
|
70
|
+
FlowIRNode,
|
|
71
|
+
TaskflowIR,
|
|
72
|
+
TaskflowIRMeta,
|
|
73
|
+
} from "./meta.ts";
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for the FlowIR compile seam.
|
|
3
|
+
*
|
|
4
|
+
* This is the **stub/projection** layer of the overstory-convergence roadmap's
|
|
5
|
+
* M1 slice: we project a pi-taskflow `Taskflow` into a `FlowIR` shape that
|
|
6
|
+
* mirrors overstory's IR contract *structurally* (nodes with `inject`/`emits`)
|
|
7
|
+
* without yet compiling to overstory's native inject/emits model. The hash
|
|
8
|
+
* contract (overstory's `hashIR` algorithm) is shared via `flowDefHash` — see
|
|
9
|
+
* `./hash.ts`. When the genuine overstory compiler is vendored later, the
|
|
10
|
+
* `usedFallbackHash` flag flips to `false` and `ir` becomes the canonical IR;
|
|
11
|
+
* until then this seam is read-only, pure, and never throws.
|
|
12
|
+
*
|
|
13
|
+
* Pure module: no IO, no Date, no randomness. Type-only where possible.
|
|
14
|
+
*
|
|
15
|
+
* @see docs/internal/overstory-convergence-roadmap.md §3 (M1)
|
|
16
|
+
* @see docs/internal/rfc-flowir-compilation.md
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Budget, Taskflow } from "../schema.ts";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Declared dependency plane (compile-time, M2)
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A phase's *declared* (static) dependency footprint, synthesized at compile
|
|
27
|
+
* time from `{steps.X}` interpolation refs (via `collectRefs`) plus `dependsOn`.
|
|
28
|
+
* `reads` = the upstream step ids this phase's task/when/branches/with/context
|
|
29
|
+
* statically reference; `writes` = the step id this phase emits (itself).
|
|
30
|
+
*
|
|
31
|
+
* This is the *declared* plane — distinct from the *observed* readSet captured
|
|
32
|
+
* at runtime (M3 `PhaseState.reads`). The two are reconciled by a **union**
|
|
33
|
+
* (`observed ∪ declared`) in `computeStaleFrontier` / `recomputeTaskflow` so a
|
|
34
|
+
* declared-but-unobserved edge (e.g. a `when` ref that never fired) is still
|
|
35
|
+
* treated as a dependency for staleness propagation. JSON-safe `Record` shape
|
|
36
|
+
* (not `Map`) so it round-trips through `RunState` persistence.
|
|
37
|
+
*/
|
|
38
|
+
export interface DeclaredDeps {
|
|
39
|
+
/** Upstream step ids statically referenced by this phase's interpolation. */
|
|
40
|
+
reads: string[];
|
|
41
|
+
/** Step id(s) this phase emits — currently `[phase.id]` (1:1 projection). */
|
|
42
|
+
writes: string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// FlowIR (1:1 projection of a Taskflow)
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A single IR node — one per pi-taskflow phase. `kind` is the native phase type
|
|
51
|
+
* (a 1:1 projection; the overstory-native kind lowering is deferred per roadmap
|
|
52
|
+
* §6.1). `inject`/`emits` mirror overstory's contract: a node *injects*
|
|
53
|
+
* (reads) the outputs of its upstream nodes and *emits* (writes) its own.
|
|
54
|
+
*/
|
|
55
|
+
export interface FlowIRNode {
|
|
56
|
+
id: string;
|
|
57
|
+
/** pi-taskflow phase type (1:1 projection; `agent`|`parallel`|`map`|…). */
|
|
58
|
+
kind: string;
|
|
59
|
+
/** Synthesized declared reads: the `{steps.X}` refs this node's task
|
|
60
|
+
* interpolates. (overstory-native `inject` lowering is deferred.) */
|
|
61
|
+
inject: string[];
|
|
62
|
+
/** What this node emits — currently `[id]` (1:1 projection). */
|
|
63
|
+
emits: string[];
|
|
64
|
+
/** Raw `when` guard passthrough (stub: not rewritten to IR conditions). */
|
|
65
|
+
when?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The compiled IR: a flat list of nodes plus flow-level metadata. */
|
|
69
|
+
export interface FlowIR {
|
|
70
|
+
name: string;
|
|
71
|
+
nodes: FlowIRNode[];
|
|
72
|
+
args?: Taskflow["args"];
|
|
73
|
+
budget?: Budget;
|
|
74
|
+
concurrency?: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Compile diagnostics
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/** A hard compile error (none in the stub; reserved for the genuine compiler). */
|
|
82
|
+
export interface CompileError {
|
|
83
|
+
phaseId?: string;
|
|
84
|
+
code: string;
|
|
85
|
+
message: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A non-fatal advisory (e.g. a `{steps.X}` ref not reachable via dependsOn). */
|
|
89
|
+
export interface CompileWarning {
|
|
90
|
+
phaseId?: string;
|
|
91
|
+
message: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Meta + composite return type
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Compile-time metadata attached to the IR. `declaredDeps` is the M2 declared
|
|
100
|
+
* plane (per-phase `DeclaredDeps`); `sidecar` carries every pi-taskflow-specific
|
|
101
|
+
* field not represented in `FlowIRNode` so the projection is lossless and can
|
|
102
|
+
* round-trip back to a runnable `Taskflow`.
|
|
103
|
+
*/
|
|
104
|
+
export interface TaskflowIRMeta {
|
|
105
|
+
sourceFlowName: string;
|
|
106
|
+
/** Per-phase declared dependency footprint (M2). JSON-safe. */
|
|
107
|
+
declaredDeps: Record<string, DeclaredDeps>;
|
|
108
|
+
/** Pi-taskflow-specific fields preserved verbatim for round-trip. */
|
|
109
|
+
sidecar: Record<string, unknown>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The composite compile result (RFC §5). `ir`/`hash` are present unless
|
|
114
|
+
* synthesis failed (stub never fails). `usedFallbackHash` is `true` whenever
|
|
115
|
+
* the stub cannot produce an overstory-canonical hash (always, in the stub:
|
|
116
|
+
* the hash is the `flowDefHash` fallback; flips to `false` once the genuine
|
|
117
|
+
* compiler is vendored).
|
|
118
|
+
*/
|
|
119
|
+
export interface TaskflowIR {
|
|
120
|
+
ir?: FlowIR;
|
|
121
|
+
meta: TaskflowIRMeta;
|
|
122
|
+
hash?: string;
|
|
123
|
+
warnings: CompileWarning[];
|
|
124
|
+
errors: CompileError[];
|
|
125
|
+
usedFallbackHash: boolean;
|
|
126
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FlowIR translation — the 1:1 projection of a pi-taskflow `Taskflow` into the
|
|
3
|
+
* `FlowIR` shape.
|
|
4
|
+
*
|
|
5
|
+
* **Stub/projection** (M1): this is a *structural* mirror, NOT a compile to
|
|
6
|
+
* overstory's native inject/emits model (which expects an explicit emit
|
|
7
|
+
* declaration pi-taskflow doesn't have — see roadmap §6.1). Each phase becomes
|
|
8
|
+
* one `FlowIRNode`; `inject` is synthesized from `{steps.X}` interpolation refs
|
|
9
|
+
* (`collectRefs`), `emits` is `[phase.id]`. The overstory-native `kind` lowering
|
|
10
|
+
* is deliberately deferred.
|
|
11
|
+
*
|
|
12
|
+
* Pure, synchronous, never throws. Used by `compileTaskflowToIR` (./index.ts).
|
|
13
|
+
*
|
|
14
|
+
* @see docs/internal/overstory-convergence-roadmap.md §3 (M1)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { collectRefs, type Phase, type Taskflow } from "../schema.ts";
|
|
18
|
+
import type {
|
|
19
|
+
CompileError,
|
|
20
|
+
CompileWarning,
|
|
21
|
+
DeclaredDeps,
|
|
22
|
+
FlowIR,
|
|
23
|
+
FlowIRNode,
|
|
24
|
+
TaskflowIRMeta,
|
|
25
|
+
} from "./meta.ts";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Sidecar: the pi-taskflow-specific fields not represented in FlowIRNode.
|
|
29
|
+
// Everything preserved verbatim so the projection is lossless and can
|
|
30
|
+
// round-trip back to a runnable Taskflow. Defined as a list so the sidecar
|
|
31
|
+
// never silently drops a field when the DSL grows (a new field is carried
|
|
32
|
+
// automatically through `Phase` indexing).
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const SIDECAR_PHASE_FIELDS = [
|
|
36
|
+
"task",
|
|
37
|
+
"over",
|
|
38
|
+
"as",
|
|
39
|
+
"branches",
|
|
40
|
+
"from",
|
|
41
|
+
"use",
|
|
42
|
+
"def",
|
|
43
|
+
"with",
|
|
44
|
+
"until",
|
|
45
|
+
"maxIterations",
|
|
46
|
+
"convergence",
|
|
47
|
+
"variants",
|
|
48
|
+
"judge",
|
|
49
|
+
"judgeAgent",
|
|
50
|
+
"mode",
|
|
51
|
+
"dependsOn",
|
|
52
|
+
"join",
|
|
53
|
+
"when",
|
|
54
|
+
"retry",
|
|
55
|
+
"output",
|
|
56
|
+
"model",
|
|
57
|
+
"thinking",
|
|
58
|
+
"tools",
|
|
59
|
+
"cwd",
|
|
60
|
+
"context",
|
|
61
|
+
"contextLimit",
|
|
62
|
+
"onBlock",
|
|
63
|
+
"eval",
|
|
64
|
+
"cache",
|
|
65
|
+
"shareContext",
|
|
66
|
+
"optional",
|
|
67
|
+
"final",
|
|
68
|
+
"concurrency",
|
|
69
|
+
] as const;
|
|
70
|
+
|
|
71
|
+
/** Build the per-phase sidecar record (verbatim copy of non-IR fields). */
|
|
72
|
+
function sidecarForPhase(phase: Phase): Record<string, unknown> {
|
|
73
|
+
const out: Record<string, unknown> = {};
|
|
74
|
+
const rec = phase as Record<string, unknown>;
|
|
75
|
+
for (const k of SIDECAR_PHASE_FIELDS) {
|
|
76
|
+
if (k in rec && rec[k] !== undefined) out[k] = rec[k];
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Translate a desugared `Taskflow` into a 1:1 `FlowIR` projection + declared
|
|
83
|
+
* dependency metadata. Never throws: malformed input yields warnings/errors in
|
|
84
|
+
* the return value, not an exception (so `/tf ir` on a broken flow still
|
|
85
|
+
* produces a structured diagnostic rather than crashing the tool).
|
|
86
|
+
*
|
|
87
|
+
* `usedFallbackHash` is `true` unconditionally in the stub: the hash produced
|
|
88
|
+
* is `flowDefHash` (the definition fingerprint), NOT the overstory-IR-canonical
|
|
89
|
+
* hash, so callers can never mistake a stub hash for a canonical one. It flips
|
|
90
|
+
* to `false` only once the genuine overstory compiler is vendored and the hash
|
|
91
|
+
* is IR-canonical; a `when` guard remains a *future* fallback driver then.
|
|
92
|
+
*/
|
|
93
|
+
export function translateTaskflow(def: Taskflow): {
|
|
94
|
+
ir: FlowIR;
|
|
95
|
+
meta: TaskflowIRMeta;
|
|
96
|
+
warnings: CompileWarning[];
|
|
97
|
+
errors: CompileError[];
|
|
98
|
+
usedFallbackHash: boolean;
|
|
99
|
+
} {
|
|
100
|
+
const warnings: CompileWarning[] = [];
|
|
101
|
+
const errors: CompileError[] = [];
|
|
102
|
+
const declaredDeps: Record<string, DeclaredDeps> = {};
|
|
103
|
+
const sidecarPhases: Record<string, unknown> = {};
|
|
104
|
+
|
|
105
|
+
// In the stub the hash is ALWAYS the fallback (flowDefHash — the definition
|
|
106
|
+
// fingerprint, not the overstory-IR-canonical hash). The `when` guard is a
|
|
107
|
+
// *future* driver (the genuine compiler can't lower conditions → fallback);
|
|
108
|
+
// today the stub unconditionally uses the fallback so callers can never
|
|
109
|
+
// mistake a stub hash for a canonical one. Flips to `false` only once the
|
|
110
|
+
// genuine overstory compiler is vendored and the hash is IR-canonical.
|
|
111
|
+
const usedFallbackHash = true;
|
|
112
|
+
|
|
113
|
+
const nodes: FlowIRNode[] = def.phases.map((phase) => {
|
|
114
|
+
const refs = collectRefs(phase);
|
|
115
|
+
// declared reads: the {steps.X} refs this phase statically references.
|
|
116
|
+
const reads = refs.steps.filter((id) => id !== phase.id);
|
|
117
|
+
declaredDeps[phase.id] = { reads, writes: [phase.id] };
|
|
118
|
+
|
|
119
|
+
// Advisory: a {steps.X} ref whose target doesn't exist (mirrors the
|
|
120
|
+
// validation check but non-fatal here — validation is the source of
|
|
121
|
+
// truth; this is a read-only diagnostic).
|
|
122
|
+
const knownIds = new Set(def.phases.map((p) => p.id));
|
|
123
|
+
for (const r of refs.steps) {
|
|
124
|
+
if (r !== phase.id && !knownIds.has(r)) {
|
|
125
|
+
warnings.push({
|
|
126
|
+
phaseId: phase.id,
|
|
127
|
+
message: `references {steps.${r}.*} but no phase '${r}' exists`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (phase.when !== undefined) {
|
|
133
|
+
// `when` is a future fallback driver; today the stub is always fallback.
|
|
134
|
+
// (Kept as a structural marker on the node for round-trip.)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
sidecarPhases[phase.id] = sidecarForPhase(phase);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
id: phase.id,
|
|
141
|
+
kind: phase.type ?? "agent",
|
|
142
|
+
inject: reads,
|
|
143
|
+
emits: [phase.id],
|
|
144
|
+
when: phase.when,
|
|
145
|
+
} satisfies FlowIRNode;
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const ir: FlowIR = {
|
|
149
|
+
name: def.name,
|
|
150
|
+
nodes,
|
|
151
|
+
args: def.args,
|
|
152
|
+
budget: def.budget,
|
|
153
|
+
concurrency: def.concurrency,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const meta: TaskflowIRMeta = {
|
|
157
|
+
sourceFlowName: def.name,
|
|
158
|
+
declaredDeps,
|
|
159
|
+
sidecar: { phases: sidecarPhases },
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return { ir, meta, warnings, errors, usedFallbackHash };
|
|
163
|
+
}
|