@osolmaz/pi-workflows 0.16.4 → 0.16.6

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 (173) hide show
  1. package/README.md +5 -5
  2. package/dist/builtins/autoimplement.workflow.d.ts +5 -1
  3. package/dist/builtins/autoimplement.workflow.js +29 -10
  4. package/dist/builtins/autoimplement.workflow.js.map +1 -1
  5. package/dist/client/client.js +3 -1
  6. package/dist/client/client.js.map +1 -1
  7. package/dist/client/protocol.d.ts +1 -1
  8. package/dist/client/protocol.js +18 -1
  9. package/dist/client/protocol.js.map +1 -1
  10. package/dist/client/view.d.ts +7 -5
  11. package/dist/extension/index.d.ts +2 -1
  12. package/dist/extension/index.js +115 -165
  13. package/dist/extension/index.js.map +1 -1
  14. package/dist/extension/recorder.d.ts +1 -1
  15. package/dist/extension/recorder.js +2 -2
  16. package/dist/extension/recorder.js.map +1 -1
  17. package/dist/extension/step-message.d.ts +1 -1
  18. package/dist/extension/step-message.js +4 -4
  19. package/dist/extension/step-message.js.map +1 -1
  20. package/dist/extension/workflow-message-coordinator.d.ts +12 -1
  21. package/dist/extension/workflow-message-coordinator.js +65 -54
  22. package/dist/extension/workflow-message-coordinator.js.map +1 -1
  23. package/dist/resource-managers/index.d.ts +1 -2
  24. package/dist/resource-managers/index.js +1 -2
  25. package/dist/resource-managers/index.js.map +1 -1
  26. package/dist/resource-managers/sqlite.d.ts +2 -295
  27. package/dist/resource-managers/sqlite.js +71 -1528
  28. package/dist/resource-managers/sqlite.js.map +1 -1
  29. package/dist/resource-managers/types.d.ts +2 -7
  30. package/dist/server/resource-runner-entry.js +2 -6
  31. package/dist/server/resource-runner-entry.js.map +1 -1
  32. package/dist/server/rpc-bridge.js +1 -1
  33. package/dist/server/rpc-bridge.js.map +1 -1
  34. package/dist/server/rpc-executor.js +2 -4
  35. package/dist/server/rpc-executor.js.map +1 -1
  36. package/dist/server/server-entry.js +3 -0
  37. package/dist/server/server-entry.js.map +1 -1
  38. package/dist/server/server.d.ts +22 -8
  39. package/dist/server/server.js +498 -543
  40. package/dist/server/server.js.map +1 -1
  41. package/dist/server/state.d.ts +16 -29
  42. package/dist/server/state.js +105 -319
  43. package/dist/server/state.js.map +1 -1
  44. package/dist/server/view.d.ts +4 -5
  45. package/dist/server/view.js +51 -51
  46. package/dist/server/view.js.map +1 -1
  47. package/dist/server/workflow-runner-entry.d.ts +1 -1
  48. package/dist/server/workflow-runner-entry.js +1 -49
  49. package/dist/server/workflow-runner-entry.js.map +1 -1
  50. package/dist/server/workflow-runner-protocol.d.ts +2 -7
  51. package/dist/server/workflow-runner-protocol.js +5 -10
  52. package/dist/server/workflow-runner-protocol.js.map +1 -1
  53. package/dist/server/workflow-runner-store.d.ts +5 -6
  54. package/dist/server/workflow-runner-store.js +7 -14
  55. package/dist/server/workflow-runner-store.js.map +1 -1
  56. package/dist/state/attempt-time.d.ts +21 -0
  57. package/dist/state/attempt-time.js +95 -0
  58. package/dist/state/attempt-time.js.map +1 -0
  59. package/dist/state/json.d.ts +2 -1
  60. package/dist/state/json.js.map +1 -1
  61. package/dist/state/project-store.d.ts +39 -0
  62. package/dist/state/project-store.js +154 -0
  63. package/dist/state/project-store.js.map +1 -0
  64. package/dist/state/prune.d.ts +28 -0
  65. package/dist/state/prune.js +278 -83
  66. package/dist/state/prune.js.map +1 -1
  67. package/dist/state/schema.js +23 -23
  68. package/dist/state/workflow-messages.d.ts +1 -1
  69. package/dist/state/workflow-messages.js +3 -1
  70. package/dist/state/workflow-messages.js.map +1 -1
  71. package/dist/viewer/backup.js +5 -0
  72. package/dist/viewer/backup.js.map +1 -1
  73. package/dist/workflows/diagnostics.d.ts +8 -0
  74. package/dist/workflows/diagnostics.js +61 -0
  75. package/dist/workflows/diagnostics.js.map +1 -0
  76. package/dist/workflows/engine.d.ts +2 -15
  77. package/dist/workflows/engine.js +125 -245
  78. package/dist/workflows/engine.js.map +1 -1
  79. package/dist/workflows/human-decision.d.ts +3 -7
  80. package/dist/workflows/human-decision.js +2 -56
  81. package/dist/workflows/human-decision.js.map +1 -1
  82. package/dist/workflows/index.d.ts +2 -1
  83. package/dist/workflows/index.js +1 -0
  84. package/dist/workflows/index.js.map +1 -1
  85. package/dist/workflows/queue.d.ts +267 -0
  86. package/dist/workflows/queue.js +1248 -0
  87. package/dist/workflows/queue.js.map +1 -0
  88. package/dist/workflows/requests.d.ts +47 -0
  89. package/dist/workflows/requests.js +158 -0
  90. package/dist/workflows/requests.js.map +1 -0
  91. package/dist/workflows/schema.js +2 -4
  92. package/dist/workflows/schema.js.map +1 -1
  93. package/dist/workflows/store.d.ts +11 -16
  94. package/dist/workflows/store.js +174 -169
  95. package/dist/workflows/store.js.map +1 -1
  96. package/dist/workflows/tool-input.d.ts +6 -9
  97. package/dist/workflows/tool-input.js +15 -12
  98. package/dist/workflows/tool-input.js.map +1 -1
  99. package/dist/workflows/transitions.d.ts +38 -0
  100. package/dist/workflows/transitions.js +276 -0
  101. package/dist/workflows/transitions.js.map +1 -0
  102. package/dist/workflows/types.d.ts +9 -42
  103. package/dist/workflows/workflow-message-content.d.ts +7 -1
  104. package/dist/workflows/workflow-message-content.js +22 -4
  105. package/dist/workflows/workflow-message-content.js.map +1 -1
  106. package/docs/2026-08-25-workflow-follow-ups.md +5 -5
  107. package/docs/2026-09-02-unify-workflow-messages-plan.md +2 -0
  108. package/docs/2026-09-04-workflow-run-state-plan.md +2 -0
  109. package/docs/2026-09-06-durable-execution-plan.md +176 -0
  110. package/docs/DEFERRED_TURNS.md +84 -105
  111. package/docs/HUMAN_DECISIONS.md +17 -19
  112. package/docs/HUMAN_DECISION_PRESENTATIONS.md +1 -1
  113. package/docs/SQLITE_STATE.md +87 -30
  114. package/docs/WORKFLOW_SERVER.md +50 -11
  115. package/docs/WORKFLOW_STEP_MESSAGES.md +72 -54
  116. package/docs/plans/2026-08-04-controller-runtime-plan.md +2 -0
  117. package/docs/plans/2026-08-21-deferred-turn-intents-plan.md +2 -0
  118. package/docs/plans/2026-08-27-workflow-terminal-restart-plan.md +2 -0
  119. package/docs/plans/2026-09-04-automatic-state-retention-plan.md +283 -0
  120. package/docs/workflows.md +109 -44
  121. package/examples/workflows/autoresearch.workflow.ts +0 -2
  122. package/examples/workflows/branch.workflow.ts +0 -4
  123. package/examples/workflows/echo.workflow.ts +0 -2
  124. package/examples/workflows/live-settings.workflow.ts +0 -2
  125. package/examples/workflows/two-turn.workflow.ts +0 -2
  126. package/herdr-plugin.toml +1 -1
  127. package/package.json +1 -1
  128. package/protocol/fixtures/client-v1.json +1 -1
  129. package/skills/pi-workflows/SKILL.md +8 -7
  130. package/src/builtins/autoimplement.workflow.ts +31 -10
  131. package/src/client/client.ts +3 -1
  132. package/src/client/protocol.ts +21 -1
  133. package/src/client/view.ts +9 -5
  134. package/src/extension/index.ts +117 -189
  135. package/src/extension/recorder.ts +2 -2
  136. package/src/extension/step-message.ts +4 -4
  137. package/src/extension/workflow-message-coordinator.ts +78 -50
  138. package/src/resource-managers/index.ts +1 -9
  139. package/src/resource-managers/sqlite.ts +330 -2655
  140. package/src/resource-managers/types.ts +2 -3
  141. package/src/server/resource-runner-entry.ts +2 -7
  142. package/src/server/rpc-bridge.ts +1 -1
  143. package/src/server/rpc-executor.ts +4 -9
  144. package/src/server/server-entry.ts +3 -0
  145. package/src/server/server.ts +568 -644
  146. package/src/server/state.ts +122 -466
  147. package/src/server/view.ts +56 -60
  148. package/src/server/workflow-runner-entry.ts +2 -55
  149. package/src/server/workflow-runner-protocol.ts +5 -15
  150. package/src/server/workflow-runner-store.ts +12 -27
  151. package/src/state/attempt-time.ts +122 -0
  152. package/src/state/json.ts +2 -1
  153. package/src/state/project-store.ts +218 -0
  154. package/src/state/prune.ts +408 -95
  155. package/src/state/schema.ts +23 -23
  156. package/src/state/workflow-messages.ts +4 -2
  157. package/src/viewer/backup.ts +7 -0
  158. package/src/workflows/diagnostics.ts +78 -0
  159. package/src/workflows/engine.ts +133 -291
  160. package/src/workflows/human-decision.ts +3 -81
  161. package/src/workflows/index.ts +8 -2
  162. package/src/workflows/queue.ts +2091 -0
  163. package/src/workflows/requests.ts +283 -0
  164. package/src/workflows/schema.ts +2 -6
  165. package/src/workflows/store.ts +223 -231
  166. package/src/workflows/tool-input.ts +20 -12
  167. package/src/workflows/transitions.ts +330 -0
  168. package/src/workflows/types.ts +9 -46
  169. package/src/workflows/workflow-message-content.ts +28 -4
  170. package/dist/resource-managers/runtime.d.ts +0 -59
  171. package/dist/resource-managers/runtime.js +0 -423
  172. package/dist/resource-managers/runtime.js.map +0 -1
  173. package/src/resource-managers/runtime.ts +0 -576
@@ -1,576 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { applyStatusPatch } from "./conditions.js";
3
- import { asAnyResourceManagerDefinition, asTypedResource } from "./definition.js";
4
- import { ResourceManagerEffectService } from "./effects.js";
5
- import { ManagedResourceConflictError } from "./errors.js";
6
- import { createResultHelpers } from "./results.js";
7
- import type { ResourceManagerStore } from "./store.js";
8
- import type {
9
- AnyResourceManagerDefinition,
10
- ResourceManagerDefinition,
11
- ResourceManagerQueueClaim,
12
- ManagedResource,
13
- ManagedResourceRef,
14
- JsonObject,
15
- ReconcileContext,
16
- ReconcileResult,
17
- } from "./types.js";
18
- import {
19
- ResourceManagerWorkflowCoordinator,
20
- type ResourceManagerWorkflowScheduler,
21
- } from "./workflows.js";
22
-
23
- const DEFAULT_MAX_CONCURRENT = 4;
24
- const DEFAULT_TIMEOUT_MS = 60_000;
25
- const DEFAULT_LEASE_MS = 120_000;
26
- const DEFAULT_POLL_INTERVAL_MS = 250;
27
- const DEFAULT_BASE_BACKOFF_MS = 1_000;
28
- const DEFAULT_MAX_BACKOFF_MS = 60_000;
29
- const DEFAULT_JITTER_RATIO = 0.2;
30
- const MIN_LEASE_MS = 300;
31
-
32
- export type ResourceManagerRuntimeOptions = {
33
- store: ResourceManagerStore;
34
- resourceManagers: AnyResourceManagerDefinition[];
35
- workflowScheduler?: ResourceManagerWorkflowScheduler;
36
- maxConcurrent?: number;
37
- resourceManagerConcurrency?: Record<string, number>;
38
- defaultTimeoutMs?: number;
39
- leaseMs?: number;
40
- pollIntervalMs?: number;
41
- baseBackoffMs?: number;
42
- maxBackoffMs?: number;
43
- jitterRatio?: number;
44
- now?: () => Date;
45
- random?: () => number;
46
- };
47
-
48
- export class ResourceManagerRuntime {
49
- readonly store: ResourceManagerStore;
50
- private readonly ownerId = `resource-manager-runtime-${randomUUID()}`;
51
- private readonly definitions = new Map<string, AnyResourceManagerDefinition>();
52
- private readonly workflowCoordinator: ResourceManagerWorkflowCoordinator;
53
- private readonly maxConcurrent: number;
54
- private readonly resourceManagerConcurrency: Record<string, number>;
55
- private readonly defaultTimeoutMs: number;
56
- private readonly leaseMs: number;
57
- private readonly pollIntervalMs: number;
58
- private readonly baseBackoffMs: number;
59
- private readonly maxBackoffMs: number;
60
- private readonly jitterRatio: number;
61
- private readonly now: () => Date;
62
- private readonly random: () => number;
63
- private readonly activeByResourceManager = new Map<string, number>();
64
- private readonly activeReconciles = new Map<string, AbortController>();
65
- private runAbort: AbortController | null = null;
66
- private runnerTasks: Promise<void>[] = [];
67
-
68
- constructor(options: ResourceManagerRuntimeOptions) {
69
- this.store = options.store;
70
- for (const definition of options.resourceManagers) {
71
- if (this.definitions.has(definition.name)) {
72
- throw new Error(`Duplicate resource manager definition: ${definition.name}`);
73
- }
74
- this.definitions.set(definition.name, definition);
75
- }
76
- this.workflowCoordinator = new ResourceManagerWorkflowCoordinator(
77
- options.store,
78
- options.workflowScheduler,
79
- );
80
- this.maxConcurrent = positiveInteger(
81
- options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT,
82
- "maxConcurrent",
83
- );
84
- this.resourceManagerConcurrency = options.resourceManagerConcurrency ?? {};
85
- for (const [name, value] of Object.entries(this.resourceManagerConcurrency)) {
86
- if (!this.definitions.has(name)) {
87
- throw new Error(`Concurrency limit names an unknown resource manager: ${name}`);
88
- }
89
- positiveInteger(value, `resourceManagerConcurrency.${name}`);
90
- }
91
- this.defaultTimeoutMs = positiveInteger(
92
- options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
93
- "defaultTimeoutMs",
94
- );
95
- this.leaseMs = positiveInteger(options.leaseMs ?? DEFAULT_LEASE_MS, "leaseMs");
96
- if (this.leaseMs < MIN_LEASE_MS) {
97
- throw new Error(`leaseMs must be at least ${MIN_LEASE_MS}`);
98
- }
99
- this.pollIntervalMs = positiveInteger(
100
- options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
101
- "pollIntervalMs",
102
- );
103
- this.baseBackoffMs = positiveInteger(
104
- options.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS,
105
- "baseBackoffMs",
106
- );
107
- this.maxBackoffMs = positiveInteger(
108
- options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS,
109
- "maxBackoffMs",
110
- );
111
- if (this.maxBackoffMs < this.baseBackoffMs) {
112
- throw new Error("maxBackoffMs must be greater than or equal to baseBackoffMs");
113
- }
114
- this.jitterRatio = options.jitterRatio ?? DEFAULT_JITTER_RATIO;
115
- if (!Number.isFinite(this.jitterRatio) || this.jitterRatio < 0 || this.jitterRatio > 1) {
116
- throw new Error("jitterRatio must be between 0 and 1");
117
- }
118
- this.now = options.now ?? (() => new Date());
119
- this.random = options.random ?? Math.random;
120
- }
121
-
122
- putResource<TSpec, TStatus>(
123
- definition: ResourceManagerDefinition<TSpec, TStatus>,
124
- key: string,
125
- spec: TSpec,
126
- ): ManagedResource<TSpec, TStatus> {
127
- const registered = this.definitions.get(definition.name);
128
- if (registered !== asAnyResourceManagerDefinition(definition)) {
129
- throw new Error(`ResourceManager definition is not registered: ${definition.name}`);
130
- }
131
- const initialStatus = definition.initialStatus(spec);
132
- const resource = this.store.putResource({
133
- resourceManager: definition.name,
134
- key,
135
- spec,
136
- initialStatus,
137
- now: this.now().toISOString(),
138
- });
139
- this.recordEvent(
140
- { resourceManager: definition.name, key },
141
- resource.metadata.generation === 1 ? "resource_applied" : "resource_updated",
142
- { generation: resource.metadata.generation },
143
- );
144
- const current = this.store.getResource<TSpec, TStatus>({
145
- resourceManager: definition.name,
146
- key,
147
- });
148
- if (current === undefined) throw new Error("Managed resource disappeared after its event");
149
- return current;
150
- }
151
-
152
- putResourceByName(resourceManager: string, key: string, spec: unknown): ManagedResource {
153
- const definition = this.definitions.get(resourceManager);
154
- if (definition === undefined) {
155
- throw new Error(`Unknown resource manager: ${resourceManager}`);
156
- }
157
- const initialStatus = definition.initialStatus(spec);
158
- const resource = this.store.putResource({
159
- resourceManager,
160
- key,
161
- spec,
162
- initialStatus,
163
- now: this.now().toISOString(),
164
- });
165
- this.recordEvent(
166
- { resourceManager, key },
167
- resource.metadata.generation === 1 ? "resource_applied" : "resource_updated",
168
- { generation: resource.metadata.generation },
169
- );
170
- const current = this.store.getResource({ resourceManager, key });
171
- if (current === undefined) throw new Error("Managed resource disappeared after its event");
172
- return current;
173
- }
174
-
175
- requestDeletion(ref: ManagedResourceRef): ManagedResource {
176
- this.store.requestDeletion(ref, this.now().toISOString());
177
- this.recordEvent(ref, "deletion_requested");
178
- const current = this.store.getResource(ref);
179
- if (current === undefined) throw new Error("Managed resource disappeared after its event");
180
- return current;
181
- }
182
-
183
- enqueue(ref: ManagedResourceRef, afterMs = 0): void {
184
- if (!Number.isSafeInteger(afterMs) || afterMs < 0) {
185
- throw new Error("afterMs must be a non-negative safe integer");
186
- }
187
- this.store.enqueue(ref, new Date(this.now().getTime() + afterMs).toISOString());
188
- }
189
-
190
- start(): void {
191
- if (this.runAbort !== null) {
192
- return;
193
- }
194
- this.runAbort = new AbortController();
195
- this.runnerTasks = Array.from({ length: this.maxConcurrent }, () =>
196
- this.runnerLoop(this.runAbort?.signal as AbortSignal),
197
- );
198
- }
199
-
200
- async stop(): Promise<void> {
201
- const abort = this.runAbort;
202
- if (abort === null) {
203
- return;
204
- }
205
- this.runAbort = null;
206
- abort.abort(new Error("Resource manager stopped"));
207
- for (const abortController of this.activeReconciles.values()) {
208
- abortController.abort(new Error("Resource manager stopped"));
209
- }
210
- await Promise.allSettled(this.runnerTasks);
211
- this.runnerTasks = [];
212
- }
213
-
214
- async runOne(): Promise<boolean> {
215
- const claim = this.claimNext();
216
- if (claim === undefined) {
217
- return false;
218
- }
219
- await this.processClaim(claim);
220
- return true;
221
- }
222
-
223
- async runUntilIdle(maxReconciles = 1_000): Promise<number> {
224
- positiveInteger(maxReconciles, "maxReconciles");
225
- let count = 0;
226
- while (count < maxReconciles && (await this.runOne())) {
227
- count += 1;
228
- }
229
- if (count === maxReconciles && this.readyQueueExists()) {
230
- throw new Error(`Resource manager exceeded ${maxReconciles} reconciliations`);
231
- }
232
- return count;
233
- }
234
-
235
- get workflowRequests(): ResourceManagerWorkflowCoordinator {
236
- return this.workflowCoordinator;
237
- }
238
-
239
- private async runnerLoop(signal: AbortSignal): Promise<void> {
240
- while (!signal.aborted) {
241
- const claim = this.claimNext();
242
- if (claim === undefined) {
243
- await sleep(this.pollIntervalMs, signal);
244
- continue;
245
- }
246
- try {
247
- await this.processClaim(claim);
248
- } catch {
249
- if (signal.aborted) return;
250
- // A stale claim means another owner now controls that resource. Keep
251
- // this runner alive so it can reconcile other queued resources.
252
- }
253
- }
254
- }
255
-
256
- private claimNext(): ResourceManagerQueueClaim | undefined {
257
- return this.store.claimNext({
258
- resourceManagers: this.eligibleResourceManagers(),
259
- ownerId: this.ownerId,
260
- leaseMs: this.leaseMs,
261
- now: this.now().toISOString(),
262
- });
263
- }
264
-
265
- private eligibleResourceManagers(): string[] {
266
- return [...this.definitions.keys()].filter((name) => {
267
- const active = this.activeByResourceManager.get(name) ?? 0;
268
- const limit = this.resourceManagerConcurrency[name] ?? this.maxConcurrent;
269
- return active < limit;
270
- });
271
- }
272
-
273
- private async processClaim(claim: ResourceManagerQueueClaim): Promise<void> {
274
- const definition = this.definitions.get(claim.resourceManager);
275
- if (definition === undefined) {
276
- return;
277
- }
278
- this.activeByResourceManager.set(
279
- claim.resourceManager,
280
- (this.activeByResourceManager.get(claim.resourceManager) ?? 0) + 1,
281
- );
282
- try {
283
- await this.reconcileClaim(definition, claim);
284
- } finally {
285
- const active = (this.activeByResourceManager.get(claim.resourceManager) ?? 1) - 1;
286
- if (active === 0) {
287
- this.activeByResourceManager.delete(claim.resourceManager);
288
- } else {
289
- this.activeByResourceManager.set(claim.resourceManager, active);
290
- }
291
- }
292
- }
293
-
294
- private async reconcileClaim(
295
- definition: AnyResourceManagerDefinition,
296
- claim: ResourceManagerQueueClaim,
297
- ): Promise<void> {
298
- const ref = { resourceManager: claim.resourceManager, key: claim.key };
299
- const resource = this.store.getResource(ref);
300
- if (resource === undefined) {
301
- this.store.settleClaim(claim, this.now().toISOString());
302
- return;
303
- }
304
- const reconcileId = randomUUID();
305
- const startedAtMs = this.now().getTime();
306
- const abort = new AbortController();
307
- this.activeReconciles.set(reconcileId, abort);
308
- const parentAbort = this.runAbort?.signal;
309
- const abortFromParent = () => abort.abort(parentAbort?.reason);
310
- parentAbort?.addEventListener("abort", abortFromParent, { once: true });
311
- const timeoutMs = definition.timeoutMs ?? this.defaultTimeoutMs;
312
- const timeout = setTimeout(
313
- () => abort.abort(new Error(`Reconciliation timed out after ${timeoutMs}ms`)),
314
- timeoutMs,
315
- );
316
- const renew = setInterval(
317
- () => {
318
- const renewed = this.store.renewClaim(claim, this.leaseMs, this.now().toISOString());
319
- if (!renewed) {
320
- abort.abort(new Error("ResourceManager queue claim was lost"));
321
- }
322
- },
323
- Math.max(100, Math.floor(this.leaseMs / 3)),
324
- );
325
- renew.unref?.();
326
- this.recordEvent(
327
- ref,
328
- "reconcile_started",
329
- {
330
- reconcileId,
331
- generation: resource.metadata.generation,
332
- },
333
- claim,
334
- );
335
-
336
- try {
337
- const result = await raceWithAbort(
338
- this.callReconciler(definition, resource, claim, abort.signal),
339
- abort.signal,
340
- );
341
- if (abort.signal.aborted) {
342
- throw abort.signal.reason ?? new Error("Reconciliation aborted");
343
- }
344
- await this.finishSuccessfulClaim(claim, resource, result, reconcileId, startedAtMs);
345
- } catch (error) {
346
- await this.finishFailedClaim(claim, ref, reconcileId, error, startedAtMs);
347
- } finally {
348
- clearTimeout(timeout);
349
- clearInterval(renew);
350
- parentAbort?.removeEventListener("abort", abortFromParent);
351
- this.activeReconciles.delete(reconcileId);
352
- }
353
- }
354
-
355
- private async callReconciler(
356
- definition: AnyResourceManagerDefinition,
357
- resource: ManagedResource,
358
- claim: ResourceManagerQueueClaim,
359
- signal: AbortSignal,
360
- ): Promise<ReconcileResult<unknown>> {
361
- const context: ReconcileContext<unknown> = {
362
- signal,
363
- effects: new ResourceManagerEffectService(this.store, resource, claim, signal),
364
- workflows: this.workflowCoordinator.forResource(resource, claim, signal),
365
- ...createResultHelpers<unknown>(),
366
- };
367
- const result = await definition.reconcile(context, asTypedResource(resource));
368
- validateResult(result);
369
- return result;
370
- }
371
-
372
- private async finishSuccessfulClaim(
373
- claim: ResourceManagerQueueClaim,
374
- resource: ManagedResource,
375
- result: ReconcileResult<unknown>,
376
- reconcileId: string,
377
- startedAtMs: number,
378
- ): Promise<void> {
379
- const ref = { resourceManager: claim.resourceManager, key: claim.key };
380
- const now = this.now();
381
- const status = applyStatusPatch(
382
- resource.status,
383
- result.status,
384
- resource.metadata.generation,
385
- now.toISOString(),
386
- );
387
- try {
388
- const updated = this.store.updateStatus({
389
- ref,
390
- expectedResourceVersion: claim.resourceVersion,
391
- claim,
392
- status,
393
- ...(result.status?.finalizers !== undefined
394
- ? { finalizers: result.status.finalizers }
395
- : {}),
396
- now: now.toISOString(),
397
- });
398
- this.recordEvent(
399
- ref,
400
- "reconcile_finished",
401
- {
402
- reconcileId,
403
- result: result.kind,
404
- generation: resource.metadata.generation,
405
- durationMs: Math.max(0, now.getTime() - startedAtMs),
406
- ...(result.kind === "requeue" && result.afterMs !== undefined
407
- ? { requeueAfterMs: result.afterMs }
408
- : {}),
409
- },
410
- claim,
411
- );
412
- if (
413
- result.kind === "settled" &&
414
- updated.metadata.deletionTimestamp !== undefined &&
415
- updated.metadata.finalizers.length === 0
416
- ) {
417
- this.recordEvent(ref, "resource_deleted", { reconcileId }, claim);
418
- this.store.deleteResource(ref, claim.resourceVersion, claim);
419
- return;
420
- }
421
- } catch (error) {
422
- if (error instanceof ManagedResourceConflictError) {
423
- this.recordEvent(ref, "reconcile_conflict", { reconcileId }, claim);
424
- this.store.requeueClaim(claim, { availableAt: now.toISOString() }, now.toISOString());
425
- return;
426
- }
427
- throw error;
428
- }
429
- if (result.kind === "settled") {
430
- this.store.settleClaim(claim, now.toISOString());
431
- return;
432
- }
433
- const afterMs = result.afterMs ?? 0;
434
- this.store.requeueClaim(
435
- claim,
436
- { availableAt: new Date(now.getTime() + afterMs).toISOString() },
437
- now.toISOString(),
438
- );
439
- }
440
-
441
- private async finishFailedClaim(
442
- claim: ResourceManagerQueueClaim,
443
- ref: ManagedResourceRef,
444
- reconcileId: string,
445
- error: unknown,
446
- startedAtMs: number,
447
- ): Promise<void> {
448
- const now = this.now();
449
- const message = boundedError(error);
450
- const delay = this.backoffMs(claim.consecutiveErrors + 1);
451
- this.recordEvent(
452
- ref,
453
- "reconcile_failed",
454
- {
455
- reconcileId,
456
- error: message,
457
- durationMs: Math.max(0, now.getTime() - startedAtMs),
458
- requeueAfterMs: delay,
459
- },
460
- claim,
461
- );
462
- this.store.requeueClaim(
463
- claim,
464
- {
465
- availableAt: new Date(now.getTime() + delay).toISOString(),
466
- error: message,
467
- },
468
- now.toISOString(),
469
- );
470
- }
471
-
472
- private backoffMs(consecutiveErrors: number): number {
473
- const exponent = Math.min(consecutiveErrors - 1, 30);
474
- const raw = Math.min(this.maxBackoffMs, this.baseBackoffMs * 2 ** exponent);
475
- const factor = 1 + (this.random() * 2 - 1) * this.jitterRatio;
476
- return Math.max(1, Math.round(raw * factor));
477
- }
478
-
479
- private readyQueueExists(): boolean {
480
- const now = this.now().getTime();
481
- return this.store.listQueue().some((item) => Date.parse(item.availableAt) <= now);
482
- }
483
-
484
- private recordEvent(
485
- ref: ManagedResourceRef,
486
- type: string,
487
- payload: JsonObject = {},
488
- claim?: ResourceManagerQueueClaim,
489
- ): void {
490
- this.store.recordEvent({
491
- resourceManager: ref.resourceManager,
492
- key: ref.key,
493
- ...(claim === undefined ? {} : { claim }),
494
- type,
495
- payload,
496
- now: this.now().toISOString(),
497
- });
498
- }
499
- }
500
-
501
- function raceWithAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
502
- return new Promise<T>((resolve, reject) => {
503
- let finished = false;
504
- const settle = (callback: () => void) => {
505
- if (finished) {
506
- return;
507
- }
508
- finished = true;
509
- signal.removeEventListener("abort", onAbort);
510
- callback();
511
- };
512
- const onAbort = () =>
513
- settle(() => reject(signal.reason ?? new Error("Reconciliation aborted")));
514
- signal.addEventListener("abort", onAbort, { once: true });
515
- operation.then(
516
- (value) => settle(() => resolve(value)),
517
- (error: unknown) => settle(() => reject(error)),
518
- );
519
- if (signal.aborted) {
520
- onAbort();
521
- }
522
- });
523
- }
524
-
525
- function validateResult(result: ReconcileResult<unknown>): void {
526
- if (result === null || typeof result !== "object") {
527
- throw new Error("ResourceManager reconcile must return a result object");
528
- }
529
- if (result.kind !== "settled" && result.kind !== "requeue") {
530
- throw new Error("ResourceManager reconcile returned an unknown result kind");
531
- }
532
- if (result.kind === "settled" && "afterMs" in result) {
533
- throw new Error("A settled reconcile result cannot specify afterMs");
534
- }
535
- if (
536
- result.kind === "requeue" &&
537
- result.afterMs !== undefined &&
538
- (!Number.isSafeInteger(result.afterMs) || result.afterMs <= 0)
539
- ) {
540
- throw new Error("Requeue afterMs must be a positive safe integer");
541
- }
542
- }
543
-
544
- function positiveInteger(value: number, name: string): number {
545
- if (!Number.isSafeInteger(value) || value <= 0) {
546
- throw new Error(`${name} must be a positive safe integer`);
547
- }
548
- return value;
549
- }
550
-
551
- function boundedError(error: unknown): string {
552
- const message = error instanceof Error ? error.message : String(error);
553
- return message.length <= 8_192 ? message : `${message.slice(0, 8_192)}…`;
554
- }
555
-
556
- async function sleep(ms: number, signal: AbortSignal): Promise<void> {
557
- if (signal.aborted) {
558
- return;
559
- }
560
- await new Promise<void>((resolve) => {
561
- let timer: NodeJS.Timeout | undefined;
562
- const finish = () => {
563
- if (timer !== undefined) {
564
- clearTimeout(timer);
565
- }
566
- signal.removeEventListener("abort", finish);
567
- resolve();
568
- };
569
- timer = setTimeout(finish, ms);
570
- timer.unref?.();
571
- signal.addEventListener("abort", finish, { once: true });
572
- if (signal.aborted) {
573
- finish();
574
- }
575
- });
576
- }