@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,278 @@
1
+ import { agent, compute, defineWorkflow, shell } from "../workflows/index.js";
2
+ import type { WorkflowNodeContext } from "../workflows/types.js";
3
+
4
+ const MIN_INTERVAL_MINUTES = 1;
5
+ const MAX_INTERVAL_MINUTES = 24 * 60;
6
+ const DEFAULT_MAX_CHECKS = 1_000;
7
+ const MAX_CHECKS = 1_000;
8
+ const MAX_OBSERVATION_CHARS = 8_000;
9
+ const MAX_REPORT_CHARS = 4_000;
10
+ const MAX_REASON_CHARS = 2_000;
11
+ const SLEEP_TIMEOUT_MARGIN_MS = 60_000;
12
+ const NODE_TIMEOUT_MARGIN_MS = 2 * 60_000;
13
+
14
+ type MonitorInput = {
15
+ task: string;
16
+ everyMinutes: number;
17
+ reportWhen?: string;
18
+ stopWhen?: string;
19
+ maxChecks?: number;
20
+ };
21
+
22
+ type MonitorConfig = {
23
+ task: string;
24
+ everyMinutes: number;
25
+ reportWhen: string;
26
+ stopWhen: string;
27
+ maxChecks: number;
28
+ };
29
+
30
+ type MonitorRoute = "continue_quiet" | "continue_report" | "stop_quiet" | "stop_report";
31
+
32
+ type MonitorCheck = {
33
+ route: MonitorRoute;
34
+ observation: string;
35
+ report?: string;
36
+ reason: string;
37
+ };
38
+
39
+ const MONITOR_ROUTES = new Set<MonitorRoute>([
40
+ "continue_quiet",
41
+ "continue_report",
42
+ "stop_quiet",
43
+ "stop_report",
44
+ ]);
45
+
46
+ function requireRecord(value: unknown, label: string): Record<string, unknown> {
47
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
48
+ throw new Error(`${label} must be an object`);
49
+ }
50
+ return value as Record<string, unknown>;
51
+ }
52
+
53
+ function requireBoundedString(value: unknown, label: string, maxChars: number): string {
54
+ if (typeof value !== "string") {
55
+ throw new Error(`${label} must be a string`);
56
+ }
57
+ const trimmed = value.trim();
58
+ if (trimmed.length === 0) {
59
+ throw new Error(`${label} must not be empty`);
60
+ }
61
+ if (trimmed.length > maxChars) {
62
+ throw new Error(`${label} must be at most ${maxChars} characters`);
63
+ }
64
+ return trimmed;
65
+ }
66
+
67
+ function prepareInput(input: unknown): MonitorConfig {
68
+ const value = requireRecord(input, "monitor input") as Partial<MonitorInput>;
69
+ const task = requireBoundedString(value.task, "task", 8_000);
70
+ if (
71
+ typeof value.everyMinutes !== "number" ||
72
+ !Number.isInteger(value.everyMinutes) ||
73
+ value.everyMinutes < MIN_INTERVAL_MINUTES ||
74
+ value.everyMinutes > MAX_INTERVAL_MINUTES
75
+ ) {
76
+ throw new Error(
77
+ `everyMinutes must be an integer from ${MIN_INTERVAL_MINUTES} through ${MAX_INTERVAL_MINUTES}`,
78
+ );
79
+ }
80
+ const maxChecks = value.maxChecks ?? DEFAULT_MAX_CHECKS;
81
+ if (!Number.isInteger(maxChecks) || maxChecks <= 0 || maxChecks > MAX_CHECKS) {
82
+ throw new Error(`maxChecks must be an integer from 1 through ${MAX_CHECKS}`);
83
+ }
84
+ return {
85
+ task,
86
+ everyMinutes: value.everyMinutes,
87
+ reportWhen:
88
+ value.reportWhen === undefined
89
+ ? "The observed state changes materially or needs the user's attention."
90
+ : requireBoundedString(value.reportWhen, "reportWhen", 4_000),
91
+ stopWhen:
92
+ value.stopWhen === undefined
93
+ ? "The user cancels the monitor or it reaches its maximum check count."
94
+ : requireBoundedString(value.stopWhen, "stopWhen", 4_000),
95
+ maxChecks,
96
+ };
97
+ }
98
+
99
+ function configFrom(outputs: Record<string, unknown>): MonitorConfig {
100
+ return outputs.prepare as MonitorConfig;
101
+ }
102
+
103
+ function completedChecks(context: WorkflowNodeContext): number {
104
+ return context.state.steps.filter((step) => step.nodeId === "check" && step.outcome === "ok")
105
+ .length;
106
+ }
107
+
108
+ function validateCheck(output: unknown): MonitorCheck {
109
+ const value = requireRecord(output, "monitor check output");
110
+ if (typeof value.route !== "string" || !MONITOR_ROUTES.has(value.route as MonitorRoute)) {
111
+ throw new Error(`route must be one of ${[...MONITOR_ROUTES].join(", ")}`);
112
+ }
113
+ const route = value.route as MonitorRoute;
114
+ const observation = requireBoundedString(value.observation, "observation", MAX_OBSERVATION_CHARS);
115
+ const reason = requireBoundedString(value.reason, "reason", MAX_REASON_CHARS);
116
+ const reports = route === "continue_report" || route === "stop_report";
117
+ const report =
118
+ value.report === undefined
119
+ ? undefined
120
+ : requireBoundedString(value.report, "report", MAX_REPORT_CHARS);
121
+ if (reports && report === undefined) {
122
+ throw new Error(`route ${route} requires a report`);
123
+ }
124
+ return {
125
+ route,
126
+ observation,
127
+ ...(report !== undefined ? { report } : {}),
128
+ reason,
129
+ };
130
+ }
131
+
132
+ function validateReportAck(output: unknown): { reported: true } {
133
+ const value = requireRecord(output, "report acknowledgement");
134
+ if (value.reported !== true) {
135
+ throw new Error("report acknowledgement must set reported to true");
136
+ }
137
+ return { reported: true };
138
+ }
139
+
140
+ function reportPrompt(outputs: Record<string, unknown>): string {
141
+ const check = outputs.check as MonitorCheck;
142
+ return [
143
+ "Write one concise normal assistant message to the user with this monitoring update:",
144
+ check.report ?? check.observation,
145
+ "Do not add unrelated detail.",
146
+ "After writing the update, submit the acknowledgement required by the workflow step contract.",
147
+ ].join("\n\n");
148
+ }
149
+
150
+ export default defineWorkflow({
151
+ name: "monitor",
152
+ title: ({ input }) => {
153
+ try {
154
+ const task = prepareInput(input).task;
155
+ return `monitor: ${task.slice(0, 80)}`;
156
+ } catch {
157
+ return "monitor";
158
+ }
159
+ },
160
+ presentationPrompt: ({ finalOutput }) => {
161
+ const result = requireRecord(finalOutput, "monitor result");
162
+ if (result.reported === true) {
163
+ return undefined;
164
+ }
165
+ return `Tell the user concisely why this monitor stopped: ${String(result.reason ?? "monitor ended")}`;
166
+ },
167
+ startAt: "prepare",
168
+ maxSteps: 5_010,
169
+ nodes: {
170
+ prepare: compute({
171
+ run: ({ input }) => prepareInput(input),
172
+ }),
173
+ guard: compute({
174
+ run: (context) => {
175
+ const config = configFrom(context.outputs);
176
+ const checks = completedChecks(context);
177
+ return checks >= config.maxChecks
178
+ ? { route: "stop", checks, reason: `Reached the ${config.maxChecks}-check limit.` }
179
+ : { route: "check", checks };
180
+ },
181
+ }),
182
+ continue_guard: compute({
183
+ run: (context) => {
184
+ const config = configFrom(context.outputs);
185
+ const checks = completedChecks(context);
186
+ return checks >= config.maxChecks
187
+ ? { route: "stop", checks, reason: `Reached the ${config.maxChecks}-check limit.` }
188
+ : { route: "sleep", checks };
189
+ },
190
+ }),
191
+ check: agent({
192
+ statusDetail: "checking monitored target",
193
+ prompt: (context) => {
194
+ const config = configFrom(context.outputs);
195
+ const previous = context.outputs.check as MonitorCheck | undefined;
196
+ const checkNumber = completedChecks(context) + 1;
197
+ return [
198
+ `Perform monitoring check ${checkNumber} of at most ${config.maxChecks}.`,
199
+ `Task: ${config.task}`,
200
+ `Report when: ${config.reportWhen}`,
201
+ `Stop when: ${config.stopWhen}`,
202
+ previous === undefined
203
+ ? "There is no previous observation. Report the initial state only when the report condition calls for it."
204
+ : `Previous accepted observation: ${previous.observation}`,
205
+ "Use available tools to inspect the current state. Observe only unless the task explicitly authorizes a mutation.",
206
+ "Choose continue_quiet, continue_report, stop_quiet, or stop_report. A report route requires concise report text.",
207
+ ].join("\n\n");
208
+ },
209
+ expectedOutput:
210
+ '{ "route": "continue_quiet" | "continue_report" | "stop_quiet" | "stop_report", "observation": "current factual state", "report": "required for report routes", "reason": "short reason" }',
211
+ validate: (output) => validateCheck(output),
212
+ }),
213
+ report_continue: agent({
214
+ statusDetail: "reporting monitor update",
215
+ prompt: ({ outputs }) => reportPrompt(outputs),
216
+ expectedOutput: '{ "reported": true }',
217
+ validate: (output) => validateReportAck(output),
218
+ }),
219
+ report_stop: agent({
220
+ statusDetail: "reporting final monitor update",
221
+ prompt: ({ outputs }) => reportPrompt(outputs),
222
+ expectedOutput: '{ "reported": true }',
223
+ validate: (output) => validateReportAck(output),
224
+ }),
225
+ sleep: shell({
226
+ statusDetail: "waiting for next monitor check",
227
+ timeoutMs: MAX_INTERVAL_MINUTES * 60_000 + NODE_TIMEOUT_MARGIN_MS,
228
+ exec: ({ outputs }) => {
229
+ const config = configFrom(outputs);
230
+ const sleepMs = config.everyMinutes * 60_000;
231
+ return {
232
+ command: process.execPath,
233
+ args: ["-e", "setTimeout(() => {}, Number(process.argv[1]))", String(sleepMs)],
234
+ timeoutMs: sleepMs + SLEEP_TIMEOUT_MARGIN_MS,
235
+ maxOutputChars: 1_024,
236
+ };
237
+ },
238
+ parse: (_result, { outputs }) => ({ waitedMinutes: configFrom(outputs).everyMinutes }),
239
+ }),
240
+ finish: compute({
241
+ run: ({ outputs }) => {
242
+ const check = outputs.check as MonitorCheck | undefined;
243
+ const guard = outputs.guard as { reason?: string } | undefined;
244
+ const continueGuard = outputs.continue_guard as { reason?: string } | undefined;
245
+ return {
246
+ reason: continueGuard?.reason ?? guard?.reason ?? check?.reason ?? "Monitor finished.",
247
+ observation: check?.observation ?? null,
248
+ reported:
249
+ outputs.report_stop !== undefined ||
250
+ (check !== undefined && check.route === "stop_report"),
251
+ };
252
+ },
253
+ }),
254
+ },
255
+ edges: [
256
+ { from: "prepare", to: "guard" },
257
+ { from: "guard", switch: { on: "$.route", cases: { check: "check", stop: "finish" } } },
258
+ {
259
+ from: "check",
260
+ switch: {
261
+ on: "$.route",
262
+ cases: {
263
+ continue_quiet: "continue_guard",
264
+ continue_report: "report_continue",
265
+ stop_quiet: "finish",
266
+ stop_report: "report_stop",
267
+ },
268
+ },
269
+ },
270
+ { from: "report_continue", to: "continue_guard" },
271
+ { from: "report_stop", to: "finish" },
272
+ {
273
+ from: "continue_guard",
274
+ switch: { on: "$.route", cases: { sleep: "sleep", stop: "finish" } },
275
+ },
276
+ { from: "sleep", to: "guard" },
277
+ ],
278
+ });
@@ -0,0 +1,110 @@
1
+ import type {
2
+ ControllerCondition,
3
+ ControllerConditionInput,
4
+ ControllerConditionStatus,
5
+ ControllerResourceStatus,
6
+ ControllerStatusPatch,
7
+ } from "./types.js";
8
+
9
+ export function conditionTrue(
10
+ type: string,
11
+ reason: string,
12
+ message?: string,
13
+ ): ControllerConditionInput {
14
+ return condition(type, true, reason, message);
15
+ }
16
+
17
+ export function conditionFalse(
18
+ type: string,
19
+ reason: string,
20
+ message?: string,
21
+ ): ControllerConditionInput {
22
+ return condition(type, false, reason, message);
23
+ }
24
+
25
+ export function conditionUnknown(
26
+ type: string,
27
+ reason: string,
28
+ message?: string,
29
+ ): ControllerConditionInput {
30
+ return condition(type, "unknown", reason, message);
31
+ }
32
+
33
+ export function applyStatusPatch<TStatus>(
34
+ current: ControllerResourceStatus<TStatus>,
35
+ patch: ControllerStatusPatch<TStatus> | undefined,
36
+ generation: number,
37
+ now: string,
38
+ ): ControllerResourceStatus<TStatus> {
39
+ return {
40
+ observedGeneration: generation,
41
+ conditions: mergeConditions(current.conditions, patch?.conditions ?? [], generation, now),
42
+ controllerStatus:
43
+ patch !== undefined && Object.hasOwn(patch, "controllerStatus")
44
+ ? (patch.controllerStatus as TStatus)
45
+ : current.controllerStatus,
46
+ ...(patch?.workflowRun === null
47
+ ? {}
48
+ : patch?.workflowRun !== undefined
49
+ ? { workflowRun: patch.workflowRun }
50
+ : current.workflowRun !== undefined
51
+ ? { workflowRun: current.workflowRun }
52
+ : {}),
53
+ };
54
+ }
55
+
56
+ export function mergeConditions(
57
+ current: ControllerCondition[],
58
+ updates: ControllerConditionInput[],
59
+ generation: number,
60
+ now: string,
61
+ ): ControllerCondition[] {
62
+ const byType = new Map(current.map((item) => [item.type, item]));
63
+ const seen = new Set<string>();
64
+ for (const update of updates) {
65
+ validateCondition(update);
66
+ if (seen.has(update.type)) {
67
+ throw new Error(`Condition ${JSON.stringify(update.type)} was updated more than once`);
68
+ }
69
+ seen.add(update.type);
70
+ const previous = byType.get(update.type);
71
+ const next: ControllerCondition = {
72
+ ...update,
73
+ observedGeneration: generation,
74
+ lastTransitionTime:
75
+ previous !== undefined && previous.status === update.status
76
+ ? previous.lastTransitionTime
77
+ : now,
78
+ };
79
+ byType.set(update.type, next);
80
+ }
81
+ return [...byType.values()];
82
+ }
83
+
84
+ function condition(
85
+ type: string,
86
+ status: ControllerConditionStatus,
87
+ reason: string,
88
+ message: string | undefined,
89
+ ): ControllerConditionInput {
90
+ const value: ControllerConditionInput = {
91
+ type,
92
+ status,
93
+ reason,
94
+ ...(message !== undefined ? { message } : {}),
95
+ };
96
+ validateCondition(value);
97
+ return value;
98
+ }
99
+
100
+ function validateCondition(value: ControllerConditionInput): void {
101
+ if (value.type.trim().length === 0) {
102
+ throw new Error("Condition type must not be empty");
103
+ }
104
+ if (value.reason.trim().length === 0) {
105
+ throw new Error("Condition reason must not be empty");
106
+ }
107
+ if (value.status !== true && value.status !== false && value.status !== "unknown") {
108
+ throw new Error(`Invalid condition status for ${JSON.stringify(value.type)}`);
109
+ }
110
+ }
@@ -0,0 +1,65 @@
1
+ import type { AnyControllerDefinition, ControllerDefinition, ControllerResource } from "./types.js";
2
+
3
+ const CONTROLLER_DEFINITION_BRAND = Symbol.for("pi-workflows.controller-definition");
4
+ const CONTROLLER_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
5
+
6
+ export function defineController<TSpec, TStatus>(
7
+ definition: ControllerDefinition<TSpec, TStatus>,
8
+ ): ControllerDefinition<TSpec, TStatus> {
9
+ assertValidControllerDefinition(definition);
10
+ if (isControllerDefinition(definition)) {
11
+ return definition;
12
+ }
13
+ Object.defineProperty(definition, CONTROLLER_DEFINITION_BRAND, {
14
+ value: true,
15
+ enumerable: false,
16
+ configurable: false,
17
+ writable: false,
18
+ });
19
+ return definition;
20
+ }
21
+
22
+ export function isControllerDefinition(value: unknown): value is AnyControllerDefinition {
23
+ return (
24
+ value !== null &&
25
+ typeof value === "object" &&
26
+ (value as Record<PropertyKey, unknown>)[CONTROLLER_DEFINITION_BRAND] === true
27
+ );
28
+ }
29
+
30
+ export function assertValidControllerDefinition<TSpec, TStatus>(
31
+ definition: ControllerDefinition<TSpec, TStatus>,
32
+ ): void {
33
+ if (definition === null || typeof definition !== "object") {
34
+ throw new Error("Invalid controller definition: expected an object");
35
+ }
36
+ if (!CONTROLLER_NAME_PATTERN.test(definition.name)) {
37
+ throw new Error(
38
+ `Invalid controller definition: name ${JSON.stringify(definition.name)} must match ${CONTROLLER_NAME_PATTERN.source}`,
39
+ );
40
+ }
41
+ if (typeof definition.initialStatus !== "function") {
42
+ throw new Error("Invalid controller definition: initialStatus must be a function");
43
+ }
44
+ if (typeof definition.reconcile !== "function") {
45
+ throw new Error("Invalid controller definition: reconcile must be a function");
46
+ }
47
+ if (
48
+ definition.timeoutMs !== undefined &&
49
+ (!Number.isSafeInteger(definition.timeoutMs) || definition.timeoutMs <= 0)
50
+ ) {
51
+ throw new Error("Invalid controller definition: timeoutMs must be a positive safe integer");
52
+ }
53
+ }
54
+
55
+ export function asAnyControllerDefinition<TSpec, TStatus>(
56
+ definition: ControllerDefinition<TSpec, TStatus>,
57
+ ): AnyControllerDefinition {
58
+ return definition as unknown as ControllerDefinition<unknown, unknown>;
59
+ }
60
+
61
+ export function asTypedResource<TSpec, TStatus>(
62
+ resource: ControllerResource,
63
+ ): ControllerResource<TSpec, TStatus> {
64
+ return resource as ControllerResource<TSpec, TStatus>;
65
+ }
@@ -0,0 +1,123 @@
1
+ import { jsonFingerprint } from "./json.js";
2
+ import type { ControllerStore } from "./store.js";
3
+ import type {
4
+ ControllerEffects,
5
+ ControllerResource,
6
+ EffectApplication,
7
+ EffectDefinition,
8
+ EffectObservation,
9
+ EffectRecord,
10
+ JsonObject,
11
+ } from "./types.js";
12
+
13
+ export class ControllerEffectService implements ControllerEffects {
14
+ private used = false;
15
+
16
+ constructor(
17
+ private readonly store: ControllerStore,
18
+ private readonly resource: ControllerResource,
19
+ private readonly signal: AbortSignal,
20
+ ) {}
21
+
22
+ async ensure<TRequest>(definition: EffectDefinition<TRequest>): Promise<EffectRecord> {
23
+ if (this.used) {
24
+ throw new Error("A reconciliation pass may ensure only one external effect");
25
+ }
26
+ this.used = true;
27
+ const requestFingerprint = jsonFingerprint(definition.request);
28
+ const reservation = this.store.reserveEffect({
29
+ key: definition.key,
30
+ resourceUid: this.resource.metadata.uid,
31
+ generation: this.resource.metadata.generation,
32
+ kind: definition.kind,
33
+ requestFingerprint,
34
+ });
35
+ if (reservation.record.state === "applied" || reservation.record.state === "rejected") {
36
+ return reservation.record;
37
+ }
38
+
39
+ if (!reservation.created) {
40
+ const observed = await this.observe(definition);
41
+ const recovered = this.applyObservation(definition.key, observed);
42
+ if (recovered !== undefined) {
43
+ return recovered;
44
+ }
45
+ }
46
+
47
+ return await this.apply(definition);
48
+ }
49
+
50
+ private async observe<TRequest>(
51
+ definition: EffectDefinition<TRequest>,
52
+ ): Promise<EffectObservation> {
53
+ try {
54
+ return await definition.observe(this.signal);
55
+ } catch (error) {
56
+ this.recordEvent("effect_observe_failed", definition.key, {
57
+ error: boundedError(error),
58
+ });
59
+ throw error;
60
+ }
61
+ }
62
+
63
+ private applyObservation(key: string, observation: EffectObservation): EffectRecord | undefined {
64
+ if (observation.state === "not_applied") {
65
+ return undefined;
66
+ }
67
+ const record = this.store.updateEffect({
68
+ resourceUid: this.resource.metadata.uid,
69
+ key,
70
+ state: observation.state === "applied" ? "applied" : "indeterminate",
71
+ ...("externalRef" in observation && observation.externalRef !== undefined
72
+ ? { externalRef: observation.externalRef }
73
+ : {}),
74
+ });
75
+ this.recordEvent(
76
+ observation.state === "applied" ? "effect_recovered" : "effect_indeterminate",
77
+ key,
78
+ );
79
+ return record;
80
+ }
81
+
82
+ private async apply<TRequest>(definition: EffectDefinition<TRequest>): Promise<EffectRecord> {
83
+ let result: EffectApplication;
84
+ try {
85
+ result = await definition.apply(this.signal);
86
+ } catch (error) {
87
+ const message = boundedError(error);
88
+ const record = this.store.updateEffect({
89
+ resourceUid: this.resource.metadata.uid,
90
+ key: definition.key,
91
+ state: "indeterminate",
92
+ error: message,
93
+ });
94
+ this.recordEvent("effect_indeterminate", definition.key, { error: message });
95
+ return record;
96
+ }
97
+ const record = this.store.updateEffect({
98
+ resourceUid: this.resource.metadata.uid,
99
+ key: definition.key,
100
+ state: result.state,
101
+ ...(result.state === "applied" && result.externalRef !== undefined
102
+ ? { externalRef: result.externalRef }
103
+ : {}),
104
+ ...(result.state !== "applied" && result.error !== undefined ? { error: result.error } : {}),
105
+ });
106
+ this.recordEvent(`effect_${result.state}`, definition.key);
107
+ return record;
108
+ }
109
+
110
+ private recordEvent(type: string, effectKey: string, extra: JsonObject = {}): void {
111
+ this.store.recordEvent({
112
+ controller: this.resource.metadata.controller,
113
+ key: this.resource.metadata.key,
114
+ type,
115
+ payload: { effectKey, ...extra },
116
+ });
117
+ }
118
+ }
119
+
120
+ function boundedError(error: unknown): string {
121
+ const message = error instanceof Error ? error.message : String(error);
122
+ return message.length <= 8_192 ? message : `${message.slice(0, 8_192)}…`;
123
+ }
@@ -0,0 +1,27 @@
1
+ export class ResourceConflictError extends Error {
2
+ constructor(controller: string, key: string) {
3
+ super(`Controller resource ${controller}/${key} changed during reconciliation`);
4
+ this.name = "ResourceConflictError";
5
+ }
6
+ }
7
+
8
+ export class ResourceNotFoundError extends Error {
9
+ constructor(controller: string, key: string) {
10
+ super(`Controller resource not found: ${controller}/${key}`);
11
+ this.name = "ResourceNotFoundError";
12
+ }
13
+ }
14
+
15
+ export class EffectRequestConflictError extends Error {
16
+ constructor(key: string) {
17
+ super(`Effect key ${JSON.stringify(key)} was reused with a different request`);
18
+ this.name = "EffectRequestConflictError";
19
+ }
20
+ }
21
+
22
+ export class WorkflowRequestConflictError extends Error {
23
+ constructor(key: string) {
24
+ super(`Workflow request key ${JSON.stringify(key)} was reused with different input`);
25
+ this.name = "WorkflowRequestConflictError";
26
+ }
27
+ }
@@ -0,0 +1,90 @@
1
+ export {
2
+ assertValidControllerDefinition,
3
+ defineController,
4
+ isControllerDefinition,
5
+ } from "./definition.js";
6
+ export {
7
+ applyStatusPatch,
8
+ conditionFalse,
9
+ conditionTrue,
10
+ conditionUnknown,
11
+ mergeConditions,
12
+ } from "./conditions.js";
13
+ export {
14
+ EffectRequestConflictError,
15
+ ResourceConflictError,
16
+ ResourceNotFoundError,
17
+ WorkflowRequestConflictError,
18
+ } from "./errors.js";
19
+ export { ControllerEffectService } from "./effects.js";
20
+ export { canonicalJson, jsonFingerprint } from "./json.js";
21
+ export { ControllerManager, type ControllerManagerOptions } from "./manager.js";
22
+ export { createResultHelpers, requeue, requeueAfter, settled } from "./results.js";
23
+ export {
24
+ SqliteControllerStore,
25
+ type RunEventRecord,
26
+ type WorkflowRunQueueRecord,
27
+ } from "./sqlite.js";
28
+ export {
29
+ CONTROLLER_STORE_SCHEMA,
30
+ controllerProjectScope,
31
+ controllerStoreBaseDir,
32
+ controllerStorePath,
33
+ projectControllerStoreBaseDir,
34
+ projectControllerStorePath,
35
+ type ControllerStore,
36
+ type EffectReservation,
37
+ type QueueItem,
38
+ type QueueRequeueOptions,
39
+ type WorkflowRecordUpdate,
40
+ type WorkflowReservation,
41
+ } from "./store.js";
42
+ export {
43
+ controllerFileStem,
44
+ controllerSearchDirs,
45
+ discoverControllers,
46
+ loadControllerFile,
47
+ loadDiscoveredControllers,
48
+ type DiscoveredController,
49
+ } from "./loader.js";
50
+ export {
51
+ WorkflowEngineScheduler,
52
+ type ResolvedChildWorkflow,
53
+ type WorkflowEngineSchedulerOptions,
54
+ } from "./workflow-engine-scheduler.js";
55
+ export {
56
+ ControllerWorkflowCoordinator,
57
+ type ControllerWorkflowScheduler,
58
+ type WorkflowSchedulerRequest,
59
+ type WorkflowSchedulerResult,
60
+ } from "./workflows.js";
61
+ export type {
62
+ AnyControllerDefinition,
63
+ ChildWorkflowRecord,
64
+ ChildWorkflowReference,
65
+ ChildWorkflowRequest,
66
+ ChildWorkflowState,
67
+ ControllerCondition,
68
+ ControllerConditionInput,
69
+ ControllerConditionStatus,
70
+ ControllerDefinition,
71
+ ControllerEffects,
72
+ ControllerEvent,
73
+ ControllerQueueClaim,
74
+ ControllerResource,
75
+ ControllerResourceRef,
76
+ ControllerResourceStatus,
77
+ ControllerStatusPatch,
78
+ ControllerWorkflows,
79
+ EffectApplication,
80
+ EffectDefinition,
81
+ EffectObservation,
82
+ EffectRecord,
83
+ EffectState,
84
+ JsonObject,
85
+ JsonPrimitive,
86
+ JsonValue,
87
+ MaybePromise,
88
+ ReconcileContext,
89
+ ReconcileResult,
90
+ } from "./types.js";