@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,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";
|
package/state/writes.ts
ADDED
|
@@ -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
|
+
}
|