pi-amq 0.1.1
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/CHANGELOG.md +66 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/THIRD_PARTY_NOTICES.md +33 -0
- package/dist/extension/guidance.d.ts +25 -0
- package/dist/extension/guidance.d.ts.map +1 -0
- package/dist/extension/guidance.js +121 -0
- package/dist/extension/guidance.js.map +1 -0
- package/dist/extension/guide.d.ts +30 -0
- package/dist/extension/guide.d.ts.map +1 -0
- package/dist/extension/guide.js +128 -0
- package/dist/extension/guide.js.map +1 -0
- package/dist/extension/index.d.ts +4 -0
- package/dist/extension/index.d.ts.map +1 -0
- package/dist/extension/index.js +6 -0
- package/dist/extension/index.js.map +1 -0
- package/dist/extension/runtime.d.ts +48 -0
- package/dist/extension/runtime.d.ts.map +1 -0
- package/dist/extension/runtime.js +1095 -0
- package/dist/extension/runtime.js.map +1 -0
- package/dist/extension/tools.d.ts +12 -0
- package/dist/extension/tools.d.ts.map +1 -0
- package/dist/extension/tools.js +198 -0
- package/dist/extension/tools.js.map +1 -0
- package/dist/extension/worker-runtime.d.ts +34 -0
- package/dist/extension/worker-runtime.d.ts.map +1 -0
- package/dist/extension/worker-runtime.js +974 -0
- package/dist/extension/worker-runtime.js.map +1 -0
- package/dist/extension/worker-tools.d.ts +140 -0
- package/dist/extension/worker-tools.d.ts.map +1 -0
- package/dist/extension/worker-tools.js +217 -0
- package/dist/extension/worker-tools.js.map +1 -0
- package/dist/integration/confirmed-bind-onboarding.d.ts +44 -0
- package/dist/integration/confirmed-bind-onboarding.d.ts.map +1 -0
- package/dist/integration/confirmed-bind-onboarding.js +199 -0
- package/dist/integration/confirmed-bind-onboarding.js.map +1 -0
- package/dist/integration/fleet-view.d.ts +66 -0
- package/dist/integration/fleet-view.d.ts.map +1 -0
- package/dist/integration/fleet-view.js +238 -0
- package/dist/integration/fleet-view.js.map +1 -0
- package/dist/integration/fresh-session-dispatcher.d.ts +66 -0
- package/dist/integration/fresh-session-dispatcher.d.ts.map +1 -0
- package/dist/integration/fresh-session-dispatcher.js +103 -0
- package/dist/integration/fresh-session-dispatcher.js.map +1 -0
- package/dist/integration/launch-preview.d.ts +31 -0
- package/dist/integration/launch-preview.d.ts.map +1 -0
- package/dist/integration/launch-preview.js +325 -0
- package/dist/integration/launch-preview.js.map +1 -0
- package/dist/integration/transport-port.d.ts +39 -0
- package/dist/integration/transport-port.d.ts.map +1 -0
- package/dist/integration/transport-port.js +2 -0
- package/dist/integration/transport-port.js.map +1 -0
- package/dist/integration/worker-completion-notifier.d.ts +22 -0
- package/dist/integration/worker-completion-notifier.d.ts.map +1 -0
- package/dist/integration/worker-completion-notifier.js +81 -0
- package/dist/integration/worker-completion-notifier.js.map +1 -0
- package/dist/transport/amq-cli-adapter.d.ts +41 -0
- package/dist/transport/amq-cli-adapter.d.ts.map +1 -0
- package/dist/transport/amq-cli-adapter.js +291 -0
- package/dist/transport/amq-cli-adapter.js.map +1 -0
- package/dist/transport/amq-message.d.ts +47 -0
- package/dist/transport/amq-message.d.ts.map +1 -0
- package/dist/transport/amq-message.js +173 -0
- package/dist/transport/amq-message.js.map +1 -0
- package/dist/transport/amq-operations.d.ts +105 -0
- package/dist/transport/amq-operations.d.ts.map +1 -0
- package/dist/transport/amq-operations.js +320 -0
- package/dist/transport/amq-operations.js.map +1 -0
- package/dist/transport/atomic-json.d.ts +18 -0
- package/dist/transport/atomic-json.d.ts.map +1 -0
- package/dist/transport/atomic-json.js +90 -0
- package/dist/transport/atomic-json.js.map +1 -0
- package/dist/transport/binding-resolver.d.ts +46 -0
- package/dist/transport/binding-resolver.d.ts.map +1 -0
- package/dist/transport/binding-resolver.js +389 -0
- package/dist/transport/binding-resolver.js.map +1 -0
- package/dist/transport/bridge-journal.d.ts +100 -0
- package/dist/transport/bridge-journal.d.ts.map +1 -0
- package/dist/transport/bridge-journal.js +459 -0
- package/dist/transport/bridge-journal.js.map +1 -0
- package/dist/transport/dlq-retry-audit.d.ts +28 -0
- package/dist/transport/dlq-retry-audit.d.ts.map +1 -0
- package/dist/transport/dlq-retry-audit.js +66 -0
- package/dist/transport/dlq-retry-audit.js.map +1 -0
- package/dist/transport/inbox-pump.d.ts +81 -0
- package/dist/transport/inbox-pump.d.ts.map +1 -0
- package/dist/transport/inbox-pump.js +558 -0
- package/dist/transport/inbox-pump.js.map +1 -0
- package/dist/transport/index.d.ts +12 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +12 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/transport/presence-projector.d.ts +29 -0
- package/dist/transport/presence-projector.d.ts.map +1 -0
- package/dist/transport/presence-projector.js +73 -0
- package/dist/transport/presence-projector.js.map +1 -0
- package/dist/transport/priority-delivery-scheduler.d.ts +85 -0
- package/dist/transport/priority-delivery-scheduler.d.ts.map +1 -0
- package/dist/transport/priority-delivery-scheduler.js +210 -0
- package/dist/transport/priority-delivery-scheduler.js.map +1 -0
- package/dist/transport/receiver-lease.d.ts +102 -0
- package/dist/transport/receiver-lease.d.ts.map +1 -0
- package/dist/transport/receiver-lease.js +778 -0
- package/dist/transport/receiver-lease.js.map +1 -0
- package/dist/transport/runtime-status.d.ts +48 -0
- package/dist/transport/runtime-status.d.ts.map +1 -0
- package/dist/transport/runtime-status.js +71 -0
- package/dist/transport/runtime-status.js.map +1 -0
- package/dist/workflow/atomic-storage.d.ts +12 -0
- package/dist/workflow/atomic-storage.d.ts.map +1 -0
- package/dist/workflow/atomic-storage.js +104 -0
- package/dist/workflow/atomic-storage.js.map +1 -0
- package/dist/workflow/budget.d.ts +18 -0
- package/dist/workflow/budget.d.ts.map +1 -0
- package/dist/workflow/budget.js +74 -0
- package/dist/workflow/budget.js.map +1 -0
- package/dist/workflow/index.d.ts +17 -0
- package/dist/workflow/index.d.ts.map +1 -0
- package/dist/workflow/index.js +17 -0
- package/dist/workflow/index.js.map +1 -0
- package/dist/workflow/lifecycle-projection.d.ts +18 -0
- package/dist/workflow/lifecycle-projection.d.ts.map +1 -0
- package/dist/workflow/lifecycle-projection.js +22 -0
- package/dist/workflow/lifecycle-projection.js.map +1 -0
- package/dist/workflow/model-fallback.d.ts +2 -0
- package/dist/workflow/model-fallback.d.ts.map +1 -0
- package/dist/workflow/model-fallback.js +17 -0
- package/dist/workflow/model-fallback.js.map +1 -0
- package/dist/workflow/pi-session-fork.d.ts +18 -0
- package/dist/workflow/pi-session-fork.d.ts.map +1 -0
- package/dist/workflow/pi-session-fork.js +80 -0
- package/dist/workflow/pi-session-fork.js.map +1 -0
- package/dist/workflow/run-artifacts.d.ts +5 -0
- package/dist/workflow/run-artifacts.d.ts.map +1 -0
- package/dist/workflow/run-artifacts.js +25 -0
- package/dist/workflow/run-artifacts.js.map +1 -0
- package/dist/workflow/run-cleanup.d.ts +70 -0
- package/dist/workflow/run-cleanup.d.ts.map +1 -0
- package/dist/workflow/run-cleanup.js +222 -0
- package/dist/workflow/run-cleanup.js.map +1 -0
- package/dist/workflow/run-gate.d.ts +43 -0
- package/dist/workflow/run-gate.d.ts.map +1 -0
- package/dist/workflow/run-gate.js +70 -0
- package/dist/workflow/run-gate.js.map +1 -0
- package/dist/workflow/run-store.d.ts +208 -0
- package/dist/workflow/run-store.d.ts.map +1 -0
- package/dist/workflow/run-store.js +2965 -0
- package/dist/workflow/run-store.js.map +1 -0
- package/dist/workflow/spawn-confirmation.d.ts +44 -0
- package/dist/workflow/spawn-confirmation.d.ts.map +1 -0
- package/dist/workflow/spawn-confirmation.js +228 -0
- package/dist/workflow/spawn-confirmation.js.map +1 -0
- package/dist/workflow/system-process-inspector.d.ts +9 -0
- package/dist/workflow/system-process-inspector.d.ts.map +1 -0
- package/dist/workflow/system-process-inspector.js +129 -0
- package/dist/workflow/system-process-inspector.js.map +1 -0
- package/dist/workflow/worker-child-extension.d.ts +3 -0
- package/dist/workflow/worker-child-extension.d.ts.map +1 -0
- package/dist/workflow/worker-child-extension.js +182 -0
- package/dist/workflow/worker-child-extension.js.map +1 -0
- package/dist/workflow/worker-gate.d.ts +74 -0
- package/dist/workflow/worker-gate.d.ts.map +1 -0
- package/dist/workflow/worker-gate.js +165 -0
- package/dist/workflow/worker-gate.js.map +1 -0
- package/dist/workflow/worker-launch.d.ts +65 -0
- package/dist/workflow/worker-launch.d.ts.map +1 -0
- package/dist/workflow/worker-launch.js +338 -0
- package/dist/workflow/worker-launch.js.map +1 -0
- package/dist/workflow/worker-model-catalog.d.ts +36 -0
- package/dist/workflow/worker-model-catalog.d.ts.map +1 -0
- package/dist/workflow/worker-model-catalog.js +105 -0
- package/dist/workflow/worker-model-catalog.js.map +1 -0
- package/dist/workflow/worker-process-supervisor.d.ts +110 -0
- package/dist/workflow/worker-process-supervisor.d.ts.map +1 -0
- package/dist/workflow/worker-process-supervisor.js +1188 -0
- package/dist/workflow/worker-process-supervisor.js.map +1 -0
- package/dist/workflow/worker-prompt-capsule.d.ts +32 -0
- package/dist/workflow/worker-prompt-capsule.d.ts.map +1 -0
- package/dist/workflow/worker-prompt-capsule.js +52 -0
- package/dist/workflow/worker-prompt-capsule.js.map +1 -0
- package/dist/workflow/worker-role-presets.d.ts +38 -0
- package/dist/workflow/worker-role-presets.d.ts.map +1 -0
- package/dist/workflow/worker-role-presets.js +201 -0
- package/dist/workflow/worker-role-presets.js.map +1 -0
- package/dist/workflow/workflow-doctor.d.ts +57 -0
- package/dist/workflow/workflow-doctor.d.ts.map +1 -0
- package/dist/workflow/workflow-doctor.js +289 -0
- package/dist/workflow/workflow-doctor.js.map +1 -0
- package/dist/workflow/workflow-lease.d.ts +125 -0
- package/dist/workflow/workflow-lease.d.ts.map +1 -0
- package/dist/workflow/workflow-lease.js +689 -0
- package/dist/workflow/workflow-lease.js.map +1 -0
- package/dist/workflow/workflow-types.d.ts +335 -0
- package/dist/workflow/workflow-types.d.ts.map +1 -0
- package/dist/workflow/workflow-types.js +11 -0
- package/dist/workflow/workflow-types.js.map +1 -0
- package/dist/workflow/workspace-paths.d.ts +4 -0
- package/dist/workflow/workspace-paths.d.ts.map +1 -0
- package/dist/workflow/workspace-paths.js +22 -0
- package/dist/workflow/workspace-paths.js.map +1 -0
- package/docs/conformance.md +116 -0
- package/docs/demo-transcript.md +34 -0
- package/docs/design.md +1481 -0
- package/docs/operator-guide.md +100 -0
- package/extension/guidance.ts +162 -0
- package/extension/guide.ts +155 -0
- package/extension/index.ts +8 -0
- package/extension/runtime.ts +1224 -0
- package/extension/tools.ts +221 -0
- package/extension/worker-runtime.ts +1150 -0
- package/extension/worker-tools.ts +276 -0
- package/integration/confirmed-bind-onboarding.ts +296 -0
- package/integration/fleet-view.ts +286 -0
- package/integration/fresh-session-dispatcher.ts +166 -0
- package/integration/launch-preview.ts +396 -0
- package/integration/transport-port.ts +29 -0
- package/integration/worker-completion-notifier.ts +98 -0
- package/package.json +55 -0
- package/roles/oracle.md +9 -0
- package/roles/researcher.md +8 -0
- package/roles/reviewer.md +9 -0
- package/roles/scout.md +8 -0
- package/roles/worker.md +10 -0
- package/scripts/demo-two-terminal.sh +59 -0
- package/skills/coordination-patterns/SKILL.md +41 -0
- package/skills/coordination-patterns/references/supervised-worker-fan-out.md +12 -0
- package/skills/coordination-patterns/references/today-mail-team.md +36 -0
- package/transport/amq-cli-adapter.ts +381 -0
- package/transport/amq-message.ts +218 -0
- package/transport/amq-operations.ts +482 -0
- package/transport/atomic-json.ts +115 -0
- package/transport/binding-resolver.ts +565 -0
- package/transport/bridge-journal.ts +588 -0
- package/transport/dlq-retry-audit.ts +97 -0
- package/transport/inbox-pump.ts +729 -0
- package/transport/index.ts +11 -0
- package/transport/presence-projector.ts +104 -0
- package/transport/priority-delivery-scheduler.ts +328 -0
- package/transport/receiver-lease.ts +945 -0
- package/transport/runtime-status.ts +112 -0
- package/workflow/.gitkeep +1 -0
- package/workflow/atomic-storage.ts +120 -0
- package/workflow/budget.ts +90 -0
- package/workflow/index.ts +16 -0
- package/workflow/lifecycle-projection.ts +37 -0
- package/workflow/model-fallback.ts +17 -0
- package/workflow/pi-session-fork.ts +96 -0
- package/workflow/run-artifacts.ts +36 -0
- package/workflow/run-cleanup.ts +315 -0
- package/workflow/run-gate.ts +122 -0
- package/workflow/run-store.ts +3890 -0
- package/workflow/spawn-confirmation.ts +305 -0
- package/workflow/system-process-inspector.ts +142 -0
- package/workflow/worker-child-extension.ts +193 -0
- package/workflow/worker-gate.ts +238 -0
- package/workflow/worker-launch.ts +485 -0
- package/workflow/worker-model-catalog.ts +165 -0
- package/workflow/worker-process-supervisor.ts +1504 -0
- package/workflow/worker-prompt-capsule.ts +81 -0
- package/workflow/worker-role-presets.ts +241 -0
- package/workflow/workflow-doctor.ts +400 -0
- package/workflow/workflow-lease.ts +905 -0
- package/workflow/workflow-types.ts +367 -0
- package/workflow/workspace-paths.ts +32 -0
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
import { randomUUID as nodeRandomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
mkdir,
|
|
5
|
+
mkdtemp,
|
|
6
|
+
readdir,
|
|
7
|
+
rename,
|
|
8
|
+
rm,
|
|
9
|
+
stat,
|
|
10
|
+
unlink,
|
|
11
|
+
} from "node:fs/promises";
|
|
12
|
+
import { dirname, isAbsolute, join, normalize, resolve } from "node:path";
|
|
13
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
isNodeError,
|
|
17
|
+
readWorkflowJson,
|
|
18
|
+
sha256,
|
|
19
|
+
syncWorkflowDirectory,
|
|
20
|
+
writeWorkflowJson,
|
|
21
|
+
} from "./atomic-storage.js";
|
|
22
|
+
|
|
23
|
+
const OWNER_KEYS = [
|
|
24
|
+
"schema",
|
|
25
|
+
"kind",
|
|
26
|
+
"stage",
|
|
27
|
+
"generation",
|
|
28
|
+
"workspaceId",
|
|
29
|
+
"resourceId",
|
|
30
|
+
"runId",
|
|
31
|
+
"workerId",
|
|
32
|
+
"attemptId",
|
|
33
|
+
"workerAttemptGeneration",
|
|
34
|
+
"supervisorGeneration",
|
|
35
|
+
"sessionPath",
|
|
36
|
+
"pid",
|
|
37
|
+
"processStartIdentity",
|
|
38
|
+
"bootIdentity",
|
|
39
|
+
"executable",
|
|
40
|
+
"hostname",
|
|
41
|
+
"createdAt",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
const GUARD_KEYS = [
|
|
45
|
+
"schema",
|
|
46
|
+
"generation",
|
|
47
|
+
"pid",
|
|
48
|
+
"processStartIdentity",
|
|
49
|
+
"bootIdentity",
|
|
50
|
+
"executable",
|
|
51
|
+
"hostname",
|
|
52
|
+
"createdAt",
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
export interface ProcessIdentity {
|
|
56
|
+
readonly pid: number;
|
|
57
|
+
readonly processStartIdentity: string;
|
|
58
|
+
readonly bootIdentity: string | null;
|
|
59
|
+
readonly executable: string;
|
|
60
|
+
readonly hostname: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type ProcessInspection =
|
|
64
|
+
| { readonly kind: "live"; readonly identity: ProcessIdentity }
|
|
65
|
+
| { readonly kind: "absent" }
|
|
66
|
+
| { readonly kind: "unknown"; readonly diagnostic: string };
|
|
67
|
+
|
|
68
|
+
export interface ProcessIdentityInspector {
|
|
69
|
+
current(): Promise<ProcessIdentity>;
|
|
70
|
+
inspect(pid: number): Promise<ProcessInspection>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface WorkflowLeaseOwnerV1 {
|
|
74
|
+
readonly schema: 1;
|
|
75
|
+
readonly kind: "supervisor" | "worker-session";
|
|
76
|
+
readonly stage: "active" | "reserved";
|
|
77
|
+
readonly generation: string;
|
|
78
|
+
readonly workspaceId: string;
|
|
79
|
+
readonly resourceId: string;
|
|
80
|
+
readonly runId: string | null;
|
|
81
|
+
readonly workerId: string | null;
|
|
82
|
+
readonly attemptId: string | null;
|
|
83
|
+
readonly workerAttemptGeneration: string | null;
|
|
84
|
+
readonly supervisorGeneration: string | null;
|
|
85
|
+
readonly sessionPath: string | null;
|
|
86
|
+
readonly pid: number;
|
|
87
|
+
readonly processStartIdentity: string;
|
|
88
|
+
readonly bootIdentity: string | null;
|
|
89
|
+
readonly executable: string;
|
|
90
|
+
readonly hostname: string;
|
|
91
|
+
readonly createdAt: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface LifecycleGuardV1 {
|
|
95
|
+
readonly schema: 1;
|
|
96
|
+
readonly generation: string;
|
|
97
|
+
readonly pid: number;
|
|
98
|
+
readonly processStartIdentity: string;
|
|
99
|
+
readonly bootIdentity: string | null;
|
|
100
|
+
readonly executable: string;
|
|
101
|
+
readonly hostname: string;
|
|
102
|
+
readonly createdAt: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface WorkflowLeaseManagerOptions {
|
|
106
|
+
readonly namespaceDirectory: string;
|
|
107
|
+
readonly workspaceId: string;
|
|
108
|
+
readonly inspector: ProcessIdentityInspector;
|
|
109
|
+
readonly randomUUID?: () => string;
|
|
110
|
+
readonly now?: () => Date;
|
|
111
|
+
readonly guardWaitMs?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface AcquireWorkerSessionInput {
|
|
115
|
+
readonly sessionPath: string;
|
|
116
|
+
readonly runId: string;
|
|
117
|
+
readonly workerId: string;
|
|
118
|
+
readonly attemptId: string;
|
|
119
|
+
readonly workerAttemptGeneration: string;
|
|
120
|
+
readonly supervisorGeneration: string;
|
|
121
|
+
readonly processIdentity: ProcessIdentity;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type WorkflowLeaseRefusalReason =
|
|
125
|
+
| "remote-owner"
|
|
126
|
+
| "unverifiable-owner"
|
|
127
|
+
| "corrupt-owner"
|
|
128
|
+
| "lifecycle-busy"
|
|
129
|
+
| "lifecycle-unverifiable";
|
|
130
|
+
|
|
131
|
+
export type WorkflowLeaseAcquireResult =
|
|
132
|
+
| {
|
|
133
|
+
readonly kind: "acquired";
|
|
134
|
+
readonly owner: WorkflowLeaseOwnerV1;
|
|
135
|
+
readonly reclaimed?: {
|
|
136
|
+
readonly owner: WorkflowLeaseOwnerV1;
|
|
137
|
+
readonly quarantinePath: string;
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
| { readonly kind: "held"; readonly owner: WorkflowLeaseOwnerV1 }
|
|
141
|
+
| {
|
|
142
|
+
readonly kind: "refused";
|
|
143
|
+
readonly reason: WorkflowLeaseRefusalReason;
|
|
144
|
+
readonly diagnostic: string;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
export type WorkflowLeaseReleaseResult =
|
|
148
|
+
| { readonly kind: "released" }
|
|
149
|
+
| { readonly kind: "not-owner"; readonly owner?: WorkflowLeaseOwnerV1 }
|
|
150
|
+
| { readonly kind: "refused"; readonly diagnostic: string };
|
|
151
|
+
|
|
152
|
+
export type WorkflowLeaseProjection =
|
|
153
|
+
| { readonly kind: "empty" }
|
|
154
|
+
| { readonly kind: "owned"; readonly owner: WorkflowLeaseOwnerV1 }
|
|
155
|
+
| { readonly kind: "corrupt"; readonly diagnostic: string };
|
|
156
|
+
|
|
157
|
+
export interface WorkerSessionLeaseDiagnostic {
|
|
158
|
+
readonly resourceId: string;
|
|
159
|
+
readonly path: string;
|
|
160
|
+
readonly projection: WorkflowLeaseProjection;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export class WorkflowLeaseFenceError extends Error {
|
|
164
|
+
constructor(message: string) {
|
|
165
|
+
super(message);
|
|
166
|
+
this.name = "WorkflowLeaseFenceError";
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
interface LeaseResource {
|
|
171
|
+
readonly kind: "supervisor" | "worker-session";
|
|
172
|
+
readonly resourceId: string;
|
|
173
|
+
readonly lockPath: string;
|
|
174
|
+
readonly guardPath: string;
|
|
175
|
+
readonly quarantineStem: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
type OwnerState =
|
|
179
|
+
| { readonly kind: "live" }
|
|
180
|
+
| { readonly kind: "stale"; readonly reason: string }
|
|
181
|
+
| { readonly kind: "remote" }
|
|
182
|
+
| { readonly kind: "unknown"; readonly diagnostic: string };
|
|
183
|
+
|
|
184
|
+
type GuardResult<T> =
|
|
185
|
+
| { readonly ok: true; readonly value: T }
|
|
186
|
+
| { readonly ok: false; readonly reason: WorkflowLeaseRefusalReason; readonly diagnostic: string };
|
|
187
|
+
|
|
188
|
+
export class WorkflowLeaseManager {
|
|
189
|
+
readonly #namespaceDirectory: string;
|
|
190
|
+
readonly #workspaceId: string;
|
|
191
|
+
readonly #inspector: ProcessIdentityInspector;
|
|
192
|
+
readonly #randomUUID: () => string;
|
|
193
|
+
readonly #now: () => Date;
|
|
194
|
+
readonly #guardWaitMs: number;
|
|
195
|
+
|
|
196
|
+
constructor(options: WorkflowLeaseManagerOptions) {
|
|
197
|
+
if (!isAbsolute(options.namespaceDirectory)) {
|
|
198
|
+
throw new TypeError("Workflow namespace must be absolute");
|
|
199
|
+
}
|
|
200
|
+
this.#namespaceDirectory = normalize(options.namespaceDirectory);
|
|
201
|
+
this.#workspaceId = requireNonEmpty(options.workspaceId, "workspace ID");
|
|
202
|
+
this.#inspector = options.inspector;
|
|
203
|
+
this.#randomUUID = options.randomUUID ?? nodeRandomUUID;
|
|
204
|
+
this.#now = options.now ?? (() => new Date());
|
|
205
|
+
this.#guardWaitMs = options.guardWaitMs ?? 1_000;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async acquireSupervisor(): Promise<WorkflowLeaseAcquireResult> {
|
|
209
|
+
const current = await this.#inspector.current();
|
|
210
|
+
validateProcessIdentity(current, "current process identity");
|
|
211
|
+
return this.#acquire(this.#supervisorResource(), ownerFromIdentity({
|
|
212
|
+
kind: "supervisor",
|
|
213
|
+
stage: "active",
|
|
214
|
+
generation: this.#randomUUID(),
|
|
215
|
+
workspaceId: this.#workspaceId,
|
|
216
|
+
resourceId: this.#workspaceId,
|
|
217
|
+
identity: current,
|
|
218
|
+
now: this.#now(),
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async releaseSupervisor(generation: string): Promise<WorkflowLeaseReleaseResult> {
|
|
223
|
+
requireNonEmpty(generation, "supervisor generation");
|
|
224
|
+
const current = await this.#inspector.current();
|
|
225
|
+
return this.#release(this.#supervisorResource(), generation, (owner) =>
|
|
226
|
+
owner.kind === "supervisor" && ownerIsProcess(owner, current));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async withCurrentSupervisor<T>(
|
|
230
|
+
generation: string,
|
|
231
|
+
operation: () => Promise<T>,
|
|
232
|
+
): Promise<T> {
|
|
233
|
+
requireNonEmpty(generation, "supervisor generation");
|
|
234
|
+
const resource = this.#supervisorResource();
|
|
235
|
+
const guarded = await this.#withGuard(resource, async (current) => {
|
|
236
|
+
const projection = await this.#inspect(resource);
|
|
237
|
+
if (projection.kind !== "owned"
|
|
238
|
+
|| projection.owner.kind !== "supervisor"
|
|
239
|
+
|| projection.owner.generation !== generation
|
|
240
|
+
|| projection.owner.workspaceId !== this.#workspaceId
|
|
241
|
+
|| !ownerIsProcess(projection.owner, current)) {
|
|
242
|
+
throw new WorkflowLeaseFenceError("Workflow supervisor generation is not current");
|
|
243
|
+
}
|
|
244
|
+
return operation();
|
|
245
|
+
});
|
|
246
|
+
if (!guarded.ok) throw new WorkflowLeaseFenceError(guarded.diagnostic);
|
|
247
|
+
return guarded.value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async inspectSupervisor(): Promise<WorkflowLeaseProjection> {
|
|
251
|
+
await this.#initialize();
|
|
252
|
+
return this.#inspect(this.#supervisorResource());
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async acquireWorkerSession(
|
|
256
|
+
input: AcquireWorkerSessionInput,
|
|
257
|
+
): Promise<WorkflowLeaseAcquireResult> {
|
|
258
|
+
const sessionPath = canonicalSessionPath(input.sessionPath);
|
|
259
|
+
validateProcessIdentity(input.processIdentity, "worker attempt process identity");
|
|
260
|
+
requireNonEmpty(input.runId, "run ID");
|
|
261
|
+
requireNonEmpty(input.workerId, "worker ID");
|
|
262
|
+
requireNonEmpty(input.attemptId, "attempt ID");
|
|
263
|
+
requireNonEmpty(input.workerAttemptGeneration, "worker attempt generation");
|
|
264
|
+
return this.withCurrentSupervisor(input.supervisorGeneration, async () => {
|
|
265
|
+
const resource = this.#workerResource(sessionPath);
|
|
266
|
+
return this.#acquire(resource, ownerFromIdentity({
|
|
267
|
+
kind: "worker-session",
|
|
268
|
+
stage: "active",
|
|
269
|
+
generation: this.#randomUUID(),
|
|
270
|
+
workspaceId: this.#workspaceId,
|
|
271
|
+
resourceId: resource.resourceId,
|
|
272
|
+
runId: input.runId,
|
|
273
|
+
workerId: input.workerId,
|
|
274
|
+
attemptId: input.attemptId,
|
|
275
|
+
workerAttemptGeneration: input.workerAttemptGeneration,
|
|
276
|
+
supervisorGeneration: input.supervisorGeneration,
|
|
277
|
+
sessionPath,
|
|
278
|
+
identity: input.processIdentity,
|
|
279
|
+
now: this.#now(),
|
|
280
|
+
}));
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async reserveWorkerSession(
|
|
285
|
+
input: Omit<AcquireWorkerSessionInput, "processIdentity">,
|
|
286
|
+
): Promise<WorkflowLeaseAcquireResult> {
|
|
287
|
+
const sessionPath = canonicalSessionPath(input.sessionPath);
|
|
288
|
+
requireNonEmpty(input.runId, "run ID");
|
|
289
|
+
requireNonEmpty(input.workerId, "worker ID");
|
|
290
|
+
requireNonEmpty(input.attemptId, "attempt ID");
|
|
291
|
+
requireNonEmpty(input.workerAttemptGeneration, "worker attempt generation");
|
|
292
|
+
return this.withCurrentSupervisor(input.supervisorGeneration, async () => {
|
|
293
|
+
const current = await this.#inspector.current();
|
|
294
|
+
const resource = this.#workerResource(sessionPath);
|
|
295
|
+
return this.#acquire(resource, ownerFromIdentity({
|
|
296
|
+
kind: "worker-session",
|
|
297
|
+
stage: "reserved",
|
|
298
|
+
generation: this.#randomUUID(),
|
|
299
|
+
workspaceId: this.#workspaceId,
|
|
300
|
+
resourceId: resource.resourceId,
|
|
301
|
+
runId: input.runId,
|
|
302
|
+
workerId: input.workerId,
|
|
303
|
+
attemptId: input.attemptId,
|
|
304
|
+
workerAttemptGeneration: input.workerAttemptGeneration,
|
|
305
|
+
supervisorGeneration: input.supervisorGeneration,
|
|
306
|
+
sessionPath,
|
|
307
|
+
identity: current,
|
|
308
|
+
now: this.#now(),
|
|
309
|
+
}));
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async activateWorkerSession(input: {
|
|
314
|
+
readonly sessionPath: string;
|
|
315
|
+
readonly generation: string;
|
|
316
|
+
readonly supervisorGeneration: string;
|
|
317
|
+
readonly workerAttemptGeneration: string;
|
|
318
|
+
readonly processIdentity: ProcessIdentity;
|
|
319
|
+
}): Promise<WorkflowLeaseOwnerV1> {
|
|
320
|
+
const sessionPath = canonicalSessionPath(input.sessionPath);
|
|
321
|
+
validateProcessIdentity(input.processIdentity, "worker attempt process identity");
|
|
322
|
+
return this.withCurrentSupervisor(input.supervisorGeneration, async () => {
|
|
323
|
+
const resource = this.#workerResource(sessionPath);
|
|
324
|
+
const guarded = await this.#withGuard(resource, async () => {
|
|
325
|
+
const projection = await this.#inspect(resource);
|
|
326
|
+
if (projection.kind !== "owned"
|
|
327
|
+
|| projection.owner.kind !== "worker-session"
|
|
328
|
+
|| projection.owner.stage !== "reserved"
|
|
329
|
+
|| projection.owner.generation !== input.generation
|
|
330
|
+
|| projection.owner.supervisorGeneration !== input.supervisorGeneration
|
|
331
|
+
|| projection.owner.workerAttemptGeneration !== input.workerAttemptGeneration
|
|
332
|
+
|| projection.owner.sessionPath !== sessionPath) {
|
|
333
|
+
throw new WorkflowLeaseFenceError("Worker-session reservation is not current");
|
|
334
|
+
}
|
|
335
|
+
const owner: WorkflowLeaseOwnerV1 = {
|
|
336
|
+
...projection.owner,
|
|
337
|
+
stage: "active",
|
|
338
|
+
pid: input.processIdentity.pid,
|
|
339
|
+
processStartIdentity: input.processIdentity.processStartIdentity,
|
|
340
|
+
bootIdentity: input.processIdentity.bootIdentity,
|
|
341
|
+
executable: input.processIdentity.executable,
|
|
342
|
+
hostname: input.processIdentity.hostname,
|
|
343
|
+
};
|
|
344
|
+
await writeWorkflowJson(join(resource.lockPath, "owner.json"), owner);
|
|
345
|
+
return owner;
|
|
346
|
+
});
|
|
347
|
+
if (!guarded.ok) throw new WorkflowLeaseFenceError(guarded.diagnostic);
|
|
348
|
+
return guarded.value;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async releaseWorkerSession(input: {
|
|
353
|
+
readonly sessionPath: string;
|
|
354
|
+
readonly generation: string;
|
|
355
|
+
readonly supervisorGeneration: string;
|
|
356
|
+
}): Promise<WorkflowLeaseReleaseResult> {
|
|
357
|
+
const sessionPath = canonicalSessionPath(input.sessionPath);
|
|
358
|
+
return this.withCurrentSupervisor(input.supervisorGeneration, () =>
|
|
359
|
+
this.#release(this.#workerResource(sessionPath), input.generation, (owner) =>
|
|
360
|
+
owner.kind === "worker-session"
|
|
361
|
+
&& owner.sessionPath === sessionPath
|
|
362
|
+
&& owner.supervisorGeneration === input.supervisorGeneration));
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async inspectWorkerSession(sessionPath: string): Promise<WorkflowLeaseProjection> {
|
|
366
|
+
await this.#initialize();
|
|
367
|
+
return this.#inspect(this.#workerResource(canonicalSessionPath(sessionPath)));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async diagnoseWorkerSessionLeases(): Promise<readonly WorkerSessionLeaseDiagnostic[]> {
|
|
371
|
+
const parent = join(this.#namespaceDirectory, "worker-session-leases");
|
|
372
|
+
let entries: string[];
|
|
373
|
+
try {
|
|
374
|
+
entries = await readdir(parent);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
if (isNodeError(error) && error.code === "ENOENT") return [];
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
const records: WorkerSessionLeaseDiagnostic[] = [];
|
|
380
|
+
for (const entry of entries.sort()) {
|
|
381
|
+
if (!entry.endsWith(".lock") || entry.startsWith(".")) continue;
|
|
382
|
+
const resourceId = entry.slice(0, -".lock".length);
|
|
383
|
+
const path = join(parent, entry);
|
|
384
|
+
records.push({
|
|
385
|
+
resourceId,
|
|
386
|
+
path,
|
|
387
|
+
projection: await this.#inspect({
|
|
388
|
+
kind: "worker-session",
|
|
389
|
+
resourceId,
|
|
390
|
+
lockPath: path,
|
|
391
|
+
guardPath: join(parent, `${resourceId}.lifecycle.guard.json`),
|
|
392
|
+
quarantineStem: `worker-session.${resourceId}`,
|
|
393
|
+
}),
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return records;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async #acquire(
|
|
400
|
+
resource: LeaseResource,
|
|
401
|
+
candidate: WorkflowLeaseOwnerV1,
|
|
402
|
+
): Promise<WorkflowLeaseAcquireResult> {
|
|
403
|
+
await this.#initialize();
|
|
404
|
+
const guarded = await this.#withGuard(resource, async (current) => {
|
|
405
|
+
const projection = await this.#inspect(resource);
|
|
406
|
+
if (projection.kind === "corrupt") {
|
|
407
|
+
return {
|
|
408
|
+
kind: "refused" as const,
|
|
409
|
+
reason: "corrupt-owner" as const,
|
|
410
|
+
diagnostic: projection.diagnostic,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
let reclaimed: { readonly owner: WorkflowLeaseOwnerV1; readonly quarantinePath: string } | undefined;
|
|
414
|
+
if (projection.kind === "owned") {
|
|
415
|
+
const state = await classifyProcessRecord(projection.owner, current, this.#inspector);
|
|
416
|
+
if (state.kind === "live") return { kind: "held" as const, owner: projection.owner };
|
|
417
|
+
if (state.kind === "remote") {
|
|
418
|
+
return {
|
|
419
|
+
kind: "refused" as const,
|
|
420
|
+
reason: "remote-owner" as const,
|
|
421
|
+
diagnostic: `Workflow lease owner is on remote host ${projection.owner.hostname}`,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (state.kind === "unknown") {
|
|
425
|
+
return {
|
|
426
|
+
kind: "refused" as const,
|
|
427
|
+
reason: "unverifiable-owner" as const,
|
|
428
|
+
diagnostic: state.diagnostic,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const quarantinePath = join(
|
|
432
|
+
this.#namespaceDirectory,
|
|
433
|
+
"quarantine",
|
|
434
|
+
`${resource.quarantineStem}.${safePathPart(projection.owner.generation)}.${this.#now().getTime()}.${safePathPart(this.#randomUUID())}`,
|
|
435
|
+
);
|
|
436
|
+
await rename(resource.lockPath, quarantinePath);
|
|
437
|
+
await syncWorkflowDirectory(dirname(resource.lockPath));
|
|
438
|
+
await syncWorkflowDirectory(this.#namespaceDirectory);
|
|
439
|
+
reclaimed = { owner: projection.owner, quarantinePath };
|
|
440
|
+
}
|
|
441
|
+
await this.#createLease(resource, candidate);
|
|
442
|
+
return {
|
|
443
|
+
kind: "acquired" as const,
|
|
444
|
+
owner: candidate,
|
|
445
|
+
...(reclaimed ? { reclaimed } : {}),
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
return guarded.ok
|
|
449
|
+
? guarded.value
|
|
450
|
+
: { kind: "refused", reason: guarded.reason, diagnostic: guarded.diagnostic };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async #release(
|
|
454
|
+
resource: LeaseResource,
|
|
455
|
+
generation: string,
|
|
456
|
+
ownerMatchesAuthority: (owner: WorkflowLeaseOwnerV1) => boolean,
|
|
457
|
+
): Promise<WorkflowLeaseReleaseResult> {
|
|
458
|
+
requireNonEmpty(generation, "lease generation");
|
|
459
|
+
await this.#initialize();
|
|
460
|
+
const guarded = await this.#withGuard(resource, async () => {
|
|
461
|
+
const projection = await this.#inspect(resource);
|
|
462
|
+
if (projection.kind === "empty") return { kind: "not-owner" as const };
|
|
463
|
+
if (projection.kind === "corrupt") {
|
|
464
|
+
return { kind: "refused" as const, diagnostic: projection.diagnostic };
|
|
465
|
+
}
|
|
466
|
+
if (projection.owner.generation !== generation || !ownerMatchesAuthority(projection.owner)) {
|
|
467
|
+
return { kind: "not-owner" as const, owner: projection.owner };
|
|
468
|
+
}
|
|
469
|
+
const releasedPath = join(
|
|
470
|
+
this.#namespaceDirectory,
|
|
471
|
+
`.${resource.quarantineStem}.released-${safePathPart(generation)}-${safePathPart(this.#randomUUID())}`,
|
|
472
|
+
);
|
|
473
|
+
await rename(resource.lockPath, releasedPath);
|
|
474
|
+
await syncWorkflowDirectory(dirname(resource.lockPath));
|
|
475
|
+
await syncWorkflowDirectory(this.#namespaceDirectory);
|
|
476
|
+
await rm(releasedPath, { recursive: true });
|
|
477
|
+
await syncWorkflowDirectory(this.#namespaceDirectory);
|
|
478
|
+
return { kind: "released" as const };
|
|
479
|
+
});
|
|
480
|
+
return guarded.ok
|
|
481
|
+
? guarded.value
|
|
482
|
+
: { kind: "refused", diagnostic: guarded.diagnostic };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async #createLease(resource: LeaseResource, owner: WorkflowLeaseOwnerV1): Promise<void> {
|
|
486
|
+
const parent = dirname(resource.lockPath);
|
|
487
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
488
|
+
const prepared = await mkdtemp(join(parent, ".lease.prepare-"));
|
|
489
|
+
await chmod(prepared, 0o700);
|
|
490
|
+
let installed = false;
|
|
491
|
+
try {
|
|
492
|
+
await writeWorkflowJson(join(prepared, "owner.json"), owner, { createOnly: true });
|
|
493
|
+
await syncWorkflowDirectory(prepared);
|
|
494
|
+
await rename(prepared, resource.lockPath);
|
|
495
|
+
installed = true;
|
|
496
|
+
await syncWorkflowDirectory(parent);
|
|
497
|
+
} finally {
|
|
498
|
+
if (!installed) await rm(prepared, { recursive: true, force: true });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
async #inspect(resource: LeaseResource): Promise<WorkflowLeaseProjection> {
|
|
503
|
+
let raw: unknown;
|
|
504
|
+
try {
|
|
505
|
+
raw = await readWorkflowJson(join(resource.lockPath, "owner.json"));
|
|
506
|
+
} catch (error) {
|
|
507
|
+
return { kind: "corrupt", diagnostic: `Workflow lease JSON is invalid: ${toError(error).message}` };
|
|
508
|
+
}
|
|
509
|
+
if (raw === undefined) {
|
|
510
|
+
try {
|
|
511
|
+
await stat(resource.lockPath);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (isNodeError(error) && error.code === "ENOENT") return { kind: "empty" };
|
|
514
|
+
throw error;
|
|
515
|
+
}
|
|
516
|
+
return { kind: "corrupt", diagnostic: "Workflow lease exists without owner.json" };
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
const owner = parseWorkflowLeaseOwnerV1(raw);
|
|
520
|
+
if (owner.workspaceId !== this.#workspaceId
|
|
521
|
+
|| owner.kind !== resource.kind
|
|
522
|
+
|| owner.resourceId !== resource.resourceId) {
|
|
523
|
+
throw new Error("Workflow lease resource identity does not match its namespace");
|
|
524
|
+
}
|
|
525
|
+
if (owner.kind === "worker-session"
|
|
526
|
+
&& owner.sessionPath !== null
|
|
527
|
+
&& this.#workerResource(owner.sessionPath).resourceId !== resource.resourceId) {
|
|
528
|
+
throw new Error("Worker-session lease path does not match its resource hash");
|
|
529
|
+
}
|
|
530
|
+
return { kind: "owned", owner };
|
|
531
|
+
} catch (error) {
|
|
532
|
+
return { kind: "corrupt", diagnostic: toError(error).message };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async #withGuard<T>(
|
|
537
|
+
resource: LeaseResource,
|
|
538
|
+
operation: (current: ProcessIdentity) => Promise<T>,
|
|
539
|
+
): Promise<GuardResult<T>> {
|
|
540
|
+
await this.#initialize();
|
|
541
|
+
await mkdir(join(resource.guardPath, ".."), { recursive: true, mode: 0o700 });
|
|
542
|
+
const current = await this.#inspector.current();
|
|
543
|
+
validateProcessIdentity(current, "current process identity");
|
|
544
|
+
const deadline = Date.now() + this.#guardWaitMs;
|
|
545
|
+
|
|
546
|
+
while (true) {
|
|
547
|
+
const guard: LifecycleGuardV1 = {
|
|
548
|
+
schema: 1,
|
|
549
|
+
generation: this.#randomUUID(),
|
|
550
|
+
pid: current.pid,
|
|
551
|
+
processStartIdentity: current.processStartIdentity,
|
|
552
|
+
bootIdentity: current.bootIdentity,
|
|
553
|
+
executable: current.executable,
|
|
554
|
+
hostname: current.hostname,
|
|
555
|
+
createdAt: this.#now().toISOString(),
|
|
556
|
+
};
|
|
557
|
+
try {
|
|
558
|
+
await writeWorkflowJson(resource.guardPath, guard, { createOnly: true });
|
|
559
|
+
} catch (error) {
|
|
560
|
+
if (!isNodeError(error) || error.code !== "EEXIST") throw error;
|
|
561
|
+
const existing = await this.#readGuard(resource.guardPath);
|
|
562
|
+
if (existing.kind === "corrupt") {
|
|
563
|
+
return { ok: false, reason: "lifecycle-unverifiable", diagnostic: existing.diagnostic };
|
|
564
|
+
}
|
|
565
|
+
if (existing.kind === "empty") continue;
|
|
566
|
+
const state = await classifyProcessRecord(existing.guard, current, this.#inspector);
|
|
567
|
+
if (state.kind === "stale") {
|
|
568
|
+
const quarantinePath = join(
|
|
569
|
+
this.#namespaceDirectory,
|
|
570
|
+
"quarantine",
|
|
571
|
+
`guard.${resource.quarantineStem}.${safePathPart(existing.guard.generation)}.${safePathPart(this.#randomUUID())}.json`,
|
|
572
|
+
);
|
|
573
|
+
try {
|
|
574
|
+
await rename(resource.guardPath, quarantinePath);
|
|
575
|
+
await syncWorkflowDirectory(dirname(resource.guardPath));
|
|
576
|
+
await syncWorkflowDirectory(this.#namespaceDirectory);
|
|
577
|
+
} catch (renameError) {
|
|
578
|
+
if (!isNodeError(renameError) || renameError.code !== "ENOENT") throw renameError;
|
|
579
|
+
}
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (state.kind === "remote" || state.kind === "unknown") {
|
|
583
|
+
return {
|
|
584
|
+
ok: false,
|
|
585
|
+
reason: "lifecycle-unverifiable",
|
|
586
|
+
diagnostic: state.kind === "remote"
|
|
587
|
+
? `Workflow lifecycle guard belongs to remote host ${existing.guard.hostname}`
|
|
588
|
+
: state.diagnostic,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
if (Date.now() >= deadline) {
|
|
592
|
+
return {
|
|
593
|
+
ok: false,
|
|
594
|
+
reason: "lifecycle-busy",
|
|
595
|
+
diagnostic: "Workflow lease lifecycle operation is busy; no owner was displaced",
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
await delay(5);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
let value: T | undefined;
|
|
603
|
+
let operationError: unknown;
|
|
604
|
+
try {
|
|
605
|
+
value = await operation(current);
|
|
606
|
+
} catch (error) {
|
|
607
|
+
operationError = error;
|
|
608
|
+
}
|
|
609
|
+
let releaseError: unknown;
|
|
610
|
+
try {
|
|
611
|
+
await this.#releaseGuard(resource.guardPath, guard.generation);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
releaseError = error;
|
|
614
|
+
}
|
|
615
|
+
if (operationError !== undefined && releaseError !== undefined) {
|
|
616
|
+
throw new AggregateError(
|
|
617
|
+
[operationError, releaseError],
|
|
618
|
+
"Workflow lease operation and guard release both failed",
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
if (operationError !== undefined) throw operationError;
|
|
622
|
+
if (releaseError !== undefined) throw releaseError;
|
|
623
|
+
return { ok: true, value: value as T };
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async #readGuard(path: string): Promise<
|
|
628
|
+
| { readonly kind: "empty" }
|
|
629
|
+
| { readonly kind: "owned"; readonly guard: LifecycleGuardV1 }
|
|
630
|
+
| { readonly kind: "corrupt"; readonly diagnostic: string }
|
|
631
|
+
> {
|
|
632
|
+
let raw: unknown;
|
|
633
|
+
try {
|
|
634
|
+
raw = await readWorkflowJson(path);
|
|
635
|
+
} catch (error) {
|
|
636
|
+
return { kind: "corrupt", diagnostic: `Workflow guard JSON is invalid: ${toError(error).message}` };
|
|
637
|
+
}
|
|
638
|
+
if (raw === undefined) return { kind: "empty" };
|
|
639
|
+
try {
|
|
640
|
+
return { kind: "owned", guard: parseLifecycleGuardV1(raw) };
|
|
641
|
+
} catch (error) {
|
|
642
|
+
return { kind: "corrupt", diagnostic: toError(error).message };
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async #releaseGuard(path: string, generation: string): Promise<void> {
|
|
647
|
+
const existing = await this.#readGuard(path);
|
|
648
|
+
if (existing.kind !== "owned" || existing.guard.generation !== generation) {
|
|
649
|
+
throw new WorkflowLeaseFenceError("Workflow lifecycle guard generation changed before release");
|
|
650
|
+
}
|
|
651
|
+
await unlink(path);
|
|
652
|
+
await syncWorkflowDirectory(dirname(path));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async #initialize(): Promise<void> {
|
|
656
|
+
await mkdir(this.#namespaceDirectory, { recursive: true, mode: 0o700 });
|
|
657
|
+
await mkdir(join(this.#namespaceDirectory, "worker-session-leases"), { recursive: true, mode: 0o700 });
|
|
658
|
+
await mkdir(join(this.#namespaceDirectory, "quarantine"), { recursive: true, mode: 0o700 });
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
#supervisorResource(): LeaseResource {
|
|
662
|
+
return {
|
|
663
|
+
kind: "supervisor",
|
|
664
|
+
resourceId: this.#workspaceId,
|
|
665
|
+
lockPath: join(this.#namespaceDirectory, "supervisor.lock"),
|
|
666
|
+
guardPath: join(this.#namespaceDirectory, "supervisor.lifecycle.guard.json"),
|
|
667
|
+
quarantineStem: "supervisor",
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
#workerResource(sessionPath: string): LeaseResource {
|
|
672
|
+
const resourceId = sha256(sessionPath).slice("sha256:".length);
|
|
673
|
+
const parent = join(this.#namespaceDirectory, "worker-session-leases");
|
|
674
|
+
return {
|
|
675
|
+
kind: "worker-session",
|
|
676
|
+
resourceId,
|
|
677
|
+
lockPath: join(parent, `${resourceId}.lock`),
|
|
678
|
+
guardPath: join(parent, `${resourceId}.lifecycle.guard.json`),
|
|
679
|
+
quarantineStem: `worker-session.${resourceId}`,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export function parseWorkflowLeaseOwnerV1(value: unknown): WorkflowLeaseOwnerV1 {
|
|
685
|
+
const object = strictRecord(value, OWNER_KEYS, "workflow lease owner");
|
|
686
|
+
if (numeric(object, "schema") !== 1) throw new Error("workflow lease owner schema must be 1");
|
|
687
|
+
const kind = enumString(object, "kind", ["supervisor", "worker-session"] as const);
|
|
688
|
+
const owner: WorkflowLeaseOwnerV1 = {
|
|
689
|
+
schema: 1,
|
|
690
|
+
kind,
|
|
691
|
+
stage: enumString(object, "stage", ["active", "reserved"] as const),
|
|
692
|
+
generation: nonEmptyString(object, "generation"),
|
|
693
|
+
workspaceId: nonEmptyString(object, "workspaceId"),
|
|
694
|
+
resourceId: nonEmptyString(object, "resourceId"),
|
|
695
|
+
runId: nullableString(object, "runId"),
|
|
696
|
+
workerId: nullableString(object, "workerId"),
|
|
697
|
+
attemptId: nullableString(object, "attemptId"),
|
|
698
|
+
workerAttemptGeneration: nullableString(object, "workerAttemptGeneration"),
|
|
699
|
+
supervisorGeneration: nullableString(object, "supervisorGeneration"),
|
|
700
|
+
sessionPath: nullableAbsolutePath(object, "sessionPath"),
|
|
701
|
+
pid: positiveInteger(object, "pid"),
|
|
702
|
+
processStartIdentity: nonEmptyString(object, "processStartIdentity"),
|
|
703
|
+
bootIdentity: nullableString(object, "bootIdentity"),
|
|
704
|
+
executable: absolutePath(object, "executable"),
|
|
705
|
+
hostname: nonEmptyString(object, "hostname"),
|
|
706
|
+
createdAt: isoTimestamp(object, "createdAt"),
|
|
707
|
+
};
|
|
708
|
+
const workerFields = [
|
|
709
|
+
owner.runId,
|
|
710
|
+
owner.workerId,
|
|
711
|
+
owner.attemptId,
|
|
712
|
+
owner.workerAttemptGeneration,
|
|
713
|
+
owner.supervisorGeneration,
|
|
714
|
+
owner.sessionPath,
|
|
715
|
+
];
|
|
716
|
+
if (kind === "supervisor" && workerFields.some((field) => field !== null)) {
|
|
717
|
+
throw new Error("Supervisor lease cannot contain worker-session fields");
|
|
718
|
+
}
|
|
719
|
+
if (kind === "supervisor" && owner.stage !== "active") {
|
|
720
|
+
throw new Error("Supervisor lease must be active");
|
|
721
|
+
}
|
|
722
|
+
if (kind === "worker-session" && workerFields.some((field) => field === null)) {
|
|
723
|
+
throw new Error("Worker-session lease requires run, worker, attempt, generation, and session fields");
|
|
724
|
+
}
|
|
725
|
+
return owner;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function parseLifecycleGuardV1(value: unknown): LifecycleGuardV1 {
|
|
729
|
+
const object = strictRecord(value, GUARD_KEYS, "workflow lifecycle guard");
|
|
730
|
+
if (numeric(object, "schema") !== 1) throw new Error("workflow guard schema must be 1");
|
|
731
|
+
return {
|
|
732
|
+
schema: 1,
|
|
733
|
+
generation: nonEmptyString(object, "generation"),
|
|
734
|
+
pid: positiveInteger(object, "pid"),
|
|
735
|
+
processStartIdentity: nonEmptyString(object, "processStartIdentity"),
|
|
736
|
+
bootIdentity: nullableString(object, "bootIdentity"),
|
|
737
|
+
executable: absolutePath(object, "executable"),
|
|
738
|
+
hostname: nonEmptyString(object, "hostname"),
|
|
739
|
+
createdAt: isoTimestamp(object, "createdAt"),
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function ownerFromIdentity(input: {
|
|
744
|
+
readonly kind: "supervisor" | "worker-session";
|
|
745
|
+
readonly stage: "active" | "reserved";
|
|
746
|
+
readonly generation: string;
|
|
747
|
+
readonly workspaceId: string;
|
|
748
|
+
readonly resourceId: string;
|
|
749
|
+
readonly runId?: string;
|
|
750
|
+
readonly workerId?: string;
|
|
751
|
+
readonly attemptId?: string;
|
|
752
|
+
readonly workerAttemptGeneration?: string;
|
|
753
|
+
readonly supervisorGeneration?: string;
|
|
754
|
+
readonly sessionPath?: string;
|
|
755
|
+
readonly identity: ProcessIdentity;
|
|
756
|
+
readonly now: Date;
|
|
757
|
+
}): WorkflowLeaseOwnerV1 {
|
|
758
|
+
return {
|
|
759
|
+
schema: 1,
|
|
760
|
+
kind: input.kind,
|
|
761
|
+
stage: input.stage,
|
|
762
|
+
generation: requireNonEmpty(input.generation, "lease generation"),
|
|
763
|
+
workspaceId: input.workspaceId,
|
|
764
|
+
resourceId: input.resourceId,
|
|
765
|
+
runId: input.runId ?? null,
|
|
766
|
+
workerId: input.workerId ?? null,
|
|
767
|
+
attemptId: input.attemptId ?? null,
|
|
768
|
+
workerAttemptGeneration: input.workerAttemptGeneration ?? null,
|
|
769
|
+
supervisorGeneration: input.supervisorGeneration ?? null,
|
|
770
|
+
sessionPath: input.sessionPath ?? null,
|
|
771
|
+
pid: input.identity.pid,
|
|
772
|
+
processStartIdentity: input.identity.processStartIdentity,
|
|
773
|
+
bootIdentity: input.identity.bootIdentity,
|
|
774
|
+
executable: input.identity.executable,
|
|
775
|
+
hostname: input.identity.hostname,
|
|
776
|
+
createdAt: input.now.toISOString(),
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
async function classifyProcessRecord(
|
|
781
|
+
record: Pick<WorkflowLeaseOwnerV1, "hostname" | "pid" | "processStartIdentity" | "bootIdentity" | "executable">,
|
|
782
|
+
current: ProcessIdentity,
|
|
783
|
+
inspector: ProcessIdentityInspector,
|
|
784
|
+
): Promise<OwnerState> {
|
|
785
|
+
if (record.hostname !== current.hostname) return { kind: "remote" };
|
|
786
|
+
const inspection = await inspector.inspect(record.pid);
|
|
787
|
+
if (inspection.kind === "absent") return { kind: "stale", reason: "PID is absent" };
|
|
788
|
+
if (inspection.kind === "unknown") return inspection;
|
|
789
|
+
const observed = inspection.identity;
|
|
790
|
+
if (record.processStartIdentity !== observed.processStartIdentity) {
|
|
791
|
+
return { kind: "stale", reason: "PID start identity changed" };
|
|
792
|
+
}
|
|
793
|
+
if (normalize(record.executable) !== normalize(observed.executable)) {
|
|
794
|
+
return { kind: "stale", reason: "PID executable changed" };
|
|
795
|
+
}
|
|
796
|
+
if (record.bootIdentity !== null
|
|
797
|
+
&& observed.bootIdentity !== null
|
|
798
|
+
&& record.bootIdentity !== observed.bootIdentity) {
|
|
799
|
+
return { kind: "stale", reason: "host boot identity changed" };
|
|
800
|
+
}
|
|
801
|
+
return { kind: "live" };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function ownerIsProcess(owner: WorkflowLeaseOwnerV1, identity: ProcessIdentity): boolean {
|
|
805
|
+
return owner.hostname === identity.hostname
|
|
806
|
+
&& owner.pid === identity.pid
|
|
807
|
+
&& owner.processStartIdentity === identity.processStartIdentity
|
|
808
|
+
&& normalize(owner.executable) === normalize(identity.executable)
|
|
809
|
+
&& (owner.bootIdentity === null
|
|
810
|
+
|| identity.bootIdentity === null
|
|
811
|
+
|| owner.bootIdentity === identity.bootIdentity);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function canonicalSessionPath(path: string): string {
|
|
815
|
+
if (!isAbsolute(path)) throw new TypeError("Worker session path must be absolute");
|
|
816
|
+
return normalize(resolve(path));
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function validateProcessIdentity(identity: ProcessIdentity, name: string): void {
|
|
820
|
+
if (!Number.isSafeInteger(identity.pid) || identity.pid <= 0) throw new Error(`${name} PID is invalid`);
|
|
821
|
+
requireNonEmpty(identity.processStartIdentity, `${name} start identity`);
|
|
822
|
+
if (!isAbsolute(identity.executable)) throw new Error(`${name} executable must be absolute`);
|
|
823
|
+
requireNonEmpty(identity.hostname, `${name} hostname`);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function strictRecord(
|
|
827
|
+
value: unknown,
|
|
828
|
+
allowed: readonly string[],
|
|
829
|
+
name: string,
|
|
830
|
+
): Record<string, unknown> {
|
|
831
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
832
|
+
throw new Error(`${name} must be an object`);
|
|
833
|
+
}
|
|
834
|
+
const object = value as Record<string, unknown>;
|
|
835
|
+
const unknown = Object.keys(object).filter((key) => !allowed.includes(key));
|
|
836
|
+
if (unknown.length > 0) throw new Error(`${name} contains unknown fields: ${unknown.join(", ")}`);
|
|
837
|
+
return object;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function enumString<T extends string>(
|
|
841
|
+
object: Record<string, unknown>,
|
|
842
|
+
key: string,
|
|
843
|
+
values: readonly T[],
|
|
844
|
+
): T {
|
|
845
|
+
const value = nonEmptyString(object, key);
|
|
846
|
+
if (!values.includes(value as T)) throw new Error(`${key} is invalid`);
|
|
847
|
+
return value as T;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function nonEmptyString(object: Record<string, unknown>, key: string): string {
|
|
851
|
+
const value = object[key];
|
|
852
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} must be a non-empty string`);
|
|
853
|
+
return value;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function nullableString(object: Record<string, unknown>, key: string): string | null {
|
|
857
|
+
const value = object[key];
|
|
858
|
+
if (value === null) return null;
|
|
859
|
+
if (typeof value !== "string" || value.length === 0) throw new Error(`${key} must be null or a non-empty string`);
|
|
860
|
+
return value;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function nullableAbsolutePath(object: Record<string, unknown>, key: string): string | null {
|
|
864
|
+
const value = nullableString(object, key);
|
|
865
|
+
if (value === null) return null;
|
|
866
|
+
if (!isAbsolute(value)) throw new Error(`${key} must be absolute`);
|
|
867
|
+
return normalize(value);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function absolutePath(object: Record<string, unknown>, key: string): string {
|
|
871
|
+
const value = nonEmptyString(object, key);
|
|
872
|
+
if (!isAbsolute(value)) throw new Error(`${key} must be absolute`);
|
|
873
|
+
return normalize(value);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function positiveInteger(object: Record<string, unknown>, key: string): number {
|
|
877
|
+
const value = numeric(object, key);
|
|
878
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${key} must be a positive integer`);
|
|
879
|
+
return value;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function numeric(object: Record<string, unknown>, key: string): number {
|
|
883
|
+
const value = object[key];
|
|
884
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${key} must be a number`);
|
|
885
|
+
return value;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function isoTimestamp(object: Record<string, unknown>, key: string): string {
|
|
889
|
+
const value = nonEmptyString(object, key);
|
|
890
|
+
if (!Number.isFinite(Date.parse(value))) throw new Error(`${key} must be an ISO timestamp`);
|
|
891
|
+
return value;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function requireNonEmpty(value: string, name: string): string {
|
|
895
|
+
if (value.length === 0) throw new TypeError(`${name} is required`);
|
|
896
|
+
return value;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function safePathPart(value: string): string {
|
|
900
|
+
return value.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function toError(error: unknown): Error {
|
|
904
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
905
|
+
}
|