@osolmaz/pi-workflows 0.3.0 → 0.5.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/README.md +11 -7
- package/dist/builtins/catalog.d.ts +2 -0
- package/dist/builtins/catalog.js +32 -0
- package/dist/builtins/catalog.js.map +1 -0
- package/dist/builtins/monitor.workflow.d.ts +2 -2
- package/dist/builtins/monitor.workflow.js +25 -25
- package/dist/builtins/monitor.workflow.js.map +1 -1
- package/dist/controllers/index.d.ts +1 -1
- package/dist/controllers/index.js.map +1 -1
- package/dist/controllers/sqlite.d.ts +70 -9
- package/dist/controllers/sqlite.js +209 -35
- package/dist/controllers/sqlite.js.map +1 -1
- package/dist/controllers/workflow-engine-scheduler.d.ts +2 -2
- package/dist/controllers/workflow-engine-scheduler.js +3 -1
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -1
- package/dist/extension/executor.d.ts +3 -0
- package/dist/extension/executor.js +11 -1
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.js +157 -108
- package/dist/extension/index.js.map +1 -1
- package/dist/host/runner.d.ts +1 -0
- package/dist/host/runner.js +68 -20
- package/dist/host/runner.js.map +1 -1
- package/dist/render/graph-render.js +3 -0
- package/dist/render/graph-render.js.map +1 -1
- package/dist/workflows/catalog.d.ts +43 -0
- package/dist/workflows/catalog.js +79 -0
- package/dist/workflows/catalog.js.map +1 -0
- package/dist/workflows/definition.d.ts +2 -1
- package/dist/workflows/definition.js +9 -1
- package/dist/workflows/definition.js.map +1 -1
- package/dist/workflows/engine.d.ts +6 -6
- package/dist/workflows/engine.js +93 -33
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/index.d.ts +3 -3
- package/dist/workflows/index.js +2 -2
- package/dist/workflows/index.js.map +1 -1
- package/dist/workflows/loader.d.ts +18 -16
- package/dist/workflows/loader.js +58 -23
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/migrate-sources.d.ts +42 -0
- package/dist/workflows/migrate-sources.js +133 -0
- package/dist/workflows/migrate-sources.js.map +1 -0
- package/dist/workflows/schema.d.ts +2 -1
- package/dist/workflows/schema.js +14 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.js +5 -2
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +44 -5
- package/docs/development.md +5 -3
- package/docs/plans/2026-08-12-coordinated-workflow-timeouts-plan.md +74 -0
- package/docs/plans/2026-08-13-built-in-workflow-catalog-plan.md +97 -0
- package/docs/plans/2026-08-13-session-addressed-workflow-notifications-plan.md +95 -0
- package/docs/run-bundles.md +22 -4
- package/docs/workflows.md +57 -14
- package/package.json +1 -1
- package/src/builtins/catalog.ts +32 -0
- package/src/builtins/monitor.workflow.ts +33 -26
- package/src/controllers/index.ts +1 -0
- package/src/controllers/sqlite.ts +353 -43
- package/src/controllers/workflow-engine-scheduler.ts +5 -2
- package/src/extension/executor.ts +12 -1
- package/src/extension/index.ts +181 -140
- package/src/host/runner.ts +78 -20
- package/src/render/graph-render.ts +3 -0
- package/src/workflows/catalog.ts +135 -0
- package/src/workflows/definition.ts +11 -0
- package/src/workflows/engine.ts +128 -53
- package/src/workflows/index.ts +7 -0
- package/src/workflows/loader.ts +70 -26
- package/src/workflows/migrate-sources.ts +174 -0
- package/src/workflows/schema.ts +16 -1
- package/src/workflows/store.ts +5 -2
- package/src/workflows/types.ts +43 -4
package/src/workflows/engine.ts
CHANGED
|
@@ -30,8 +30,10 @@ import type {
|
|
|
30
30
|
WorkflowNodeDefinition,
|
|
31
31
|
WorkflowNodeOutcome,
|
|
32
32
|
WorkflowNodeResult,
|
|
33
|
+
WorkflowNotificationSink,
|
|
33
34
|
WorkflowRunResult,
|
|
34
35
|
WorkflowRunState,
|
|
36
|
+
WorkflowSource,
|
|
35
37
|
WorkflowStepRecord,
|
|
36
38
|
WorkflowTraceEventDraft,
|
|
37
39
|
} from "./types.js";
|
|
@@ -39,6 +41,7 @@ import type {
|
|
|
39
41
|
const DEFAULT_NODE_TIMEOUT_MS = 15 * 60_000;
|
|
40
42
|
const DEFAULT_MAX_STEPS = 100;
|
|
41
43
|
const TITLE_TIMEOUT_MS = 30_000;
|
|
44
|
+
const TIMEOUT_RESOLUTION_TIMEOUT_MS = 30_000;
|
|
42
45
|
// Covers the shell SIGTERM → SIGKILL escalation (1s) plus stdio close.
|
|
43
46
|
const ABORT_CLEANUP_GRACE_MS = 2_000;
|
|
44
47
|
|
|
@@ -72,6 +75,7 @@ type NodeAttempt = {
|
|
|
72
75
|
*/
|
|
73
76
|
export class WorkflowEngine {
|
|
74
77
|
private readonly executor: AgentStepExecutor;
|
|
78
|
+
private readonly notificationSink: WorkflowNotificationSink | undefined;
|
|
75
79
|
private readonly store: WorkflowRunStore;
|
|
76
80
|
private readonly defaultNodeTimeoutMs: number;
|
|
77
81
|
private readonly maxSteps: number;
|
|
@@ -86,6 +90,7 @@ export class WorkflowEngine {
|
|
|
86
90
|
|
|
87
91
|
constructor(options: WorkflowEngineOptions) {
|
|
88
92
|
this.executor = options.executor;
|
|
93
|
+
this.notificationSink = options.notificationSink;
|
|
89
94
|
this.store = options.store ?? new WorkflowRunStore(options.outputRoot);
|
|
90
95
|
this.defaultNodeTimeoutMs = options.defaultNodeTimeoutMs ?? DEFAULT_NODE_TIMEOUT_MS;
|
|
91
96
|
this.maxSteps = options.maxSteps ?? DEFAULT_MAX_STEPS;
|
|
@@ -140,7 +145,7 @@ export class WorkflowEngine {
|
|
|
140
145
|
async run(
|
|
141
146
|
workflow: WorkflowDefinition,
|
|
142
147
|
input: unknown,
|
|
143
|
-
options: {
|
|
148
|
+
options: { workflowSource?: WorkflowSource; runId?: string } = {},
|
|
144
149
|
): Promise<WorkflowRunResult> {
|
|
145
150
|
validateWorkflowDefinition(workflow);
|
|
146
151
|
// Fail before any bundle exists so bad input cannot leave a partial run
|
|
@@ -157,8 +162,7 @@ export class WorkflowEngine {
|
|
|
157
162
|
const state = await this.createRunState(
|
|
158
163
|
workflow,
|
|
159
164
|
normalizedInput,
|
|
160
|
-
options.
|
|
161
|
-
options.workflowHash,
|
|
165
|
+
options.workflowSource,
|
|
162
166
|
options.runId,
|
|
163
167
|
);
|
|
164
168
|
const runDir = await this.store.initializeRunBundle(workflow, state);
|
|
@@ -196,7 +200,7 @@ export class WorkflowEngine {
|
|
|
196
200
|
async resumeRun(
|
|
197
201
|
workflow: WorkflowDefinition,
|
|
198
202
|
runId: string,
|
|
199
|
-
options: {
|
|
203
|
+
options: { workflowSource?: WorkflowSource; force?: boolean } = {},
|
|
200
204
|
): Promise<WorkflowRunResult> {
|
|
201
205
|
validateWorkflowDefinition(workflow);
|
|
202
206
|
// Reset before any await: a park or cancel landing during preparation
|
|
@@ -207,11 +211,8 @@ export class WorkflowEngine {
|
|
|
207
211
|
const bundle = await this.store.prepareRunResume(runId);
|
|
208
212
|
const { runDir } = bundle;
|
|
209
213
|
const state = bundle.state;
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
options.workflowHash !== undefined &&
|
|
213
|
-
state.workflowHash !== options.workflowHash;
|
|
214
|
-
if (hashMismatch && options.force !== true) {
|
|
214
|
+
const sourceMismatch = workflowSourceMismatch(state, options.workflowSource);
|
|
215
|
+
if (sourceMismatch && options.force !== true) {
|
|
215
216
|
throw new WorkflowSourceChangedError(runId);
|
|
216
217
|
}
|
|
217
218
|
|
|
@@ -230,7 +231,7 @@ export class WorkflowEngine {
|
|
|
230
231
|
payload: {
|
|
231
232
|
...(point.nodeId !== null ? { resumeAt: point.nodeId } : {}),
|
|
232
233
|
replayedSteps: state.steps.length,
|
|
233
|
-
...(
|
|
234
|
+
...(sourceMismatch ? { workflowSourceMismatch: true, forced: true } : {}),
|
|
234
235
|
},
|
|
235
236
|
});
|
|
236
237
|
await this.onRunStarted?.(runDir, state);
|
|
@@ -283,7 +284,7 @@ export class WorkflowEngine {
|
|
|
283
284
|
workflow: WorkflowDefinition,
|
|
284
285
|
parentRunId: string,
|
|
285
286
|
input: unknown,
|
|
286
|
-
options: {
|
|
287
|
+
options: { workflowSource?: WorkflowSource; runId?: string; force?: boolean } = {},
|
|
287
288
|
): Promise<WorkflowRunResult> {
|
|
288
289
|
validateWorkflowDefinition(workflow);
|
|
289
290
|
this.cancelled = false;
|
|
@@ -298,11 +299,8 @@ export class WorkflowEngine {
|
|
|
298
299
|
`Cannot continue workflow run ${parentRunId} with status ${parent.state.status}`,
|
|
299
300
|
);
|
|
300
301
|
}
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
options.workflowHash !== undefined &&
|
|
304
|
-
parent.state.workflowHash !== options.workflowHash;
|
|
305
|
-
if (hashMismatch && options.force !== true) {
|
|
302
|
+
const sourceMismatch = workflowSourceMismatch(parent.state, options.workflowSource);
|
|
303
|
+
if (sourceMismatch && options.force !== true) {
|
|
306
304
|
throw new WorkflowSourceChangedError(parentRunId);
|
|
307
305
|
}
|
|
308
306
|
|
|
@@ -315,8 +313,7 @@ export class WorkflowEngine {
|
|
|
315
313
|
const state = await this.createRunState(
|
|
316
314
|
workflow,
|
|
317
315
|
normalizedInput,
|
|
318
|
-
options.
|
|
319
|
-
options.workflowHash,
|
|
316
|
+
options.workflowSource,
|
|
320
317
|
options.runId,
|
|
321
318
|
);
|
|
322
319
|
state.parentRunId = parentRunId;
|
|
@@ -477,8 +474,7 @@ export class WorkflowEngine {
|
|
|
477
474
|
private async createRunState(
|
|
478
475
|
workflow: WorkflowDefinition,
|
|
479
476
|
input: unknown,
|
|
480
|
-
|
|
481
|
-
workflowHash: string | undefined,
|
|
477
|
+
workflowSource: WorkflowSource | undefined,
|
|
482
478
|
runId: string | undefined,
|
|
483
479
|
): Promise<WorkflowRunState> {
|
|
484
480
|
const now = new Date().toISOString();
|
|
@@ -488,8 +484,7 @@ export class WorkflowEngine {
|
|
|
488
484
|
runId: runId ?? createRunId(workflow.name),
|
|
489
485
|
workflowName: workflow.name,
|
|
490
486
|
...(await this.resolveTitleBounded(workflow, input)),
|
|
491
|
-
...(
|
|
492
|
-
...(workflowHash !== undefined ? { workflowHash } : {}),
|
|
487
|
+
...(workflowSource !== undefined ? { workflowSource } : {}),
|
|
493
488
|
startedAt: now,
|
|
494
489
|
updatedAt: now,
|
|
495
490
|
status: "running",
|
|
@@ -758,36 +753,43 @@ export class WorkflowEngine {
|
|
|
758
753
|
node: WorkflowNodeDefinition,
|
|
759
754
|
meta: NodeExecutionMeta,
|
|
760
755
|
): Promise<NodeExecution> {
|
|
761
|
-
const timeoutMs = node.timeoutMs ?? this.defaultNodeTimeoutMs;
|
|
762
756
|
const abort = new AbortController();
|
|
757
|
+
const context = this.createNodeContext(state, abort.signal);
|
|
758
|
+
let timer: NodeJS.Timeout | undefined;
|
|
759
|
+
let dispatchSettled: Promise<void> | undefined;
|
|
763
760
|
this.activeAbort = abort;
|
|
764
|
-
if (this.parked) {
|
|
765
|
-
// A park that landed during the node_started persist must not let the
|
|
766
|
-
// node dispatch: its discarded side effects would rerun on resume.
|
|
767
|
-
throw new RunParkedError();
|
|
768
|
-
}
|
|
769
|
-
if (this.cancelled) {
|
|
770
|
-
throw new CancelledError();
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
const timer = setTimeout(() => {
|
|
774
|
-
abort.abort(new TimeoutError(timeoutMs));
|
|
775
|
-
}, timeoutMs);
|
|
776
|
-
const dispatched = this.dispatchNode(
|
|
777
|
-
workflow,
|
|
778
|
-
state,
|
|
779
|
-
runDir,
|
|
780
|
-
nodeId,
|
|
781
|
-
attemptId,
|
|
782
|
-
node,
|
|
783
|
-
abort.signal,
|
|
784
|
-
meta,
|
|
785
|
-
);
|
|
786
|
-
const dispatchSettled = dispatched.then(
|
|
787
|
-
() => undefined,
|
|
788
|
-
() => undefined,
|
|
789
|
-
);
|
|
790
761
|
try {
|
|
762
|
+
if (this.parked) {
|
|
763
|
+
// A park that landed during the node_started persist must not let the
|
|
764
|
+
// node dispatch: its discarded side effects would rerun on resume.
|
|
765
|
+
throw new RunParkedError();
|
|
766
|
+
}
|
|
767
|
+
if (this.cancelled) {
|
|
768
|
+
throw new CancelledError();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const timeoutMs = await this.resolveNodeTimeout(node, context, abort);
|
|
772
|
+
if (abort.signal.aborted) {
|
|
773
|
+
throw abortError(abort.signal);
|
|
774
|
+
}
|
|
775
|
+
timer = setTimeout(() => {
|
|
776
|
+
abort.abort(new TimeoutError(timeoutMs));
|
|
777
|
+
}, timeoutMs);
|
|
778
|
+
const dispatched = this.dispatchNode(
|
|
779
|
+
workflow,
|
|
780
|
+
state,
|
|
781
|
+
runDir,
|
|
782
|
+
nodeId,
|
|
783
|
+
attemptId,
|
|
784
|
+
node,
|
|
785
|
+
context,
|
|
786
|
+
abort.signal,
|
|
787
|
+
meta,
|
|
788
|
+
);
|
|
789
|
+
dispatchSettled = dispatched.then(
|
|
790
|
+
() => undefined,
|
|
791
|
+
() => undefined,
|
|
792
|
+
);
|
|
791
793
|
// Race the dispatch against the abort signal so timeouts and cancel
|
|
792
794
|
// take effect even for node callbacks that never observe the signal.
|
|
793
795
|
const execution = await Promise.race([dispatched, abortRejection(abort.signal)]);
|
|
@@ -799,7 +801,7 @@ export class WorkflowEngine {
|
|
|
799
801
|
assertJsonSerializable(execution.output, `Node ${nodeId} output`);
|
|
800
802
|
return execution;
|
|
801
803
|
} catch (error) {
|
|
802
|
-
if (node.nodeType === "action" && "exec" in node) {
|
|
804
|
+
if (node.nodeType === "action" && "exec" in node && dispatchSettled !== undefined) {
|
|
803
805
|
// Give the killed shell command a short grace period to close so its
|
|
804
806
|
// action receipt lands in `meta` before the failed attempt persists.
|
|
805
807
|
await Promise.race([
|
|
@@ -809,9 +811,37 @@ export class WorkflowEngine {
|
|
|
809
811
|
}
|
|
810
812
|
const reason: unknown = abort.signal.aborted ? abort.signal.reason : undefined;
|
|
811
813
|
throw reason instanceof TimeoutError || reason instanceof CancelledError ? reason : error;
|
|
814
|
+
} finally {
|
|
815
|
+
if (timer !== undefined) {
|
|
816
|
+
clearTimeout(timer);
|
|
817
|
+
}
|
|
818
|
+
if (this.activeAbort === abort) {
|
|
819
|
+
this.activeAbort = null;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private async resolveNodeTimeout(
|
|
825
|
+
node: WorkflowNodeDefinition,
|
|
826
|
+
context: WorkflowNodeContext,
|
|
827
|
+
abort: AbortController,
|
|
828
|
+
): Promise<number> {
|
|
829
|
+
const configured = node.timeoutMs;
|
|
830
|
+
if (typeof configured !== "function") {
|
|
831
|
+
return assertValidTimeout(configured ?? this.defaultNodeTimeoutMs);
|
|
832
|
+
}
|
|
833
|
+
const timer = setTimeout(
|
|
834
|
+
() => abort.abort(new TimeoutError(TIMEOUT_RESOLUTION_TIMEOUT_MS)),
|
|
835
|
+
TIMEOUT_RESOLUTION_TIMEOUT_MS,
|
|
836
|
+
);
|
|
837
|
+
try {
|
|
838
|
+
const resolved = await Promise.race([
|
|
839
|
+
Promise.resolve(configured(context)),
|
|
840
|
+
abortRejection(abort.signal),
|
|
841
|
+
]);
|
|
842
|
+
return assertValidTimeout(resolved);
|
|
812
843
|
} finally {
|
|
813
844
|
clearTimeout(timer);
|
|
814
|
-
this.activeAbort = null;
|
|
815
845
|
}
|
|
816
846
|
}
|
|
817
847
|
|
|
@@ -822,10 +852,10 @@ export class WorkflowEngine {
|
|
|
822
852
|
nodeId: string,
|
|
823
853
|
attemptId: string,
|
|
824
854
|
node: WorkflowNodeDefinition,
|
|
855
|
+
context: WorkflowNodeContext,
|
|
825
856
|
signal: AbortSignal,
|
|
826
857
|
meta: NodeExecutionMeta,
|
|
827
858
|
): Promise<NodeExecution> {
|
|
828
|
-
const context = this.createNodeContext(state, signal);
|
|
829
859
|
switch (node.nodeType) {
|
|
830
860
|
case "agent":
|
|
831
861
|
return await this.runAgentNode(
|
|
@@ -841,6 +871,29 @@ export class WorkflowEngine {
|
|
|
841
871
|
);
|
|
842
872
|
case "compute":
|
|
843
873
|
return { output: await node.run(context), promptText: null };
|
|
874
|
+
case "notify": {
|
|
875
|
+
if (this.notificationSink === undefined) {
|
|
876
|
+
throw new Error(`Workflow node ${nodeId} requires a notification sink`);
|
|
877
|
+
}
|
|
878
|
+
const content = await node.message(context);
|
|
879
|
+
if (typeof content !== "string" || content.trim().length === 0) {
|
|
880
|
+
throw new Error(`Workflow node ${nodeId} notification must be a non-empty string`);
|
|
881
|
+
}
|
|
882
|
+
const notificationIndex =
|
|
883
|
+
state.steps.filter(
|
|
884
|
+
(step) => step.nodeId === nodeId && step.nodeType === "notify" && step.outcome === "ok",
|
|
885
|
+
).length + 1;
|
|
886
|
+
const receipt = await this.notificationSink.notify({
|
|
887
|
+
runId: state.runId,
|
|
888
|
+
workflowName: workflow.name,
|
|
889
|
+
nodeId,
|
|
890
|
+
attemptId,
|
|
891
|
+
notificationIndex,
|
|
892
|
+
kind: node.kind ?? "progress",
|
|
893
|
+
content: content.trim(),
|
|
894
|
+
});
|
|
895
|
+
return { output: receipt, promptText: null };
|
|
896
|
+
}
|
|
844
897
|
case "action":
|
|
845
898
|
return await this.runActionNode(node, context, signal, meta);
|
|
846
899
|
case "checkpoint":
|
|
@@ -1044,7 +1097,13 @@ async function runShellActionNode(
|
|
|
1044
1097
|
return { output, promptText: null, action: shellReceipt(result) };
|
|
1045
1098
|
}
|
|
1046
1099
|
|
|
1047
|
-
|
|
1100
|
+
function assertValidTimeout(value: number): number {
|
|
1101
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1102
|
+
throw new Error("Node timeoutMs must resolve to a finite positive number");
|
|
1103
|
+
}
|
|
1104
|
+
return value;
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1048
1107
|
/** The error carried by an aborted signal, normalized to an Error. */
|
|
1049
1108
|
function abortError(signal: AbortSignal): Error {
|
|
1050
1109
|
const reason: unknown = signal.reason ?? new CancelledError();
|
|
@@ -1069,6 +1128,22 @@ function abortRejection(signal: AbortSignal): Promise<never> {
|
|
|
1069
1128
|
* Failing here turns a bad callback return value into a normal node failure
|
|
1070
1129
|
* instead of corrupting the run state.
|
|
1071
1130
|
*/
|
|
1131
|
+
function workflowSourceMismatch(
|
|
1132
|
+
state: WorkflowRunState,
|
|
1133
|
+
source: WorkflowSource | undefined,
|
|
1134
|
+
): boolean {
|
|
1135
|
+
if (source === undefined) return false;
|
|
1136
|
+
if (state.workflowSource !== undefined) {
|
|
1137
|
+
return !isDeepStrictEqual(state.workflowSource, source);
|
|
1138
|
+
}
|
|
1139
|
+
// Bounded compatibility check for pre-catalog file runs. Startup normally
|
|
1140
|
+
// converts these records with migrateLegacyWorkflowSources first.
|
|
1141
|
+
return (
|
|
1142
|
+
state.workflowHash !== undefined &&
|
|
1143
|
+
(source.kind !== "file" || state.workflowHash !== source.hash)
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1072
1147
|
function assertJsonSerializable(value: unknown, what: string): void {
|
|
1073
1148
|
let encoded: string | undefined;
|
|
1074
1149
|
try {
|
package/src/workflows/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ export {
|
|
|
5
5
|
compute,
|
|
6
6
|
defineWorkflow,
|
|
7
7
|
isWorkflowDefinition,
|
|
8
|
+
notify,
|
|
8
9
|
shell,
|
|
9
10
|
} from "./definition.js";
|
|
10
11
|
export { decision, decisionEdge, type DecisionDefinition } from "./decision.js";
|
|
@@ -21,6 +22,7 @@ export {
|
|
|
21
22
|
discoverWorkflows,
|
|
22
23
|
loadWorkflowFile,
|
|
23
24
|
resolveWorkflowRef,
|
|
25
|
+
resolveWorkflowSource,
|
|
24
26
|
workflowFileStem,
|
|
25
27
|
workflowSearchDirs,
|
|
26
28
|
type DiscoveredWorkflow,
|
|
@@ -64,6 +66,7 @@ export type {
|
|
|
64
66
|
ComputeNodeDefinition,
|
|
65
67
|
FunctionActionNodeDefinition,
|
|
66
68
|
MaybePromise,
|
|
69
|
+
NotifyNodeDefinition,
|
|
67
70
|
ShellActionExecution,
|
|
68
71
|
ShellActionNodeDefinition,
|
|
69
72
|
ShellActionResult,
|
|
@@ -78,11 +81,15 @@ export type {
|
|
|
78
81
|
WorkflowNodeOutcome,
|
|
79
82
|
WorkflowNodeResult,
|
|
80
83
|
WorkflowNodeSnapshot,
|
|
84
|
+
WorkflowNotificationReceipt,
|
|
85
|
+
WorkflowNotificationRequest,
|
|
86
|
+
WorkflowNotificationSink,
|
|
81
87
|
WorkflowPresentationContext,
|
|
82
88
|
WorkflowRunManifest,
|
|
83
89
|
WorkflowRunResult,
|
|
84
90
|
WorkflowRunState,
|
|
85
91
|
WorkflowRunStatus,
|
|
92
|
+
WorkflowSource,
|
|
86
93
|
WorkflowSessionBinding,
|
|
87
94
|
WorkflowSessionEntryRecord,
|
|
88
95
|
WorkflowStepRecord,
|
package/src/workflows/loader.ts
CHANGED
|
@@ -4,14 +4,16 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { createJiti } from "jiti";
|
|
7
|
+
import type { BuiltinWorkflowCatalog } from "./catalog.js";
|
|
7
8
|
import { isWorkflowDefinition } from "./definition.js";
|
|
8
|
-
import
|
|
9
|
+
import { WorkflowSourceChangedError } from "./errors.js";
|
|
10
|
+
import type { WorkflowDefinition, WorkflowSource } from "./types.js";
|
|
9
11
|
|
|
10
12
|
const WORKFLOW_FILE_SUFFIXES = [".workflow.ts", ".workflow.js", ".workflow.mts", ".workflow.mjs"];
|
|
11
13
|
|
|
12
14
|
export type DiscoveredWorkflow = {
|
|
13
15
|
name: string;
|
|
14
|
-
|
|
16
|
+
ref: string;
|
|
15
17
|
source: "project" | "global" | "builtin" | "path";
|
|
16
18
|
};
|
|
17
19
|
|
|
@@ -20,23 +22,27 @@ export type WorkflowSearchPaths = {
|
|
|
20
22
|
homeDir?: string;
|
|
21
23
|
};
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
export type ResolvedWorkflow = {
|
|
26
|
+
definition: WorkflowDefinition;
|
|
27
|
+
source: WorkflowSource;
|
|
28
|
+
sourceKind: DiscoveredWorkflow["source"];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Directories scanned for user workflow files, in precedence order. */
|
|
24
32
|
export function workflowSearchDirs(
|
|
25
33
|
options: WorkflowSearchPaths,
|
|
26
|
-
): { dir: string; source: "project" | "global"
|
|
34
|
+
): { dir: string; source: "project" | "global" }[] {
|
|
27
35
|
const homeDir = options.homeDir ?? os.homedir();
|
|
28
|
-
const builtinDir = fileURLToPath(new URL("../builtins/", import.meta.url));
|
|
29
36
|
return [
|
|
30
37
|
{ dir: path.join(options.cwd, ".pi", "workflows"), source: "project" },
|
|
31
38
|
{ dir: path.join(homeDir, ".pi", "agent", "workflows"), source: "global" },
|
|
32
|
-
{ dir: builtinDir, source: "builtin" },
|
|
33
39
|
];
|
|
34
40
|
}
|
|
35
41
|
|
|
36
|
-
/** SHA-256 of a workflow source file
|
|
42
|
+
/** SHA-256 of a user workflow source file. */
|
|
37
43
|
export async function hashWorkflowSource(filePath: string): Promise<string> {
|
|
38
44
|
return createHash("sha256")
|
|
39
|
-
.update(await fs.readFile(filePath))
|
|
45
|
+
.update(await fs.readFile(path.resolve(filePath)))
|
|
40
46
|
.digest("hex");
|
|
41
47
|
}
|
|
42
48
|
|
|
@@ -50,12 +56,11 @@ export function workflowFileStem(filePath: string): string {
|
|
|
50
56
|
return suffix ? base.slice(0, -suffix.length) : base;
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
// Alias
|
|
54
|
-
//
|
|
55
|
-
// (tests, tsx) or from the built dist inside the installed package.
|
|
59
|
+
// Alias package imports to this process's workflow API. User files can reload,
|
|
60
|
+
// but their node constructors and validators remain from one engine version.
|
|
56
61
|
const SELF_ENTRY = path.join(path.dirname(fileURLToPath(import.meta.url)), "index");
|
|
57
62
|
|
|
58
|
-
/** Load a workflow module from disk.
|
|
63
|
+
/** Load a user workflow module from disk. */
|
|
59
64
|
export async function loadWorkflowFile(filePath: string): Promise<WorkflowDefinition> {
|
|
60
65
|
const absolutePath = path.resolve(filePath);
|
|
61
66
|
const jiti = createJiti(pathToFileURL(absolutePath).href, {
|
|
@@ -70,22 +75,26 @@ export async function loadWorkflowFile(filePath: string): Promise<WorkflowDefini
|
|
|
70
75
|
return loaded;
|
|
71
76
|
}
|
|
72
77
|
|
|
73
|
-
/** Discover
|
|
78
|
+
/** Discover user workflows first, then unshadowed catalog built-ins. */
|
|
74
79
|
export async function discoverWorkflows(
|
|
75
80
|
options: WorkflowSearchPaths,
|
|
81
|
+
catalog?: BuiltinWorkflowCatalog,
|
|
76
82
|
): Promise<DiscoveredWorkflow[]> {
|
|
77
83
|
const discovered: DiscoveredWorkflow[] = [];
|
|
78
84
|
const seenNames = new Set<string>();
|
|
79
85
|
for (const { dir, source } of workflowSearchDirs(options)) {
|
|
80
86
|
for (const filePath of await listWorkflowFiles(dir)) {
|
|
81
87
|
const name = workflowFileStem(filePath);
|
|
82
|
-
if (seenNames.has(name))
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
88
|
+
if (seenNames.has(name)) continue;
|
|
85
89
|
seenNames.add(name);
|
|
86
|
-
discovered.push({ name,
|
|
90
|
+
discovered.push({ name, ref: filePath, source });
|
|
87
91
|
}
|
|
88
92
|
}
|
|
93
|
+
for (const builtin of catalog?.list() ?? []) {
|
|
94
|
+
if (seenNames.has(builtin.definition.name)) continue;
|
|
95
|
+
seenNames.add(builtin.definition.name);
|
|
96
|
+
discovered.push({ name: builtin.definition.name, ref: builtin.ref, source: "builtin" });
|
|
97
|
+
}
|
|
89
98
|
return discovered;
|
|
90
99
|
}
|
|
91
100
|
|
|
@@ -102,26 +111,61 @@ async function listWorkflowFiles(dir: string): Promise<string[]> {
|
|
|
102
111
|
.sort();
|
|
103
112
|
}
|
|
104
113
|
|
|
105
|
-
/**
|
|
106
|
-
* Resolve a `/workflow` argument to a workflow file. Accepts a discovered
|
|
107
|
-
* workflow name or a direct path to a `*.workflow.ts` file.
|
|
108
|
-
*/
|
|
114
|
+
/** Resolve a workflow name, stable built-in ref, or direct user file path. */
|
|
109
115
|
export async function resolveWorkflowRef(
|
|
110
116
|
ref: string,
|
|
111
117
|
options: WorkflowSearchPaths,
|
|
112
|
-
|
|
118
|
+
catalog?: BuiltinWorkflowCatalog,
|
|
119
|
+
): Promise<ResolvedWorkflow> {
|
|
120
|
+
if (ref.startsWith("builtin:")) {
|
|
121
|
+
const id = ref.slice("builtin:".length);
|
|
122
|
+
const builtin = catalog?.get(id);
|
|
123
|
+
if (builtin === undefined) throw new Error(`Unknown built-in workflow ${JSON.stringify(ref)}`);
|
|
124
|
+
return {
|
|
125
|
+
definition: builtin.definition,
|
|
126
|
+
source: { kind: "builtin", id: builtin.id, revision: builtin.revision },
|
|
127
|
+
sourceKind: "builtin",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
113
130
|
if (looksLikePath(ref)) {
|
|
114
131
|
const absolutePath = path.resolve(options.cwd, ref);
|
|
115
132
|
await fs.access(absolutePath);
|
|
116
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
definition: await loadWorkflowFile(absolutePath),
|
|
135
|
+
source: { kind: "file", path: absolutePath, hash: await hashWorkflowSource(absolutePath) },
|
|
136
|
+
sourceKind: "path",
|
|
137
|
+
};
|
|
117
138
|
}
|
|
118
|
-
const discovered = await discoverWorkflows(options);
|
|
139
|
+
const discovered = await discoverWorkflows(options, catalog);
|
|
119
140
|
const match = discovered.find((workflow) => workflow.name === ref);
|
|
120
|
-
if (
|
|
141
|
+
if (match === undefined) {
|
|
121
142
|
const available = discovered.map((workflow) => workflow.name).join(", ") || "(none)";
|
|
122
143
|
throw new Error(`Unknown workflow ${JSON.stringify(ref)}. Available workflows: ${available}`);
|
|
123
144
|
}
|
|
124
|
-
return
|
|
145
|
+
if (match.source === "builtin") return await resolveWorkflowRef(match.ref, options, catalog);
|
|
146
|
+
const absolutePath = path.resolve(match.ref);
|
|
147
|
+
return {
|
|
148
|
+
definition: await loadWorkflowFile(absolutePath),
|
|
149
|
+
source: { kind: "file", path: absolutePath, hash: await hashWorkflowSource(absolutePath) },
|
|
150
|
+
sourceKind: match.source,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Resolve an already persisted canonical source. */
|
|
155
|
+
export async function resolveWorkflowSource(
|
|
156
|
+
source: WorkflowSource,
|
|
157
|
+
catalog?: BuiltinWorkflowCatalog,
|
|
158
|
+
runId = source.kind === "builtin" ? `builtin:${source.id}` : source.path,
|
|
159
|
+
): Promise<WorkflowDefinition> {
|
|
160
|
+
if (source.kind === "builtin") {
|
|
161
|
+
if (catalog === undefined) throw new Error(`No built-in workflow catalog for ${source.id}`);
|
|
162
|
+
return catalog.resolve(source, runId);
|
|
163
|
+
}
|
|
164
|
+
const actualHash = await hashWorkflowSource(source.path);
|
|
165
|
+
if (actualHash !== source.hash) {
|
|
166
|
+
throw new WorkflowSourceChangedError(runId);
|
|
167
|
+
}
|
|
168
|
+
return await loadWorkflowFile(source.path);
|
|
125
169
|
}
|
|
126
170
|
|
|
127
171
|
function looksLikePath(ref: string): boolean {
|