@juicesharp/rpiv-workflow 1.14.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 +449 -0
- package/api.ts +557 -0
- package/audit.ts +217 -0
- package/built-ins.ts +65 -0
- package/command.ts +137 -0
- package/docs/cover.png +0 -0
- package/docs/cover.svg +120 -0
- package/docs/workflow-authoring.md +629 -0
- package/docs/workflow-basics.md +122 -0
- package/docs-protocol.ts +106 -0
- package/fanout.ts +96 -0
- package/host.ts +97 -0
- package/index.ts +230 -0
- package/internal-utils.ts +69 -0
- package/internal.ts +27 -0
- package/layers.ts +33 -0
- package/lifecycle.ts +274 -0
- package/load/cache.test.ts +82 -0
- package/load/cache.ts +40 -0
- package/load/index.ts +159 -0
- package/load/merge.ts +136 -0
- package/load/normalize.ts +73 -0
- package/load/paths.ts +32 -0
- package/load/resolve-default.ts +43 -0
- package/load/shape-guards.test.ts +74 -0
- package/load/shape-guards.ts +42 -0
- package/messages.ts +185 -0
- package/outcomes/collectors/directory-path.test.ts +64 -0
- package/outcomes/collectors/directory-path.ts +40 -0
- package/outcomes/collectors/index.ts +21 -0
- package/outcomes/collectors/tool-call.test.ts +110 -0
- package/outcomes/collectors/tool-call.ts +63 -0
- package/outcomes/collectors/transcript-path.test.ts +70 -0
- package/outcomes/collectors/transcript-path.ts +53 -0
- package/outcomes/collectors/union.test.ts +59 -0
- package/outcomes/collectors/union.ts +55 -0
- package/outcomes/collectors/url.test.ts +67 -0
- package/outcomes/collectors/url.ts +45 -0
- package/outcomes/collectors/workspace-diff.test.ts +107 -0
- package/outcomes/collectors/workspace-diff.ts +123 -0
- package/outcomes/git-commit.test.ts +194 -0
- package/outcomes/git-commit.ts +192 -0
- package/outcomes/index.ts +22 -0
- package/outcomes/parsers/index.ts +11 -0
- package/outcomes/parsers/json-body.test.ts +80 -0
- package/outcomes/parsers/json-body.ts +50 -0
- package/outcomes/side-effect.ts +26 -0
- package/output-spec.ts +170 -0
- package/output.ts +98 -0
- package/package.json +83 -0
- package/preview.ts +120 -0
- package/routing.ts +79 -0
- package/runner/chain-advance.ts +185 -0
- package/runner/index.ts +7 -0
- package/runner/runner.ts +356 -0
- package/runner/script-stage.ts +240 -0
- package/runner/stage-lifecycle.ts +447 -0
- package/sessions/extraction.ts +297 -0
- package/sessions/index.ts +7 -0
- package/sessions/sessions.ts +269 -0
- package/sessions/spawn.ts +135 -0
- package/state/index.ts +27 -0
- package/state/paths.ts +46 -0
- package/state/reads.ts +190 -0
- package/state/state.ts +115 -0
- package/state/writes.ts +58 -0
- package/transcript.ts +156 -0
- package/triggers.ts +27 -0
- package/typebox-adapter.ts +48 -0
- package/types.ts +237 -0
- package/validate-output.ts +120 -0
- package/validate-workflow.ts +491 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Internal utilities shared across the rpiv-workflow package.
|
|
3
|
+
*
|
|
4
|
+
* Not part of the public surface — not re-exported from `index.ts`. If a
|
|
5
|
+
* helper graduates into the documented authoring or embedding contract,
|
|
6
|
+
* move it out of here and into the appropriate domain module.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { isAbsolute, join } from "node:path";
|
|
10
|
+
import type { StageDef } from "./api.js";
|
|
11
|
+
import type { Artifact } from "./handle.js";
|
|
12
|
+
import type { RunState } from "./types.js";
|
|
13
|
+
|
|
14
|
+
/** Exhaustiveness guard for discriminated-union switches. */
|
|
15
|
+
export function assertNever(value: never): never {
|
|
16
|
+
throw new Error(`assertNever: unreachable value ${String(value)}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Canonical accessor for "the primary artifact the chain is currently
|
|
21
|
+
* carrying." Reads the rolling slot maintained by the runner —
|
|
22
|
+
* produces stages update it on success; side-effect stages leave it
|
|
23
|
+
* alone. Replaces the load-bearing single-string artifact_path mirror
|
|
24
|
+
* from the pre-collector shape.
|
|
25
|
+
*/
|
|
26
|
+
export function currentPrimaryArtifact(state: RunState): Artifact | undefined {
|
|
27
|
+
return state.primaryArtifact;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the `state.named` key a produces stage appends its `Output`
|
|
32
|
+
* envelope onto. Two layers of fallback, in priority order:
|
|
33
|
+
* 1. `stage.outcome?.name` — categorical name carried by the outcome.
|
|
34
|
+
* 2. The stage's record key — always defined.
|
|
35
|
+
*
|
|
36
|
+
* Single source of truth for the key derivation so the skill-stage path
|
|
37
|
+
* and the script-stage path stay in lockstep, and so `validateWorkflow`
|
|
38
|
+
* can compute the same key set at load time.
|
|
39
|
+
*/
|
|
40
|
+
export function resolvePublishName(def: StageDef, stageName: string): string {
|
|
41
|
+
return def.outcome?.name ?? stageName;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Race a promise against `ms`. The inner promise is NOT cancelled — Pi's
|
|
46
|
+
* `ctx.waitForIdle()` has no abort signal today; the dangling promise becomes
|
|
47
|
+
* inert when the next stage's `newSession` replaces the ctx.
|
|
48
|
+
*/
|
|
49
|
+
export async function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
50
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
51
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
52
|
+
timer = setTimeout(() => reject(new Error(message)), ms);
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
return await Promise.race([promise, timeout]);
|
|
56
|
+
} finally {
|
|
57
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolve `p` against `cwd`. Returns `p` unchanged if it is already absolute;
|
|
63
|
+
* otherwise joins `cwd + p` with the platform path separator. Uses
|
|
64
|
+
* `path.isAbsolute` so Windows drive-letter paths are handled correctly
|
|
65
|
+
* (POSIX-only `startsWith("/")` checks miss `C:\...`).
|
|
66
|
+
*/
|
|
67
|
+
export function resolveUnderCwd(cwd: string, p: string): string {
|
|
68
|
+
return isAbsolute(p) ? p : join(cwd, p);
|
|
69
|
+
}
|
package/internal.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-only / framework-internal surface.
|
|
3
|
+
*
|
|
4
|
+
* Exports below are NOT part of the package's authoring or embedding
|
|
5
|
+
* contract. They exist solely for:
|
|
6
|
+
*
|
|
7
|
+
* - rpiv-pi's `[I3]` regression test that exercises `recordStage`
|
|
8
|
+
* directly (asserting the JSONL append + stageNumber monotonicity).
|
|
9
|
+
* - `test/setup.ts`'s per-worker `beforeEach` reset of module-level
|
|
10
|
+
* singleton state (the built-in workflow registry, the jiti import
|
|
11
|
+
* cache).
|
|
12
|
+
*
|
|
13
|
+
* Anything that consumers can rely on for production work lives on
|
|
14
|
+
* `./index.js`. This file is reachable as
|
|
15
|
+
* `@juicesharp/rpiv-workflow/internal` via the package's `exports`
|
|
16
|
+
* field — keep that path stable so the rpiv-pi test + repo-wide
|
|
17
|
+
* setup don't break.
|
|
18
|
+
*
|
|
19
|
+
* Adding a new export here is a signal you have test-coupling to
|
|
20
|
+
* production state. Reach for it sparingly; prefer making the
|
|
21
|
+
* production module itself idempotent across `beforeEach` resets.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export { recordStage } from "./audit.js";
|
|
25
|
+
export { __resetBuiltIns, getBuiltIns } from "./built-ins.js";
|
|
26
|
+
export { __resetLifecycleRegistry } from "./lifecycle.js";
|
|
27
|
+
export { __resetLoadCache } from "./load/cache.js";
|
package/layers.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared layer-vocabulary for the workflow loader + validator.
|
|
3
|
+
*
|
|
4
|
+
* Lives in its own module so `load.ts` (loader / merge) and `validate-workflow.ts`
|
|
5
|
+
* (issue attribution) can both reference the same union without a circular
|
|
6
|
+
* import. `load.ts` depends on `validate-workflow.ts` for `validateWorkflow`, so
|
|
7
|
+
* declaring `ConfigLayer` here keeps the dependency direction strict and
|
|
8
|
+
* eliminates the silent-drift risk of two parallel string-literal unions.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { assertNever } from "./internal-utils.js";
|
|
12
|
+
|
|
13
|
+
export type ConfigLayer = "built-in" | "user" | "project";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Single source for layer → display string. Wrap every interpolation site
|
|
17
|
+
* (`${layer}` in template strings, banner builders, issue prefixes) in this
|
|
18
|
+
* helper so adding a new `ConfigLayer` value surfaces as a compile error at
|
|
19
|
+
* every consumer, not just at the loader's switch. The current display
|
|
20
|
+
* form is the layer name verbatim — keep that invariant if you change it.
|
|
21
|
+
*/
|
|
22
|
+
export function renderConfigLayer(layer: ConfigLayer): string {
|
|
23
|
+
switch (layer) {
|
|
24
|
+
case "built-in":
|
|
25
|
+
return "built-in";
|
|
26
|
+
case "user":
|
|
27
|
+
return "user";
|
|
28
|
+
case "project":
|
|
29
|
+
return "project";
|
|
30
|
+
default:
|
|
31
|
+
return assertNever(layer);
|
|
32
|
+
}
|
|
33
|
+
}
|
package/lifecycle.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lifecycle callbacks — typed observation surface for in-flight runs.
|
|
3
|
+
*
|
|
4
|
+
* Two subscription paths converge through the same `LifecycleListeners`
|
|
5
|
+
* shape:
|
|
6
|
+
*
|
|
7
|
+
* - **Per-call** — embedders set `RunWorkflowOptions.lifecycle?` when
|
|
8
|
+
* driving `runWorkflow` directly.
|
|
9
|
+
* - **Global** — sibling extensions (rpiv-pi widget, future metrics)
|
|
10
|
+
* call `registerLifecycle(listeners)` at extension load. Returns a
|
|
11
|
+
* disposer; multiple registrations fan out per event.
|
|
12
|
+
*
|
|
13
|
+
* Every event fires AFTER the corresponding JSONL row lands on disk
|
|
14
|
+
* (onStageEnd / onStageError / onRoute / onFanoutUnitEnd). Listeners
|
|
15
|
+
* thus see consistent state when they read past rows via
|
|
16
|
+
* `readLastStage` / `readAllStages`.
|
|
17
|
+
*
|
|
18
|
+
* Listener throws are caught + logged via `ctx.ui.notify(..., "warning")`
|
|
19
|
+
* and never halt the run — listeners are observers, not gates. Other
|
|
20
|
+
* registered listeners still fire normally.
|
|
21
|
+
*
|
|
22
|
+
* Pre-flight rejections (`workflow.start` not declared, continue-policy
|
|
23
|
+
* without a host) fire ZERO lifecycle events — the run never acquired
|
|
24
|
+
* a `runId` and lifecycle events would deliver an incomplete context.
|
|
25
|
+
* The `RunWorkflowResult` envelope still surfaces the error to the
|
|
26
|
+
* caller through the existing path.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { FanoutUnit } from "./api.js";
|
|
30
|
+
import { MSG_LIFECYCLE_THREW } from "./messages.js";
|
|
31
|
+
import type { Output } from "./output.js";
|
|
32
|
+
import type { RunWorkflowResult } from "./runner/runner.js";
|
|
33
|
+
import type { RunTrigger } from "./triggers.js";
|
|
34
|
+
import type { RunState } from "./types.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Run-scoped context shared by every lifecycle callback. Mirrors the
|
|
38
|
+
* shape `EdgeContext` and `FanoutContext` already use (frozen identity
|
|
39
|
+
* + `Readonly<RunState>`) so listeners reconstruct "where am I" without
|
|
40
|
+
* widening the per-event payload.
|
|
41
|
+
*/
|
|
42
|
+
export interface LifecycleContext {
|
|
43
|
+
cwd: string;
|
|
44
|
+
runId: string;
|
|
45
|
+
workflow: string;
|
|
46
|
+
totalStages: number;
|
|
47
|
+
/** What triggered this run; defaulted at `runWorkflow` entry if `options.trigger` was omitted. */
|
|
48
|
+
trigger: RunTrigger;
|
|
49
|
+
state: Readonly<RunState>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Projection of a stage as observed by a listener — the runner's
|
|
54
|
+
* internal resolved view minus its `def` field (which leaks DSL
|
|
55
|
+
* internals). Distinct from `StageDef` (authored shape) and
|
|
56
|
+
* `WorkflowStage` (JSONL row).
|
|
57
|
+
*
|
|
58
|
+
* Discriminated on `kind`:
|
|
59
|
+
* - `"skill"` — stage dispatches a Pi skill body (`/skill:<skill>`).
|
|
60
|
+
* - `"script"` — stage runs a TS function; no skill body.
|
|
61
|
+
*/
|
|
62
|
+
export type StageRef =
|
|
63
|
+
| { kind: "skill"; name: string; stageNumber: number; skill: string }
|
|
64
|
+
| { kind: "script"; name: string; stageNumber: number };
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Opt-in lifecycle listeners. Single subscriber per slot per bundle —
|
|
68
|
+
* fan-out is a userland wrapper. Every callback may return a Promise;
|
|
69
|
+
* the runner awaits it before advancing (back-pressure for free).
|
|
70
|
+
*
|
|
71
|
+
* Every event fires AFTER its corresponding JSONL row lands on disk
|
|
72
|
+
* (onStageEnd / onStageError / onRoute / onFanoutUnitEnd), so a
|
|
73
|
+
* listener that calls `readLastStage(cwd, ctx.runId)` from inside the
|
|
74
|
+
* callback is guaranteed to observe the just-recorded row.
|
|
75
|
+
*
|
|
76
|
+
* Listener throws are caught + surfaced via `ctx.ui.notify(..., "warning")`
|
|
77
|
+
* and never halt the run — listeners are observers, not gates.
|
|
78
|
+
*
|
|
79
|
+
* @example Per-call observation from an embedder
|
|
80
|
+
* ```ts
|
|
81
|
+
* import { runWorkflow, type LifecycleListeners } from "@juicesharp/rpiv-workflow";
|
|
82
|
+
*
|
|
83
|
+
* const listeners: LifecycleListeners = {
|
|
84
|
+
* onWorkflowStart: (ctx) =>
|
|
85
|
+
* console.log(`▶ ${ctx.workflow} (${ctx.totalStages} stages) [${ctx.trigger.kind}]`),
|
|
86
|
+
* onStageStart: (stage) => console.log(` → ${stage.stageNumber}. ${stage.name}`),
|
|
87
|
+
* onStageEnd: (stage, output) => console.log(` ✓ ${stage.name}: ${output.kind}`),
|
|
88
|
+
* onStageRetry: (stage, attempt) => console.warn(` ⟲ ${stage.name} retry #${attempt}`),
|
|
89
|
+
* onStageError: (stage, error) => console.error(` ✗ ${stage.name}: ${error}`),
|
|
90
|
+
* onRoute: (from, to) => console.log(` ↪ ${from.name} → ${to}`),
|
|
91
|
+
* onFanoutStart: (stage, units) => console.log(` ⇉ ${stage.name} × ${units.length}`),
|
|
92
|
+
* onFanoutUnitEnd: (stage, _u, i) => console.log(` · ${stage.name} #${i}`),
|
|
93
|
+
* onWorkflowEnd: (result) =>
|
|
94
|
+
* console.log(result.success ? "✓ done" : `✗ ${result.error ?? "halted"}`),
|
|
95
|
+
* };
|
|
96
|
+
*
|
|
97
|
+
* await runWorkflow({ workflow, input, host, lifecycle: listeners });
|
|
98
|
+
* ```
|
|
99
|
+
*
|
|
100
|
+
* @example Cross-package fan-out via `registerLifecycle`
|
|
101
|
+
* ```ts
|
|
102
|
+
* import { registerLifecycle } from "@juicesharp/rpiv-workflow";
|
|
103
|
+
*
|
|
104
|
+
* const dispose = registerLifecycle({
|
|
105
|
+
* onWorkflowStart: (ctx) => widget.open(ctx.runId, ctx.workflow),
|
|
106
|
+
* onStageEnd: (stage, _o, ctx) => widget.markDone(ctx.runId, stage.name),
|
|
107
|
+
* });
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export interface LifecycleListeners {
|
|
111
|
+
/** After JSONL header lands; before the start stage's preflight. */
|
|
112
|
+
onWorkflowStart?(ctx: LifecycleContext): void | Promise<void>;
|
|
113
|
+
|
|
114
|
+
/** After preflight + skill check; before the Pi session opens (or `run()` is called for script stages). */
|
|
115
|
+
onStageStart?(stage: StageRef, ctx: LifecycleContext): void | Promise<void>;
|
|
116
|
+
|
|
117
|
+
/** After the stage's success row lands in JSONL. `output` is the validated envelope. */
|
|
118
|
+
onStageEnd?(stage: StageRef, output: Output, ctx: LifecycleContext): void | Promise<void>;
|
|
119
|
+
|
|
120
|
+
/** After `outputSchema` rejection, before the runner re-prompts. `attempt` is 1-based. */
|
|
121
|
+
onStageRetry?(stage: StageRef, attempt: number, ctx: LifecycleContext): void | Promise<void>;
|
|
122
|
+
|
|
123
|
+
/** After the stage's "failed"/"aborted" row lands in JSONL. Terminal for the run. */
|
|
124
|
+
onStageError?(stage: StageRef, error: string, ctx: LifecycleContext): void | Promise<void>;
|
|
125
|
+
|
|
126
|
+
/** After an `EdgeFn` picks and its routing-decision row lands. `to` may be the `STOP` sentinel literal `"stop"`. */
|
|
127
|
+
onRoute?(from: StageRef, to: string, ctx: LifecycleContext): void | Promise<void>;
|
|
128
|
+
|
|
129
|
+
/** After the `FanoutFn` returns ≥1 units; before unit 1's session opens. */
|
|
130
|
+
onFanoutStart?(stage: StageRef, units: readonly FanoutUnit[], ctx: LifecycleContext): void | Promise<void>;
|
|
131
|
+
|
|
132
|
+
/** Per-unit, before the unit's session opens. `unitIndex` is 1-based. */
|
|
133
|
+
onFanoutUnitStart?(
|
|
134
|
+
stage: StageRef,
|
|
135
|
+
unit: FanoutUnit,
|
|
136
|
+
unitIndex: number,
|
|
137
|
+
ctx: LifecycleContext,
|
|
138
|
+
): void | Promise<void>;
|
|
139
|
+
|
|
140
|
+
/** Per-unit, after the unit's JSONL row lands. */
|
|
141
|
+
onFanoutUnitEnd?(stage: StageRef, unit: FanoutUnit, unitIndex: number, ctx: LifecycleContext): void | Promise<void>;
|
|
142
|
+
|
|
143
|
+
/** Last call — `result` is the same envelope `runWorkflow` returns. */
|
|
144
|
+
onWorkflowEnd?(result: RunWorkflowResult, ctx: LifecycleContext): void | Promise<void>;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Internal — dispatcher (consumed by the runner; not exported publicly)
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
/** Subset of `WorkflowContext` the dispatcher needs for throw-safe logging. */
|
|
152
|
+
export interface DispatchHost {
|
|
153
|
+
ui: { notify(message: string, level?: "info" | "warning" | "error"): void };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Fan-out + throw-safe invoker for one event across every registered
|
|
158
|
+
* listener bundle. Constructed once per `runWorkflow` call and threaded
|
|
159
|
+
* through `RunContext` so every firing site uses the same instance.
|
|
160
|
+
*
|
|
161
|
+
* Snapshot semantics: `collectBundles` is called per `fire(...)`, so a
|
|
162
|
+
* registration made mid-event (`registerLifecycle` from inside a
|
|
163
|
+
* callback) applies to subsequent events but not the in-flight one.
|
|
164
|
+
*
|
|
165
|
+
* Sequential await: bundles run in registration order; the per-call
|
|
166
|
+
* bundle (when present) fires after every globally-registered bundle.
|
|
167
|
+
* `await` between them gives listeners back-pressure for free.
|
|
168
|
+
*/
|
|
169
|
+
export class LifecycleDispatcher {
|
|
170
|
+
constructor(private readonly perCall: LifecycleListeners | undefined) {}
|
|
171
|
+
|
|
172
|
+
async fire<E extends keyof LifecycleListeners>(
|
|
173
|
+
host: DispatchHost,
|
|
174
|
+
event: E,
|
|
175
|
+
...args: Parameters<NonNullable<LifecycleListeners[E]>>
|
|
176
|
+
): Promise<void> {
|
|
177
|
+
for (const bundle of collectBundles(this.perCall)) {
|
|
178
|
+
const fn = bundle[event];
|
|
179
|
+
if (!fn) continue;
|
|
180
|
+
try {
|
|
181
|
+
await (fn as (...a: unknown[]) => unknown)(...(args as unknown[]));
|
|
182
|
+
} catch (e) {
|
|
183
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
184
|
+
host.ui.notify(MSG_LIFECYCLE_THREW(event, reason), "warning");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Global registry — cross-package fan-out
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Anchored on a `Symbol.for` slot so a duplicate module load (extension +
|
|
196
|
+
* sibling cross-package resolution) shares one registry. Same pattern
|
|
197
|
+
* `registerBuiltIns` uses for the workflow registry.
|
|
198
|
+
*/
|
|
199
|
+
const REGISTRY_KEY = Symbol.for("@juicesharp/rpiv-workflow:lifecycle");
|
|
200
|
+
|
|
201
|
+
type Global = Record<symbol, unknown>;
|
|
202
|
+
|
|
203
|
+
function getRegistry(): LifecycleListeners[] {
|
|
204
|
+
const g = globalThis as unknown as Global;
|
|
205
|
+
let registry = g[REGISTRY_KEY] as LifecycleListeners[] | undefined;
|
|
206
|
+
if (!registry) {
|
|
207
|
+
registry = [];
|
|
208
|
+
g[REGISTRY_KEY] = registry;
|
|
209
|
+
}
|
|
210
|
+
return registry;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Register a cross-package lifecycle-listener bundle. Returns a disposer
|
|
215
|
+
* that removes it. Multiple registrations coexist — every fired event walks
|
|
216
|
+
* the full registry in registration order, then the per-call bundle.
|
|
217
|
+
*
|
|
218
|
+
* Snapshot semantics: each `fire(...)` call observes the registry as it
|
|
219
|
+
* stands at that instant. A registration made mid-event applies to
|
|
220
|
+
* subsequent events but not the in-flight one.
|
|
221
|
+
*
|
|
222
|
+
* Throws from listeners are caught + logged via `ctx.ui.notify(..., "warning")`
|
|
223
|
+
* and never halt the run. One listener bug never affects other listeners
|
|
224
|
+
* or the run itself.
|
|
225
|
+
*/
|
|
226
|
+
export function registerLifecycle(listeners: LifecycleListeners): () => void {
|
|
227
|
+
const registry = getRegistry();
|
|
228
|
+
registry.push(listeners);
|
|
229
|
+
return () => {
|
|
230
|
+
const idx = registry.indexOf(listeners);
|
|
231
|
+
if (idx >= 0) registry.splice(idx, 1);
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Test reset — wired into the repo-wide test setup so cross-test
|
|
237
|
+
* registration leaks don't bias the next case.
|
|
238
|
+
*/
|
|
239
|
+
export function __resetLifecycleRegistry(): void {
|
|
240
|
+
getRegistry().length = 0;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Globally-registered bundles fire first (in registration order), then
|
|
245
|
+
* the per-call bundle. Snapshot at fire time — a registration made
|
|
246
|
+
* inside a listener body applies to subsequent events, not the
|
|
247
|
+
* in-flight one.
|
|
248
|
+
*/
|
|
249
|
+
function collectBundles(perCall: LifecycleListeners | undefined): readonly LifecycleListeners[] {
|
|
250
|
+
const global = getRegistry();
|
|
251
|
+
return perCall ? [...global, perCall] : [...global];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Build a `LifecycleContext` from the runner's per-run identity. */
|
|
255
|
+
export function buildLifecycleContext(args: {
|
|
256
|
+
cwd: string;
|
|
257
|
+
runId: string;
|
|
258
|
+
workflow: string;
|
|
259
|
+
totalStages: number;
|
|
260
|
+
trigger: RunTrigger;
|
|
261
|
+
state: Readonly<RunState>;
|
|
262
|
+
}): LifecycleContext {
|
|
263
|
+
return args;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Build the `"skill"` arm of `StageRef`. */
|
|
267
|
+
export function skillStageRef(name: string, stageNumber: number, skill: string): StageRef {
|
|
268
|
+
return { kind: "skill", name, stageNumber, skill };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Build the `"script"` arm of `StageRef` — skillless TS stages. */
|
|
272
|
+
export function scriptStageRef(name: string, stageNumber: number): StageRef {
|
|
273
|
+
return { kind: "script", name, stageNumber };
|
|
274
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct tests for the mtime-keyed jiti import cache. Exercises both the
|
|
3
|
+
* cache-miss path (fresh `jiti.import`) and the cache-hit path (returns
|
|
4
|
+
* the stored parsed value without re-evaluating top-level code).
|
|
5
|
+
*
|
|
6
|
+
* `loadWorkflows` integration tests cover the cache miss every time
|
|
7
|
+
* (each `loadWorkflows` call hits the file once); the cache-hit branch
|
|
8
|
+
* only fires when the same path is imported twice within one
|
|
9
|
+
* `__resetLoadCache()` boundary, which `test/setup.ts:beforeEach`
|
|
10
|
+
* normally prevents. These tests opt out of the global reset by calling
|
|
11
|
+
* `cachedImport` directly.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
18
|
+
import { __resetLoadCache, cachedImport } from "./cache.js";
|
|
19
|
+
|
|
20
|
+
const TMP_ROOT = mkdtempSync(join(tmpdir(), "rpiv-workflow-cache-"));
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
__resetLoadCache();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
__resetLoadCache();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// One-shot cleanup so we don't leak the tmp directory.
|
|
31
|
+
process.on("exit", () => {
|
|
32
|
+
rmSync(TMP_ROOT, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const writeFixture = (name: string, body: string): string => {
|
|
36
|
+
const path = join(TMP_ROOT, name);
|
|
37
|
+
writeFileSync(path, body, "utf-8");
|
|
38
|
+
return path;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe("cachedImport", () => {
|
|
42
|
+
it("returns the parsed default export on first call (cache miss)", async () => {
|
|
43
|
+
const path = writeFixture("miss.ts", "export default 'first';\n");
|
|
44
|
+
const value = await cachedImport(path);
|
|
45
|
+
expect(value).toBe("first");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("returns the cached value when mtime is unchanged (cache hit)", async () => {
|
|
49
|
+
const path = writeFixture("hit.ts", "export default { v: 1 };\n");
|
|
50
|
+
const first = await cachedImport(path);
|
|
51
|
+
const second = await cachedImport(path);
|
|
52
|
+
// Same reference — proves the second call short-circuited via the cache
|
|
53
|
+
// (a re-evaluated `jiti.import` would return a freshly-allocated object).
|
|
54
|
+
expect(second).toBe(first);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("re-imports when mtime advances (edit invalidates the cache)", async () => {
|
|
58
|
+
const path = writeFixture("edit.ts", "export default 'v1';\n");
|
|
59
|
+
const first = await cachedImport(path);
|
|
60
|
+
expect(first).toBe("v1");
|
|
61
|
+
|
|
62
|
+
// Rewrite + bump mtime far enough that the cache key changes even on
|
|
63
|
+
// coarse-mtime filesystems.
|
|
64
|
+
writeFileSync(path, "export default 'v2';\n", "utf-8");
|
|
65
|
+
const future = new Date(Date.now() + 60_000);
|
|
66
|
+
utimesSync(path, future, future);
|
|
67
|
+
|
|
68
|
+
const second = await cachedImport(path);
|
|
69
|
+
expect(second).toBe("v2");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("__resetLoadCache forces a fresh re-import on the next call", async () => {
|
|
73
|
+
const path = writeFixture("reset.ts", "export default { tag: 'x' };\n");
|
|
74
|
+
const first = await cachedImport(path);
|
|
75
|
+
__resetLoadCache();
|
|
76
|
+
const second = await cachedImport(path);
|
|
77
|
+
// Different references prove the reset cleared the entry — jiti returned
|
|
78
|
+
// a freshly-evaluated object.
|
|
79
|
+
expect(second).not.toBe(first);
|
|
80
|
+
expect(second).toEqual(first);
|
|
81
|
+
});
|
|
82
|
+
});
|
package/load/cache.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mtime-keyed jiti import cache. jiti's own caches are disabled so `/reload`
|
|
3
|
+
* picks up edits without restart; this wrapper layers a stat-driven cache
|
|
4
|
+
* on top so unchanged overlays don't re-evaluate top-level code on every
|
|
5
|
+
* `/wf` invocation.
|
|
6
|
+
*
|
|
7
|
+
* The `jiti` instance lives here so the cache and the underlying importer
|
|
8
|
+
* co-locate. Other loader modules import `cachedImport` — none touches
|
|
9
|
+
* `jiti` directly.
|
|
10
|
+
*
|
|
11
|
+
* The cache does not invalidate on file deletion — a stale entry for a
|
|
12
|
+
* deleted overlay sits dormant (the enumerator never passes it back to
|
|
13
|
+
* `cachedImport`). The cache resets on `__resetLoadCache()` (wired into
|
|
14
|
+
* `test/setup.ts` `beforeEach`) and on process exit.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { statSync } from "node:fs";
|
|
18
|
+
import { createJiti } from "jiti";
|
|
19
|
+
|
|
20
|
+
const jiti = createJiti(import.meta.url, {
|
|
21
|
+
// Bypass jiti's module cache so /reload picks up edits without restart.
|
|
22
|
+
moduleCache: false,
|
|
23
|
+
fsCache: false,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const overlayCache = new Map<string, { mtimeMs: number; parsed: unknown }>();
|
|
27
|
+
|
|
28
|
+
export async function cachedImport(path: string): Promise<unknown> {
|
|
29
|
+
const stat = statSync(path);
|
|
30
|
+
const cached = overlayCache.get(path);
|
|
31
|
+
if (cached && cached.mtimeMs === stat.mtimeMs) return cached.parsed;
|
|
32
|
+
const value = await jiti.import(path, { default: true });
|
|
33
|
+
overlayCache.set(path, { mtimeMs: stat.mtimeMs, parsed: value });
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Test-only reset. Wired into `test/setup.ts` `beforeEach`. */
|
|
38
|
+
export function __resetLoadCache(): void {
|
|
39
|
+
overlayCache.clear();
|
|
40
|
+
}
|
package/load/index.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jiti-based loader for user-authored workflows.
|
|
3
|
+
*
|
|
4
|
+
* Layered merge: `built-in` ← `user` ← `project`. Within each non-built-in
|
|
5
|
+
* layer, pack files merge first (alpha-sorted filename), then the
|
|
6
|
+
* config file — so the file the user wrote by hand wins over any packs
|
|
7
|
+
* they installed.
|
|
8
|
+
*
|
|
9
|
+
* Paths (per layer):
|
|
10
|
+
* user — config `~/.config/rpiv-workflow/workflows.config.ts`
|
|
11
|
+
* packs `~/.config/rpiv-workflow/workflows/*.ts`
|
|
12
|
+
* project — config `<cwd>/.rpiv-workflow/workflows.config.ts`
|
|
13
|
+
* packs `<cwd>/.rpiv-workflow/workflows/*.ts`
|
|
14
|
+
*
|
|
15
|
+
* Config file — accepts three default-export shapes:
|
|
16
|
+
* 1. A single `Workflow` — single-entry namespace
|
|
17
|
+
* 2. `Workflow[]` — multi-entry, default required if > 1
|
|
18
|
+
* 3. `{ workflows, default? }` — full envelope, explicit default
|
|
19
|
+
*
|
|
20
|
+
* Pack file — accepts only `Workflow | Workflow[]`. The envelope form
|
|
21
|
+
* is rejected because `default` lives in the config file (one source of
|
|
22
|
+
* truth per layer).
|
|
23
|
+
*
|
|
24
|
+
* `default` cascades layer-by-layer (project config > user config >
|
|
25
|
+
* first registered workflow in insertion order). When no workflows are
|
|
26
|
+
* registered at all, `default` is `undefined` and `command.ts` surfaces
|
|
27
|
+
* a "no workflows registered" notify instead of running anything. Within
|
|
28
|
+
* a layer only the config file can set `default`.
|
|
29
|
+
*
|
|
30
|
+
* jiti loads `.ts` directly — no build step required of users. Loader
|
|
31
|
+
* failures (file throws on import, exports the wrong shape) are captured as
|
|
32
|
+
* `LoadIssue`s; the loader itself never throws to its caller.
|
|
33
|
+
*
|
|
34
|
+
* SECURITY NOTE — `jiti.import` synchronously evaluates every overlay
|
|
35
|
+
* file's top-level code on first load and on every edit (mtime-driven
|
|
36
|
+
* invalidation via `cache.ts`). The threat boundary is the same as
|
|
37
|
+
* `npm install` (post-install scripts), `tsx some-script.ts`, or any
|
|
38
|
+
* tool that respects `<cwd>` configuration: Pi already operates in a
|
|
39
|
+
* context that implicitly trusts the current working directory. Users
|
|
40
|
+
* running Pi in a freshly-cloned untrusted repo should diff
|
|
41
|
+
* `.rpiv-workflow/workflows.config.ts` and `.rpiv-workflow/workflows/*.ts`
|
|
42
|
+
* (the config file + pack files) before running `/wf`.
|
|
43
|
+
*
|
|
44
|
+
* Module map:
|
|
45
|
+
* ./paths.ts — OverlayPaths + per-layer path helpers
|
|
46
|
+
* ./shape-guards.ts — isWorkflow, isEnvelope, describe, formatError
|
|
47
|
+
* ./normalize.ts — normalizeDefaultExport + NormalizeResult
|
|
48
|
+
* ./merge.ts — LoadAccumulator, LayerOutcome, loadLayer, mergeOverlay, loadError
|
|
49
|
+
* ./resolve-default.ts — resolveDefault (first-workflow fallback)
|
|
50
|
+
* ./cache.ts — mtime-keyed jiti import cache + __resetLoadCache
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import type { Workflow } from "../api.js";
|
|
54
|
+
import { getBuiltIns } from "../built-ins.js";
|
|
55
|
+
import type { ConfigLayer } from "../layers.js";
|
|
56
|
+
import { validateWorkflow, type WorkflowValidationIssue } from "../validate-workflow.js";
|
|
57
|
+
import { type LoadAccumulator, loadLayer } from "./merge.js";
|
|
58
|
+
import { projectOverlayPaths, userOverlayPaths } from "./paths.js";
|
|
59
|
+
import { resolveDefault } from "./resolve-default.js";
|
|
60
|
+
|
|
61
|
+
// ===========================================================================
|
|
62
|
+
// Public types
|
|
63
|
+
// ===========================================================================
|
|
64
|
+
|
|
65
|
+
export type { ConfigLayer } from "../layers.js";
|
|
66
|
+
export { __resetLoadCache } from "./cache.js";
|
|
67
|
+
export type { OverlayPaths } from "./paths.js";
|
|
68
|
+
export { projectOverlayPaths, userOverlayPaths } from "./paths.js";
|
|
69
|
+
|
|
70
|
+
export interface LoadIssue {
|
|
71
|
+
kind: "load";
|
|
72
|
+
layer: ConfigLayer;
|
|
73
|
+
path?: string;
|
|
74
|
+
severity: "error" | "warning";
|
|
75
|
+
message: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type Issue = LoadIssue | (WorkflowValidationIssue & { kind: "validation"; layer: ConfigLayer; path?: string });
|
|
79
|
+
|
|
80
|
+
export interface LoadedWorkflows {
|
|
81
|
+
workflows: readonly Workflow[];
|
|
82
|
+
/**
|
|
83
|
+
* Resolved default workflow name. `undefined` when no layer registered any
|
|
84
|
+
* workflows — consumers must handle this (see `command.ts`'s
|
|
85
|
+
* "no workflows registered" path).
|
|
86
|
+
*/
|
|
87
|
+
default: string | undefined;
|
|
88
|
+
/** Which layer each merged workflow name came from. */
|
|
89
|
+
workflowSources: ReadonlyMap<string, ConfigLayer>;
|
|
90
|
+
/** Every layer that registered at least one workflow, low-to-high. */
|
|
91
|
+
layers: readonly ConfigLayer[];
|
|
92
|
+
/** Aggregated load + validation issues. Errors block the runner; warnings are advisory. */
|
|
93
|
+
issues: readonly Issue[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ===========================================================================
|
|
97
|
+
// Public lookup helpers
|
|
98
|
+
// ===========================================================================
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Lookup a workflow by name in a merged `LoadedWorkflows`. Anticipates
|
|
102
|
+
* the "rerun by name" / `listRuns` past-runs API that will share the
|
|
103
|
+
* lookup; consolidated here so future callers don't reach back into
|
|
104
|
+
* `loaded.workflows.find(...)` ad-hoc.
|
|
105
|
+
*/
|
|
106
|
+
export function findWorkflow(loaded: LoadedWorkflows, name: string): Workflow | undefined {
|
|
107
|
+
return loaded.workflows.find((w) => w.name === name);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ===========================================================================
|
|
111
|
+
// Orchestrator
|
|
112
|
+
// ===========================================================================
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Load every active layer, merge by workflow name, validate, and return the
|
|
116
|
+
* resolved set. Never throws — load + validation errors flow through `issues`.
|
|
117
|
+
*/
|
|
118
|
+
export async function loadWorkflows(cwd: string): Promise<LoadedWorkflows> {
|
|
119
|
+
const acc: LoadAccumulator = {
|
|
120
|
+
issues: [],
|
|
121
|
+
workflowMap: new Map(),
|
|
122
|
+
sources: new Map(),
|
|
123
|
+
sourcePaths: new Map(),
|
|
124
|
+
};
|
|
125
|
+
const layers: ConfigLayer[] = getBuiltIns().length > 0 ? ["built-in"] : [];
|
|
126
|
+
|
|
127
|
+
for (const w of getBuiltIns()) {
|
|
128
|
+
acc.workflowMap.set(w.name, w);
|
|
129
|
+
acc.sources.set(w.name, "built-in");
|
|
130
|
+
acc.sourcePaths.set(w.name, undefined);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const userOutcome = await loadLayer(userOverlayPaths(), "user", acc);
|
|
134
|
+
if (userOutcome.contributed) layers.push("user");
|
|
135
|
+
|
|
136
|
+
const projectOutcome = await loadLayer(projectOverlayPaths(cwd), "project", acc);
|
|
137
|
+
if (projectOutcome.contributed) layers.push("project");
|
|
138
|
+
|
|
139
|
+
// Validate every merged workflow once. Validation runs even on built-in so
|
|
140
|
+
// that a future built-in regression surfaces in the same channel as user
|
|
141
|
+
// errors. Each issue is attributed to the exact file the surviving workflow
|
|
142
|
+
// came from (pack or config) so `/wf` previews can render
|
|
143
|
+
// `[<layer> config (<path>)] workflow "X": ...` errors.
|
|
144
|
+
for (const w of acc.workflowMap.values()) {
|
|
145
|
+
const layer = acc.sources.get(w.name) ?? "built-in";
|
|
146
|
+
const path = acc.sourcePaths.get(w.name);
|
|
147
|
+
for (const v of validateWorkflow(w)) acc.issues.push({ ...v, kind: "validation", layer, path });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const defaultName = resolveDefault(projectOutcome.configDefault, userOutcome.configDefault, acc);
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
workflows: [...acc.workflowMap.values()],
|
|
154
|
+
default: defaultName,
|
|
155
|
+
workflowSources: acc.sources,
|
|
156
|
+
layers,
|
|
157
|
+
issues: acc.issues,
|
|
158
|
+
};
|
|
159
|
+
}
|