@cjhyy/code-shell-server 0.9.47 → 0.9.49
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 +4 -0
- package/dist/desktop-web/http-api.d.ts +9 -0
- package/dist/desktop-web/http-api.js +17 -1
- package/dist/http-byte-range.d.ts +5 -0
- package/dist/http-byte-range.js +19 -0
- package/dist/hub/auth-store.d.ts +2 -0
- package/dist/hub/auth-store.js +26 -7
- package/dist/hub/files.js +71 -9
- package/dist/index.panels.d.ts +1 -0
- package/dist/index.panels.js +1 -0
- package/dist/mobile-remote/access-passcode.d.ts +5 -0
- package/dist/mobile-remote/access-passcode.js +48 -9
- package/dist/mobile-remote/codex-room-agent.js +3 -1
- package/dist/mobile-remote/mobile-chat-turn.d.ts +6 -0
- package/dist/mobile-remote/mobile-chat-turn.js +15 -2
- package/dist/mobile-remote/mobile-client-event-validator.js +30 -1
- package/dist/mobile-remote/mobile-run-dispatch.d.ts +1 -0
- package/dist/mobile-remote/mobile-run-dispatch.js +13 -5
- package/dist/mobile-remote/mobile-static.d.ts +5 -6
- package/dist/mobile-remote/mobile-static.js +13 -14
- package/dist/mobile-remote/remote-host-manager.d.ts +7 -1
- package/dist/mobile-remote/remote-host-manager.js +132 -56
- package/dist/mobile-remote/resident-agent.js +3 -1
- package/dist/mobile-remote/room-manager.d.ts +4 -0
- package/dist/mobile-remote/room-manager.js +5 -0
- package/dist/mobile-remote/room-process-stop.d.ts +8 -0
- package/dist/mobile-remote/room-process-stop.js +18 -0
- package/dist/mobile-remote/tunnel-manager.d.ts +1 -0
- package/dist/mobile-remote/tunnel-manager.js +45 -20
- package/dist/panels/background-service-process.d.ts +39 -0
- package/dist/panels/background-service-process.js +457 -0
- package/dist/panels/background-service-store.d.ts +67 -0
- package/dist/panels/background-service-store.js +539 -0
- package/dist/panels/background-services.d.ts +193 -0
- package/dist/panels/background-services.js +1159 -0
- package/dist/panels/bridge-contract.d.ts +1 -1
- package/dist/panels/execution-gate.d.ts +10 -2
- package/dist/panels/execution-gate.js +36 -2
- package/dist/panels/http.d.ts +1 -0
- package/dist/panels/management-http.d.ts +1 -0
- package/dist/panels/management.d.ts +3 -0
- package/dist/panels/management.js +105 -21
- package/dist/panels/package-mutation.d.ts +1 -1
- package/dist/panels/package-mutation.js +3 -10
- package/dist/panels/process-files.js +28 -3
- package/dist/panels/resources/library.d.ts +1 -4
- package/dist/panels/resources/library.js +2 -18
- package/dist/panels/resources/references.js +21 -8
- package/dist/panels/runtime.js +1 -1
- package/dist/panels/tool-jobs.d.ts +2 -0
- package/dist/panels/tool-jobs.js +1 -1
- package/dist/serve/headless-server.js +113 -79
- package/dist/sessions-service.d.ts +4 -0
- package/dist/sessions-service.js +17 -0
- package/dist/static-files.d.ts +7 -0
- package/dist/static-files.js +114 -0
- package/package.json +6 -6
|
@@ -0,0 +1,1159 @@
|
|
|
1
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { PanelBridgeError } from "./bridge-contract.js";
|
|
4
|
+
import { mkdir } from "node:fs/promises";
|
|
5
|
+
import { isAbsolute } from "node:path";
|
|
6
|
+
import { panelExecutionProject, panelExecutionGate, } from "./execution-gate.js";
|
|
7
|
+
import { BackgroundServiceStore, identityGroupStatus, serviceSystemIdentity, upgradeLocalServiceIdentity, } from "./background-service-store.js";
|
|
8
|
+
import { spawnServiceProcess } from "./background-service-process.js";
|
|
9
|
+
export const panelBackgroundServiceMethods = [
|
|
10
|
+
"services.get",
|
|
11
|
+
"services.setDesired",
|
|
12
|
+
"services.configure",
|
|
13
|
+
"services.attach",
|
|
14
|
+
"services.detach",
|
|
15
|
+
"services.read",
|
|
16
|
+
"services.control",
|
|
17
|
+
];
|
|
18
|
+
export const backgroundServiceLimits = {
|
|
19
|
+
maxRunning: 4,
|
|
20
|
+
maxPerApp: 2,
|
|
21
|
+
maxPerScope: 1,
|
|
22
|
+
healthyResetMs: 60_000,
|
|
23
|
+
maxRetainedScopes: 4096,
|
|
24
|
+
maxChannels: 4,
|
|
25
|
+
maxChannelBytes: 1 << 20,
|
|
26
|
+
maxConfigBytes: 64 << 10,
|
|
27
|
+
minCommitIntervalMs: 1000,
|
|
28
|
+
drainTimeoutMs: 10_000,
|
|
29
|
+
maxLifetimeMs: 21_600_000,
|
|
30
|
+
maxConsecutiveFailures: 5,
|
|
31
|
+
maxServices: 128,
|
|
32
|
+
};
|
|
33
|
+
const clone = (value) => structuredClone(value);
|
|
34
|
+
function state(record) {
|
|
35
|
+
const { desired, desiredRevision, configRevision, phase, generation, revision, startedAt, restarts, lastError, controls, } = record;
|
|
36
|
+
return clone({
|
|
37
|
+
desired,
|
|
38
|
+
desiredRevision,
|
|
39
|
+
configRevision,
|
|
40
|
+
phase,
|
|
41
|
+
generation,
|
|
42
|
+
revision,
|
|
43
|
+
startedAt,
|
|
44
|
+
restarts,
|
|
45
|
+
lastError,
|
|
46
|
+
controls,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
function requestKey(value) {
|
|
50
|
+
if (typeof value !== "string" || !value.length || value.length > 256)
|
|
51
|
+
throw new Error("SERVICE_REQUEST_KEY_REQUIRED");
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function validateRecord(record) {
|
|
55
|
+
const counter = (value) => Number.isSafeInteger(value) && value >= 0;
|
|
56
|
+
const identifier = (value) => typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
|
|
57
|
+
if (!record.scope ||
|
|
58
|
+
!identifier(record.scope.appId) ||
|
|
59
|
+
!identifier(record.scope.name) ||
|
|
60
|
+
!isAbsolute(record.scope.projectPath) ||
|
|
61
|
+
![
|
|
62
|
+
record.commit,
|
|
63
|
+
record.generation,
|
|
64
|
+
record.desiredRevision,
|
|
65
|
+
record.configRevision,
|
|
66
|
+
record.restarts,
|
|
67
|
+
].every(counter) ||
|
|
68
|
+
[record.stopRequestedGeneration, record.failureGeneration].some((value) => value !== undefined && (!counter(value) || value > record.generation)) ||
|
|
69
|
+
!["off", "on-demand", "always"].includes(record.desired) ||
|
|
70
|
+
![
|
|
71
|
+
"stopped",
|
|
72
|
+
"starting",
|
|
73
|
+
"running",
|
|
74
|
+
"draining",
|
|
75
|
+
"backoff",
|
|
76
|
+
"failed",
|
|
77
|
+
"paused",
|
|
78
|
+
"revoked",
|
|
79
|
+
"stop-unconfirmed",
|
|
80
|
+
"needs-consent",
|
|
81
|
+
].includes(record.phase) ||
|
|
82
|
+
typeof record.incarnation !== "string" ||
|
|
83
|
+
typeof record.revision !== "string" ||
|
|
84
|
+
!record.channels ||
|
|
85
|
+
Object.keys(record.channels).length > 4 ||
|
|
86
|
+
!Array.isArray(record.controls) ||
|
|
87
|
+
record.controls.length > 32 ||
|
|
88
|
+
!record.definition ||
|
|
89
|
+
!record.definition.channels ||
|
|
90
|
+
(record.process !== null &&
|
|
91
|
+
(!record.process ||
|
|
92
|
+
!counter(record.process.pid) ||
|
|
93
|
+
record.process.pid <= 1 ||
|
|
94
|
+
typeof record.process.start !== "string" ||
|
|
95
|
+
typeof record.process.host !== "string")) ||
|
|
96
|
+
Object.values(record.channels).some((channel) => !counter(channel.seq) || !counter(channel.generation) || !counter(channel.committedAt)))
|
|
97
|
+
throw new Error("SERVICE_RECORD_CORRUPT");
|
|
98
|
+
}
|
|
99
|
+
function canonicalConfig(value) {
|
|
100
|
+
if (Array.isArray(value))
|
|
101
|
+
return `[${value.map(canonicalConfig).join(",")}]`;
|
|
102
|
+
if (value && typeof value === "object")
|
|
103
|
+
return `{${Object.keys(value)
|
|
104
|
+
.sort()
|
|
105
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalConfig(value[key])}`)
|
|
106
|
+
.join(",")}}`;
|
|
107
|
+
return JSON.stringify(value);
|
|
108
|
+
}
|
|
109
|
+
/** Host-only coordinator. Package code never receives the authoritative storage path. */
|
|
110
|
+
export class PanelBackgroundService {
|
|
111
|
+
options;
|
|
112
|
+
store;
|
|
113
|
+
records = new Map();
|
|
114
|
+
processes = new Map();
|
|
115
|
+
attachments = new Map();
|
|
116
|
+
pending = new Map();
|
|
117
|
+
failedCommits = new Map();
|
|
118
|
+
emergency;
|
|
119
|
+
storageRetryAt = 0;
|
|
120
|
+
publishTimers = new Map();
|
|
121
|
+
idleSince = new Map();
|
|
122
|
+
heldForUpdate = new Set();
|
|
123
|
+
tail = Promise.resolve();
|
|
124
|
+
timer;
|
|
125
|
+
initialized = false;
|
|
126
|
+
initialization;
|
|
127
|
+
initializationRetryAt = 0;
|
|
128
|
+
initializationFailures = 0;
|
|
129
|
+
initializationError;
|
|
130
|
+
recoveringStorage = false;
|
|
131
|
+
lastAvailability = false;
|
|
132
|
+
availabilityRevision = 0;
|
|
133
|
+
availabilityListeners = new Set();
|
|
134
|
+
stopping = false;
|
|
135
|
+
ticking = false;
|
|
136
|
+
unregister;
|
|
137
|
+
unregisterMutation;
|
|
138
|
+
unregisterDrain;
|
|
139
|
+
now;
|
|
140
|
+
constructor(options) {
|
|
141
|
+
this.options = options;
|
|
142
|
+
this.store = new BackgroundServiceStore(options.rootDir, options.fault, options.identities);
|
|
143
|
+
this.now = options.now ?? Date.now;
|
|
144
|
+
}
|
|
145
|
+
exclusive(work) {
|
|
146
|
+
const result = this.tail.then(work);
|
|
147
|
+
this.tail = result.catch(() => { });
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
isAvailable() {
|
|
151
|
+
return (this.initialized &&
|
|
152
|
+
!this.initialization &&
|
|
153
|
+
!this.recoveringStorage &&
|
|
154
|
+
!this.stopping &&
|
|
155
|
+
this.store.available());
|
|
156
|
+
}
|
|
157
|
+
/** Subscribe to transitions; callers read isAvailable() for the initial snapshot. */
|
|
158
|
+
subscribeAvailability(listener) {
|
|
159
|
+
this.availabilityListeners.add(listener);
|
|
160
|
+
return () => {
|
|
161
|
+
this.availabilityListeners.delete(listener);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
notifyAvailability() {
|
|
165
|
+
const available = this.isAvailable();
|
|
166
|
+
if (available === this.lastAvailability)
|
|
167
|
+
return;
|
|
168
|
+
this.lastAvailability = available;
|
|
169
|
+
const revision = ++this.availabilityRevision;
|
|
170
|
+
for (const listener of [...this.availabilityListeners]) {
|
|
171
|
+
// An observer can synchronously shut down or unsubscribe another observer.
|
|
172
|
+
// A nested transition supersedes this delivery even if its value returns.
|
|
173
|
+
if (revision !== this.availabilityRevision || this.isAvailable() !== available)
|
|
174
|
+
break;
|
|
175
|
+
if (!this.availabilityListeners.has(listener))
|
|
176
|
+
continue;
|
|
177
|
+
try {
|
|
178
|
+
listener(available);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
/* observers cannot abort custody or durable lifecycle work */
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
groupStatus(identity) {
|
|
186
|
+
return (this.options.groupStatus ?? identityGroupStatus)(identity);
|
|
187
|
+
}
|
|
188
|
+
legacyLocalityDiagnostic(identity) {
|
|
189
|
+
const system = this.options.identities
|
|
190
|
+
? this.options.identities.current(process.pid)
|
|
191
|
+
: serviceSystemIdentity();
|
|
192
|
+
if (!identity.machine && identity.boot && system?.boot && identity.boot !== system.boot)
|
|
193
|
+
return "SERVICE_LEGACY_LOCALITY_UNCONFIRMED; legacy process record has no machine proof across boots; ownership must be verified before retry";
|
|
194
|
+
}
|
|
195
|
+
markStopUnconfirmed(key, record) {
|
|
196
|
+
const diagnostic = record.process && this.legacyLocalityDiagnostic(record.process);
|
|
197
|
+
if (record.phase !== "stop-unconfirmed" || (diagnostic && record.lastError !== diagnostic))
|
|
198
|
+
this.emit(this.commit(key, (next) => {
|
|
199
|
+
next.activeGeneration = null;
|
|
200
|
+
next.phase = "stop-unconfirmed";
|
|
201
|
+
if (diagnostic)
|
|
202
|
+
next.lastError = diagnostic;
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
startTimer() {
|
|
206
|
+
if (this.timer || this.stopping)
|
|
207
|
+
return;
|
|
208
|
+
this.timer = setInterval(() => {
|
|
209
|
+
if (this.ticking)
|
|
210
|
+
return;
|
|
211
|
+
this.ticking = true;
|
|
212
|
+
void this.tick()
|
|
213
|
+
.catch(() => { })
|
|
214
|
+
.finally(() => {
|
|
215
|
+
this.ticking = false;
|
|
216
|
+
});
|
|
217
|
+
}, 1000);
|
|
218
|
+
this.timer.unref();
|
|
219
|
+
}
|
|
220
|
+
async initialize() {
|
|
221
|
+
if (this.stopping)
|
|
222
|
+
throw new Error("SERVICE_UNAVAILABLE");
|
|
223
|
+
if (this.initialization)
|
|
224
|
+
return this.initialization;
|
|
225
|
+
if (this.initialized)
|
|
226
|
+
return;
|
|
227
|
+
// One supervisor timer also retries transient lock/inspection failures. It
|
|
228
|
+
// never releases custody belonging to an already initialized coordinator.
|
|
229
|
+
this.startTimer();
|
|
230
|
+
if (this.now() < this.initializationRetryAt)
|
|
231
|
+
throw this.initializationError;
|
|
232
|
+
const attempt = this.exclusive(async () => {
|
|
233
|
+
if (this.stopping)
|
|
234
|
+
throw new Error("SERVICE_UNAVAILABLE");
|
|
235
|
+
try {
|
|
236
|
+
this.store.acquire();
|
|
237
|
+
const records = this.store.all();
|
|
238
|
+
for (const record of records)
|
|
239
|
+
validateRecord(record);
|
|
240
|
+
for (const record of records)
|
|
241
|
+
this.records.set(this.store.key(record.scope), record);
|
|
242
|
+
this.unregister = (this.options.gate ?? panelExecutionGate).register(() => [...this.records.values()]
|
|
243
|
+
.filter((record) => record.process !== null)
|
|
244
|
+
.map((record) => ({
|
|
245
|
+
...record.scope,
|
|
246
|
+
holder: {
|
|
247
|
+
kind: "service",
|
|
248
|
+
label: record.phase === "stop-unconfirmed"
|
|
249
|
+
? `${record.scope.name}: stop-unconfirmed; ${this.legacyLocalityDiagnostic(record.process) ?? `original process group ${record.process.pid} must exit before retry`}`
|
|
250
|
+
: record.scope.name,
|
|
251
|
+
},
|
|
252
|
+
})));
|
|
253
|
+
this.unregisterDrain = (this.options.gate ?? panelExecutionGate).registerServiceDrain((matches) => this.drain(matches));
|
|
254
|
+
this.unregisterMutation = (this.options.gate ?? panelExecutionGate).onMutationSettled(() => this.resumeAfterUpdate());
|
|
255
|
+
this.initialized = true;
|
|
256
|
+
this.initializationFailures = 0;
|
|
257
|
+
this.initializationRetryAt = 0;
|
|
258
|
+
this.initializationError = undefined;
|
|
259
|
+
for (const key of this.records.keys()) {
|
|
260
|
+
try {
|
|
261
|
+
await this.reconcile(key);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
/* one scope cannot release other scopes' custody */
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
if (!this.initialized && !this.processes.size) {
|
|
270
|
+
this.unregister?.();
|
|
271
|
+
this.unregisterMutation?.();
|
|
272
|
+
this.unregisterDrain?.();
|
|
273
|
+
this.store.release();
|
|
274
|
+
this.initializationError = error;
|
|
275
|
+
this.initializationFailures++;
|
|
276
|
+
this.initializationRetryAt =
|
|
277
|
+
this.now() +
|
|
278
|
+
Math.min(60_000, 5000 * 2 ** Math.min(16, this.initializationFailures - 1));
|
|
279
|
+
}
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
this.initialization = attempt;
|
|
284
|
+
try {
|
|
285
|
+
await attempt;
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
if (this.initialization === attempt)
|
|
289
|
+
this.initialization = undefined;
|
|
290
|
+
this.notifyAvailability();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
commit(key, mutate) {
|
|
294
|
+
if (!this.store.available())
|
|
295
|
+
throw new Error("SERVICE_STORAGE_UNAVAILABLE");
|
|
296
|
+
const next = clone(this.records.get(key));
|
|
297
|
+
mutate(next);
|
|
298
|
+
next.commit++;
|
|
299
|
+
validateRecord(next);
|
|
300
|
+
try {
|
|
301
|
+
this.store.commit(key, next);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
if (!this.failedCommits.has(key))
|
|
305
|
+
this.failedCommits.set(key, next);
|
|
306
|
+
this.beginEmergencyStop();
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
this.records.set(key, next);
|
|
310
|
+
return next;
|
|
311
|
+
}
|
|
312
|
+
beginEmergencyStop() {
|
|
313
|
+
this.notifyAvailability();
|
|
314
|
+
this.pending.clear();
|
|
315
|
+
for (const timer of this.publishTimers.values())
|
|
316
|
+
clearTimeout(timer);
|
|
317
|
+
this.publishTimers.clear();
|
|
318
|
+
// No disk write is a prerequisite to stopping an owned executor.
|
|
319
|
+
this.emergency ??= Promise.all([...this.processes].map(async ([key, actual]) => {
|
|
320
|
+
const confirmed = await actual.process.terminate(0).catch(() => false);
|
|
321
|
+
if (confirmed && this.processes.get(key) === actual)
|
|
322
|
+
this.processes.delete(key);
|
|
323
|
+
})).then(() => { });
|
|
324
|
+
}
|
|
325
|
+
async recoverStorage() {
|
|
326
|
+
this.beginEmergencyStop();
|
|
327
|
+
await this.emergency;
|
|
328
|
+
if (this.now() < this.storageRetryAt)
|
|
329
|
+
return;
|
|
330
|
+
this.storageRetryAt = this.now() + 5000;
|
|
331
|
+
// Do not re-enable writes while a previous owned process may still publish.
|
|
332
|
+
if (this.processes.size) {
|
|
333
|
+
this.emergency = undefined;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
this.recoveringStorage = true;
|
|
337
|
+
try {
|
|
338
|
+
const disk = this.store.recover(validateRecord);
|
|
339
|
+
for (const record of disk) {
|
|
340
|
+
validateRecord(record);
|
|
341
|
+
this.records.set(this.store.key(record.scope), record);
|
|
342
|
+
}
|
|
343
|
+
for (const [key, candidate] of this.failedCommits) {
|
|
344
|
+
const durable = this.records.get(key);
|
|
345
|
+
if (!durable || candidate.commit > durable.commit)
|
|
346
|
+
this.records.set(key, candidate);
|
|
347
|
+
}
|
|
348
|
+
for (const [key, record] of this.records) {
|
|
349
|
+
const unconfirmed = record.process && this.groupStatus(record.process) !== "dead";
|
|
350
|
+
const next = this.commit(key, (value) => {
|
|
351
|
+
value.activeGeneration = null;
|
|
352
|
+
if (unconfirmed) {
|
|
353
|
+
value.phase = "stop-unconfirmed";
|
|
354
|
+
value.lastError = "Storage recovered; previous process group exit remains unconfirmed";
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
value.process = null;
|
|
358
|
+
value.worker = null;
|
|
359
|
+
const settled = this.exitedPhase(value);
|
|
360
|
+
if (value.paused ||
|
|
361
|
+
["paused", "failed", "revoked", "needs-consent"].includes(value.phase) ||
|
|
362
|
+
["paused", "failed", "revoked"].includes(settled)) {
|
|
363
|
+
if (value.paused)
|
|
364
|
+
value.phase = "paused";
|
|
365
|
+
else if (["paused", "failed", "revoked"].includes(settled))
|
|
366
|
+
value.phase = settled;
|
|
367
|
+
}
|
|
368
|
+
else if (value.desired === "off")
|
|
369
|
+
value.phase = value.stopPhase ?? "stopped";
|
|
370
|
+
else {
|
|
371
|
+
value.phase = "backoff";
|
|
372
|
+
value.nextStartAt = Math.max(value.nextStartAt, this.now() + 5000);
|
|
373
|
+
value.lastError =
|
|
374
|
+
"Recovered after storage failure; previous executors confirmed exited";
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
this.emit(next);
|
|
378
|
+
}
|
|
379
|
+
this.failedCommits.clear();
|
|
380
|
+
this.emergency = undefined;
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
this.store.quarantine();
|
|
384
|
+
this.beginEmergencyStop();
|
|
385
|
+
}
|
|
386
|
+
finally {
|
|
387
|
+
this.recoveringStorage = false;
|
|
388
|
+
this.notifyAvailability();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
emit(record, channel) {
|
|
392
|
+
try {
|
|
393
|
+
this.options.onEvent?.(record.scope, {
|
|
394
|
+
name: record.scope.name,
|
|
395
|
+
state: state(record),
|
|
396
|
+
generation: record.generation,
|
|
397
|
+
...(channel ? { channel, seq: record.channels[channel].seq } : {}),
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
/* observers cannot abort committed lifecycle work */
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async ensure(scope) {
|
|
405
|
+
if (!this.initialized || this.stopping || !this.store.available())
|
|
406
|
+
throw new Error("SERVICE_UNAVAILABLE");
|
|
407
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(scope.name))
|
|
408
|
+
throw new Error("SERVICE_INVALID_NAME");
|
|
409
|
+
scope = { ...scope, projectPath: panelExecutionProject(scope.projectPath) };
|
|
410
|
+
const description = await this.options.describe(scope);
|
|
411
|
+
const key = this.store.key(scope);
|
|
412
|
+
if (!this.records.has(key)) {
|
|
413
|
+
if (this.records.size >= backgroundServiceLimits.maxRetainedScopes)
|
|
414
|
+
throw new Error("SERVICE_RECORD_CAPACITY");
|
|
415
|
+
this.compactInactive();
|
|
416
|
+
const record = this.emptyRecord(scope, description);
|
|
417
|
+
record.commit = 1;
|
|
418
|
+
try {
|
|
419
|
+
this.store.commit(key, record);
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
if (!this.failedCommits.has(key))
|
|
423
|
+
this.failedCommits.set(key, record);
|
|
424
|
+
this.beginEmergencyStop();
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
this.records.set(key, record);
|
|
428
|
+
}
|
|
429
|
+
return key;
|
|
430
|
+
}
|
|
431
|
+
emptyRecord(scope, description) {
|
|
432
|
+
return {
|
|
433
|
+
format: "panel.background-service/1",
|
|
434
|
+
scope,
|
|
435
|
+
incarnation: randomUUID(),
|
|
436
|
+
commit: 0,
|
|
437
|
+
desired: "off",
|
|
438
|
+
desiredRevision: 0,
|
|
439
|
+
configRevision: 0,
|
|
440
|
+
phase: "stopped",
|
|
441
|
+
generation: 0,
|
|
442
|
+
activeGeneration: null,
|
|
443
|
+
revision: description.revision,
|
|
444
|
+
startedAt: null,
|
|
445
|
+
restarts: 0,
|
|
446
|
+
lastError: null,
|
|
447
|
+
controls: [],
|
|
448
|
+
process: null,
|
|
449
|
+
config: {},
|
|
450
|
+
channels: {},
|
|
451
|
+
definition: description.definition,
|
|
452
|
+
nextStartAt: 0,
|
|
453
|
+
paused: false,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
compactInactive() {
|
|
457
|
+
const full = [...this.records].filter(([, record]) => !record.compacted);
|
|
458
|
+
if (full.length < backgroundServiceLimits.maxServices)
|
|
459
|
+
return;
|
|
460
|
+
const inactive = full
|
|
461
|
+
.filter(([key, record]) => record.desired === "off" &&
|
|
462
|
+
!record.process &&
|
|
463
|
+
![...this.attachments.values()].some((value) => value.key === key))
|
|
464
|
+
.sort((a, b) => a[1].commit - b[1].commit);
|
|
465
|
+
if (!inactive.length)
|
|
466
|
+
throw new Error("SERVICE_RECORD_CAPACITY");
|
|
467
|
+
const [key] = inactive[0];
|
|
468
|
+
const compacted = this.commit(key, (record) => {
|
|
469
|
+
// Preserve incarnation, every monotonic counter, configuration and control receipts.
|
|
470
|
+
// Only obsolete snapshot payloads are compacted; null is a new publication, not ABA.
|
|
471
|
+
record.compacted = true;
|
|
472
|
+
for (const value of Object.values(record.channels)) {
|
|
473
|
+
value.value = null;
|
|
474
|
+
value.seq++;
|
|
475
|
+
value.committedAt = this.now();
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
for (const channel of Object.keys(compacted.channels))
|
|
479
|
+
this.emit(compacted, channel);
|
|
480
|
+
}
|
|
481
|
+
async inspect(scope) {
|
|
482
|
+
if (!this.initialized || this.stopping)
|
|
483
|
+
throw new Error("SERVICE_UNAVAILABLE");
|
|
484
|
+
if (!/^[a-z][a-z0-9-]{0,63}$/.test(scope.name))
|
|
485
|
+
throw new Error("SERVICE_INVALID_NAME");
|
|
486
|
+
scope = { ...scope, projectPath: panelExecutionProject(scope.projectPath) };
|
|
487
|
+
const existing = this.records.get(this.store.key(scope));
|
|
488
|
+
let description;
|
|
489
|
+
try {
|
|
490
|
+
description = await this.options.describe(scope);
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
if (!existing ||
|
|
494
|
+
(error instanceof PanelBridgeError && ["REVOKED", "PERMISSION_DENIED"].includes(error.code)))
|
|
495
|
+
throw error;
|
|
496
|
+
return { ...existing, lastError: String(error).slice(0, 2048) };
|
|
497
|
+
}
|
|
498
|
+
const record = existing ?? this.emptyRecord(scope, description);
|
|
499
|
+
if (!this.store.available())
|
|
500
|
+
return {
|
|
501
|
+
...record,
|
|
502
|
+
phase: "stop-unconfirmed",
|
|
503
|
+
lastError: "SERVICE_STORAGE_UNAVAILABLE; owned executors are being stopped",
|
|
504
|
+
};
|
|
505
|
+
return record;
|
|
506
|
+
}
|
|
507
|
+
receipt(record, key, action) {
|
|
508
|
+
const previous = record.controls.find((control) => control.requestKey === key);
|
|
509
|
+
if (previous && previous.action !== action)
|
|
510
|
+
throw new PanelBridgeError("SERVICE_REQUEST_KEY_CONFLICT", "SERVICE_REQUEST_KEY_CONFLICT");
|
|
511
|
+
return !!previous;
|
|
512
|
+
}
|
|
513
|
+
remember(record, key, action, result = "committed") {
|
|
514
|
+
record.controls = [
|
|
515
|
+
...record.controls,
|
|
516
|
+
{ requestKey: key, action, result, at: this.now() },
|
|
517
|
+
].slice(-32);
|
|
518
|
+
}
|
|
519
|
+
get(scope) {
|
|
520
|
+
return this.exclusive(async () => state(await this.inspect(scope)));
|
|
521
|
+
}
|
|
522
|
+
read(scope, channel, afterSeq) {
|
|
523
|
+
return this.exclusive(async () => {
|
|
524
|
+
const record = await this.inspect(scope);
|
|
525
|
+
if (!Object.hasOwn(record.definition.channels, channel))
|
|
526
|
+
throw new Error("SERVICE_UNKNOWN_CHANNEL");
|
|
527
|
+
const snapshot = record.channels[channel];
|
|
528
|
+
if (!snapshot)
|
|
529
|
+
return { generation: record.generation, seq: 0, committedAt: null, value: null };
|
|
530
|
+
return afterSeq === snapshot.seq ? { unchanged: true } : clone(snapshot);
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
setDesired(scope, input) {
|
|
534
|
+
return this.exclusive(async () => {
|
|
535
|
+
if (!["off", "on-demand", "always"].includes(input.desired))
|
|
536
|
+
throw new Error("SERVICE_INVALID_DESIRED");
|
|
537
|
+
const key = await this.ensure(scope), receipt = requestKey(input.requestKey), current = this.records.get(key);
|
|
538
|
+
if (this.receipt(current, receipt, `desired:${input.desired}`))
|
|
539
|
+
return state(current);
|
|
540
|
+
if (input.expectedRevision !== current.desiredRevision)
|
|
541
|
+
throw new PanelBridgeError("SERVICE_REVISION_CONFLICT", "SERVICE_REVISION_CONFLICT");
|
|
542
|
+
const wantsStart = input.desired === "always" ||
|
|
543
|
+
(input.desired === "on-demand" &&
|
|
544
|
+
[...this.attachments.values()].some((attachment) => attachment.key === key));
|
|
545
|
+
if (wantsStart && !current.process)
|
|
546
|
+
this.assertCapacity(current);
|
|
547
|
+
this.commit(key, (record) => {
|
|
548
|
+
record.desired = input.desired;
|
|
549
|
+
record.desiredRevision++;
|
|
550
|
+
record.paused = false;
|
|
551
|
+
if (input.desired !== "off") {
|
|
552
|
+
record.restarts = 0;
|
|
553
|
+
record.nextStartAt = 0;
|
|
554
|
+
if (!record.process)
|
|
555
|
+
record.phase = "stopped";
|
|
556
|
+
}
|
|
557
|
+
if (input.desired === "off") {
|
|
558
|
+
record.stopRequestedGeneration = record.generation;
|
|
559
|
+
record.stopPhase = "stopped";
|
|
560
|
+
record.activeGeneration = null;
|
|
561
|
+
record.phase = record.process ? "draining" : "stopped";
|
|
562
|
+
}
|
|
563
|
+
this.remember(record, receipt, `desired:${input.desired}`);
|
|
564
|
+
});
|
|
565
|
+
if (input.desired === "off")
|
|
566
|
+
await this.stop(key, "stopped");
|
|
567
|
+
else
|
|
568
|
+
await this.reconcile(key);
|
|
569
|
+
const result = this.records.get(key);
|
|
570
|
+
this.emit(result);
|
|
571
|
+
return state(result);
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
configure(scope, input) {
|
|
575
|
+
return this.exclusive(async () => {
|
|
576
|
+
if (!input.config ||
|
|
577
|
+
typeof input.config !== "object" ||
|
|
578
|
+
Array.isArray(input.config) ||
|
|
579
|
+
Buffer.byteLength(JSON.stringify(input.config)) > backgroundServiceLimits.maxConfigBytes)
|
|
580
|
+
throw new Error("SERVICE_INVALID_CONFIG");
|
|
581
|
+
const key = await this.ensure(scope), receipt = requestKey(input.requestKey), current = this.records.get(key);
|
|
582
|
+
const action = `configure:${createHash("sha256").update(canonicalConfig(input.config)).digest("hex")}`;
|
|
583
|
+
if (this.receipt(current, receipt, action))
|
|
584
|
+
return {
|
|
585
|
+
configRevision: Number(current.controls.find((control) => control.requestKey === receipt).result),
|
|
586
|
+
};
|
|
587
|
+
if (isDeepStrictEqual(current.config, input.config)) {
|
|
588
|
+
this.commit(key, (record) => this.remember(record, receipt, action, String(record.configRevision)));
|
|
589
|
+
return { configRevision: current.configRevision };
|
|
590
|
+
}
|
|
591
|
+
if (input.expectedConfigRevision !== current.configRevision)
|
|
592
|
+
throw new PanelBridgeError("SERVICE_CONFIG_CONFLICT", "SERVICE_CONFIG_CONFLICT");
|
|
593
|
+
const next = this.commit(key, (record) => {
|
|
594
|
+
record.config = clone(input.config);
|
|
595
|
+
record.configRevision++;
|
|
596
|
+
this.remember(record, receipt, action, String(record.configRevision));
|
|
597
|
+
});
|
|
598
|
+
this.processes
|
|
599
|
+
.get(key)
|
|
600
|
+
?.process.send({ t: "config", config: next.config, configRevision: next.configRevision });
|
|
601
|
+
return { configRevision: next.configRevision };
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
attach(scope, guestId) {
|
|
605
|
+
return this.exclusive(async () => {
|
|
606
|
+
const key = await this.ensure(scope);
|
|
607
|
+
const existing = [...this.attachments].find(([, value]) => value.key === key && value.guestId === guestId);
|
|
608
|
+
const attachId = existing?.[0] ?? randomUUID();
|
|
609
|
+
if (!existing) {
|
|
610
|
+
if (this.attachments.size >= 512)
|
|
611
|
+
throw new Error("SERVICE_ATTACH_CAPACITY");
|
|
612
|
+
this.attachments.set(attachId, { key, guestId });
|
|
613
|
+
}
|
|
614
|
+
this.idleSince.delete(key);
|
|
615
|
+
await this.reconcile(key);
|
|
616
|
+
return { attachId, state: state(this.records.get(key)) };
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
detach(attachId, guestId) {
|
|
620
|
+
return this.exclusive(() => {
|
|
621
|
+
if (this.attachments.get(attachId)?.guestId === guestId)
|
|
622
|
+
this.attachments.delete(attachId);
|
|
623
|
+
return { detached: true };
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
detachGuest(guestId) {
|
|
627
|
+
for (const [id, attachment] of this.attachments)
|
|
628
|
+
if (attachment.guestId === guestId)
|
|
629
|
+
this.attachments.delete(id);
|
|
630
|
+
}
|
|
631
|
+
control(scope, input) {
|
|
632
|
+
return this.exclusive(async () => {
|
|
633
|
+
if (!["poke", "pause", "resume", "stop", "restart"].includes(input.action))
|
|
634
|
+
throw new Error("SERVICE_INVALID_ACTION");
|
|
635
|
+
const key = await this.ensure(scope), receipt = requestKey(input.requestKey), current = this.records.get(key);
|
|
636
|
+
if (this.receipt(current, receipt, input.action))
|
|
637
|
+
return state(current);
|
|
638
|
+
this.commit(key, (record) => {
|
|
639
|
+
if (input.action === "stop") {
|
|
640
|
+
record.desired = "off";
|
|
641
|
+
record.desiredRevision++;
|
|
642
|
+
}
|
|
643
|
+
if (input.action === "pause")
|
|
644
|
+
record.paused = true;
|
|
645
|
+
if (input.action === "resume" || input.action === "restart") {
|
|
646
|
+
record.paused = false;
|
|
647
|
+
record.restarts = 0;
|
|
648
|
+
record.nextStartAt = 0;
|
|
649
|
+
if (!record.process)
|
|
650
|
+
record.phase = "stopped";
|
|
651
|
+
}
|
|
652
|
+
if (["stop", "pause", "restart"].includes(input.action)) {
|
|
653
|
+
record.stopRequestedGeneration = record.generation;
|
|
654
|
+
record.stopPhase = input.action === "pause" ? "paused" : "stopped";
|
|
655
|
+
record.activeGeneration = null;
|
|
656
|
+
record.phase = record.process ? "draining" : "stopped";
|
|
657
|
+
}
|
|
658
|
+
this.remember(record, receipt, input.action);
|
|
659
|
+
});
|
|
660
|
+
if (["stop", "pause", "restart"].includes(input.action))
|
|
661
|
+
await this.stop(key, input.action === "pause" ? "paused" : "stopped");
|
|
662
|
+
await this.reconcile(key);
|
|
663
|
+
if (input.action === "poke")
|
|
664
|
+
this.processes.get(key)?.process.send({ t: "poke" });
|
|
665
|
+
const result = this.records.get(key);
|
|
666
|
+
this.emit(result);
|
|
667
|
+
return state(result);
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
async stop(key, phase) {
|
|
671
|
+
const current = this.records.get(key);
|
|
672
|
+
this.pending.delete(key);
|
|
673
|
+
const timer = this.publishTimers.get(key);
|
|
674
|
+
if (timer)
|
|
675
|
+
clearTimeout(timer);
|
|
676
|
+
this.publishTimers.delete(key);
|
|
677
|
+
let failure;
|
|
678
|
+
try {
|
|
679
|
+
this.emit(this.commit(key, (record) => {
|
|
680
|
+
record.activeGeneration = null;
|
|
681
|
+
record.stopRequestedGeneration = record.generation;
|
|
682
|
+
record.stopPhase = phase;
|
|
683
|
+
record.phase = record.process ? "draining" : phase;
|
|
684
|
+
}));
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
failure = error;
|
|
688
|
+
}
|
|
689
|
+
const running = this.processes.get(key);
|
|
690
|
+
const confirmed = running
|
|
691
|
+
? await running.process.terminate(failure ? 0 : backgroundServiceLimits.drainTimeoutMs)
|
|
692
|
+
: !current.process || this.groupStatus(current.process) === "dead";
|
|
693
|
+
if (confirmed)
|
|
694
|
+
this.processes.delete(key);
|
|
695
|
+
if (failure)
|
|
696
|
+
throw failure;
|
|
697
|
+
this.emit(this.commit(key, (record) => {
|
|
698
|
+
record.phase = confirmed ? phase : "stop-unconfirmed";
|
|
699
|
+
if (confirmed) {
|
|
700
|
+
record.process = null;
|
|
701
|
+
record.worker = null;
|
|
702
|
+
}
|
|
703
|
+
}));
|
|
704
|
+
}
|
|
705
|
+
assertCapacity(record) {
|
|
706
|
+
const occupied = [...this.records.values()].filter((value) => value.process);
|
|
707
|
+
if (occupied.length >= backgroundServiceLimits.maxRunning ||
|
|
708
|
+
occupied.filter((value) => value.scope.appId === record.scope.appId &&
|
|
709
|
+
value.scope.projectPath === record.scope.projectPath).length >= backgroundServiceLimits.maxPerScope ||
|
|
710
|
+
occupied.filter((value) => value.scope.appId === record.scope.appId).length >=
|
|
711
|
+
backgroundServiceLimits.maxPerApp)
|
|
712
|
+
throw new PanelBridgeError("SERVICE_CAPACITY", "SERVICE_CAPACITY");
|
|
713
|
+
}
|
|
714
|
+
exitedPhase(record) {
|
|
715
|
+
if (record.stopRequestedGeneration === record.generation) {
|
|
716
|
+
// Internal failure cleanup requests custody stop too. Its terminal state
|
|
717
|
+
// still follows the failure budget, unlike explicit stop/pause/revoke.
|
|
718
|
+
if (record.stopPhase === "backoff")
|
|
719
|
+
return record.restarts >= backgroundServiceLimits.maxConsecutiveFailures
|
|
720
|
+
? "failed"
|
|
721
|
+
: "backoff";
|
|
722
|
+
return record.stopPhase ?? (record.paused ? "paused" : "stopped");
|
|
723
|
+
}
|
|
724
|
+
if (record.failureGeneration === record.generation)
|
|
725
|
+
return record.restarts >= backgroundServiceLimits.maxConsecutiveFailures
|
|
726
|
+
? "failed"
|
|
727
|
+
: "backoff";
|
|
728
|
+
return record.paused
|
|
729
|
+
? "paused"
|
|
730
|
+
: ["failed", "revoked", "needs-consent"].includes(record.phase)
|
|
731
|
+
? record.phase
|
|
732
|
+
: "stopped";
|
|
733
|
+
}
|
|
734
|
+
async reconcile(key) {
|
|
735
|
+
if (this.stopping || this.heldForUpdate.has(key))
|
|
736
|
+
return;
|
|
737
|
+
if (!this.store.available()) {
|
|
738
|
+
await this.recoverStorage();
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
let record = this.records.get(key);
|
|
742
|
+
const upgradedProcess = record.process && upgradeLocalServiceIdentity(record.process);
|
|
743
|
+
const upgradedWorker = record.worker && upgradeLocalServiceIdentity(record.worker);
|
|
744
|
+
if (upgradedProcess !== record.process || upgradedWorker !== record.worker) {
|
|
745
|
+
record = this.commit(key, (next) => {
|
|
746
|
+
next.process = upgradedProcess;
|
|
747
|
+
next.worker = upgradedWorker;
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
if (record.process && record.phase === "stop-unconfirmed" && this.processes.has(key)) {
|
|
751
|
+
if (this.groupStatus(record.process) === "dead")
|
|
752
|
+
this.processes.delete(key);
|
|
753
|
+
else {
|
|
754
|
+
this.markStopUnconfirmed(key, record);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (record.process && !this.processes.has(key)) {
|
|
759
|
+
if (this.groupStatus(record.process) !== "dead") {
|
|
760
|
+
this.markStopUnconfirmed(key, record);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
record = this.commit(key, (next) => {
|
|
764
|
+
next.process = null;
|
|
765
|
+
next.worker = null;
|
|
766
|
+
next.activeGeneration = null;
|
|
767
|
+
next.phase = this.exitedPhase(next);
|
|
768
|
+
});
|
|
769
|
+
this.emit(record);
|
|
770
|
+
}
|
|
771
|
+
const attached = [...this.attachments.values()].some((value) => value.key === key);
|
|
772
|
+
if (attached)
|
|
773
|
+
this.idleSince.delete(key);
|
|
774
|
+
else if (!this.idleSince.has(key))
|
|
775
|
+
this.idleSince.set(key, this.now());
|
|
776
|
+
const shouldRun = !record.paused &&
|
|
777
|
+
(record.desired === "always" ||
|
|
778
|
+
(record.desired === "on-demand" &&
|
|
779
|
+
(attached ||
|
|
780
|
+
(this.processes.has(key) &&
|
|
781
|
+
this.now() - this.idleSince.get(key) < record.definition.idleGraceMs))));
|
|
782
|
+
if (!shouldRun) {
|
|
783
|
+
if (record.process)
|
|
784
|
+
await this.stop(key, record.paused ? "paused" : "stopped");
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
if (!record.process &&
|
|
788
|
+
(record.restarts >= backgroundServiceLimits.maxConsecutiveFailures ||
|
|
789
|
+
["failed", "revoked", "needs-consent"].includes(record.phase)))
|
|
790
|
+
return;
|
|
791
|
+
if (!record.process && record.nextStartAt > this.now())
|
|
792
|
+
return;
|
|
793
|
+
let description;
|
|
794
|
+
try {
|
|
795
|
+
description = await this.options.describe(record.scope);
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
const revoked = error instanceof PanelBridgeError && ["REVOKED", "PERMISSION_DENIED"].includes(error.code);
|
|
799
|
+
if (revoked) {
|
|
800
|
+
if (record.phase !== "revoked") {
|
|
801
|
+
this.commit(key, (next) => {
|
|
802
|
+
next.desired = "off";
|
|
803
|
+
next.desiredRevision++;
|
|
804
|
+
next.stopRequestedGeneration = next.generation;
|
|
805
|
+
next.stopPhase = "revoked";
|
|
806
|
+
next.activeGeneration = null;
|
|
807
|
+
});
|
|
808
|
+
await this.stop(key, "revoked");
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
if (record.process)
|
|
813
|
+
await this.stop(key, "backoff");
|
|
814
|
+
this.failAttempt(key, error);
|
|
815
|
+
}
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
if (this.processes.has(key)) {
|
|
819
|
+
if (record.revision !== description.revision ||
|
|
820
|
+
this.now() - record.startedAt >= record.definition.maxLifetimeMs)
|
|
821
|
+
await this.stop(key, "stopped");
|
|
822
|
+
else {
|
|
823
|
+
if (record.phase === "running" &&
|
|
824
|
+
record.restarts &&
|
|
825
|
+
this.now() - record.startedAt >= backgroundServiceLimits.healthyResetMs)
|
|
826
|
+
this.emit(this.commit(key, (next) => {
|
|
827
|
+
next.restarts = 0;
|
|
828
|
+
next.lastError = null;
|
|
829
|
+
}));
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
record = this.records.get(key);
|
|
833
|
+
if (record.process)
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (record.restarts >= backgroundServiceLimits.maxConsecutiveFailures ||
|
|
837
|
+
record.nextStartAt > this.now())
|
|
838
|
+
return;
|
|
839
|
+
try {
|
|
840
|
+
this.assertCapacity(record);
|
|
841
|
+
}
|
|
842
|
+
catch (error) {
|
|
843
|
+
this.emit(this.commit(key, (next) => {
|
|
844
|
+
next.phase = "backoff";
|
|
845
|
+
next.nextStartAt = this.now() + 5000;
|
|
846
|
+
next.lastError = error instanceof Error ? error.message : String(error);
|
|
847
|
+
}));
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
const gate = this.options.gate ?? panelExecutionGate;
|
|
851
|
+
const release = gate.enter({
|
|
852
|
+
...record.scope,
|
|
853
|
+
holder: { kind: "service", label: record.scope.name },
|
|
854
|
+
});
|
|
855
|
+
try {
|
|
856
|
+
const executable = typeof description.executable === "function"
|
|
857
|
+
? await description.executable()
|
|
858
|
+
: description.executable;
|
|
859
|
+
if (this.stopping)
|
|
860
|
+
return;
|
|
861
|
+
await mkdir(description.cwd, { recursive: true, mode: 0o700 });
|
|
862
|
+
if (this.stopping)
|
|
863
|
+
return;
|
|
864
|
+
if (record.compacted)
|
|
865
|
+
this.compactInactive();
|
|
866
|
+
record = this.commit(key, (next) => {
|
|
867
|
+
next.generation++;
|
|
868
|
+
delete next.stopRequestedGeneration;
|
|
869
|
+
delete next.failureGeneration;
|
|
870
|
+
delete next.stopPhase;
|
|
871
|
+
next.activeGeneration = next.generation;
|
|
872
|
+
next.phase = "starting";
|
|
873
|
+
next.revision = description.revision;
|
|
874
|
+
next.definition = description.definition;
|
|
875
|
+
next.startedAt = this.now();
|
|
876
|
+
next.compacted = false;
|
|
877
|
+
next.lastError = null;
|
|
878
|
+
});
|
|
879
|
+
this.emit(record);
|
|
880
|
+
const generation = record.generation;
|
|
881
|
+
const process = await (this.options.spawn ?? spawnServiceProcess)({
|
|
882
|
+
...description,
|
|
883
|
+
executable,
|
|
884
|
+
maxLineBytes: Math.max(...Object.values(description.definition.channels).map((channel) => channel.maxBytes)) + 4096,
|
|
885
|
+
onLine: (message) => this.onLine(key, generation, message),
|
|
886
|
+
onExit: (error) => {
|
|
887
|
+
void this.exclusive(() => this.onExit(key, generation, error)).catch(() => { });
|
|
888
|
+
},
|
|
889
|
+
});
|
|
890
|
+
// Commit the verified custody identity BEFORE the guardian may launch package code.
|
|
891
|
+
try {
|
|
892
|
+
await this.options.afterSpawnBeforeIdentity?.(process.identity);
|
|
893
|
+
record = this.commit(key, (next) => {
|
|
894
|
+
next.process = process.identity;
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
catch (error) {
|
|
898
|
+
await process.terminate(0);
|
|
899
|
+
throw error;
|
|
900
|
+
}
|
|
901
|
+
this.processes.set(key, { process, generation });
|
|
902
|
+
if (this.stopping) {
|
|
903
|
+
await this.stop(key, "stopped");
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
const member = await process.prepare();
|
|
907
|
+
record = this.commit(key, (next) => {
|
|
908
|
+
next.worker = member;
|
|
909
|
+
});
|
|
910
|
+
if (this.stopping) {
|
|
911
|
+
await this.stop(key, "stopped");
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
process.activate({
|
|
915
|
+
t: "hello",
|
|
916
|
+
protocol: 1,
|
|
917
|
+
generation,
|
|
918
|
+
config: record.config,
|
|
919
|
+
configRevision: record.configRevision,
|
|
920
|
+
last: Object.fromEntries(Object.entries(record.channels).map(([name, value]) => [name, value.value])),
|
|
921
|
+
});
|
|
922
|
+
this.emit(record);
|
|
923
|
+
}
|
|
924
|
+
catch (error) {
|
|
925
|
+
if (this.processes.has(key))
|
|
926
|
+
await this.stop(key, "backoff").catch(() => { });
|
|
927
|
+
if (this.store.available())
|
|
928
|
+
this.failAttempt(key, error);
|
|
929
|
+
throw error;
|
|
930
|
+
}
|
|
931
|
+
finally {
|
|
932
|
+
release();
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
failAttempt(key, error, generation) {
|
|
936
|
+
if (generation !== undefined && this.records.get(key).failureGeneration === generation)
|
|
937
|
+
return;
|
|
938
|
+
this.emit(this.commit(key, (next) => {
|
|
939
|
+
next.activeGeneration = null;
|
|
940
|
+
next.restarts++;
|
|
941
|
+
if (generation !== undefined)
|
|
942
|
+
next.failureGeneration = generation;
|
|
943
|
+
next.phase = next.process
|
|
944
|
+
? "stop-unconfirmed"
|
|
945
|
+
: next.restarts >= backgroundServiceLimits.maxConsecutiveFailures
|
|
946
|
+
? "failed"
|
|
947
|
+
: "backoff";
|
|
948
|
+
next.nextStartAt =
|
|
949
|
+
this.now() + Math.min(300_000, 5000 * 2 ** Math.min(16, next.restarts - 1));
|
|
950
|
+
next.lastError = String(error).slice(0, 2048);
|
|
951
|
+
}));
|
|
952
|
+
}
|
|
953
|
+
onLine(key, generation, message) {
|
|
954
|
+
const record = this.records.get(key);
|
|
955
|
+
const actual = this.processes.get(key);
|
|
956
|
+
if (!actual || actual.generation !== generation || record.activeGeneration !== generation)
|
|
957
|
+
return;
|
|
958
|
+
if (message?.t === "ready") {
|
|
959
|
+
void this.exclusive(() => {
|
|
960
|
+
const current = this.records.get(key);
|
|
961
|
+
if (this.processes.get(key) !== actual ||
|
|
962
|
+
current.activeGeneration !== generation ||
|
|
963
|
+
current.phase !== "starting")
|
|
964
|
+
return;
|
|
965
|
+
this.emit(this.commit(key, (next) => {
|
|
966
|
+
next.phase = "running";
|
|
967
|
+
}));
|
|
968
|
+
}).catch(() => { });
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
if (message?.t !== "publish")
|
|
972
|
+
return;
|
|
973
|
+
const channel = typeof message.channel === "string" &&
|
|
974
|
+
Object.hasOwn(record.definition.channels, message.channel)
|
|
975
|
+
? record.definition.channels[message.channel]
|
|
976
|
+
: undefined;
|
|
977
|
+
if (!channel || Buffer.byteLength(JSON.stringify(message.value)) > channel.maxBytes)
|
|
978
|
+
throw new Error("SERVICE_CHANNEL_LIMIT");
|
|
979
|
+
let pending = this.pending.get(key);
|
|
980
|
+
if (!pending) {
|
|
981
|
+
pending = { actual, values: new Map() };
|
|
982
|
+
this.pending.set(key, pending);
|
|
983
|
+
}
|
|
984
|
+
if (pending.actual !== actual) {
|
|
985
|
+
pending = { actual, values: new Map() };
|
|
986
|
+
this.pending.set(key, pending);
|
|
987
|
+
}
|
|
988
|
+
pending.values.set(message.channel, clone(message.value));
|
|
989
|
+
if (this.publishTimers.has(key))
|
|
990
|
+
return;
|
|
991
|
+
const previous = Math.max(0, ...Object.values(record.channels).map((value) => value.committedAt));
|
|
992
|
+
const timer = setTimeout(() => {
|
|
993
|
+
// External preparation may wait; stop must still reach the commit point.
|
|
994
|
+
// Keep the timer marker until completion to bound in-flight work to one.
|
|
995
|
+
void Promise.resolve()
|
|
996
|
+
.then(() => this.options.beforePublish?.())
|
|
997
|
+
.then(() => this.exclusive(() => {
|
|
998
|
+
const current = this.records.get(key);
|
|
999
|
+
if (this.processes.get(key) !== actual ||
|
|
1000
|
+
current.activeGeneration !== generation ||
|
|
1001
|
+
!["running", "starting"].includes(current.phase)) {
|
|
1002
|
+
if (this.pending.get(key)?.actual === actual)
|
|
1003
|
+
this.pending.delete(key);
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
const values = this.pending.get(key)?.actual === actual
|
|
1007
|
+
? this.pending.get(key).values
|
|
1008
|
+
: undefined;
|
|
1009
|
+
this.pending.delete(key);
|
|
1010
|
+
if (!values?.size)
|
|
1011
|
+
return;
|
|
1012
|
+
const next = this.commit(key, (record) => {
|
|
1013
|
+
for (const [name, value] of values)
|
|
1014
|
+
record.channels[name] = {
|
|
1015
|
+
generation,
|
|
1016
|
+
seq: (record.channels[name]?.seq ?? 0) + 1,
|
|
1017
|
+
committedAt: this.now(),
|
|
1018
|
+
value,
|
|
1019
|
+
};
|
|
1020
|
+
});
|
|
1021
|
+
for (const name of values.keys())
|
|
1022
|
+
this.emit(next, name);
|
|
1023
|
+
}))
|
|
1024
|
+
.catch(() => { })
|
|
1025
|
+
.finally(() => {
|
|
1026
|
+
if (this.publishTimers.get(key) !== timer)
|
|
1027
|
+
return;
|
|
1028
|
+
this.publishTimers.delete(key);
|
|
1029
|
+
const next = this.processes.get(key), pending = this.pending.get(key);
|
|
1030
|
+
const value = pending && pending.actual === next
|
|
1031
|
+
? pending.values.entries().next().value
|
|
1032
|
+
: undefined;
|
|
1033
|
+
if (!this.stopping && next && value)
|
|
1034
|
+
this.onLine(key, next.generation, {
|
|
1035
|
+
t: "publish",
|
|
1036
|
+
channel: value[0],
|
|
1037
|
+
value: value[1],
|
|
1038
|
+
});
|
|
1039
|
+
});
|
|
1040
|
+
}, Math.max(0, previous + backgroundServiceLimits.minCommitIntervalMs - this.now()));
|
|
1041
|
+
this.publishTimers.set(key, timer);
|
|
1042
|
+
}
|
|
1043
|
+
async onExit(key, generation, error) {
|
|
1044
|
+
if (this.processes.get(key)?.generation !== generation)
|
|
1045
|
+
return;
|
|
1046
|
+
this.pending.delete(key);
|
|
1047
|
+
if (!this.store.available()) {
|
|
1048
|
+
this.beginEmergencyStop();
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
let record = this.records.get(key);
|
|
1052
|
+
// Revoking publication is not a stop request. Count a lost guardian once,
|
|
1053
|
+
// even when its group remains alive until a later close event or tick.
|
|
1054
|
+
const expected = record.stopRequestedGeneration === generation || this.stopping;
|
|
1055
|
+
if (!expected) {
|
|
1056
|
+
this.failAttempt(key, error ?? "Service exited unexpectedly", generation);
|
|
1057
|
+
record = this.records.get(key);
|
|
1058
|
+
}
|
|
1059
|
+
if (record.process && this.groupStatus(record.process) !== "dead") {
|
|
1060
|
+
this.emit(this.commit(key, (next) => {
|
|
1061
|
+
next.activeGeneration = null;
|
|
1062
|
+
next.phase = "stop-unconfirmed";
|
|
1063
|
+
}));
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
this.processes.delete(key);
|
|
1067
|
+
const next = this.commit(key, (next) => {
|
|
1068
|
+
next.process = null;
|
|
1069
|
+
next.worker = null;
|
|
1070
|
+
next.activeGeneration = null;
|
|
1071
|
+
next.phase = this.exitedPhase(next);
|
|
1072
|
+
});
|
|
1073
|
+
this.emit(next);
|
|
1074
|
+
}
|
|
1075
|
+
tick() {
|
|
1076
|
+
if (this.stopping)
|
|
1077
|
+
return Promise.resolve();
|
|
1078
|
+
if (!this.initialized)
|
|
1079
|
+
return this.initialize();
|
|
1080
|
+
return this.exclusive(async () => {
|
|
1081
|
+
if (!this.store.available()) {
|
|
1082
|
+
await this.recoverStorage();
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
for (const key of this.records.keys()) {
|
|
1086
|
+
try {
|
|
1087
|
+
await this.reconcile(key);
|
|
1088
|
+
}
|
|
1089
|
+
catch {
|
|
1090
|
+
/* other scopes still progress */
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
drain(matches) {
|
|
1096
|
+
return this.exclusive(async () => {
|
|
1097
|
+
const selected = [...this.records].filter(([, record]) => matches(record.scope));
|
|
1098
|
+
for (const [key] of selected)
|
|
1099
|
+
this.heldForUpdate.add(key);
|
|
1100
|
+
await Promise.all(selected
|
|
1101
|
+
.filter(([, record]) => record.process)
|
|
1102
|
+
.map(([key, record]) => this.stop(key, record.paused ? "paused" : (record.stopPhase ?? "stopped"))));
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
/** Called after the catalog mutation releases its admission barrier, success or rollback. */
|
|
1106
|
+
resumeAfterUpdate() {
|
|
1107
|
+
this.heldForUpdate.clear();
|
|
1108
|
+
void this.tick().catch(() => { });
|
|
1109
|
+
}
|
|
1110
|
+
revoke(appId) {
|
|
1111
|
+
return this.exclusive(async () => {
|
|
1112
|
+
for (const [key, record] of this.records)
|
|
1113
|
+
if (record.scope.appId === appId) {
|
|
1114
|
+
this.commit(key, (next) => {
|
|
1115
|
+
next.desired = "off";
|
|
1116
|
+
next.desiredRevision++;
|
|
1117
|
+
next.stopRequestedGeneration = next.generation;
|
|
1118
|
+
next.stopPhase = "revoked";
|
|
1119
|
+
next.activeGeneration = null;
|
|
1120
|
+
next.config = {};
|
|
1121
|
+
next.configRevision++;
|
|
1122
|
+
for (const channel of Object.values(next.channels)) {
|
|
1123
|
+
channel.value = null;
|
|
1124
|
+
channel.seq++;
|
|
1125
|
+
channel.committedAt = this.now();
|
|
1126
|
+
}
|
|
1127
|
+
});
|
|
1128
|
+
await this.stop(key, "revoked");
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
async shutdown() {
|
|
1133
|
+
this.stopping = true;
|
|
1134
|
+
this.notifyAvailability();
|
|
1135
|
+
if (this.timer)
|
|
1136
|
+
clearInterval(this.timer);
|
|
1137
|
+
for (const timer of this.publishTimers.values())
|
|
1138
|
+
clearTimeout(timer);
|
|
1139
|
+
this.publishTimers.clear();
|
|
1140
|
+
await this.exclusive(async () => {
|
|
1141
|
+
await Promise.all([...this.records]
|
|
1142
|
+
.filter(([, record]) => record.process)
|
|
1143
|
+
.map(async ([key, record]) => {
|
|
1144
|
+
try {
|
|
1145
|
+
await this.stop(key, record.stopPhase ?? (record.paused ? "paused" : "stopped"));
|
|
1146
|
+
}
|
|
1147
|
+
catch {
|
|
1148
|
+
await this.processes.get(key)?.process.terminate(0);
|
|
1149
|
+
}
|
|
1150
|
+
}));
|
|
1151
|
+
await this.emergency;
|
|
1152
|
+
});
|
|
1153
|
+
this.unregister?.();
|
|
1154
|
+
this.unregisterMutation?.();
|
|
1155
|
+
this.unregisterDrain?.();
|
|
1156
|
+
this.store.release();
|
|
1157
|
+
this.initialized = false;
|
|
1158
|
+
}
|
|
1159
|
+
}
|