@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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/api.ts +557 -0
  4. package/audit.ts +217 -0
  5. package/built-ins.ts +65 -0
  6. package/command.ts +137 -0
  7. package/docs/cover.png +0 -0
  8. package/docs/cover.svg +120 -0
  9. package/docs/workflow-authoring.md +629 -0
  10. package/docs/workflow-basics.md +122 -0
  11. package/docs-protocol.ts +106 -0
  12. package/fanout.ts +96 -0
  13. package/host.ts +97 -0
  14. package/index.ts +230 -0
  15. package/internal-utils.ts +69 -0
  16. package/internal.ts +27 -0
  17. package/layers.ts +33 -0
  18. package/lifecycle.ts +274 -0
  19. package/load/cache.test.ts +82 -0
  20. package/load/cache.ts +40 -0
  21. package/load/index.ts +159 -0
  22. package/load/merge.ts +136 -0
  23. package/load/normalize.ts +73 -0
  24. package/load/paths.ts +32 -0
  25. package/load/resolve-default.ts +43 -0
  26. package/load/shape-guards.test.ts +74 -0
  27. package/load/shape-guards.ts +42 -0
  28. package/messages.ts +185 -0
  29. package/outcomes/collectors/directory-path.test.ts +64 -0
  30. package/outcomes/collectors/directory-path.ts +40 -0
  31. package/outcomes/collectors/index.ts +21 -0
  32. package/outcomes/collectors/tool-call.test.ts +110 -0
  33. package/outcomes/collectors/tool-call.ts +63 -0
  34. package/outcomes/collectors/transcript-path.test.ts +70 -0
  35. package/outcomes/collectors/transcript-path.ts +53 -0
  36. package/outcomes/collectors/union.test.ts +59 -0
  37. package/outcomes/collectors/union.ts +55 -0
  38. package/outcomes/collectors/url.test.ts +67 -0
  39. package/outcomes/collectors/url.ts +45 -0
  40. package/outcomes/collectors/workspace-diff.test.ts +107 -0
  41. package/outcomes/collectors/workspace-diff.ts +123 -0
  42. package/outcomes/git-commit.test.ts +194 -0
  43. package/outcomes/git-commit.ts +192 -0
  44. package/outcomes/index.ts +22 -0
  45. package/outcomes/parsers/index.ts +11 -0
  46. package/outcomes/parsers/json-body.test.ts +80 -0
  47. package/outcomes/parsers/json-body.ts +50 -0
  48. package/outcomes/side-effect.ts +26 -0
  49. package/output-spec.ts +170 -0
  50. package/output.ts +98 -0
  51. package/package.json +83 -0
  52. package/preview.ts +120 -0
  53. package/routing.ts +79 -0
  54. package/runner/chain-advance.ts +185 -0
  55. package/runner/index.ts +7 -0
  56. package/runner/runner.ts +356 -0
  57. package/runner/script-stage.ts +240 -0
  58. package/runner/stage-lifecycle.ts +447 -0
  59. package/sessions/extraction.ts +297 -0
  60. package/sessions/index.ts +7 -0
  61. package/sessions/sessions.ts +269 -0
  62. package/sessions/spawn.ts +135 -0
  63. package/state/index.ts +27 -0
  64. package/state/paths.ts +46 -0
  65. package/state/reads.ts +190 -0
  66. package/state/state.ts +115 -0
  67. package/state/writes.ts +58 -0
  68. package/transcript.ts +156 -0
  69. package/triggers.ts +27 -0
  70. package/typebox-adapter.ts +48 -0
  71. package/types.ts +237 -0
  72. package/validate-output.ts +120 -0
  73. package/validate-workflow.ts +491 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Session policy dispatch. Owns the three policy-specific decisions the
3
+ * stage / phase machinery has to make per session: which branch offset
4
+ * the outcome sees, how the session is opened, and how an
5
+ * already-established session is sent to.
6
+ *
7
+ * Two handlers — `FRESH_HANDLER` and `CONTINUE_HANDLER` — implement the
8
+ * interface; `handlerFor(policy)` picks. Everything else in the
9
+ * `sessions/` directory is policy-agnostic.
10
+ */
11
+
12
+ import type { SessionPolicy } from "../api.js";
13
+ import type { WorkflowHost } from "../host.js";
14
+ import type { RunnerCtx } from "../types.js";
15
+
16
+ /**
17
+ * Three policy-specific decisions that used to live as five ternaries
18
+ * scattered across sessions.ts:
19
+ *
20
+ * - `branchOffset(captured)` — the offset outcomes apply to skip
21
+ * the prior-stage prefix in continue sessions. Fresh ignores the
22
+ * stage-side captured value (it's `undefined` from
23
+ * `computeBranchOffset` for fresh stages anyway); continue returns
24
+ * it as-is.
25
+ * - `spawn(ctx, prompt, body, host?)` — open the session and run `body`
26
+ * on whichever ctx is valid for that policy (fresh → freshCtx
27
+ * inside `withSession`; continue → the supplied ctx, after a
28
+ * send+waitForIdle settles the existing session). `cancelled: true`
29
+ * means a fresh session was cancelled before `withSession` ran.
30
+ * - `send(ctx, msg, host?)` — send into an already-established session
31
+ * and wait for it to settle (used by the validation-retry path).
32
+ *
33
+ * `host` is the registry-level fallback for continue stages — used only
34
+ * when `ctx.sendUserMessage` is absent (the outer command ctx at the
35
+ * very start of a workflow whose first stage is continue-policy).
36
+ * Everywhere else (continue stages following any other stage),
37
+ * `ctx.sendUserMessage` is present and preferred because Pi marks the
38
+ * captured host stale after `ctx.newSession()`. `enforceSessionInvariants`
39
+ * still requires a host whenever any stage is continue-policy so the
40
+ * start-stage path has a working fallback. Fresh ignores `host` entirely.
41
+ */
42
+ export interface SessionPolicyHandler {
43
+ branchOffset(capturedOffset: number | undefined): number | undefined;
44
+ spawn(
45
+ ctx: RunnerCtx,
46
+ prompt: string,
47
+ body: (sessionCtx: RunnerCtx) => Promise<void>,
48
+ host?: WorkflowHost,
49
+ ): Promise<{ cancelled: boolean }>;
50
+ send(ctx: RunnerCtx, msg: string, host?: WorkflowHost): Promise<void>;
51
+ }
52
+
53
+ export const FRESH_HANDLER: SessionPolicyHandler = {
54
+ branchOffset: () => undefined,
55
+ async spawn(ctx, prompt, body) {
56
+ const { cancelled } = await ctx.newSession({
57
+ withSession: async (freshCtx) => {
58
+ if (!freshCtx.sendUserMessage) {
59
+ throw new Error("FRESH_HANDLER.spawn: replacement ctx missing sendUserMessage");
60
+ }
61
+ await freshCtx.sendUserMessage(prompt);
62
+ await body(freshCtx);
63
+ },
64
+ });
65
+ return { cancelled };
66
+ },
67
+ async send(ctx, msg) {
68
+ // Inside fresh-policy stages, ctx is the replacement delivered to
69
+ // `withSession` — Pi guarantees `sendUserMessage` is present there.
70
+ // The port marks it optional because the outer command ctx lacks it.
71
+ if (!ctx.sendUserMessage) {
72
+ throw new Error("FRESH_HANDLER.send: replacement ctx missing sendUserMessage");
73
+ }
74
+ await ctx.sendUserMessage(msg);
75
+ },
76
+ };
77
+
78
+ export const CONTINUE_HANDLER: SessionPolicyHandler = {
79
+ branchOffset: (captured) => captured,
80
+ async spawn(ctx, prompt, body, host) {
81
+ // Prefer the live `ctx.sendUserMessage` over the captured `host`.
82
+ // Pi marks the registry-level host handle stale after the first
83
+ // `ctx.newSession()`, so a continue stage that follows a fresh
84
+ // stage would throw "extension ctx is stale" if we called
85
+ // `host.sendUserMessage` here. The inner replacement ctx delivered
86
+ // to `withSession` always exposes `sendUserMessage` (the port
87
+ // marks it optional because only the outer command ctx lacks it).
88
+ // `host` remains the fallback for the workflow-start-with-continue
89
+ // case where there is no inner ctx yet — `enforceSessionInvariants`
90
+ // requires a host whenever any stage is continue-policy, so the
91
+ // fallback can be taken safely.
92
+ //
93
+ // Awaiting the send (vs fire-and-forget) lands transport errors on
94
+ // this stage's halt path; pre-I5b we discarded the promise and the
95
+ // runner walked the chain blind on rejection.
96
+ await sendIntoExistingSession(ctx, host, prompt);
97
+ await ctx.waitForIdle();
98
+ await body(ctx);
99
+ return { cancelled: false };
100
+ },
101
+ async send(ctx, msg, host) {
102
+ // Same precedence as spawn: live ctx first, captured host as
103
+ // fallback. Validation-retry sends always run inside a stage's
104
+ // session, so `ctx.sendUserMessage` is present in practice; the
105
+ // host branch is there for symmetry with spawn.
106
+ await sendIntoExistingSession(ctx, host, msg);
107
+ await ctx.waitForIdle();
108
+ },
109
+ };
110
+
111
+ /**
112
+ * Send a message into the already-active session. Prefers the live
113
+ * `ctx.sendUserMessage`; falls back to the captured registry-level
114
+ * `host.sendUserMessage` only when ctx doesn't expose one (workflow start
115
+ * with a continue-first-stage). Throws if neither path is available —
116
+ * `enforceSessionInvariants` should have rejected the workflow before
117
+ * we land here.
118
+ */
119
+ async function sendIntoExistingSession(ctx: RunnerCtx, host: WorkflowHost | undefined, msg: string): Promise<void> {
120
+ if (ctx.sendUserMessage) {
121
+ await ctx.sendUserMessage(msg);
122
+ return;
123
+ }
124
+ if (host) {
125
+ await host.sendUserMessage(msg);
126
+ return;
127
+ }
128
+ throw new Error(
129
+ "CONTINUE_HANDLER: neither ctx.sendUserMessage nor a workflow host available — continue policy requires one of them",
130
+ );
131
+ }
132
+
133
+ export function handlerFor(policy: SessionPolicy | undefined): SessionPolicyHandler {
134
+ return policy === "continue" ? CONTINUE_HANDLER : FRESH_HANDLER;
135
+ }
package/state/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * JSONL state public surface. Internal layout lives in `state.ts`'s
3
+ * header; this barrel re-exports only the symbols the rest of the
4
+ * package consumes.
5
+ */
6
+
7
+ export type {
8
+ RoutingDecision,
9
+ RunSummary,
10
+ StageStatus,
11
+ WorkflowHeader,
12
+ WorkflowStage,
13
+ } from "./state.js";
14
+ export {
15
+ appendRoutingDecision,
16
+ appendStage,
17
+ generateRunId,
18
+ listArtifacts,
19
+ listRuns,
20
+ readAllStages,
21
+ readHeader,
22
+ readLastStage,
23
+ readRoutingDecisions,
24
+ stateFilePath,
25
+ workflowsDir,
26
+ writeHeader,
27
+ } from "./state.js";
package/state/paths.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Path + run-id helpers for the JSONL audit store. Pure functions —
3
+ * no I/O. Writes and reads import from here so the on-disk layout has
4
+ * one authoritative source.
5
+ *
6
+ * <cwd>/.rpiv/workflows/<run-id>.jsonl
7
+ *
8
+ * The slug format mirrors `skills/_shared/now.mjs` so audit files
9
+ * sort chronologically by filename.
10
+ */
11
+
12
+ import { randomBytes } from "node:crypto";
13
+ import { join } from "node:path";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Run-id generation (mirrors skills/_shared/now.mjs slug pattern)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /** 2 bytes → 4 hex chars; prevents sub-second `/wf` collisions. */
20
+ const RUN_ID_SUFFIX_BYTES = 2;
21
+ const SLUG_FIELD_WIDTH = 2;
22
+ /** "YYYY-MM-DDTHH:MM:SS" — strips fractional + timezone tail of toISOString. */
23
+ const ISO_DATETIME_LENGTH = 19;
24
+
25
+ /** Format: `YYYY-MM-DD_HH-MM-SS-<4hex>`. `suffix` overridable for tests. */
26
+ export function generateRunId(
27
+ now: Date = new Date(),
28
+ suffix: string = randomBytes(RUN_ID_SUFFIX_BYTES).toString("hex"),
29
+ ): string {
30
+ const pad = (n: number) => String(n).padStart(SLUG_FIELD_WIDTH, "0");
31
+ const iso = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
32
+ const slug = iso.slice(0, ISO_DATETIME_LENGTH).replaceAll(":", "-").replace("T", "_");
33
+ return `${slug}-${suffix}`;
34
+ }
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Directory resolution
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export function workflowsDir(cwd: string): string {
41
+ return join(cwd, ".rpiv", "workflows");
42
+ }
43
+
44
+ export function stateFilePath(cwd: string, runId: string): string {
45
+ return join(workflowsDir(cwd), `${runId}.jsonl`);
46
+ }
package/state/reads.ts ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Fail-soft JSONL readers. Shape-filtered, never positional — the same
3
+ * file can carry a header, stage rows, and routing rows, and readers
4
+ * pluck whichever rows match their predicate.
5
+ *
6
+ * Per-line parse: each `JSON.parse` runs in its own try/catch — a single
7
+ * truncated trailing line (process killed mid-append, ENOSPC, network
8
+ * FS hiccup) MUST NOT erase prior rows.
9
+ *
10
+ * Public surface:
11
+ *
12
+ * - Per-row readers (`readLastStage`, `readAllStages`,
13
+ * `readRoutingDecisions`) — open ONE run's JSONL and project rows
14
+ * of the matching shape.
15
+ * - Past-runs API (`readHeader`, `listRuns`, `listArtifacts`) —
16
+ * header-only or projection-only reads sized for inspect UIs.
17
+ */
18
+
19
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
20
+ import type { Artifact } from "../handle.js";
21
+ import { stateFilePath, workflowsDir } from "./paths.js";
22
+ import type { RoutingDecision, RunSummary, WorkflowHeader, WorkflowStage } from "./state.js";
23
+
24
+ /**
25
+ * Reads every line, filters by shape (not position). Header has no
26
+ * `stageNumber`; routing rows carry `type: "routing"`; stage rows have
27
+ * `stageNumber: number` and no `type`. Starting at line 0 keeps the first
28
+ * stage row recoverable even if a transient writeHeader failure left the
29
+ * file without its header.
30
+ *
31
+ * Each line's `JSON.parse` runs in its own try/catch — a truncated trailing
32
+ * line (process killed mid-`appendFileSync`, ENOSPC, network FS hiccup)
33
+ * MUST NOT erase prior rows. Malformed lines emit a one-shot warn and are
34
+ * skipped; readers see every well-formed row that landed on disk.
35
+ */
36
+ function readJsonlRows<T>(cwd: string, runId: string, match: (row: unknown) => row is T): T[] {
37
+ let lines: string[];
38
+ try {
39
+ const filePath = stateFilePath(cwd, runId);
40
+ if (!existsSync(filePath)) return [];
41
+ const content = readFileSync(filePath, "utf-8").trim();
42
+ if (!content) return [];
43
+ lines = content.split("\n");
44
+ } catch (e) {
45
+ console.warn(`[rpiv-workflow] workflow state: ${e instanceof Error ? e.message : String(e)}`);
46
+ return [];
47
+ }
48
+
49
+ const rows: T[] = [];
50
+ for (const line of lines) {
51
+ let parsed: unknown;
52
+ try {
53
+ parsed = JSON.parse(line);
54
+ } catch (e) {
55
+ console.warn(
56
+ `[rpiv-workflow] workflow state: skipping malformed JSONL row — ${e instanceof Error ? e.message : String(e)}`,
57
+ );
58
+ continue;
59
+ }
60
+ if (match(parsed)) rows.push(parsed);
61
+ }
62
+ return rows;
63
+ }
64
+
65
+ const isWorkflowStage = (row: unknown): row is WorkflowStage =>
66
+ !!row &&
67
+ typeof (row as { stageNumber?: unknown }).stageNumber === "number" &&
68
+ typeof (row as { stage?: unknown }).stage === "string";
69
+
70
+ const isRoutingDecision = (row: unknown): row is RoutingDecision =>
71
+ !!row && (row as { type?: unknown }).type === "routing";
72
+
73
+ const isWorkflowHeader = (row: unknown): row is WorkflowHeader =>
74
+ !!row &&
75
+ typeof (row as { runId?: unknown }).runId === "string" &&
76
+ typeof (row as { workflow?: unknown }).workflow === "string" &&
77
+ typeof (row as { input?: unknown }).input === "string" &&
78
+ typeof (row as { ts?: unknown }).ts === "string";
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Per-run readers
82
+ // ---------------------------------------------------------------------------
83
+
84
+ export function readLastStage(cwd: string, runId: string): WorkflowStage | undefined {
85
+ const stages = readJsonlRows(cwd, runId, isWorkflowStage);
86
+ return stages.length ? stages[stages.length - 1] : undefined;
87
+ }
88
+
89
+ export function readAllStages(cwd: string, runId: string): WorkflowStage[] {
90
+ return readJsonlRows(cwd, runId, isWorkflowStage);
91
+ }
92
+
93
+ export function readRoutingDecisions(cwd: string, runId: string): RoutingDecision[] {
94
+ return readJsonlRows(cwd, runId, isRoutingDecision);
95
+ }
96
+
97
+ /**
98
+ * Project a run's stage rows to the (stage, artifact) pairs that
99
+ * actually carried at least one artifact. One entry per artifact —
100
+ * stages with multi-artifact collectors expand to N entries. Used by
101
+ * `notifyPartialArtifacts` for the failure recap and by past-runs UIs
102
+ * (the `listRuns` API) for run summaries.
103
+ *
104
+ * `stage` is the workflow stage's record key (always present); `skill`
105
+ * is the Pi skill body when this row recorded a skill stage (absent
106
+ * for script stages).
107
+ *
108
+ * Reads from `output.artifacts` (single source); rows without an
109
+ * output, or with an empty artifacts list, contribute nothing.
110
+ */
111
+ export function listArtifacts(
112
+ cwd: string,
113
+ runId: string,
114
+ ): Array<{ stage: string; skill?: string; artifact: Artifact }> {
115
+ const out: Array<{ stage: string; skill?: string; artifact: Artifact }> = [];
116
+ for (const s of readAllStages(cwd, runId)) {
117
+ const artifacts = s.output?.artifacts;
118
+ if (!artifacts) continue;
119
+ for (const artifact of artifacts) out.push({ stage: s.stage, skill: s.skill, artifact });
120
+ }
121
+ return out;
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Past-runs enumeration (header-only)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ /**
129
+ * Read only the first JSONL line and parse it as a `WorkflowHeader`. Used
130
+ * by `listRuns` so enumerating N past runs reads N first-lines instead
131
+ * of fully parsing every row in every file. Returns undefined when the
132
+ * file is missing, empty, or the first line doesn't match the header
133
+ * shape.
134
+ *
135
+ * Fail-soft like every other reader — never throws.
136
+ */
137
+ export function readHeader(cwd: string, runId: string): WorkflowHeader | undefined {
138
+ try {
139
+ const filePath = stateFilePath(cwd, runId);
140
+ if (!existsSync(filePath)) return undefined;
141
+ const content = readFileSync(filePath, "utf-8");
142
+ const firstLine = content.split("\n", 1)[0] ?? "";
143
+ if (!firstLine) return undefined;
144
+ const parsed = JSON.parse(firstLine);
145
+ return isWorkflowHeader(parsed) ? parsed : undefined;
146
+ } catch {
147
+ // Malformed JSON or I/O error — caller treats as "header unreadable".
148
+ return undefined;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Enumerate every `<cwd>/.rpiv/workflows/<run-id>.jsonl` and return its
154
+ * header projected as a `RunSummary`. Empty array when the workflows
155
+ * directory doesn't exist (no runs yet). Files without a valid header
156
+ * are skipped silently (corrupt / mid-write).
157
+ *
158
+ * Header-only reads — full stage rows aren't parsed (see `readHeader`'s
159
+ * doc). Past-runs UIs page through the summary; opening a specific run
160
+ * for inspection still calls `readAllStages` / `listArtifacts`.
161
+ *
162
+ * Sort is filesystem-order — callers that want chronological order can
163
+ * sort by `ts` (run-id slug already encodes time, so a string sort on
164
+ * `runId` is monotonic for runs created on the same host).
165
+ */
166
+ export function listRuns(cwd: string): RunSummary[] {
167
+ const dir = workflowsDir(cwd);
168
+ let entries: string[];
169
+ try {
170
+ entries = readdirSync(dir);
171
+ } catch {
172
+ // Directory doesn't exist (no runs yet) or unreadable — treat as empty.
173
+ return [];
174
+ }
175
+ const summaries: RunSummary[] = [];
176
+ for (const name of entries) {
177
+ if (!name.endsWith(".jsonl")) continue;
178
+ const runId = name.slice(0, -".jsonl".length);
179
+ const header = readHeader(cwd, runId);
180
+ if (header)
181
+ summaries.push({
182
+ runId: header.runId,
183
+ workflow: header.workflow,
184
+ input: header.input,
185
+ ts: header.ts,
186
+ trigger: header.trigger,
187
+ });
188
+ }
189
+ return summaries;
190
+ }
package/state/state.ts ADDED
@@ -0,0 +1,115 @@
1
+ /**
2
+ * JSONL state at `.rpiv/workflows/<run-id>.jsonl`. Append-only audit
3
+ * trail; every line is a self-contained JSON object. All I/O is
4
+ * fail-soft (logs via console.warn with `[rpiv-workflow]` prefix, never
5
+ * throws).
6
+ *
7
+ * Internally split into three modules:
8
+ * - paths.ts — workflowsDir + stateFilePath + generateRunId
9
+ * - writes.ts — tryAppendJsonl + writeHeader + appendStage +
10
+ * appendRoutingDecision
11
+ * - reads.ts — readLastStage + readAllStages + readRoutingDecisions +
12
+ * listArtifacts + readHeader + listRuns
13
+ *
14
+ * This file owns the row shapes + types + the public barrel; everything
15
+ * else lives in a focused module.
16
+ */
17
+
18
+ import type { Output } from "../output.js";
19
+ import type { RunTrigger } from "../triggers.js";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Row shapes
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export type StageStatus = "completed" | "failed" | "skipped" | "aborted";
26
+
27
+ /**
28
+ * Audit files are debug artifacts — no migration provided. Readers
29
+ * shape-filter on `stageNumber`, so any rows that don't satisfy the
30
+ * current shape are silently skipped.
31
+ *
32
+ * Two identity fields:
33
+ * - `stage` — the workflow stage's record key. Always present. The
34
+ * audit row's "which step ran" answer; stable across skill-body
35
+ * overrides and aliased stages.
36
+ * - `skill?` — the Pi skill body invoked, when this row records a
37
+ * skill stage. Absent for script stages (`StageDef.run`); equal to
38
+ * `stage` in the common case where `produces()` / `acts()` defaults
39
+ * the skill body to the record key.
40
+ *
41
+ * The row no longer carries a top-level `artifact` field — discovery
42
+ * moved into the collector, and the canonical artifact list lives on
43
+ * `output.artifacts`. Readers project from there via `listArtifacts`.
44
+ */
45
+ export interface WorkflowStage {
46
+ stageNumber: number;
47
+ stage: string;
48
+ skill?: string;
49
+ status: StageStatus;
50
+ ts: string;
51
+ output?: Output;
52
+ /**
53
+ * Reason a terminal-failure row was written — mirrors the
54
+ * `state.termination.error` set by `recordTerminalFailure`. Present
55
+ * only on `status: "failed" | "aborted"` rows; absent on completed /
56
+ * skipped rows. Persisting it here means post-mortems work from
57
+ * JSONL alone, without depending on a transient `ctx.ui.notify` toast.
58
+ */
59
+ errMsg?: string;
60
+ }
61
+
62
+ /** First line of the JSONL file. */
63
+ export interface WorkflowHeader {
64
+ runId: string;
65
+ workflow: string;
66
+ input: string;
67
+ ts: string;
68
+ /**
69
+ * What triggered the run. Optional so older JSONL files (written
70
+ * before the trigger field was added) still parse — readers treat
71
+ * `undefined` as "trigger unknown."
72
+ */
73
+ trigger?: RunTrigger;
74
+ }
75
+
76
+ /**
77
+ * Returned by `listRuns` — projection of a JSONL header for past-run
78
+ * enumeration UIs. Distinct from `WorkflowHeader` only by intent (this
79
+ * is the "what you see in a list" shape); kept structurally compatible
80
+ * so callers that want the raw header can pass `RunSummary` through.
81
+ */
82
+ export interface RunSummary {
83
+ runId: string;
84
+ /** Workflow name (matches `Workflow.name` at run-time). */
85
+ workflow: string;
86
+ /** Original `/wf` input the user typed. */
87
+ input: string;
88
+ /** ISO-8601 timestamp the run started at — slug-sortable. */
89
+ ts: string;
90
+ /** Mirrors `WorkflowHeader.trigger`; undefined for legacy rows. */
91
+ trigger?: RunTrigger;
92
+ }
93
+
94
+ export interface RoutingDecision {
95
+ type: "routing";
96
+ fromStageIndex: number;
97
+ fromStage: string;
98
+ decision: string;
99
+ ts: string;
100
+ }
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // Public barrel — paths + writes + reads
104
+ // ---------------------------------------------------------------------------
105
+
106
+ export { generateRunId, stateFilePath, workflowsDir } from "./paths.js";
107
+ export {
108
+ listArtifacts,
109
+ listRuns,
110
+ readAllStages,
111
+ readHeader,
112
+ readLastStage,
113
+ readRoutingDecisions,
114
+ } from "./reads.js";
115
+ export { appendRoutingDecision, appendStage, writeHeader } from "./writes.js";
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Fail-soft JSONL appends. Every public helper here is a thin wrapper
3
+ * around `tryAppendJsonl` so the write protocol (mkdirSync + append +
4
+ * stderr warn on throw) lives in one place — changes to atomicity,
5
+ * checksums, or alternative storage backends touch this file and this
6
+ * file only.
7
+ *
8
+ * writeHeader — best-effort; discards the return.
9
+ * appendStage — boolean; allocator gates monotonic counters on it.
10
+ * appendRoutingDecision — boolean; telemetry-not-state, dropped rows surface up.
11
+ */
12
+
13
+ import { appendFileSync, mkdirSync } from "node:fs";
14
+ import { stateFilePath, workflowsDir } from "./paths.js";
15
+ import type { RoutingDecision, WorkflowHeader, WorkflowStage } from "./state.js";
16
+
17
+ /**
18
+ * Shared append primitive: ensure the workflows directory exists, then
19
+ * append one JSON-serialised row + newline. Returns true on success;
20
+ * on any throw, warns to stderr and returns false. The three public
21
+ * append helpers below are thin wrappers — `writeHeader` discards the
22
+ * return (best-effort), the others gate counters / telemetry on it.
23
+ */
24
+ function tryAppendJsonl(cwd: string, runId: string, row: unknown): boolean {
25
+ try {
26
+ const dir = workflowsDir(cwd);
27
+ mkdirSync(dir, { recursive: true });
28
+ const filePath = stateFilePath(cwd, runId);
29
+ appendFileSync(filePath, `${JSON.stringify(row)}\n`, "utf-8");
30
+ return true;
31
+ } catch (e) {
32
+ console.warn(`[rpiv-workflow] workflow state: ${e instanceof Error ? e.message : String(e)}`);
33
+ return false;
34
+ }
35
+ }
36
+
37
+ export function writeHeader(cwd: string, header: WorkflowHeader): void {
38
+ tryAppendJsonl(cwd, header.runId, header);
39
+ }
40
+
41
+ /** Returns true on successful write — callers gate counters on this. */
42
+ export function appendStage(cwd: string, runId: string, stage: WorkflowStage): boolean {
43
+ return tryAppendJsonl(cwd, runId, stage);
44
+ }
45
+
46
+ /**
47
+ * Returns true on successful write — callers surface the failure to the user
48
+ * (warning notification + result-envelope flag) so an absent row is not silently
49
+ * conflated with "deterministic edge, no decision recorded." Unlike `appendStage`,
50
+ * a dropped routing row does NOT halt the chain: the routing decision has
51
+ * already been made in memory (see runner.ts `nextNode`), and no in-memory
52
+ * state mirrors routing rows the way it mirrors stage rows — routing is
53
+ * write-only telemetry. Halting on telemetry failure would punish the user
54
+ * for transient disk weather without preserving any invariant.
55
+ */
56
+ export function appendRoutingDecision(cwd: string, runId: string, row: RoutingDecision): boolean {
57
+ return tryAppendJsonl(cwd, runId, row);
58
+ }