@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.
- package/README.md +71 -5
- package/dist/builtins/monitor.workflow.d.ts +71 -0
- package/dist/builtins/monitor.workflow.js +234 -0
- package/dist/builtins/monitor.workflow.js.map +1 -0
- package/dist/controllers/conditions.d.ts +6 -0
- package/dist/controllers/conditions.js +68 -0
- package/dist/controllers/conditions.js.map +1 -0
- package/dist/controllers/definition.d.ts +6 -0
- package/dist/controllers/definition.js +45 -0
- package/dist/controllers/definition.js.map +1 -0
- package/dist/controllers/effects.d.ts +14 -0
- package/dist/controllers/effects.js +104 -0
- package/dist/controllers/effects.js.map +1 -0
- package/dist/controllers/errors.d.ts +12 -0
- package/dist/controllers/errors.js +25 -0
- package/dist/controllers/errors.js.map +1 -0
- package/dist/controllers/index.d.ts +13 -0
- package/dist/controllers/index.js +13 -0
- package/dist/controllers/index.js.map +1 -0
- package/dist/controllers/json.d.ts +5 -0
- package/dist/controllers/json.js +57 -0
- package/dist/controllers/json.js.map +1 -0
- package/dist/controllers/loader.d.ts +23 -0
- package/dist/controllers/loader.js +74 -0
- package/dist/controllers/loader.js.map +1 -0
- package/dist/controllers/manager.d.ts +58 -0
- package/dist/controllers/manager.js +399 -0
- package/dist/controllers/manager.js.map +1 -0
- package/dist/controllers/results.d.ts +5 -0
- package/dist/controllers/results.js +32 -0
- package/dist/controllers/results.js.map +1 -0
- package/dist/controllers/sqlite.d.ts +212 -0
- package/dist/controllers/sqlite.js +1009 -0
- package/dist/controllers/sqlite.js.map +1 -0
- package/dist/controllers/store.d.ts +112 -0
- package/dist/controllers/store.js +32 -0
- package/dist/controllers/store.js.map +1 -0
- package/dist/controllers/types.d.ts +159 -0
- package/dist/controllers/types.js +2 -0
- package/dist/controllers/types.js.map +1 -0
- package/dist/controllers/workflow-engine-scheduler.d.ts +25 -0
- package/dist/controllers/workflow-engine-scheduler.js +93 -0
- package/dist/controllers/workflow-engine-scheduler.js.map +1 -0
- package/dist/controllers/workflows.d.ts +27 -0
- package/dist/controllers/workflows.js +109 -0
- package/dist/controllers/workflows.js.map +1 -0
- package/dist/extension/controller-host.d.ts +47 -0
- package/dist/extension/controller-host.js +110 -0
- package/dist/extension/controller-host.js.map +1 -0
- package/dist/extension/executor.js +1 -1
- package/dist/extension/executor.js.map +1 -1
- package/dist/extension/index.d.ts +7 -0
- package/dist/extension/index.js +1053 -96
- package/dist/extension/index.js.map +1 -1
- package/dist/extension/recorder.d.ts +6 -0
- package/dist/extension/recorder.js +20 -8
- package/dist/extension/recorder.js.map +1 -1
- package/dist/extension/workflow-tool.d.ts +28 -0
- package/dist/extension/workflow-tool.js +33 -0
- package/dist/extension/workflow-tool.js.map +1 -0
- package/dist/host/processes.d.ts +24 -0
- package/dist/host/processes.js +114 -0
- package/dist/host/processes.js.map +1 -0
- package/dist/host/rpc-bridge.d.ts +9 -0
- package/dist/host/rpc-bridge.js +39 -0
- package/dist/host/rpc-bridge.js.map +1 -0
- package/dist/host/rpc-executor.d.ts +38 -0
- package/dist/host/rpc-executor.js +254 -0
- package/dist/host/rpc-executor.js.map +1 -0
- package/dist/host/runner.d.ts +49 -0
- package/dist/host/runner.js +350 -0
- package/dist/host/runner.js.map +1 -0
- package/dist/viewer/cli.d.ts +7 -3
- package/dist/viewer/cli.js +150 -19
- package/dist/viewer/cli.js.map +1 -1
- package/dist/workflows/engine.d.ts +36 -0
- package/dist/workflows/engine.js +244 -14
- package/dist/workflows/engine.js.map +1 -1
- package/dist/workflows/errors.d.ts +23 -0
- package/dist/workflows/errors.js +38 -0
- package/dist/workflows/errors.js.map +1 -1
- package/dist/workflows/graph.js +0 -5
- package/dist/workflows/graph.js.map +1 -1
- package/dist/workflows/loader.d.ts +5 -3
- package/dist/workflows/loader.js +10 -1
- package/dist/workflows/loader.js.map +1 -1
- package/dist/workflows/schema.js +1 -1
- package/dist/workflows/schema.js.map +1 -1
- package/dist/workflows/store.d.ts +50 -6
- package/dist/workflows/store.js +446 -51
- package/dist/workflows/store.js.map +1 -1
- package/dist/workflows/types.d.ts +10 -0
- package/docs/CONTROLLERS.md +215 -0
- package/docs/development.md +12 -9
- package/docs/plans/2026-08-04-controller-runtime-plan.md +169 -0
- package/docs/plans/2026-08-05-always-on-workflows-plan.md +125 -0
- package/docs/plans/2026-08-10-agent-managed-monitor-workflows-plan.md +184 -0
- package/docs/run-bundles.md +66 -27
- package/docs/workflows.md +131 -11
- package/examples/controllers/pull-request.controller.ts +215 -0
- package/package.json +10 -2
- package/src/builtins/monitor.workflow.ts +278 -0
- package/src/controllers/conditions.ts +110 -0
- package/src/controllers/definition.ts +65 -0
- package/src/controllers/effects.ts +123 -0
- package/src/controllers/errors.ts +27 -0
- package/src/controllers/index.ts +90 -0
- package/src/controllers/json.ts +62 -0
- package/src/controllers/loader.ts +104 -0
- package/src/controllers/manager.ts +533 -0
- package/src/controllers/results.ts +46 -0
- package/src/controllers/sqlite.ts +1427 -0
- package/src/controllers/store.ts +160 -0
- package/src/controllers/types.ts +183 -0
- package/src/controllers/workflow-engine-scheduler.ts +145 -0
- package/src/controllers/workflows.ts +152 -0
- package/src/extension/controller-host.ts +163 -0
- package/src/extension/executor.ts +1 -1
- package/src/extension/index.ts +1243 -117
- package/src/extension/recorder.ts +65 -36
- package/src/extension/workflow-tool.ts +59 -0
- package/src/host/processes.ts +119 -0
- package/src/host/rpc-bridge.ts +44 -0
- package/src/host/rpc-executor.ts +299 -0
- package/src/host/runner.ts +406 -0
- package/src/viewer/cli.ts +167 -21
- package/src/workflows/engine.ts +327 -13
- package/src/workflows/errors.ts +45 -0
- package/src/workflows/graph.ts +0 -5
- package/src/workflows/loader.ts +13 -3
- package/src/workflows/schema.ts +1 -1
- package/src/workflows/store.ts +555 -46
- package/src/workflows/types.ts +10 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { JsonValue } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export function assertJsonValue(value: unknown, description: string): asserts value is JsonValue {
|
|
5
|
+
normalizeJson(value, description, new Set());
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function canonicalJson(value: unknown, description = "value"): string {
|
|
9
|
+
return JSON.stringify(normalizeJson(value, description, new Set()));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function jsonFingerprint(value: unknown): string {
|
|
13
|
+
return createHash("sha256").update(canonicalJson(value)).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseStoredJson<T>(value: string, description: string): T {
|
|
17
|
+
let parsed: unknown;
|
|
18
|
+
try {
|
|
19
|
+
parsed = JSON.parse(value);
|
|
20
|
+
} catch (error) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
`${description} contains invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
assertJsonValue(parsed, description);
|
|
26
|
+
return parsed as T;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeJson(value: unknown, path: string, seen: Set<object>): JsonValue {
|
|
30
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
if (typeof value === "number") {
|
|
34
|
+
if (!Number.isFinite(value)) {
|
|
35
|
+
throw new Error(`${path} contains a non-finite number`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
if (typeof value !== "object") {
|
|
40
|
+
throw new Error(`${path} is not JSON-serializable`);
|
|
41
|
+
}
|
|
42
|
+
if (seen.has(value)) {
|
|
43
|
+
throw new Error(`${path} contains a cycle`);
|
|
44
|
+
}
|
|
45
|
+
seen.add(value);
|
|
46
|
+
try {
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
return value.map((item, index) => normalizeJson(item, `${path}[${index}]`, seen));
|
|
49
|
+
}
|
|
50
|
+
const prototype = Object.getPrototypeOf(value) as object | null;
|
|
51
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
52
|
+
throw new Error(`${path} must contain only plain objects and arrays`);
|
|
53
|
+
}
|
|
54
|
+
const result: Record<string, JsonValue> = {};
|
|
55
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
56
|
+
result[key] = normalizeJson((value as Record<string, unknown>)[key], `${path}.${key}`, seen);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
} finally {
|
|
60
|
+
seen.delete(value);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { createJiti } from "jiti";
|
|
6
|
+
import { isControllerDefinition } from "./definition.js";
|
|
7
|
+
import type { AnyControllerDefinition } from "./types.js";
|
|
8
|
+
|
|
9
|
+
const CONTROLLER_FILE_SUFFIXES = [
|
|
10
|
+
".controller.ts",
|
|
11
|
+
".controller.js",
|
|
12
|
+
".controller.mts",
|
|
13
|
+
".controller.mjs",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export type DiscoveredController = {
|
|
17
|
+
name: string;
|
|
18
|
+
path: string;
|
|
19
|
+
source: "project" | "global";
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function controllerSearchDirs(options: {
|
|
23
|
+
cwd: string;
|
|
24
|
+
homeDir?: string;
|
|
25
|
+
}): { dir: string; source: DiscoveredController["source"] }[] {
|
|
26
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
27
|
+
return [
|
|
28
|
+
{ dir: path.join(options.cwd, ".pi", "controllers"), source: "project" },
|
|
29
|
+
{ dir: path.join(homeDir, ".pi", "agent", "controllers"), source: "global" },
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function controllerFileStem(filePath: string): string {
|
|
34
|
+
const base = path.basename(filePath);
|
|
35
|
+
const suffix = CONTROLLER_FILE_SUFFIXES.find((candidate) => base.endsWith(candidate));
|
|
36
|
+
return suffix === undefined ? base : base.slice(0, -suffix.length);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const CONTROLLER_ENTRY = path.join(path.dirname(fileURLToPath(import.meta.url)), "index");
|
|
40
|
+
const WORKFLOW_ENTRY = path.join(
|
|
41
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
42
|
+
"../workflows/index",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
export async function loadControllerFile(filePath: string): Promise<AnyControllerDefinition> {
|
|
46
|
+
const absolutePath = path.resolve(filePath);
|
|
47
|
+
const jiti = createJiti(pathToFileURL(absolutePath).href, {
|
|
48
|
+
interopDefault: true,
|
|
49
|
+
moduleCache: false,
|
|
50
|
+
alias: {
|
|
51
|
+
"@osolmaz/pi-workflows/controllers": CONTROLLER_ENTRY,
|
|
52
|
+
"@osolmaz/pi-workflows": WORKFLOW_ENTRY,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const loaded = (await jiti.import(absolutePath, { default: true })) as unknown;
|
|
56
|
+
if (!isControllerDefinition(loaded)) {
|
|
57
|
+
throw new Error(`Controller module must default-export defineController(...): ${absolutePath}`);
|
|
58
|
+
}
|
|
59
|
+
return loaded;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function discoverControllers(options: {
|
|
63
|
+
cwd: string;
|
|
64
|
+
homeDir?: string;
|
|
65
|
+
}): Promise<DiscoveredController[]> {
|
|
66
|
+
const discovered: DiscoveredController[] = [];
|
|
67
|
+
const seen = new Set<string>();
|
|
68
|
+
for (const { dir, source } of controllerSearchDirs(options)) {
|
|
69
|
+
for (const filePath of await listControllerFiles(dir)) {
|
|
70
|
+
const name = controllerFileStem(filePath);
|
|
71
|
+
if (seen.has(name)) {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
seen.add(name);
|
|
75
|
+
discovered.push({ name, path: filePath, source });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return discovered;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function loadDiscoveredControllers(options: {
|
|
82
|
+
cwd: string;
|
|
83
|
+
homeDir?: string;
|
|
84
|
+
}): Promise<AnyControllerDefinition[]> {
|
|
85
|
+
return await Promise.all(
|
|
86
|
+
(await discoverControllers(options)).map(async (entry) => await loadControllerFile(entry.path)),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function listControllerFiles(dir: string): Promise<string[]> {
|
|
91
|
+
let entries: import("node:fs").Dirent[];
|
|
92
|
+
try {
|
|
93
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
return entries
|
|
98
|
+
.filter(
|
|
99
|
+
(entry) =>
|
|
100
|
+
entry.isFile() && CONTROLLER_FILE_SUFFIXES.some((suffix) => entry.name.endsWith(suffix)),
|
|
101
|
+
)
|
|
102
|
+
.map((entry) => path.join(dir, entry.name))
|
|
103
|
+
.sort();
|
|
104
|
+
}
|
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { applyStatusPatch } from "./conditions.js";
|
|
3
|
+
import { asAnyControllerDefinition, asTypedResource } from "./definition.js";
|
|
4
|
+
import { ControllerEffectService } from "./effects.js";
|
|
5
|
+
import { ResourceConflictError } from "./errors.js";
|
|
6
|
+
import { createResultHelpers } from "./results.js";
|
|
7
|
+
import type { ControllerStore } from "./store.js";
|
|
8
|
+
import type {
|
|
9
|
+
AnyControllerDefinition,
|
|
10
|
+
ControllerDefinition,
|
|
11
|
+
ControllerQueueClaim,
|
|
12
|
+
ControllerResource,
|
|
13
|
+
ControllerResourceRef,
|
|
14
|
+
JsonObject,
|
|
15
|
+
ReconcileContext,
|
|
16
|
+
ReconcileResult,
|
|
17
|
+
} from "./types.js";
|
|
18
|
+
import { ControllerWorkflowCoordinator, type ControllerWorkflowScheduler } from "./workflows.js";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_MAX_CONCURRENT = 4;
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
22
|
+
const DEFAULT_LEASE_MS = 120_000;
|
|
23
|
+
const DEFAULT_POLL_INTERVAL_MS = 250;
|
|
24
|
+
const DEFAULT_BASE_BACKOFF_MS = 1_000;
|
|
25
|
+
const DEFAULT_MAX_BACKOFF_MS = 60_000;
|
|
26
|
+
const DEFAULT_JITTER_RATIO = 0.2;
|
|
27
|
+
const MIN_LEASE_MS = 300;
|
|
28
|
+
|
|
29
|
+
export type ControllerManagerOptions = {
|
|
30
|
+
store: ControllerStore;
|
|
31
|
+
controllers: AnyControllerDefinition[];
|
|
32
|
+
workflowScheduler?: ControllerWorkflowScheduler;
|
|
33
|
+
maxConcurrent?: number;
|
|
34
|
+
controllerConcurrency?: Record<string, number>;
|
|
35
|
+
defaultTimeoutMs?: number;
|
|
36
|
+
leaseMs?: number;
|
|
37
|
+
pollIntervalMs?: number;
|
|
38
|
+
baseBackoffMs?: number;
|
|
39
|
+
maxBackoffMs?: number;
|
|
40
|
+
jitterRatio?: number;
|
|
41
|
+
now?: () => Date;
|
|
42
|
+
random?: () => number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export class ControllerManager {
|
|
46
|
+
readonly store: ControllerStore;
|
|
47
|
+
private readonly definitions = new Map<string, AnyControllerDefinition>();
|
|
48
|
+
private readonly workflowCoordinator: ControllerWorkflowCoordinator;
|
|
49
|
+
private readonly maxConcurrent: number;
|
|
50
|
+
private readonly controllerConcurrency: Record<string, number>;
|
|
51
|
+
private readonly defaultTimeoutMs: number;
|
|
52
|
+
private readonly leaseMs: number;
|
|
53
|
+
private readonly pollIntervalMs: number;
|
|
54
|
+
private readonly baseBackoffMs: number;
|
|
55
|
+
private readonly maxBackoffMs: number;
|
|
56
|
+
private readonly jitterRatio: number;
|
|
57
|
+
private readonly now: () => Date;
|
|
58
|
+
private readonly random: () => number;
|
|
59
|
+
private readonly activeByController = new Map<string, number>();
|
|
60
|
+
private readonly activeReconciles = new Map<string, AbortController>();
|
|
61
|
+
private runAbort: AbortController | null = null;
|
|
62
|
+
private workerTasks: Promise<void>[] = [];
|
|
63
|
+
|
|
64
|
+
constructor(options: ControllerManagerOptions) {
|
|
65
|
+
this.store = options.store;
|
|
66
|
+
for (const definition of options.controllers) {
|
|
67
|
+
if (this.definitions.has(definition.name)) {
|
|
68
|
+
throw new Error(`Duplicate controller definition: ${definition.name}`);
|
|
69
|
+
}
|
|
70
|
+
this.definitions.set(definition.name, definition);
|
|
71
|
+
}
|
|
72
|
+
this.workflowCoordinator = new ControllerWorkflowCoordinator(
|
|
73
|
+
options.store,
|
|
74
|
+
options.workflowScheduler,
|
|
75
|
+
);
|
|
76
|
+
this.maxConcurrent = positiveInteger(
|
|
77
|
+
options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT,
|
|
78
|
+
"maxConcurrent",
|
|
79
|
+
);
|
|
80
|
+
this.controllerConcurrency = options.controllerConcurrency ?? {};
|
|
81
|
+
for (const [name, value] of Object.entries(this.controllerConcurrency)) {
|
|
82
|
+
if (!this.definitions.has(name)) {
|
|
83
|
+
throw new Error(`Concurrency limit names an unknown controller: ${name}`);
|
|
84
|
+
}
|
|
85
|
+
positiveInteger(value, `controllerConcurrency.${name}`);
|
|
86
|
+
}
|
|
87
|
+
this.defaultTimeoutMs = positiveInteger(
|
|
88
|
+
options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
89
|
+
"defaultTimeoutMs",
|
|
90
|
+
);
|
|
91
|
+
this.leaseMs = positiveInteger(options.leaseMs ?? DEFAULT_LEASE_MS, "leaseMs");
|
|
92
|
+
if (this.leaseMs < MIN_LEASE_MS) {
|
|
93
|
+
throw new Error(`leaseMs must be at least ${MIN_LEASE_MS}`);
|
|
94
|
+
}
|
|
95
|
+
this.pollIntervalMs = positiveInteger(
|
|
96
|
+
options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
|
|
97
|
+
"pollIntervalMs",
|
|
98
|
+
);
|
|
99
|
+
this.baseBackoffMs = positiveInteger(
|
|
100
|
+
options.baseBackoffMs ?? DEFAULT_BASE_BACKOFF_MS,
|
|
101
|
+
"baseBackoffMs",
|
|
102
|
+
);
|
|
103
|
+
this.maxBackoffMs = positiveInteger(
|
|
104
|
+
options.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS,
|
|
105
|
+
"maxBackoffMs",
|
|
106
|
+
);
|
|
107
|
+
if (this.maxBackoffMs < this.baseBackoffMs) {
|
|
108
|
+
throw new Error("maxBackoffMs must be greater than or equal to baseBackoffMs");
|
|
109
|
+
}
|
|
110
|
+
this.jitterRatio = options.jitterRatio ?? DEFAULT_JITTER_RATIO;
|
|
111
|
+
if (!Number.isFinite(this.jitterRatio) || this.jitterRatio < 0 || this.jitterRatio > 1) {
|
|
112
|
+
throw new Error("jitterRatio must be between 0 and 1");
|
|
113
|
+
}
|
|
114
|
+
this.now = options.now ?? (() => new Date());
|
|
115
|
+
this.random = options.random ?? Math.random;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
putResource<TSpec, TStatus>(
|
|
119
|
+
definition: ControllerDefinition<TSpec, TStatus>,
|
|
120
|
+
key: string,
|
|
121
|
+
spec: TSpec,
|
|
122
|
+
): ControllerResource<TSpec, TStatus> {
|
|
123
|
+
const registered = this.definitions.get(definition.name);
|
|
124
|
+
if (registered !== asAnyControllerDefinition(definition)) {
|
|
125
|
+
throw new Error(`Controller definition is not registered: ${definition.name}`);
|
|
126
|
+
}
|
|
127
|
+
const initialStatus = definition.initialStatus(spec);
|
|
128
|
+
const resource = this.store.putResource({
|
|
129
|
+
controller: definition.name,
|
|
130
|
+
key,
|
|
131
|
+
spec,
|
|
132
|
+
initialStatus,
|
|
133
|
+
now: this.now().toISOString(),
|
|
134
|
+
});
|
|
135
|
+
this.recordEvent(
|
|
136
|
+
{ controller: definition.name, key },
|
|
137
|
+
resource.metadata.generation === 1 ? "resource_applied" : "resource_updated",
|
|
138
|
+
{ generation: resource.metadata.generation },
|
|
139
|
+
);
|
|
140
|
+
return resource;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
putResourceByName(controller: string, key: string, spec: unknown): ControllerResource {
|
|
144
|
+
const definition = this.definitions.get(controller);
|
|
145
|
+
if (definition === undefined) {
|
|
146
|
+
throw new Error(`Unknown controller: ${controller}`);
|
|
147
|
+
}
|
|
148
|
+
const initialStatus = definition.initialStatus(spec);
|
|
149
|
+
const resource = this.store.putResource({
|
|
150
|
+
controller,
|
|
151
|
+
key,
|
|
152
|
+
spec,
|
|
153
|
+
initialStatus,
|
|
154
|
+
now: this.now().toISOString(),
|
|
155
|
+
});
|
|
156
|
+
this.recordEvent(
|
|
157
|
+
{ controller, key },
|
|
158
|
+
resource.metadata.generation === 1 ? "resource_applied" : "resource_updated",
|
|
159
|
+
{ generation: resource.metadata.generation },
|
|
160
|
+
);
|
|
161
|
+
return resource;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
requestDeletion(ref: ControllerResourceRef): ControllerResource {
|
|
165
|
+
const resource = this.store.requestDeletion(ref, this.now().toISOString());
|
|
166
|
+
this.recordEvent(ref, "deletion_requested");
|
|
167
|
+
return resource;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
enqueue(ref: ControllerResourceRef, afterMs = 0): void {
|
|
171
|
+
if (!Number.isSafeInteger(afterMs) || afterMs < 0) {
|
|
172
|
+
throw new Error("afterMs must be a non-negative safe integer");
|
|
173
|
+
}
|
|
174
|
+
this.store.enqueue(ref, new Date(this.now().getTime() + afterMs).toISOString());
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
start(): void {
|
|
178
|
+
if (this.runAbort !== null) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.runAbort = new AbortController();
|
|
182
|
+
this.workerTasks = Array.from({ length: this.maxConcurrent }, () =>
|
|
183
|
+
this.workerLoop(this.runAbort?.signal as AbortSignal),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async stop(): Promise<void> {
|
|
188
|
+
const abort = this.runAbort;
|
|
189
|
+
if (abort === null) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.runAbort = null;
|
|
193
|
+
abort.abort(new Error("Controller manager stopped"));
|
|
194
|
+
for (const controller of this.activeReconciles.values()) {
|
|
195
|
+
controller.abort(new Error("Controller manager stopped"));
|
|
196
|
+
}
|
|
197
|
+
await Promise.allSettled(this.workerTasks);
|
|
198
|
+
this.workerTasks = [];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async runOne(): Promise<boolean> {
|
|
202
|
+
const claim = this.claimNext();
|
|
203
|
+
if (claim === undefined) {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
await this.processClaim(claim);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async runUntilIdle(maxReconciles = 1_000): Promise<number> {
|
|
211
|
+
positiveInteger(maxReconciles, "maxReconciles");
|
|
212
|
+
let count = 0;
|
|
213
|
+
while (count < maxReconciles && (await this.runOne())) {
|
|
214
|
+
count += 1;
|
|
215
|
+
}
|
|
216
|
+
if (count === maxReconciles && this.readyQueueExists()) {
|
|
217
|
+
throw new Error(`Controller manager exceeded ${maxReconciles} reconciliations`);
|
|
218
|
+
}
|
|
219
|
+
return count;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
get workflowRequests(): ControllerWorkflowCoordinator {
|
|
223
|
+
return this.workflowCoordinator;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async workerLoop(signal: AbortSignal): Promise<void> {
|
|
227
|
+
while (!signal.aborted) {
|
|
228
|
+
const claim = this.claimNext();
|
|
229
|
+
if (claim === undefined) {
|
|
230
|
+
await sleep(this.pollIntervalMs, signal);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
await this.processClaim(claim);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private claimNext(): ControllerQueueClaim | undefined {
|
|
238
|
+
return this.store.claimNext({
|
|
239
|
+
controllers: this.eligibleControllers(),
|
|
240
|
+
leaseMs: this.leaseMs,
|
|
241
|
+
now: this.now().toISOString(),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private eligibleControllers(): string[] {
|
|
246
|
+
return [...this.definitions.keys()].filter((name) => {
|
|
247
|
+
const active = this.activeByController.get(name) ?? 0;
|
|
248
|
+
const limit = this.controllerConcurrency[name] ?? this.maxConcurrent;
|
|
249
|
+
return active < limit;
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private async processClaim(claim: ControllerQueueClaim): Promise<void> {
|
|
254
|
+
const definition = this.definitions.get(claim.controller);
|
|
255
|
+
if (definition === undefined) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
this.activeByController.set(
|
|
259
|
+
claim.controller,
|
|
260
|
+
(this.activeByController.get(claim.controller) ?? 0) + 1,
|
|
261
|
+
);
|
|
262
|
+
try {
|
|
263
|
+
await this.reconcileClaim(definition, claim);
|
|
264
|
+
} finally {
|
|
265
|
+
const active = (this.activeByController.get(claim.controller) ?? 1) - 1;
|
|
266
|
+
if (active === 0) {
|
|
267
|
+
this.activeByController.delete(claim.controller);
|
|
268
|
+
} else {
|
|
269
|
+
this.activeByController.set(claim.controller, active);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async reconcileClaim(
|
|
275
|
+
definition: AnyControllerDefinition,
|
|
276
|
+
claim: ControllerQueueClaim,
|
|
277
|
+
): Promise<void> {
|
|
278
|
+
const ref = { controller: claim.controller, key: claim.key };
|
|
279
|
+
const resource = this.store.getResource(ref);
|
|
280
|
+
if (resource === undefined) {
|
|
281
|
+
this.store.settleClaim(claim, this.now().toISOString());
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const reconcileId = randomUUID();
|
|
285
|
+
const startedAtMs = this.now().getTime();
|
|
286
|
+
const abort = new AbortController();
|
|
287
|
+
this.activeReconciles.set(reconcileId, abort);
|
|
288
|
+
const parentAbort = this.runAbort?.signal;
|
|
289
|
+
const abortFromParent = () => abort.abort(parentAbort?.reason);
|
|
290
|
+
parentAbort?.addEventListener("abort", abortFromParent, { once: true });
|
|
291
|
+
const timeoutMs = definition.timeoutMs ?? this.defaultTimeoutMs;
|
|
292
|
+
const timeout = setTimeout(
|
|
293
|
+
() => abort.abort(new Error(`Reconciliation timed out after ${timeoutMs}ms`)),
|
|
294
|
+
timeoutMs,
|
|
295
|
+
);
|
|
296
|
+
const renew = setInterval(
|
|
297
|
+
() => {
|
|
298
|
+
const renewed = this.store.renewClaim(claim, this.leaseMs, this.now().toISOString());
|
|
299
|
+
if (!renewed) {
|
|
300
|
+
abort.abort(new Error("Controller queue claim was lost"));
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
Math.max(100, Math.floor(this.leaseMs / 3)),
|
|
304
|
+
);
|
|
305
|
+
renew.unref?.();
|
|
306
|
+
this.recordEvent(ref, "reconcile_started", {
|
|
307
|
+
reconcileId,
|
|
308
|
+
generation: resource.metadata.generation,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const result = await raceWithAbort(
|
|
313
|
+
this.callReconciler(definition, resource, abort.signal),
|
|
314
|
+
abort.signal,
|
|
315
|
+
);
|
|
316
|
+
if (abort.signal.aborted) {
|
|
317
|
+
throw abort.signal.reason ?? new Error("Reconciliation aborted");
|
|
318
|
+
}
|
|
319
|
+
await this.finishSuccessfulClaim(claim, resource, result, reconcileId, startedAtMs);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
await this.finishFailedClaim(claim, ref, reconcileId, error, startedAtMs);
|
|
322
|
+
} finally {
|
|
323
|
+
clearTimeout(timeout);
|
|
324
|
+
clearInterval(renew);
|
|
325
|
+
parentAbort?.removeEventListener("abort", abortFromParent);
|
|
326
|
+
this.activeReconciles.delete(reconcileId);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private async callReconciler(
|
|
331
|
+
definition: AnyControllerDefinition,
|
|
332
|
+
resource: ControllerResource,
|
|
333
|
+
signal: AbortSignal,
|
|
334
|
+
): Promise<ReconcileResult<unknown>> {
|
|
335
|
+
const context: ReconcileContext<unknown> = {
|
|
336
|
+
signal,
|
|
337
|
+
effects: new ControllerEffectService(this.store, resource, signal),
|
|
338
|
+
workflows: this.workflowCoordinator.forResource(resource, signal),
|
|
339
|
+
...createResultHelpers<unknown>(),
|
|
340
|
+
};
|
|
341
|
+
const result = await definition.reconcile(context, asTypedResource(resource));
|
|
342
|
+
validateResult(result);
|
|
343
|
+
return result;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private async finishSuccessfulClaim(
|
|
347
|
+
claim: ControllerQueueClaim,
|
|
348
|
+
resource: ControllerResource,
|
|
349
|
+
result: ReconcileResult<unknown>,
|
|
350
|
+
reconcileId: string,
|
|
351
|
+
startedAtMs: number,
|
|
352
|
+
): Promise<void> {
|
|
353
|
+
const ref = { controller: claim.controller, key: claim.key };
|
|
354
|
+
const now = this.now();
|
|
355
|
+
const status = applyStatusPatch(
|
|
356
|
+
resource.status,
|
|
357
|
+
result.status,
|
|
358
|
+
resource.metadata.generation,
|
|
359
|
+
now.toISOString(),
|
|
360
|
+
);
|
|
361
|
+
try {
|
|
362
|
+
const updated = this.store.updateStatus({
|
|
363
|
+
ref,
|
|
364
|
+
expectedResourceVersion: resource.metadata.resourceVersion,
|
|
365
|
+
status,
|
|
366
|
+
...(result.status?.finalizers !== undefined
|
|
367
|
+
? { finalizers: result.status.finalizers }
|
|
368
|
+
: {}),
|
|
369
|
+
now: now.toISOString(),
|
|
370
|
+
});
|
|
371
|
+
this.recordEvent(ref, "reconcile_finished", {
|
|
372
|
+
reconcileId,
|
|
373
|
+
result: result.kind,
|
|
374
|
+
generation: resource.metadata.generation,
|
|
375
|
+
durationMs: Math.max(0, now.getTime() - startedAtMs),
|
|
376
|
+
...(result.kind === "requeue" && result.afterMs !== undefined
|
|
377
|
+
? { requeueAfterMs: result.afterMs }
|
|
378
|
+
: {}),
|
|
379
|
+
});
|
|
380
|
+
if (
|
|
381
|
+
result.kind === "settled" &&
|
|
382
|
+
updated.metadata.deletionTimestamp !== undefined &&
|
|
383
|
+
updated.metadata.finalizers.length === 0
|
|
384
|
+
) {
|
|
385
|
+
this.recordEvent(ref, "resource_deleted", { reconcileId });
|
|
386
|
+
this.store.deleteResource(ref, updated.metadata.resourceVersion);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
} catch (error) {
|
|
390
|
+
if (error instanceof ResourceConflictError) {
|
|
391
|
+
this.recordEvent(ref, "reconcile_conflict", { reconcileId });
|
|
392
|
+
this.store.requeueClaim(claim, { availableAt: now.toISOString() }, now.toISOString());
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
397
|
+
if (result.kind === "settled") {
|
|
398
|
+
this.store.settleClaim(claim, now.toISOString());
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const afterMs = result.afterMs ?? 0;
|
|
402
|
+
this.store.requeueClaim(
|
|
403
|
+
claim,
|
|
404
|
+
{ availableAt: new Date(now.getTime() + afterMs).toISOString() },
|
|
405
|
+
now.toISOString(),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private async finishFailedClaim(
|
|
410
|
+
claim: ControllerQueueClaim,
|
|
411
|
+
ref: ControllerResourceRef,
|
|
412
|
+
reconcileId: string,
|
|
413
|
+
error: unknown,
|
|
414
|
+
startedAtMs: number,
|
|
415
|
+
): Promise<void> {
|
|
416
|
+
const now = this.now();
|
|
417
|
+
const message = boundedError(error);
|
|
418
|
+
const delay = this.backoffMs(claim.consecutiveErrors + 1);
|
|
419
|
+
this.recordEvent(ref, "reconcile_failed", {
|
|
420
|
+
reconcileId,
|
|
421
|
+
error: message,
|
|
422
|
+
durationMs: Math.max(0, now.getTime() - startedAtMs),
|
|
423
|
+
requeueAfterMs: delay,
|
|
424
|
+
});
|
|
425
|
+
this.store.requeueClaim(
|
|
426
|
+
claim,
|
|
427
|
+
{
|
|
428
|
+
availableAt: new Date(now.getTime() + delay).toISOString(),
|
|
429
|
+
error: message,
|
|
430
|
+
},
|
|
431
|
+
now.toISOString(),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private backoffMs(consecutiveErrors: number): number {
|
|
436
|
+
const exponent = Math.min(consecutiveErrors - 1, 30);
|
|
437
|
+
const raw = Math.min(this.maxBackoffMs, this.baseBackoffMs * 2 ** exponent);
|
|
438
|
+
const factor = 1 + (this.random() * 2 - 1) * this.jitterRatio;
|
|
439
|
+
return Math.max(1, Math.round(raw * factor));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private readyQueueExists(): boolean {
|
|
443
|
+
const now = this.now().getTime();
|
|
444
|
+
return this.store.listQueue().some((item) => Date.parse(item.availableAt) <= now);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private recordEvent(ref: ControllerResourceRef, type: string, payload: JsonObject = {}): void {
|
|
448
|
+
this.store.recordEvent({
|
|
449
|
+
controller: ref.controller,
|
|
450
|
+
key: ref.key,
|
|
451
|
+
type,
|
|
452
|
+
payload,
|
|
453
|
+
now: this.now().toISOString(),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function raceWithAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
459
|
+
return new Promise<T>((resolve, reject) => {
|
|
460
|
+
let finished = false;
|
|
461
|
+
const settle = (callback: () => void) => {
|
|
462
|
+
if (finished) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
finished = true;
|
|
466
|
+
signal.removeEventListener("abort", onAbort);
|
|
467
|
+
callback();
|
|
468
|
+
};
|
|
469
|
+
const onAbort = () =>
|
|
470
|
+
settle(() => reject(signal.reason ?? new Error("Reconciliation aborted")));
|
|
471
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
472
|
+
operation.then(
|
|
473
|
+
(value) => settle(() => resolve(value)),
|
|
474
|
+
(error: unknown) => settle(() => reject(error)),
|
|
475
|
+
);
|
|
476
|
+
if (signal.aborted) {
|
|
477
|
+
onAbort();
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function validateResult(result: ReconcileResult<unknown>): void {
|
|
483
|
+
if (result === null || typeof result !== "object") {
|
|
484
|
+
throw new Error("Controller reconcile must return a result object");
|
|
485
|
+
}
|
|
486
|
+
if (result.kind !== "settled" && result.kind !== "requeue") {
|
|
487
|
+
throw new Error("Controller reconcile returned an unknown result kind");
|
|
488
|
+
}
|
|
489
|
+
if (result.kind === "settled" && "afterMs" in result) {
|
|
490
|
+
throw new Error("A settled reconcile result cannot specify afterMs");
|
|
491
|
+
}
|
|
492
|
+
if (
|
|
493
|
+
result.kind === "requeue" &&
|
|
494
|
+
result.afterMs !== undefined &&
|
|
495
|
+
(!Number.isSafeInteger(result.afterMs) || result.afterMs <= 0)
|
|
496
|
+
) {
|
|
497
|
+
throw new Error("Requeue afterMs must be a positive safe integer");
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function positiveInteger(value: number, name: string): number {
|
|
502
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
503
|
+
throw new Error(`${name} must be a positive safe integer`);
|
|
504
|
+
}
|
|
505
|
+
return value;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function boundedError(error: unknown): string {
|
|
509
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
510
|
+
return message.length <= 8_192 ? message : `${message.slice(0, 8_192)}…`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
514
|
+
if (signal.aborted) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
await new Promise<void>((resolve) => {
|
|
518
|
+
let timer: NodeJS.Timeout | undefined;
|
|
519
|
+
const finish = () => {
|
|
520
|
+
if (timer !== undefined) {
|
|
521
|
+
clearTimeout(timer);
|
|
522
|
+
}
|
|
523
|
+
signal.removeEventListener("abort", finish);
|
|
524
|
+
resolve();
|
|
525
|
+
};
|
|
526
|
+
timer = setTimeout(finish, ms);
|
|
527
|
+
timer.unref?.();
|
|
528
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
529
|
+
if (signal.aborted) {
|
|
530
|
+
finish();
|
|
531
|
+
}
|
|
532
|
+
});
|
|
533
|
+
}
|