@osolmaz/pi-workflows 0.2.0 → 0.3.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 (133) hide show
  1. package/README.md +71 -5
  2. package/dist/builtins/monitor.workflow.d.ts +71 -0
  3. package/dist/builtins/monitor.workflow.js +234 -0
  4. package/dist/builtins/monitor.workflow.js.map +1 -0
  5. package/dist/controllers/conditions.d.ts +6 -0
  6. package/dist/controllers/conditions.js +68 -0
  7. package/dist/controllers/conditions.js.map +1 -0
  8. package/dist/controllers/definition.d.ts +6 -0
  9. package/dist/controllers/definition.js +45 -0
  10. package/dist/controllers/definition.js.map +1 -0
  11. package/dist/controllers/effects.d.ts +14 -0
  12. package/dist/controllers/effects.js +104 -0
  13. package/dist/controllers/effects.js.map +1 -0
  14. package/dist/controllers/errors.d.ts +12 -0
  15. package/dist/controllers/errors.js +25 -0
  16. package/dist/controllers/errors.js.map +1 -0
  17. package/dist/controllers/index.d.ts +13 -0
  18. package/dist/controllers/index.js +13 -0
  19. package/dist/controllers/index.js.map +1 -0
  20. package/dist/controllers/json.d.ts +5 -0
  21. package/dist/controllers/json.js +57 -0
  22. package/dist/controllers/json.js.map +1 -0
  23. package/dist/controllers/loader.d.ts +23 -0
  24. package/dist/controllers/loader.js +74 -0
  25. package/dist/controllers/loader.js.map +1 -0
  26. package/dist/controllers/manager.d.ts +58 -0
  27. package/dist/controllers/manager.js +399 -0
  28. package/dist/controllers/manager.js.map +1 -0
  29. package/dist/controllers/results.d.ts +5 -0
  30. package/dist/controllers/results.js +32 -0
  31. package/dist/controllers/results.js.map +1 -0
  32. package/dist/controllers/sqlite.d.ts +212 -0
  33. package/dist/controllers/sqlite.js +1009 -0
  34. package/dist/controllers/sqlite.js.map +1 -0
  35. package/dist/controllers/store.d.ts +112 -0
  36. package/dist/controllers/store.js +32 -0
  37. package/dist/controllers/store.js.map +1 -0
  38. package/dist/controllers/types.d.ts +159 -0
  39. package/dist/controllers/types.js +2 -0
  40. package/dist/controllers/types.js.map +1 -0
  41. package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
  42. package/dist/controllers/workflow-engine-scheduler.js +93 -0
  43. package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
  44. package/dist/controllers/workflows.d.ts +27 -0
  45. package/dist/controllers/workflows.js +109 -0
  46. package/dist/controllers/workflows.js.map +1 -0
  47. package/dist/extension/controller-host.d.ts +47 -0
  48. package/dist/extension/controller-host.js +110 -0
  49. package/dist/extension/controller-host.js.map +1 -0
  50. package/dist/extension/executor.js +1 -1
  51. package/dist/extension/executor.js.map +1 -1
  52. package/dist/extension/index.d.ts +7 -0
  53. package/dist/extension/index.js +1053 -96
  54. package/dist/extension/index.js.map +1 -1
  55. package/dist/extension/recorder.d.ts +6 -0
  56. package/dist/extension/recorder.js +20 -8
  57. package/dist/extension/recorder.js.map +1 -1
  58. package/dist/extension/workflow-tool.d.ts +28 -0
  59. package/dist/extension/workflow-tool.js +33 -0
  60. package/dist/extension/workflow-tool.js.map +1 -0
  61. package/dist/host/processes.d.ts +24 -0
  62. package/dist/host/processes.js +114 -0
  63. package/dist/host/processes.js.map +1 -0
  64. package/dist/host/rpc-bridge.d.ts +9 -0
  65. package/dist/host/rpc-bridge.js +39 -0
  66. package/dist/host/rpc-bridge.js.map +1 -0
  67. package/dist/host/rpc-executor.d.ts +38 -0
  68. package/dist/host/rpc-executor.js +254 -0
  69. package/dist/host/rpc-executor.js.map +1 -0
  70. package/dist/host/runner.d.ts +49 -0
  71. package/dist/host/runner.js +350 -0
  72. package/dist/host/runner.js.map +1 -0
  73. package/dist/viewer/cli.d.ts +7 -3
  74. package/dist/viewer/cli.js +150 -19
  75. package/dist/viewer/cli.js.map +1 -1
  76. package/dist/workflows/engine.d.ts +36 -0
  77. package/dist/workflows/engine.js +244 -14
  78. package/dist/workflows/engine.js.map +1 -1
  79. package/dist/workflows/errors.d.ts +23 -0
  80. package/dist/workflows/errors.js +38 -0
  81. package/dist/workflows/errors.js.map +1 -1
  82. package/dist/workflows/graph.js +0 -5
  83. package/dist/workflows/graph.js.map +1 -1
  84. package/dist/workflows/loader.d.ts +5 -3
  85. package/dist/workflows/loader.js +10 -1
  86. package/dist/workflows/loader.js.map +1 -1
  87. package/dist/workflows/schema.js +1 -1
  88. package/dist/workflows/schema.js.map +1 -1
  89. package/dist/workflows/store.d.ts +50 -6
  90. package/dist/workflows/store.js +446 -51
  91. package/dist/workflows/store.js.map +1 -1
  92. package/dist/workflows/types.d.ts +10 -0
  93. package/docs/CONTROLLERS.md +215 -0
  94. package/docs/development.md +12 -9
  95. package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
  96. package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
  97. package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
  98. package/docs/run-bundles.md +66 -27
  99. package/docs/workflows.md +131 -11
  100. package/examples/controllers/pull-request.controller.ts +215 -0
  101. package/package.json +10 -2
  102. package/src/builtins/monitor.workflow.ts +278 -0
  103. package/src/controllers/conditions.ts +110 -0
  104. package/src/controllers/definition.ts +65 -0
  105. package/src/controllers/effects.ts +123 -0
  106. package/src/controllers/errors.ts +27 -0
  107. package/src/controllers/index.ts +90 -0
  108. package/src/controllers/json.ts +62 -0
  109. package/src/controllers/loader.ts +104 -0
  110. package/src/controllers/manager.ts +533 -0
  111. package/src/controllers/results.ts +46 -0
  112. package/src/controllers/sqlite.ts +1427 -0
  113. package/src/controllers/store.ts +160 -0
  114. package/src/controllers/types.ts +183 -0
  115. package/src/controllers/workflow-engine-scheduler.ts +145 -0
  116. package/src/controllers/workflows.ts +152 -0
  117. package/src/extension/controller-host.ts +163 -0
  118. package/src/extension/executor.ts +1 -1
  119. package/src/extension/index.ts +1243 -117
  120. package/src/extension/recorder.ts +65 -36
  121. package/src/extension/workflow-tool.ts +59 -0
  122. package/src/host/processes.ts +119 -0
  123. package/src/host/rpc-bridge.ts +44 -0
  124. package/src/host/rpc-executor.ts +299 -0
  125. package/src/host/runner.ts +406 -0
  126. package/src/viewer/cli.ts +167 -21
  127. package/src/workflows/engine.ts +327 -13
  128. package/src/workflows/errors.ts +45 -0
  129. package/src/workflows/graph.ts +0 -5
  130. package/src/workflows/loader.ts +13 -3
  131. package/src/workflows/schema.ts +1 -1
  132. package/src/workflows/store.ts +555 -46
  133. package/src/workflows/types.ts +10 -0
@@ -0,0 +1,160 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type {
6
+ ChildWorkflowRecord,
7
+ ControllerEvent,
8
+ ControllerQueueClaim,
9
+ ControllerResource,
10
+ ControllerResourceRef,
11
+ ControllerResourceStatus,
12
+ EffectRecord,
13
+ JsonObject,
14
+ } from "./types.js";
15
+
16
+ export const CONTROLLER_STORE_SCHEMA = "pi-workflows.controller-store.v1" as const;
17
+
18
+ export type QueueRequeueOptions = {
19
+ availableAt: string;
20
+ error?: string;
21
+ };
22
+
23
+ export type QueueItem = {
24
+ controller: string;
25
+ key: string;
26
+ availableAt: string;
27
+ consecutiveErrors: number;
28
+ claimExpiresAt?: string;
29
+ };
30
+
31
+ export type EffectReservation = {
32
+ record: EffectRecord;
33
+ created: boolean;
34
+ };
35
+
36
+ export type WorkflowReservation = {
37
+ record: ChildWorkflowRecord;
38
+ created: boolean;
39
+ };
40
+
41
+ export type WorkflowRecordUpdate = {
42
+ runId?: string | null;
43
+ state: ChildWorkflowRecord["state"];
44
+ attempt?: number;
45
+ error?: string | null;
46
+ };
47
+
48
+ export interface ControllerStore {
49
+ close(): void;
50
+
51
+ putResource<TSpec, TStatus>(options: {
52
+ controller: string;
53
+ key: string;
54
+ spec: TSpec;
55
+ initialStatus: TStatus;
56
+ now?: string;
57
+ }): ControllerResource<TSpec, TStatus>;
58
+ getResource<TSpec = unknown, TStatus = unknown>(
59
+ ref: ControllerResourceRef,
60
+ ): ControllerResource<TSpec, TStatus> | undefined;
61
+ getResourceByUid(uid: string): ControllerResource | undefined;
62
+ listResources<TSpec = unknown, TStatus = unknown>(options?: {
63
+ controller?: string;
64
+ }): ControllerResource<TSpec, TStatus>[];
65
+ updateStatus<TStatus>(options: {
66
+ ref: ControllerResourceRef;
67
+ expectedResourceVersion: number;
68
+ status: ControllerResourceStatus<TStatus>;
69
+ finalizers?: string[];
70
+ now?: string;
71
+ }): ControllerResource<unknown, TStatus>;
72
+ requestDeletion(ref: ControllerResourceRef, now?: string): ControllerResource;
73
+ updateFinalizers(options: {
74
+ ref: ControllerResourceRef;
75
+ expectedResourceVersion: number;
76
+ finalizers: string[];
77
+ now?: string;
78
+ }): ControllerResource;
79
+ deleteResource(ref: ControllerResourceRef, expectedResourceVersion: number): boolean;
80
+
81
+ enqueue(ref: ControllerResourceRef, availableAt?: string): void;
82
+ claimNext(options: {
83
+ controllers: string[];
84
+ leaseMs: number;
85
+ now?: string;
86
+ }): ControllerQueueClaim | undefined;
87
+ renewClaim(claim: ControllerQueueClaim, leaseMs: number, now?: string): boolean;
88
+ settleClaim(claim: ControllerQueueClaim, now?: string): boolean;
89
+ requeueClaim(claim: ControllerQueueClaim, options: QueueRequeueOptions, now?: string): boolean;
90
+ listQueue(): QueueItem[];
91
+
92
+ reserveEffect(options: {
93
+ key: string;
94
+ resourceUid: string;
95
+ generation: number;
96
+ kind: string;
97
+ requestFingerprint: string;
98
+ now?: string;
99
+ }): EffectReservation;
100
+ getEffect(resourceUid: string, key: string): EffectRecord | undefined;
101
+ updateEffect(options: {
102
+ resourceUid: string;
103
+ key: string;
104
+ state: EffectRecord["state"];
105
+ externalRef?: string;
106
+ error?: string;
107
+ now?: string;
108
+ }): EffectRecord;
109
+ listEffects(resourceUid: string): EffectRecord[];
110
+
111
+ reserveWorkflow(options: {
112
+ resourceUid: string;
113
+ requestKey: string;
114
+ workflow: string;
115
+ inputFingerprint: string;
116
+ }): WorkflowReservation;
117
+ getWorkflow(resourceUid: string, requestKey: string): ChildWorkflowRecord | undefined;
118
+ getWorkflowByRequestId(requestId: string): ChildWorkflowRecord | undefined;
119
+ updateWorkflow(requestId: string, update: WorkflowRecordUpdate): ChildWorkflowRecord;
120
+ listWorkflows(resourceUid: string): ChildWorkflowRecord[];
121
+
122
+ recordEvent(options: {
123
+ controller: string;
124
+ key: string;
125
+ type: string;
126
+ payload?: JsonObject;
127
+ now?: string;
128
+ }): ControllerEvent;
129
+ listEvents(options?: { controller?: string; key?: string; limit?: number }): ControllerEvent[];
130
+ }
131
+
132
+ export function controllerStoreBaseDir(homeDir: string = os.homedir()): string {
133
+ const override = process.env.PI_WORKFLOWS_CONTROLLER_DIR;
134
+ if (override !== undefined && override.length > 0) {
135
+ return override;
136
+ }
137
+ return path.join(homeDir, ".pi", "agent", "workflows", "controllers");
138
+ }
139
+
140
+ export function controllerStorePath(homeDir: string = os.homedir()): string {
141
+ return path.join(controllerStoreBaseDir(homeDir), "controller.sqlite");
142
+ }
143
+
144
+ export function controllerProjectScope(cwd: string): string {
145
+ let canonicalCwd: string;
146
+ try {
147
+ canonicalCwd = fs.realpathSync.native(cwd);
148
+ } catch {
149
+ canonicalCwd = path.resolve(cwd);
150
+ }
151
+ return createHash("sha256").update(canonicalCwd).digest("hex").slice(0, 24);
152
+ }
153
+
154
+ export function projectControllerStoreBaseDir(cwd: string, homeDir: string = os.homedir()): string {
155
+ return path.join(controllerStoreBaseDir(homeDir), "projects", controllerProjectScope(cwd));
156
+ }
157
+
158
+ export function projectControllerStorePath(cwd: string, homeDir: string = os.homedir()): string {
159
+ return path.join(projectControllerStoreBaseDir(cwd, homeDir), "controller.sqlite");
160
+ }
@@ -0,0 +1,183 @@
1
+ export type MaybePromise<T> = T | Promise<T>;
2
+ export type JsonPrimitive = string | number | boolean | null;
3
+ export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
4
+ export type JsonObject = { [key: string]: JsonValue };
5
+
6
+ export type ControllerConditionStatus = true | false | "unknown";
7
+
8
+ export type ControllerCondition = {
9
+ type: string;
10
+ status: ControllerConditionStatus;
11
+ reason: string;
12
+ message?: string;
13
+ observedGeneration: number;
14
+ lastTransitionTime: string;
15
+ };
16
+
17
+ export type ChildWorkflowState =
18
+ | "pending"
19
+ | "running"
20
+ | "waiting"
21
+ | "succeeded"
22
+ | "failed"
23
+ | "interrupted";
24
+
25
+ export type ChildWorkflowReference = {
26
+ requestId: string;
27
+ runId?: string;
28
+ state: ChildWorkflowState;
29
+ attempt: number;
30
+ };
31
+
32
+ export type ControllerResourceStatus<TStatus> = {
33
+ observedGeneration: number;
34
+ conditions: ControllerCondition[];
35
+ controllerStatus: TStatus;
36
+ workflowRun?: ChildWorkflowReference;
37
+ };
38
+
39
+ export type ControllerResource<TSpec = unknown, TStatus = unknown> = {
40
+ metadata: {
41
+ uid: string;
42
+ controller: string;
43
+ key: string;
44
+ resourceVersion: number;
45
+ generation: number;
46
+ deletionTimestamp?: string;
47
+ finalizers: string[];
48
+ };
49
+ spec: TSpec;
50
+ status: ControllerResourceStatus<TStatus>;
51
+ };
52
+
53
+ export type ControllerResourceRef = {
54
+ controller: string;
55
+ key: string;
56
+ };
57
+
58
+ export type ControllerConditionInput = {
59
+ type: string;
60
+ status: ControllerConditionStatus;
61
+ reason: string;
62
+ message?: string;
63
+ };
64
+
65
+ export type ControllerStatusPatch<TStatus> = {
66
+ controllerStatus?: TStatus;
67
+ conditions?: ControllerConditionInput[];
68
+ workflowRun?: ChildWorkflowReference | null;
69
+ finalizers?: string[];
70
+ };
71
+
72
+ export type ReconcileResult<TStatus> =
73
+ | {
74
+ kind: "settled";
75
+ status?: ControllerStatusPatch<TStatus>;
76
+ }
77
+ | {
78
+ kind: "requeue";
79
+ afterMs?: number;
80
+ status?: ControllerStatusPatch<TStatus>;
81
+ };
82
+
83
+ export type EffectState = "pending" | "applied" | "rejected" | "indeterminate";
84
+
85
+ export type EffectRecord = {
86
+ key: string;
87
+ resourceUid: string;
88
+ generation: number;
89
+ kind: string;
90
+ state: EffectState;
91
+ requestFingerprint: string;
92
+ startedAt: string;
93
+ completedAt?: string;
94
+ externalRef?: string;
95
+ error?: string;
96
+ };
97
+
98
+ export type EffectObservation =
99
+ | { state: "applied"; externalRef?: string }
100
+ | { state: "not_applied" }
101
+ | { state: "indeterminate" };
102
+
103
+ export type EffectApplication =
104
+ | { state: "applied"; externalRef?: string }
105
+ | { state: "rejected"; error: string }
106
+ | { state: "indeterminate"; error?: string };
107
+
108
+ export type EffectDefinition<TRequest> = {
109
+ key: string;
110
+ kind: string;
111
+ request: TRequest;
112
+ observe: (signal: AbortSignal) => MaybePromise<EffectObservation>;
113
+ apply: (signal: AbortSignal) => MaybePromise<EffectApplication>;
114
+ };
115
+
116
+ export interface ControllerEffects {
117
+ ensure<TRequest>(definition: EffectDefinition<TRequest>): Promise<EffectRecord>;
118
+ }
119
+
120
+ export type ChildWorkflowRequest = {
121
+ requestKey: string;
122
+ workflow: string;
123
+ input: unknown;
124
+ };
125
+
126
+ export type ChildWorkflowRecord = ChildWorkflowReference & {
127
+ resourceUid: string;
128
+ requestKey: string;
129
+ inputFingerprint: string;
130
+ workflow: string;
131
+ error?: string;
132
+ };
133
+
134
+ export interface ControllerWorkflows {
135
+ ensure(request: ChildWorkflowRequest): Promise<ChildWorkflowRecord>;
136
+ }
137
+
138
+ export type ReconcileContext<TStatus> = {
139
+ signal: AbortSignal;
140
+ effects: ControllerEffects;
141
+ workflows: ControllerWorkflows;
142
+ settled(status?: ControllerStatusPatch<TStatus>): ReconcileResult<TStatus>;
143
+ requeue(status?: ControllerStatusPatch<TStatus>): ReconcileResult<TStatus>;
144
+ requeueAfter(afterMs: number, status?: ControllerStatusPatch<TStatus>): ReconcileResult<TStatus>;
145
+ };
146
+
147
+ export type ControllerDefinition<TSpec = unknown, TStatus = unknown> = {
148
+ name: string;
149
+ initialStatus: (spec: TSpec) => TStatus;
150
+ reconcile: (
151
+ context: ReconcileContext<TStatus>,
152
+ resource: ControllerResource<TSpec, TStatus>,
153
+ ) => MaybePromise<ReconcileResult<TStatus>>;
154
+ timeoutMs?: number;
155
+ };
156
+
157
+ export type AnyControllerDefinition = {
158
+ name: string;
159
+ initialStatus(spec: unknown): unknown;
160
+ reconcile(
161
+ context: ReconcileContext<unknown>,
162
+ resource: ControllerResource,
163
+ ): MaybePromise<ReconcileResult<unknown>>;
164
+ timeoutMs?: number;
165
+ };
166
+
167
+ export type ControllerQueueClaim = {
168
+ controller: string;
169
+ key: string;
170
+ token: string;
171
+ queueVersion: number;
172
+ consecutiveErrors: number;
173
+ expiresAt: string;
174
+ };
175
+
176
+ export type ControllerEvent = {
177
+ seq: number;
178
+ recordedAt: string;
179
+ controller: string;
180
+ key: string;
181
+ type: string;
182
+ payload: JsonObject;
183
+ };
@@ -0,0 +1,145 @@
1
+ import { WorkflowEngine } from "../workflows/engine.js";
2
+ import { readLastTraceEvent, readRunBundle, type WorkflowRunStore } from "../workflows/store.js";
3
+ import type {
4
+ WorkflowDefinition,
5
+ WorkflowRunResult,
6
+ WorkflowRunStatus,
7
+ } from "../workflows/types.js";
8
+ import type {
9
+ ControllerWorkflowScheduler,
10
+ WorkflowSchedulerRequest,
11
+ WorkflowSchedulerResult,
12
+ } from "./workflows.js";
13
+
14
+ export type ResolvedChildWorkflow = {
15
+ workflow: WorkflowDefinition;
16
+ workflowPath?: string;
17
+ };
18
+
19
+ export type WorkflowEngineSchedulerOptions = {
20
+ store: WorkflowRunStore;
21
+ resolveWorkflow: (name: string) => Promise<ResolvedChildWorkflow>;
22
+ createEngine: (request: WorkflowSchedulerRequest) => WorkflowEngine;
23
+ /**
24
+ * Release resources the engine held after its run settles, for example a
25
+ * headless child process. Called once per finished run.
26
+ */
27
+ disposeEngine?: (engine: WorkflowEngine) => Promise<void>;
28
+ };
29
+
30
+ export class WorkflowEngineScheduler implements ControllerWorkflowScheduler {
31
+ private readonly active = new Map<string, { runId: string; promise: Promise<void> }>();
32
+
33
+ constructor(private readonly options: WorkflowEngineSchedulerOptions) {}
34
+
35
+ async ensure(
36
+ request: WorkflowSchedulerRequest,
37
+ signal: AbortSignal,
38
+ onComplete: (result: WorkflowSchedulerResult) => void,
39
+ ): Promise<WorkflowSchedulerResult> {
40
+ if (signal.aborted) {
41
+ throw signal.reason ?? new Error("Workflow scheduling aborted");
42
+ }
43
+ const activeKey = requestKey(request);
44
+ const active = this.active.get(activeKey);
45
+ if (active !== undefined) {
46
+ return { state: "running", runId: active.runId };
47
+ }
48
+
49
+ const bundle = await readRunBundle(this.options.store.runDirFor(request.runId));
50
+ if (bundle !== null) {
51
+ const recovered =
52
+ bundle.state.status === "running"
53
+ ? await this.options.store.markRunInterrupted(request.runId)
54
+ : bundle;
55
+ if (recovered !== null) {
56
+ const lastTraceEvent = await readLastTraceEvent(
57
+ recovered.runDir,
58
+ recovered.manifest.paths.trace,
59
+ );
60
+ return resultFromStatus(
61
+ recovered.state.status,
62
+ request.runId,
63
+ recovered.state.error,
64
+ lastTraceEvent?.type === "run_interrupted",
65
+ );
66
+ }
67
+ return { state: "pending" };
68
+ }
69
+
70
+ await this.options.store.quarantineIncompleteRun(request.runId);
71
+ const resolved = await this.options.resolveWorkflow(request.workflow);
72
+ if (signal.aborted) {
73
+ throw signal.reason ?? new Error("Workflow scheduling aborted");
74
+ }
75
+ const runId = request.runId;
76
+ const engine = this.options.createEngine(request);
77
+ const promise = engine
78
+ .run(resolved.workflow, request.input, {
79
+ runId,
80
+ ...(resolved.workflowPath !== undefined ? { workflowPath: resolved.workflowPath } : {}),
81
+ })
82
+ .then((result) => {
83
+ callCompletion(onComplete, resultFromRun(result));
84
+ })
85
+ .catch((error: unknown) => {
86
+ callCompletion(onComplete, {
87
+ state: "failed",
88
+ runId,
89
+ error: error instanceof Error ? error.message : String(error),
90
+ });
91
+ })
92
+ .finally(() => {
93
+ this.active.delete(activeKey);
94
+ void this.options.disposeEngine?.(engine).catch(() => undefined);
95
+ });
96
+ this.active.set(activeKey, { runId, promise });
97
+ return { state: "running", runId };
98
+ }
99
+
100
+ async waitForIdle(): Promise<void> {
101
+ await Promise.all([...this.active.values()].map((entry) => entry.promise));
102
+ }
103
+ }
104
+
105
+ function callCompletion(
106
+ callback: (result: WorkflowSchedulerResult) => void,
107
+ result: WorkflowSchedulerResult,
108
+ ): void {
109
+ try {
110
+ callback(result);
111
+ } catch {
112
+ // A later reconciliation recovers completion from the immutable run bundle.
113
+ }
114
+ }
115
+
116
+ function requestKey(request: WorkflowSchedulerRequest): string {
117
+ return `${request.requestId}:${request.attempt}`;
118
+ }
119
+
120
+ function resultFromRun(result: WorkflowRunResult): WorkflowSchedulerResult {
121
+ return resultFromStatus(result.state.status, result.state.runId, result.state.error);
122
+ }
123
+
124
+ function resultFromStatus(
125
+ status: WorkflowRunStatus,
126
+ runId: string,
127
+ error?: string,
128
+ interrupted = false,
129
+ ): WorkflowSchedulerResult {
130
+ if (interrupted) {
131
+ return { state: "interrupted", runId, ...(error !== undefined ? { error } : {}) };
132
+ }
133
+ switch (status) {
134
+ case "running":
135
+ return { state: "running", runId };
136
+ case "waiting":
137
+ return { state: "waiting", runId };
138
+ case "completed":
139
+ return { state: "succeeded", runId };
140
+ case "failed":
141
+ case "timed_out":
142
+ case "cancelled":
143
+ return { state: "failed", runId, ...(error !== undefined ? { error } : {}) };
144
+ }
145
+ }
@@ -0,0 +1,152 @@
1
+ import { createRunId } from "../workflows/store.js";
2
+ import { jsonFingerprint } from "./json.js";
3
+ import type { ControllerStore } from "./store.js";
4
+ import type {
5
+ ChildWorkflowRecord,
6
+ ChildWorkflowRequest,
7
+ ChildWorkflowState,
8
+ ControllerResource,
9
+ ControllerWorkflows,
10
+ } from "./types.js";
11
+
12
+ export type WorkflowSchedulerRequest = {
13
+ requestId: string;
14
+ attempt: number;
15
+ workflow: string;
16
+ input: unknown;
17
+ runId: string;
18
+ };
19
+
20
+ export type WorkflowSchedulerResult = {
21
+ state: ChildWorkflowState;
22
+ runId?: string;
23
+ error?: string;
24
+ };
25
+
26
+ export interface ControllerWorkflowScheduler {
27
+ ensure(
28
+ request: WorkflowSchedulerRequest,
29
+ signal: AbortSignal,
30
+ onComplete: (result: WorkflowSchedulerResult) => void,
31
+ ): Promise<WorkflowSchedulerResult>;
32
+ }
33
+
34
+ export class ControllerWorkflowCoordinator {
35
+ constructor(
36
+ private readonly store: ControllerStore,
37
+ private readonly scheduler?: ControllerWorkflowScheduler,
38
+ ) {}
39
+
40
+ forResource(resource: ControllerResource, signal: AbortSignal): ControllerWorkflows {
41
+ return {
42
+ ensure: async (request) => await this.ensure(resource, request, signal),
43
+ };
44
+ }
45
+
46
+ complete(requestId: string, result: WorkflowSchedulerResult): ChildWorkflowRecord {
47
+ const previous = this.store.getWorkflowByRequestId(requestId);
48
+ if (previous === undefined) {
49
+ throw new Error(`Workflow request not found: ${requestId}`);
50
+ }
51
+ const record = this.store.updateWorkflow(requestId, {
52
+ state: result.state,
53
+ ...(result.runId !== undefined ? { runId: result.runId } : {}),
54
+ ...(result.error !== undefined ? { error: result.error } : { error: null }),
55
+ });
56
+ this.enqueueParent(record.resourceUid);
57
+ this.recordEvent(record, "workflow_state_changed");
58
+ return record;
59
+ }
60
+
61
+ private async ensure(
62
+ resource: ControllerResource,
63
+ request: ChildWorkflowRequest,
64
+ signal: AbortSignal,
65
+ ): Promise<ChildWorkflowRecord> {
66
+ const inputFingerprint = jsonFingerprint(request.input);
67
+ const reservation = this.store.reserveWorkflow({
68
+ resourceUid: resource.metadata.uid,
69
+ requestKey: request.requestKey,
70
+ workflow: request.workflow,
71
+ inputFingerprint,
72
+ });
73
+ let record = reservation.record;
74
+ if (record.state === "succeeded" || record.state === "failed" || record.state === "waiting") {
75
+ return record;
76
+ }
77
+ if (this.scheduler === undefined) {
78
+ return record;
79
+ }
80
+ if (record.state === "interrupted" || record.attempt === 0) {
81
+ record = this.store.updateWorkflow(record.requestId, {
82
+ state: "pending",
83
+ attempt: record.attempt + 1,
84
+ runId: createRunId(record.workflow),
85
+ error: null,
86
+ });
87
+ } else if (record.runId === undefined) {
88
+ record = this.store.updateWorkflow(record.requestId, {
89
+ state: "pending",
90
+ runId: createRunId(record.workflow),
91
+ error: null,
92
+ });
93
+ }
94
+ if (record.runId === undefined) {
95
+ throw new Error(`Workflow request has no reserved run ID: ${record.requestId}`);
96
+ }
97
+ const result = await this.scheduler.ensure(
98
+ {
99
+ requestId: record.requestId,
100
+ attempt: record.attempt,
101
+ workflow: record.workflow,
102
+ input: request.input,
103
+ runId: record.runId,
104
+ },
105
+ signal,
106
+ (completed) => {
107
+ this.complete(record.requestId, completed);
108
+ },
109
+ );
110
+ record = this.store.updateWorkflow(record.requestId, {
111
+ state: result.state,
112
+ ...(result.runId !== undefined ? { runId: result.runId } : {}),
113
+ ...(result.error !== undefined ? { error: result.error } : { error: null }),
114
+ });
115
+ if (reservation.created || result.state !== "running") {
116
+ this.recordEvent(
117
+ record,
118
+ reservation.created ? "workflow_requested" : "workflow_state_changed",
119
+ );
120
+ }
121
+ return record;
122
+ }
123
+
124
+ private enqueueParent(resourceUid: string): void {
125
+ const resource = this.store.getResourceByUid(resourceUid);
126
+ if (resource !== undefined) {
127
+ this.store.enqueue({
128
+ controller: resource.metadata.controller,
129
+ key: resource.metadata.key,
130
+ });
131
+ }
132
+ }
133
+
134
+ private recordEvent(record: ChildWorkflowRecord, type: string): void {
135
+ const resource = this.store.getResourceByUid(record.resourceUid);
136
+ if (resource === undefined) {
137
+ return;
138
+ }
139
+ this.store.recordEvent({
140
+ controller: resource.metadata.controller,
141
+ key: resource.metadata.key,
142
+ type,
143
+ payload: {
144
+ requestId: record.requestId,
145
+ requestKey: record.requestKey,
146
+ state: record.state,
147
+ attempt: record.attempt,
148
+ ...(record.runId !== undefined ? { runId: record.runId } : {}),
149
+ },
150
+ });
151
+ }
152
+ }