@abelxiaoxing/cadence 2.0.13 → 2.0.15
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 +163 -70
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/docs/remote-access.md +13 -0
- package/docs/reuse-and-distribution.md +13 -2
- package/docs/service-management.md +72 -15
- package/licenses/pi-web-ui-reuse.md +2 -0
- package/package.json +5 -2
- package/src/conversation-context.mjs +147 -0
- package/src/conversation-resources.mjs +47 -0
- package/src/native-build.ts +1 -1
- package/src/native-client.ts +2 -2
- package/src/operator-cli.mjs +4 -7
- package/src/sdk-worker.mjs +41 -8
- package/src/service/contracts/workbench.ts +303 -0
- package/src/service/coordination.ts +276 -44
- package/src/service-cli.mjs +1437 -568
- package/src/service-client.ts +49 -0
- package/src/service-host.mjs +15969 -13027
- package/src/service-http-client.ts +50 -24
- package/src/service-wire.ts +26 -4
- package/src/task-store.ts +12 -11
- package/src/worker-public.mjs +1 -0
- package/web/dist/assets/cadence.css +1 -1
- package/web/dist/assets/cadence.js +13 -7
- package/web/dist/manifest.json +7 -7
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/** Finite, authenticated workbench API. Ordinary chat is not task admission. */
|
|
2
|
+
import {
|
|
3
|
+
bool,
|
|
4
|
+
canonicalJson,
|
|
5
|
+
closed,
|
|
6
|
+
decodeId,
|
|
7
|
+
integer,
|
|
8
|
+
list,
|
|
9
|
+
nullable,
|
|
10
|
+
oneOf,
|
|
11
|
+
text,
|
|
12
|
+
} from "./core.ts";
|
|
13
|
+
import { type ChoiceVersion, decodeChoiceVersion } from "./models.ts";
|
|
14
|
+
|
|
15
|
+
export type WorkbenchRead =
|
|
16
|
+
| { kind: "conversations" }
|
|
17
|
+
| { kind: "task-origin"; taskId: string }
|
|
18
|
+
| { kind: "conversation"; id: string; before: string | null }
|
|
19
|
+
| { kind: "files"; path: string }
|
|
20
|
+
| { kind: "file"; path: string }
|
|
21
|
+
| { kind: "directories"; path: string }
|
|
22
|
+
| { kind: "projects" };
|
|
23
|
+
export type WorkbenchCommand =
|
|
24
|
+
| {
|
|
25
|
+
kind: "link-task";
|
|
26
|
+
commandId: string;
|
|
27
|
+
epoch: string;
|
|
28
|
+
taskId: string;
|
|
29
|
+
conversationId: string;
|
|
30
|
+
}
|
|
31
|
+
| {
|
|
32
|
+
kind: "send";
|
|
33
|
+
commandId: string;
|
|
34
|
+
conversationId: string;
|
|
35
|
+
head: string | null;
|
|
36
|
+
text: string;
|
|
37
|
+
model: ChoiceVersion;
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
kind: "interrupt";
|
|
41
|
+
commandId: string;
|
|
42
|
+
conversationId: string;
|
|
43
|
+
turnId: string;
|
|
44
|
+
epoch: string;
|
|
45
|
+
}
|
|
46
|
+
| { kind: "open-project"; commandId: string; path: string; epoch: string };
|
|
47
|
+
export type ChatStatus =
|
|
48
|
+
| "queued"
|
|
49
|
+
| "running"
|
|
50
|
+
| "completed"
|
|
51
|
+
| "failed"
|
|
52
|
+
| "canceled"
|
|
53
|
+
| "timeout"
|
|
54
|
+
| "unknown";
|
|
55
|
+
export interface ChatItem {
|
|
56
|
+
id: string;
|
|
57
|
+
role: "user" | "assistant" | "tool";
|
|
58
|
+
text: string;
|
|
59
|
+
name: string;
|
|
60
|
+
args: string;
|
|
61
|
+
error: boolean;
|
|
62
|
+
}
|
|
63
|
+
export interface ChatTurn {
|
|
64
|
+
id: string;
|
|
65
|
+
epoch: string;
|
|
66
|
+
status: ChatStatus;
|
|
67
|
+
items: ChatItem[];
|
|
68
|
+
notice: string;
|
|
69
|
+
}
|
|
70
|
+
export interface ChatSummary {
|
|
71
|
+
id: string;
|
|
72
|
+
title: string;
|
|
73
|
+
head: string;
|
|
74
|
+
status: ChatStatus;
|
|
75
|
+
updatedAt: number;
|
|
76
|
+
}
|
|
77
|
+
export interface FileEntry {
|
|
78
|
+
name: string;
|
|
79
|
+
path: string;
|
|
80
|
+
directory: boolean;
|
|
81
|
+
}
|
|
82
|
+
export type WorkbenchResult =
|
|
83
|
+
| { kind: "task-origin"; taskId: string; conversationId: string | null }
|
|
84
|
+
| { kind: "conversations"; entries: ChatSummary[] }
|
|
85
|
+
| {
|
|
86
|
+
kind: "conversation";
|
|
87
|
+
summary: ChatSummary | null;
|
|
88
|
+
turns: ChatTurn[];
|
|
89
|
+
before: string | null;
|
|
90
|
+
}
|
|
91
|
+
| {
|
|
92
|
+
kind: "files" | "directories";
|
|
93
|
+
path: string;
|
|
94
|
+
parent: string | null;
|
|
95
|
+
entries: FileEntry[];
|
|
96
|
+
limited: boolean;
|
|
97
|
+
}
|
|
98
|
+
| { kind: "file"; path: string; text: string; limited: boolean }
|
|
99
|
+
| { kind: "projects"; entries: { path: string; current: boolean }[] }
|
|
100
|
+
| { kind: "accepted"; commandId: string }
|
|
101
|
+
| { kind: "project"; commandId: string; path: string; link: string };
|
|
102
|
+
export interface WorkbenchPort {
|
|
103
|
+
read(request: WorkbenchRead): WorkbenchResult;
|
|
104
|
+
command(
|
|
105
|
+
request: WorkbenchCommand,
|
|
106
|
+
): WorkbenchResult | Promise<WorkbenchResult>;
|
|
107
|
+
}
|
|
108
|
+
const statuses = [
|
|
109
|
+
"queued",
|
|
110
|
+
"running",
|
|
111
|
+
"completed",
|
|
112
|
+
"failed",
|
|
113
|
+
"canceled",
|
|
114
|
+
"timeout",
|
|
115
|
+
"unknown",
|
|
116
|
+
] as const;
|
|
117
|
+
export function decodeWorkbenchRead(v: unknown): WorkbenchRead {
|
|
118
|
+
const kind = Object.getOwnPropertyDescriptor(v ?? {}, "kind")?.value;
|
|
119
|
+
switch (kind) {
|
|
120
|
+
case "conversations":
|
|
121
|
+
case "projects":
|
|
122
|
+
closed(v, ["kind"]);
|
|
123
|
+
return { kind };
|
|
124
|
+
case "task-origin": {
|
|
125
|
+
const o = closed(v, ["kind", "taskId"]);
|
|
126
|
+
return { kind, taskId: decodeId(o.taskId) };
|
|
127
|
+
}
|
|
128
|
+
case "conversation": {
|
|
129
|
+
const o = closed(v, ["kind", "id", "before"]);
|
|
130
|
+
return { kind, id: decodeId(o.id), before: nullable(o.before, decodeId) };
|
|
131
|
+
}
|
|
132
|
+
default: {
|
|
133
|
+
const o = closed(v, ["kind", "path"]);
|
|
134
|
+
return {
|
|
135
|
+
kind: oneOf(o.kind, ["files", "file", "directories"]),
|
|
136
|
+
path: text(o.path, 4096),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
export function decodeWorkbenchCommand(v: unknown): WorkbenchCommand {
|
|
142
|
+
const kind = Object.getOwnPropertyDescriptor(v ?? {}, "kind")?.value;
|
|
143
|
+
if (kind === "link-task") {
|
|
144
|
+
const o = closed(v, [
|
|
145
|
+
"kind",
|
|
146
|
+
"commandId",
|
|
147
|
+
"epoch",
|
|
148
|
+
"taskId",
|
|
149
|
+
"conversationId",
|
|
150
|
+
]);
|
|
151
|
+
return {
|
|
152
|
+
kind,
|
|
153
|
+
commandId: decodeId(o.commandId),
|
|
154
|
+
epoch: decodeId(o.epoch),
|
|
155
|
+
taskId: decodeId(o.taskId),
|
|
156
|
+
conversationId: decodeId(o.conversationId),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (kind === "send") {
|
|
160
|
+
const o = closed(v, [
|
|
161
|
+
"kind",
|
|
162
|
+
"commandId",
|
|
163
|
+
"conversationId",
|
|
164
|
+
"head",
|
|
165
|
+
"text",
|
|
166
|
+
"model",
|
|
167
|
+
]);
|
|
168
|
+
return {
|
|
169
|
+
kind,
|
|
170
|
+
commandId: decodeId(o.commandId),
|
|
171
|
+
conversationId: decodeId(o.conversationId),
|
|
172
|
+
head: nullable(o.head, decodeId),
|
|
173
|
+
text: text(o.text, 16 * 1024, true),
|
|
174
|
+
model: decodeChoiceVersion(o.model),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (kind === "interrupt") {
|
|
178
|
+
const o = closed(v, [
|
|
179
|
+
"kind",
|
|
180
|
+
"commandId",
|
|
181
|
+
"conversationId",
|
|
182
|
+
"turnId",
|
|
183
|
+
"epoch",
|
|
184
|
+
]);
|
|
185
|
+
return {
|
|
186
|
+
kind,
|
|
187
|
+
commandId: decodeId(o.commandId),
|
|
188
|
+
conversationId: decodeId(o.conversationId),
|
|
189
|
+
turnId: decodeId(o.turnId),
|
|
190
|
+
epoch: decodeId(o.epoch),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
const o = closed(v, ["kind", "commandId", "path", "epoch"]);
|
|
194
|
+
return {
|
|
195
|
+
kind: oneOf(o.kind, ["open-project"]),
|
|
196
|
+
commandId: decodeId(o.commandId),
|
|
197
|
+
path: text(o.path, 4096, true),
|
|
198
|
+
epoch: decodeId(o.epoch),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
export function decodeChatTurn(v: unknown): ChatTurn {
|
|
202
|
+
const o = closed(v, ["id", "epoch", "status", "items", "notice"]);
|
|
203
|
+
return {
|
|
204
|
+
id: decodeId(o.id),
|
|
205
|
+
epoch: decodeId(o.epoch),
|
|
206
|
+
status: oneOf(o.status, statuses),
|
|
207
|
+
notice: text(o.notice, 1024),
|
|
208
|
+
items: list(o.items, 256, (v) => {
|
|
209
|
+
const i = closed(v, ["id", "role", "text", "name", "args", "error"]);
|
|
210
|
+
return {
|
|
211
|
+
id: text(i.id, 160, true),
|
|
212
|
+
role: oneOf(i.role, ["user", "assistant", "tool"]),
|
|
213
|
+
text: text(i.text, 256 * 1024),
|
|
214
|
+
name: text(i.name, 128),
|
|
215
|
+
args: text(i.args, 64 * 1024),
|
|
216
|
+
error: bool(i.error),
|
|
217
|
+
};
|
|
218
|
+
}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
export function decodeChatSummary(v: unknown): ChatSummary {
|
|
222
|
+
const o = closed(v, ["id", "title", "head", "status", "updatedAt"]);
|
|
223
|
+
return {
|
|
224
|
+
id: decodeId(o.id),
|
|
225
|
+
title: text(o.title, 512, true),
|
|
226
|
+
head: decodeId(o.head),
|
|
227
|
+
status: oneOf(o.status, statuses),
|
|
228
|
+
updatedAt: integer(o.updatedAt, 0),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
export function decodeWorkbenchResult(v: unknown): WorkbenchResult {
|
|
232
|
+
// A complete finite reply fits the existing page ceiling. No arbitrary file body.
|
|
233
|
+
canonicalJson(v, 512 * 1024);
|
|
234
|
+
const kind = Object.getOwnPropertyDescriptor(v ?? {}, "kind")?.value;
|
|
235
|
+
if (kind === "task-origin") {
|
|
236
|
+
const o = closed(v, ["kind", "taskId", "conversationId"]);
|
|
237
|
+
return {
|
|
238
|
+
kind,
|
|
239
|
+
taskId: decodeId(o.taskId),
|
|
240
|
+
conversationId: nullable(o.conversationId, decodeId),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
if (kind === "conversations") {
|
|
244
|
+
const o = closed(v, ["kind", "entries"]);
|
|
245
|
+
return { kind, entries: list(o.entries, 200, decodeChatSummary) };
|
|
246
|
+
}
|
|
247
|
+
if (kind === "conversation") {
|
|
248
|
+
const o = closed(v, ["kind", "summary", "turns", "before"]);
|
|
249
|
+
return {
|
|
250
|
+
kind,
|
|
251
|
+
summary: nullable(o.summary, decodeChatSummary),
|
|
252
|
+
turns: list(o.turns, 20, decodeChatTurn),
|
|
253
|
+
before: nullable(o.before, decodeId),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (kind === "files" || kind === "directories") {
|
|
257
|
+
const o = closed(v, ["kind", "path", "parent", "entries", "limited"]);
|
|
258
|
+
return {
|
|
259
|
+
kind,
|
|
260
|
+
path: text(o.path, 4096),
|
|
261
|
+
parent: nullable(o.parent, (p) => text(p, 4096)),
|
|
262
|
+
limited: bool(o.limited),
|
|
263
|
+
entries: list(o.entries, 200, (v) => {
|
|
264
|
+
const e = closed(v, ["name", "path", "directory"]);
|
|
265
|
+
return {
|
|
266
|
+
name: text(e.name, 1024, true),
|
|
267
|
+
path: text(e.path, 4096, true),
|
|
268
|
+
directory: bool(e.directory),
|
|
269
|
+
};
|
|
270
|
+
}),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (kind === "file") {
|
|
274
|
+
const o = closed(v, ["kind", "path", "text", "limited"]);
|
|
275
|
+
return {
|
|
276
|
+
kind,
|
|
277
|
+
path: text(o.path, 4096),
|
|
278
|
+
text: text(o.text, 256 * 1024),
|
|
279
|
+
limited: bool(o.limited),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
if (kind === "projects") {
|
|
283
|
+
const o = closed(v, ["kind", "entries"]);
|
|
284
|
+
return {
|
|
285
|
+
kind,
|
|
286
|
+
entries: list(o.entries, 128, (v) => {
|
|
287
|
+
const e = closed(v, ["path", "current"]);
|
|
288
|
+
return { path: text(e.path, 4096, true), current: bool(e.current) };
|
|
289
|
+
}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (kind === "accepted") {
|
|
293
|
+
const o = closed(v, ["kind", "commandId"]);
|
|
294
|
+
return { kind, commandId: decodeId(o.commandId) };
|
|
295
|
+
}
|
|
296
|
+
const o = closed(v, ["kind", "commandId", "path", "link"]);
|
|
297
|
+
return {
|
|
298
|
+
kind: oneOf(o.kind, ["project"]),
|
|
299
|
+
commandId: decodeId(o.commandId),
|
|
300
|
+
path: text(o.path, 4096, true),
|
|
301
|
+
link: text(o.link, 8192, true),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
@@ -150,6 +150,42 @@ function names(path: string, allowed: readonly string[]): void {
|
|
|
150
150
|
dir.closeSync();
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
+
interface RegistryLock {
|
|
154
|
+
schema: "cadence-service-registry-lock/1";
|
|
155
|
+
token: string;
|
|
156
|
+
pid: number;
|
|
157
|
+
host: string;
|
|
158
|
+
}
|
|
159
|
+
function decodeRegistryLock(snapshot: FileSnapshot): RegistryLock {
|
|
160
|
+
const o = closed(json(snapshot), ["schema", "token", "pid", "host"]);
|
|
161
|
+
return {
|
|
162
|
+
schema: literal(o.schema, "cadence-service-registry-lock/1"),
|
|
163
|
+
token: decodeId(o.token),
|
|
164
|
+
// kill(0)/negative process-group probes must never be possible.
|
|
165
|
+
pid: integer(o.pid, 1, 2147483647),
|
|
166
|
+
host: text(o.host, 255, true),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
export interface RegistryLockDiagnostic {
|
|
170
|
+
reason: "registry-locked";
|
|
171
|
+
owner: "live" | "absent" | "ambiguous" | "unreadable";
|
|
172
|
+
}
|
|
173
|
+
export interface RegistryRecoveryInspection {
|
|
174
|
+
scope: "global-registry";
|
|
175
|
+
epoch: string; // exact lock incarnation, not a service epoch
|
|
176
|
+
coordinationDigest: string;
|
|
177
|
+
owner: "live" | "absent" | "ambiguous";
|
|
178
|
+
process: { pid: number; host: string };
|
|
179
|
+
reservations: number;
|
|
180
|
+
previousResolver: "live" | "absent" | "ambiguous" | null;
|
|
181
|
+
resolution: "local-interactive-only";
|
|
182
|
+
}
|
|
183
|
+
const registryNames = [
|
|
184
|
+
"format.json",
|
|
185
|
+
"roots.json",
|
|
186
|
+
"registry.lock",
|
|
187
|
+
"recovery-v1",
|
|
188
|
+
];
|
|
153
189
|
interface RegistryView {
|
|
154
190
|
format: FileSnapshot;
|
|
155
191
|
snapshot: FileSnapshot;
|
|
@@ -240,11 +276,7 @@ export class ServiceCoordination {
|
|
|
240
276
|
}
|
|
241
277
|
private registry(): RegistryView | undefined {
|
|
242
278
|
if (!this.files.directory(this.registryDirectory)) return undefined;
|
|
243
|
-
names(this.registryDirectory,
|
|
244
|
-
"format.json",
|
|
245
|
-
"roots.json",
|
|
246
|
-
"registry.lock",
|
|
247
|
-
]);
|
|
279
|
+
names(this.registryDirectory, registryNames);
|
|
248
280
|
const format = this.files.read(this.registryPath("format.json"));
|
|
249
281
|
const snapshot = this.files.read(
|
|
250
282
|
this.registryPath("roots.json"),
|
|
@@ -283,9 +315,9 @@ export class ServiceCoordination {
|
|
|
283
315
|
},
|
|
284
316
|
};
|
|
285
317
|
}
|
|
286
|
-
assertNamespace(walks?: DirectoryWalks): void {
|
|
287
|
-
if (!this.files.directory(
|
|
288
|
-
names(
|
|
318
|
+
assertNamespace(walks?: DirectoryWalks, directory = this.directory): void {
|
|
319
|
+
if (!this.files.directory(directory, false, walks)) refused();
|
|
320
|
+
names(directory, [
|
|
289
321
|
"format.json",
|
|
290
322
|
"config.json",
|
|
291
323
|
"registration.json",
|
|
@@ -295,19 +327,21 @@ export class ServiceCoordination {
|
|
|
295
327
|
"access.json",
|
|
296
328
|
"host-config.json",
|
|
297
329
|
"history-v2",
|
|
330
|
+
"conversations-v1",
|
|
298
331
|
]);
|
|
299
|
-
this.files.directory(join(
|
|
332
|
+
this.files.directory(join(directory, "history-v2"), false, walks);
|
|
300
333
|
}
|
|
301
|
-
private selected(
|
|
334
|
+
private selected(
|
|
335
|
+
directory = this.directory,
|
|
336
|
+
root = this.store.root,
|
|
337
|
+
stoppedOnly = false,
|
|
338
|
+
): Selected | undefined {
|
|
302
339
|
const walks = new DirectoryWalks();
|
|
303
|
-
if (!this.files.directory(
|
|
304
|
-
this.assertNamespace(walks);
|
|
305
|
-
const
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
walks,
|
|
309
|
-
);
|
|
310
|
-
const config = this.files.read(this.path("config.json"), undefined, walks);
|
|
340
|
+
if (!this.files.directory(directory, false, walks)) return undefined;
|
|
341
|
+
this.assertNamespace(walks, directory);
|
|
342
|
+
const path = (name: string) => join(directory, name);
|
|
343
|
+
const manifest = this.files.read(path("format.json"), undefined, walks);
|
|
344
|
+
const config = this.files.read(path("config.json"), undefined, walks);
|
|
311
345
|
if (!manifest || !config) refused();
|
|
312
346
|
const f = closed(json(manifest), [
|
|
313
347
|
"schema",
|
|
@@ -316,39 +350,45 @@ export class ServiceCoordination {
|
|
|
316
350
|
"configIdentity",
|
|
317
351
|
]);
|
|
318
352
|
literal(f.schema, "cadence-service-format/1");
|
|
319
|
-
literal(f.root,
|
|
353
|
+
literal(f.root, root);
|
|
320
354
|
literal(f.stateHome, this.stateHome);
|
|
321
355
|
literal(f.configIdentity, config.identity);
|
|
322
356
|
const c = decodeOwnerConfig(json(config));
|
|
323
357
|
const hostConfig = this.files.read(
|
|
324
|
-
|
|
358
|
+
path("host-config.json"),
|
|
325
359
|
undefined,
|
|
326
360
|
walks,
|
|
327
361
|
);
|
|
328
362
|
if (hostConfig && hostConfig.identity !== c.policyIdentity) refused();
|
|
329
363
|
const registration = this.files.read(
|
|
330
|
-
|
|
364
|
+
path("registration.json"),
|
|
331
365
|
undefined,
|
|
332
366
|
walks,
|
|
333
367
|
);
|
|
334
|
-
const owner = this.files.read(
|
|
368
|
+
const owner = this.files.read(path("owner.lock"), undefined, walks);
|
|
335
369
|
const capability = this.files.read(
|
|
336
|
-
|
|
370
|
+
path("local-capability.json"),
|
|
337
371
|
undefined,
|
|
338
372
|
walks,
|
|
339
373
|
);
|
|
340
|
-
const checkout = this.files.read(
|
|
374
|
+
const checkout = this.files.read(
|
|
375
|
+
join(dirname(directory), "checkout.lock"),
|
|
376
|
+
8192,
|
|
377
|
+
walks,
|
|
378
|
+
);
|
|
341
379
|
const value = registration
|
|
342
380
|
? decodeRegistration(json(registration))
|
|
343
381
|
: undefined;
|
|
344
382
|
if (
|
|
345
383
|
value &&
|
|
346
|
-
(value.root !==
|
|
384
|
+
(value.root !== root ||
|
|
347
385
|
value.stateHome !== this.stateHome ||
|
|
348
386
|
value.configIdentity !== config.identity ||
|
|
349
387
|
!endpointMatches(c.endpoint, value.endpoint))
|
|
350
388
|
)
|
|
351
389
|
refused();
|
|
390
|
+
if (stoppedOnly && (checkout || (value && value.phase !== "stopped")))
|
|
391
|
+
throw new ContractError("quarantine");
|
|
352
392
|
if (value && value.phase !== "stopped") {
|
|
353
393
|
if (!owner || !checkout || !capability) refused();
|
|
354
394
|
const lock = closed(json(owner), [
|
|
@@ -447,21 +487,24 @@ export class ServiceCoordination {
|
|
|
447
487
|
) => T,
|
|
448
488
|
): T {
|
|
449
489
|
this.files.directory(this.registryDirectory, true);
|
|
450
|
-
names(this.registryDirectory,
|
|
451
|
-
"format.json",
|
|
452
|
-
"roots.json",
|
|
453
|
-
"registry.lock",
|
|
454
|
-
]);
|
|
490
|
+
names(this.registryDirectory, registryNames);
|
|
455
491
|
const lockPath = this.registryPath("registry.lock");
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
492
|
+
let lock: FileSnapshot;
|
|
493
|
+
try {
|
|
494
|
+
lock = this.files.create(
|
|
495
|
+
lockPath,
|
|
496
|
+
encoded({
|
|
497
|
+
schema: "cadence-service-registry-lock/1",
|
|
498
|
+
token: randomUUID(),
|
|
499
|
+
pid: process.pid,
|
|
500
|
+
host: hostname(),
|
|
501
|
+
}),
|
|
502
|
+
);
|
|
503
|
+
} catch (error) {
|
|
504
|
+
if (error instanceof Error && "code" in error && error.code === "EEXIST")
|
|
505
|
+
throw new ContractError("registry-locked");
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
465
508
|
let dirty = false;
|
|
466
509
|
try {
|
|
467
510
|
let format = this.files.read(this.registryPath("format.json"));
|
|
@@ -498,11 +541,7 @@ export class ServiceCoordination {
|
|
|
498
541
|
() => {
|
|
499
542
|
this.files.assert(lockPath, lock);
|
|
500
543
|
this.files.assert(this.registryPath("format.json"), format);
|
|
501
|
-
names(this.registryDirectory,
|
|
502
|
-
"format.json",
|
|
503
|
-
"roots.json",
|
|
504
|
-
"registry.lock",
|
|
505
|
-
]);
|
|
544
|
+
names(this.registryDirectory, registryNames);
|
|
506
545
|
},
|
|
507
546
|
);
|
|
508
547
|
this.files.remove(lockPath, lock);
|
|
@@ -695,6 +734,199 @@ export class ServiceCoordination {
|
|
|
695
734
|
});
|
|
696
735
|
});
|
|
697
736
|
}
|
|
737
|
+
/** Readonly and secret-free; never probes another host or reclaims on age. */
|
|
738
|
+
registryLockDiagnostic(): RegistryLockDiagnostic | undefined {
|
|
739
|
+
try {
|
|
740
|
+
const lock = this.files.read(this.registryPath("registry.lock"));
|
|
741
|
+
if (!lock) return undefined;
|
|
742
|
+
const value = decodeRegistryLock(lock);
|
|
743
|
+
return {
|
|
744
|
+
reason: "registry-locked",
|
|
745
|
+
owner: recoveryOwnerStatus(value.pid, value.host),
|
|
746
|
+
};
|
|
747
|
+
} catch {
|
|
748
|
+
return { reason: "registry-locked", owner: "unreadable" };
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
private registryRecoveryState() {
|
|
752
|
+
const lock = this.files.read(this.registryPath("registry.lock"));
|
|
753
|
+
if (!lock) throw new ContractError("conflict");
|
|
754
|
+
const value = decodeRegistryLock(lock);
|
|
755
|
+
names(this.registryDirectory, registryNames);
|
|
756
|
+
const format = this.files.read(this.registryPath("format.json"));
|
|
757
|
+
const roots = this.files.read(
|
|
758
|
+
this.registryPath("roots.json"),
|
|
759
|
+
REGISTRY_BYTES,
|
|
760
|
+
);
|
|
761
|
+
// A crash immediately after exclusive creation may precede initialization.
|
|
762
|
+
const registry = !format && !roots ? undefined : this.registry();
|
|
763
|
+
const entries = registry?.value.entries ?? [];
|
|
764
|
+
const protectedRoots = new Set(entries.map((row) => hash(row.root)));
|
|
765
|
+
const proofs: unknown[] = [];
|
|
766
|
+
// Older releases removed a reservation BEFORE the checkout fence. A dead
|
|
767
|
+
// short-lock PID cannot make those unreserved, partially released projects
|
|
768
|
+
// safe. Verify every unreserved service selection; never repair it here.
|
|
769
|
+
const directory = opendirSync(this.files.namespace);
|
|
770
|
+
try {
|
|
771
|
+
let count = 0;
|
|
772
|
+
for (
|
|
773
|
+
let entry = directory.readSync();
|
|
774
|
+
entry;
|
|
775
|
+
entry = directory.readSync()
|
|
776
|
+
) {
|
|
777
|
+
if (++count > 1024) throw new ContractError("capacity");
|
|
778
|
+
if (
|
|
779
|
+
!/^[a-f0-9]{64}$/.test(entry.name) ||
|
|
780
|
+
protectedRoots.has(entry.name)
|
|
781
|
+
)
|
|
782
|
+
continue;
|
|
783
|
+
const service = join(this.files.namespace, entry.name, "service-v1");
|
|
784
|
+
if (!this.files.directory(service)) continue;
|
|
785
|
+
const manifest = this.files.read(join(service, "format.json"));
|
|
786
|
+
if (!manifest) refused();
|
|
787
|
+
const f = closed(json(manifest), [
|
|
788
|
+
"schema",
|
|
789
|
+
"root",
|
|
790
|
+
"stateHome",
|
|
791
|
+
"configIdentity",
|
|
792
|
+
]);
|
|
793
|
+
const root = pathValue(f.root);
|
|
794
|
+
if (hash(root) !== entry.name) refused();
|
|
795
|
+
// Retained stopped metadata remains checkable even when the consumer
|
|
796
|
+
// deleted/moved that old working directory. Never resolve/recreate it.
|
|
797
|
+
const selected = this.selected(service, root, true);
|
|
798
|
+
if (!selected) throw new ContractError("quarantine");
|
|
799
|
+
proofs.push([
|
|
800
|
+
entry.name,
|
|
801
|
+
...[
|
|
802
|
+
selected.manifest,
|
|
803
|
+
selected.config,
|
|
804
|
+
selected.hostConfig,
|
|
805
|
+
selected.registration,
|
|
806
|
+
].map((file) => (file ? [file.identity, file.stamp] : null)),
|
|
807
|
+
]);
|
|
808
|
+
}
|
|
809
|
+
} finally {
|
|
810
|
+
directory.closeSync();
|
|
811
|
+
}
|
|
812
|
+
proofs.sort((a, b) =>
|
|
813
|
+
JSON.stringify(a).localeCompare(JSON.stringify(b), "en"),
|
|
814
|
+
);
|
|
815
|
+
const stateDigest = hash(
|
|
816
|
+
encoded([
|
|
817
|
+
lock.identity,
|
|
818
|
+
lock.stamp,
|
|
819
|
+
format ? [format.identity, format.stamp] : null,
|
|
820
|
+
roots ? [roots.identity, roots.stamp] : null,
|
|
821
|
+
proofs,
|
|
822
|
+
]),
|
|
823
|
+
);
|
|
824
|
+
const epoch = hash(encoded([lock.identity, lock.stamp]));
|
|
825
|
+
const archive = this.registryPath(join("recovery-v1", epoch));
|
|
826
|
+
const attempts: FileSnapshot[] = [];
|
|
827
|
+
let previousResolver: RegistryRecoveryInspection["previousResolver"] = null;
|
|
828
|
+
if (this.files.directory(archive)) {
|
|
829
|
+
// Immutable, exclusive attempts serialize resolvers. If one crashes before
|
|
830
|
+
// release, a fresh confirmation may append the next attempt ONLY after its
|
|
831
|
+
// PID is absent. No stale secondary lock needs deletion or takeover.
|
|
832
|
+
names(
|
|
833
|
+
archive,
|
|
834
|
+
Array.from({ length: 128 }, (_, n) => `${n}.json`),
|
|
835
|
+
);
|
|
836
|
+
for (let n = 0; n < 128; n++) {
|
|
837
|
+
const attempt = this.files.read(join(archive, `${n}.json`), 32768);
|
|
838
|
+
if (!attempt) {
|
|
839
|
+
names(
|
|
840
|
+
archive,
|
|
841
|
+
attempts.map((_, i) => `${i}.json`),
|
|
842
|
+
);
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
const o = closed(json(attempt, 32768), [
|
|
846
|
+
"schema",
|
|
847
|
+
"lock",
|
|
848
|
+
"pid",
|
|
849
|
+
"host",
|
|
850
|
+
"digest",
|
|
851
|
+
]);
|
|
852
|
+
literal(o.schema, "cadence-registry-recovery/1");
|
|
853
|
+
literal(o.lock, lock.bytes.toString("utf8"));
|
|
854
|
+
decodeHash(o.digest);
|
|
855
|
+
previousResolver = recoveryOwnerStatus(
|
|
856
|
+
integer(o.pid, 1, 2147483647),
|
|
857
|
+
text(o.host, 255, true),
|
|
858
|
+
);
|
|
859
|
+
attempts.push(attempt);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const inspection: RegistryRecoveryInspection = {
|
|
863
|
+
scope: "global-registry",
|
|
864
|
+
epoch,
|
|
865
|
+
coordinationDigest: hash(
|
|
866
|
+
encoded([stateDigest, attempts.map((a) => [a.identity, a.stamp])]),
|
|
867
|
+
),
|
|
868
|
+
owner: recoveryOwnerStatus(value.pid, value.host),
|
|
869
|
+
process: { pid: value.pid, host: value.host },
|
|
870
|
+
reservations: entries.length,
|
|
871
|
+
previousResolver,
|
|
872
|
+
resolution: "local-interactive-only",
|
|
873
|
+
};
|
|
874
|
+
return { lock, archive, attempts, stateDigest, inspection };
|
|
875
|
+
}
|
|
876
|
+
inspectRegistryRecovery(): RegistryRecoveryInspection {
|
|
877
|
+
return this.registryRecoveryState().inspection;
|
|
878
|
+
}
|
|
879
|
+
/** Local native management only. Retire just the proven dead short lock after
|
|
880
|
+
* durable evidence; reservations, owner/checkout locks and history stay intact. */
|
|
881
|
+
resolveRegistryRecovery(
|
|
882
|
+
expected: RegistryRecoveryInspection,
|
|
883
|
+
assertAuthority: () => void,
|
|
884
|
+
) {
|
|
885
|
+
assertAuthority();
|
|
886
|
+
const state = this.registryRecoveryState();
|
|
887
|
+
if (encoded(state.inspection) !== encoded(expected))
|
|
888
|
+
throw new ContractError("conflict");
|
|
889
|
+
if (
|
|
890
|
+
expected.owner !== "absent" ||
|
|
891
|
+
(expected.previousResolver !== null &&
|
|
892
|
+
expected.previousResolver !== "absent")
|
|
893
|
+
)
|
|
894
|
+
throw new ContractError("quarantine");
|
|
895
|
+
if (state.attempts.length >= 128) throw new ContractError("capacity");
|
|
896
|
+
assertAuthority();
|
|
897
|
+
const receiptPath = join(state.archive, `${state.attempts.length}.json`);
|
|
898
|
+
const receipt = this.files.create(
|
|
899
|
+
receiptPath,
|
|
900
|
+
encoded({
|
|
901
|
+
schema: "cadence-registry-recovery/1",
|
|
902
|
+
lock: state.lock.bytes.toString("utf8"),
|
|
903
|
+
pid: process.pid,
|
|
904
|
+
host: hostname(),
|
|
905
|
+
digest: expected.coordinationDigest,
|
|
906
|
+
}),
|
|
907
|
+
32768,
|
|
908
|
+
);
|
|
909
|
+
// create fsyncs/closes the original receipt before any release effect.
|
|
910
|
+
assertAuthority();
|
|
911
|
+
const current = this.registryRecoveryState();
|
|
912
|
+
if (
|
|
913
|
+
current.stateDigest !== state.stateDigest ||
|
|
914
|
+
current.inspection.owner !== "absent" ||
|
|
915
|
+
current.attempts.length !== state.attempts.length + 1
|
|
916
|
+
)
|
|
917
|
+
throw new ContractError("conflict");
|
|
918
|
+
for (const [n, attempt] of state.attempts.entries())
|
|
919
|
+
this.files.assert(join(state.archive, `${n}.json`), attempt, 32768);
|
|
920
|
+
this.files.assert(receiptPath, receipt, 32768);
|
|
921
|
+
assertAuthority();
|
|
922
|
+
this.files.remove(this.registryPath("registry.lock"), state.lock);
|
|
923
|
+
return {
|
|
924
|
+
status: "resolved",
|
|
925
|
+
scope: "global-registry",
|
|
926
|
+
recoveryId: expected.epoch,
|
|
927
|
+
notice: "registry-only-project-fences-preserved",
|
|
928
|
+
} as const;
|
|
929
|
+
}
|
|
698
930
|
/** Capture exact private resources BEFORE the append/confirmation awaits. The
|
|
699
931
|
* closure is internal local management authority, never a wire capability.
|
|
700
932
|
* A durable bridge must be verified by the caller before EVERY release effect. */
|