@juicesharp/rpiv-workflow 1.16.0 → 1.16.1

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 (2) hide show
  1. package/iterate.ts +142 -0
  2. package/package.json +3 -2
package/iterate.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Iterate iteration — the sequential, accumulating dual of `fanout.ts`. When a
3
+ * stage opts in via `StageDef.iterate`, the runner pulls units one at a time
4
+ * (calling the user's `IterateFn` per unit, feeding it the prior units'
5
+ * validated `Output`s) and runs one Pi session per unit. Unlike fanout, each
6
+ * unit runs the stage's `outcome` collector — it reuses `runStageSession`
7
+ * verbatim, so every unit gets the same collector → validate → record →
8
+ * accumulate path as a one-shot `produces` stage.
9
+ *
10
+ * `runner.ts` (via `stage-lifecycle.ts`) injects the runner primitives through
11
+ * `IterateDeps` so this module never imports back (cycle-free), mirroring how
12
+ * `runFanout` receives `FanoutDeps`.
13
+ *
14
+ * Two run-wide bounds are the only safety nets: the generator's own `null`
15
+ * terminator, and `run.maxIterations` (the backstop for a generator that never
16
+ * returns null). No markdown regex, no per-convention counter — rpiv-workflow
17
+ * stays convention-agnostic; the `IterateFn` body owns the convention.
18
+ */
19
+
20
+ import type { StageDef } from "./api.js";
21
+ import { iterateRowStage } from "./audit.js";
22
+ import type { Artifact } from "./handle.js";
23
+ import { MSG_ITERATE_ZERO_UNITS, MSG_STAGE_COMPLETE, STATUS_ITERATE_UNIT, STATUS_KEY } from "./messages.js";
24
+ import type { Output } from "./output.js";
25
+ import type { RunContext, RunnerCtx, StageSession } from "./types.js";
26
+
27
+ export interface IterateDeps {
28
+ /**
29
+ * Dispatch one unit through the standard stage-session path (collector →
30
+ * validate → record → accumulate). The same `runStageSession` a normal
31
+ * `produces` stage uses — iterate adds no bespoke session machinery.
32
+ */
33
+ runStageSession: (ctx: RunnerCtx, s: StageSession) => Promise<void>;
34
+ /**
35
+ * Resume the chain after the iterate node's units finish (generator
36
+ * returned null). Receives the iterate node's REAL name so routing looks up
37
+ * the outgoing edge from it — the per-unit audit decoration never leaks
38
+ * into routing.
39
+ */
40
+ advanceAfter: (curCtx: RunnerCtx, completedName: string, completedIdx: number, run: RunContext) => Promise<void>;
41
+ /** Re-capture the outcome's pre-stage snapshot per unit (each unit is its own produces pass). */
42
+ captureSnapshot: (def: StageDef, idx: number, run: RunContext) => Promise<unknown>;
43
+ /** Record the terminal failure when the `maxIterations` backstop trips. */
44
+ haltIterations: (curCtx: RunnerCtx, run: RunContext, stageName: string, count: number) => Promise<void>;
45
+ }
46
+
47
+ /**
48
+ * `skill` is the bundled skill body (threaded by the runner), not the node
49
+ * name — aliased nodes tag unit rows + prompts with the skill body so audit
50
+ * consumers don't see two labels for the same work.
51
+ *
52
+ * `currentName` is the iterate node's REAL name in the workflow — passed to
53
+ * `advanceAfter` once the generator terminates, and used (undecorated) for
54
+ * `state.named` keying via `resolvePublishName`.
55
+ *
56
+ * `entryArtifact` is the stage-entry primary, FROZEN across every unit (the
57
+ * rolling primary advances to each unit's output, but the generator keeps
58
+ * seeing its true source — see `IterateContext.artifact`).
59
+ *
60
+ * `accumulated` carries this stage's prior validated `Output`s in order. The
61
+ * continuation-style self-call appends the unit just produced (read from
62
+ * `run.state.output`, which `tryRecordStage` sets immediately before
63
+ * `onSuccess`).
64
+ */
65
+ export async function runIterate(
66
+ curCtx: RunnerCtx,
67
+ stageIdx: number,
68
+ currentName: string,
69
+ skill: string,
70
+ def: StageDef,
71
+ entryArtifact: Artifact | undefined,
72
+ accumulated: readonly Output[],
73
+ run: RunContext,
74
+ deps: IterateDeps,
75
+ ): Promise<void> {
76
+ const unit = await def.iterate!({
77
+ cwd: run.cwd,
78
+ artifact: entryArtifact,
79
+ state: run.state,
80
+ accumulated,
81
+ index: accumulated.length,
82
+ });
83
+
84
+ // Generator terminated — complete the stage and resume the chain from the
85
+ // real node name. A first-call null is the zero-unit no-op: nothing is
86
+ // published, the primary stays at the entry artifact — warn (not error) so
87
+ // the author notices the empty input. A null after ≥1 unit is a normal
88
+ // completion.
89
+ if (!unit) {
90
+ if (accumulated.length === 0) curCtx.ui.notify(MSG_ITERATE_ZERO_UNITS(skill), "warning");
91
+ else curCtx.ui.notify(MSG_STAGE_COMPLETE(skill), "info");
92
+ await deps.advanceAfter(curCtx, currentName, stageIdx, run);
93
+ return;
94
+ }
95
+
96
+ // Backstop: the generator wants another unit but we've hit the run-wide
97
+ // cap. Halt with a terminal failure (mirrors the backward-jump guard) so a
98
+ // runaway generator can't loop forever.
99
+ if (accumulated.length >= run.maxIterations) {
100
+ await deps.haltIterations(curCtx, run, currentName, accumulated.length);
101
+ return;
102
+ }
103
+
104
+ curCtx.ui.setStatus(STATUS_KEY, STATUS_ITERATE_UNIT(stageIdx + 1, run.totalStages, skill, unit.label));
105
+ const snapshot = await deps.captureSnapshot(def, stageIdx, run);
106
+
107
+ await deps.runStageSession(curCtx, {
108
+ cwd: run.cwd,
109
+ runId: run.runId,
110
+ state: run.state,
111
+ prompt: `/skill:${skill} ${unit.prompt}`,
112
+ // Decorated for the JSONL row + status; named keying still resolves to
113
+ // outcome.name (mandatory for iterate), so the decoration never splits
114
+ // the accumulation slot.
115
+ stageName: iterateRowStage(currentName, unit.id ?? unit.label),
116
+ skill,
117
+ lifecycle: run.lifecycle,
118
+ runIdentity: { workflow: run.workflow.name, totalStages: run.totalStages, trigger: run.trigger },
119
+ stage: def,
120
+ stageIndex: stageIdx,
121
+ snapshot,
122
+ branchOffset: undefined,
123
+ onFailure: undefined,
124
+ onSuccess: (freshCtx) => {
125
+ // `tryRecordStage` set `state.output` to this unit's validated Output
126
+ // (and `maybeAdvancePrimary` already pushed it onto state.named) before
127
+ // onSuccess fired. Thread it into `accumulated` for the next pull.
128
+ const produced = run.state.output!;
129
+ return runIterate(
130
+ freshCtx,
131
+ stageIdx,
132
+ currentName,
133
+ skill,
134
+ def,
135
+ entryArtifact,
136
+ [...accumulated, produced],
137
+ run,
138
+ deps,
139
+ );
140
+ },
141
+ });
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juicesharp/rpiv-workflow",
3
- "version": "1.16.0",
3
+ "version": "1.16.1",
4
4
  "description": "Pi extension. Chain skills into typed multi-stage workflows with audited JSONL state, predicate routing, and per-stage output validation. Skill-agnostic — bring your own skills.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -42,6 +42,7 @@
42
42
  "handle.ts",
43
43
  "host.ts",
44
44
  "internal-utils.ts",
45
+ "iterate.ts",
45
46
  "layers.ts",
46
47
  "lifecycle.ts",
47
48
  "load",
@@ -76,7 +77,7 @@
76
77
  ]
77
78
  },
78
79
  "dependencies": {
79
- "@juicesharp/rpiv-config": "^1.16.0",
80
+ "@juicesharp/rpiv-config": "^1.16.1",
80
81
  "jiti": "^2.7.0"
81
82
  },
82
83
  "peerDependencies": {