wowdump 0.2.1 → 0.3.2
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/LICENSE +1 -1
- package/README.md +17 -111
- package/dist/adapters/reader.js +33 -0
- package/dist/analysis/disassemble.js +77 -0
- package/dist/{frida-runtime.js → analysis/frida-runtime.js} +3 -25
- package/dist/analysis/runtime-script.js +36 -0
- package/dist/cli.js +563 -0
- package/dist/core/profile-engine.js +238 -0
- package/dist/frida-worker.js +99 -0
- package/dist/reader/broker.js +475 -0
- package/dist/reader/client.js +1 -0
- package/dist/reader/launcher.js +219 -0
- package/dist/reader/main.js +100 -0
- package/dist/reader/protocol.js +1 -0
- package/dist/reader/windows.js +242 -0
- package/dist/reader-main.js +2 -0
- package/dist/toolchain.js +123 -0
- package/package.json +19 -37
- package/skills/wowdump/SKILL.md +22 -0
- package/skills/wowdump/references/commands.md +63 -0
- package/skills/wowdump/references/disassemble.md +18 -0
- package/skills/wowdump/references/dynamic.md +54 -0
- package/skills/wowdump/references/evidence-workflow.md +41 -0
- package/skills/wowdump/references/profiles.md +34 -0
- package/skills/wowdump/references/request-schema.md +28 -0
- package/skills/wowdump/references/workflow.md +44 -0
- package/skills/wowdump/scripts/dynamic-session.js +133 -0
- package/dist/agent.js +0 -1335
- package/dist/analysis-path.js +0 -38
- package/dist/analysis-process-log.js +0 -146
- package/dist/broker-client.js +0 -411
- package/dist/broker-codec.js +0 -148
- package/dist/broker-core.js +0 -1045
- package/dist/broker-gateway.js +0 -447
- package/dist/broker-ledger.js +0 -196
- package/dist/broker-main.js +0 -291
- package/dist/broker-protocol.js +0 -119
- package/dist/broker-runtime.js +0 -1283
- package/dist/broker-server.js +0 -466
- package/dist/build-bundle-loader.js +0 -183
- package/dist/build-bundle.js +0 -11
- package/dist/discovery.js +0 -59
- package/dist/dry-run.js +0 -38
- package/dist/error-log.js +0 -71
- package/dist/focus-errors.js +0 -63
- package/dist/focus-service.js +0 -1855
- package/dist/focused-session.js +0 -1357
- package/dist/mcp-main.js +0 -51
- package/dist/mcp.js +0 -924
- package/dist/observability.js +0 -41
- package/dist/process-log-lock.js +0 -195
- package/dist/processes.js +0 -47
- package/dist/runtime-config.js +0 -399
- package/dist/session.js +0 -145
- package/dist/storage.js +0 -12
- package/dist/wow-analysis.js +0 -1430
- package/resources/builds/retail/12.0.7.68974/build-profile.json +0 -290
- package/resources/builds/retail/12.0.7.68974/data-sources.json +0 -1633
- package/resources/builds/retail/12.0.7.68974/lua-targets.jsonl +0 -5130
- package/resources/builds/retail/12.0.7.68974/manifest.json +0 -63
- package/resources/builds/retail/12.0.7.68974/signatures.json +0 -260
- /package/dist/{adapters.js → core/build-adapters.js} +0 -0
- /package/dist/{types.js → core/types.js} +0 -0
package/dist/broker-core.js
DELETED
|
@@ -1,1045 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { BROKER_PROTOCOL_VERSION, BROKER_SCHEMA_VERSION, BrokerProtocolError, canonicalPayloadHash, validateRequest } from "./broker-protocol.js";
|
|
3
|
-
import { FileBrokerLedgerStore } from "./broker-ledger.js";
|
|
4
|
-
export const systemBrokerClock = {
|
|
5
|
-
now: () => performance.now(),
|
|
6
|
-
setTimeout: (callback, delayMs) => {
|
|
7
|
-
const timer = setTimeout(callback, delayMs);
|
|
8
|
-
timer.unref();
|
|
9
|
-
return timer;
|
|
10
|
-
},
|
|
11
|
-
clearTimeout: handle => clearTimeout(handle)
|
|
12
|
-
};
|
|
13
|
-
const TERMINAL = new Set(["completed", "before_failed", "rolled_back", "rollback_unavailable", "rollback_failed", "irreversible_failed", "outcome_unknown"]);
|
|
14
|
-
const CLEANUP_STEPS = ["stop_gate", "after_snapshot", "trace_dispose", "hook_cleanup", "script_unload", "artifact_freeze", "ref_release", "attachment_detach"];
|
|
15
|
-
const RELEVANT_STEPS = {
|
|
16
|
-
session: CLEANUP_STEPS.slice(0, 7), trace: ["stop_gate", "after_snapshot", "trace_dispose", "artifact_freeze", "ref_release"],
|
|
17
|
-
interceptor: ["stop_gate", "hook_cleanup", "ref_release"], hook: ["stop_gate", "hook_cleanup", "ref_release"],
|
|
18
|
-
script: ["stop_gate", "script_unload", "ref_release"], handle: ["ref_release"], attachment: ["ref_release", "attachment_detach"]
|
|
19
|
-
};
|
|
20
|
-
const ALLOWED_TRANSITIONS = {
|
|
21
|
-
validated: new Set(["before_captured", "executing", "completed", "before_failed", "outcome_unknown"]),
|
|
22
|
-
before_captured: new Set(["executing", "before_failed", "outcome_unknown"]),
|
|
23
|
-
executing: new Set(["executed", "after_validated", "completed", "outcome_unknown", "rollback_started", "rollback_unavailable", "rollback_failed", "irreversible_failed"]),
|
|
24
|
-
executed: new Set(["after_validated", "completed", "outcome_unknown", "rollback_started", "rollback_unavailable", "rollback_failed", "irreversible_failed"]),
|
|
25
|
-
after_validated: new Set(["completed", "rollback_started", "outcome_unknown"]),
|
|
26
|
-
completed: new Set(),
|
|
27
|
-
before_failed: new Set(),
|
|
28
|
-
rollback_started: new Set(["rollback_executed", "rollback_unavailable", "rollback_failed", "outcome_unknown"]),
|
|
29
|
-
rollback_executed: new Set(["rollback_validated", "rollback_failed", "outcome_unknown"]),
|
|
30
|
-
rollback_validated: new Set(["rolled_back", "rollback_failed", "outcome_unknown"]),
|
|
31
|
-
rolled_back: new Set(),
|
|
32
|
-
rollback_unavailable: new Set(),
|
|
33
|
-
rollback_failed: new Set(),
|
|
34
|
-
irreversible_failed: new Set(),
|
|
35
|
-
outcome_unknown: new Set()
|
|
36
|
-
};
|
|
37
|
-
export class BrokerCore {
|
|
38
|
-
options;
|
|
39
|
-
instanceId = randomUUID();
|
|
40
|
-
startedAt;
|
|
41
|
-
ledger = new Map();
|
|
42
|
-
resources = new Map();
|
|
43
|
-
clients = new Map();
|
|
44
|
-
lastAcceptedRequestAt;
|
|
45
|
-
lastAcceptedRequestId = null;
|
|
46
|
-
timer;
|
|
47
|
-
accepting = true;
|
|
48
|
-
shutdownPromise;
|
|
49
|
-
clock;
|
|
50
|
-
idleTimeoutMs;
|
|
51
|
-
runningByClient = new Map();
|
|
52
|
-
queuesByClient = new Map();
|
|
53
|
-
activeRequests = new Set();
|
|
54
|
-
drainWaiters = new Set();
|
|
55
|
-
requestCancellers = new Map();
|
|
56
|
-
lifecycleTail = Promise.resolve();
|
|
57
|
-
lifecycleFailure;
|
|
58
|
-
ledgerStore;
|
|
59
|
-
cleanupDeadlines = new Map();
|
|
60
|
-
constructor(options) {
|
|
61
|
-
this.options = options;
|
|
62
|
-
this.clock = options.clock ?? systemBrokerClock;
|
|
63
|
-
this.idleTimeoutMs = options.idleTimeoutMs ?? 1_200_000;
|
|
64
|
-
this.startedAt = this.lastAcceptedRequestAt = this.clock.now();
|
|
65
|
-
options.runtime.bindFatalHandlers?.({
|
|
66
|
-
captureFatal: (sessionId, pid, buildKey, error) => this.handleCaptureFatal(sessionId, pid, buildKey, error),
|
|
67
|
-
durationExpired: (sessionId, pid, buildKey) => this.handleCaptureExpiry(sessionId, pid, buildKey),
|
|
68
|
-
attachmentFatal: (pid, buildKey) => this.handleTargetExit(pid, buildKey),
|
|
69
|
-
brokerFatal: reason => this.shutdown(reason),
|
|
70
|
-
});
|
|
71
|
-
if (options.ledgerPath) {
|
|
72
|
-
this.ledgerStore = new FileBrokerLedgerStore(options.ledgerPath);
|
|
73
|
-
for (const [requestId, entry] of this.ledgerStore.load())
|
|
74
|
-
this.ledger.set(requestId, entry);
|
|
75
|
-
for (const entry of this.ledger.values()) {
|
|
76
|
-
if (!entry.terminal)
|
|
77
|
-
this.transition(entry, "outcome_unknown", { error: { code: "MUTATION_OUTCOME_UNKNOWN", message: "Broker restarted before the durable request outcome was recorded", nextAction: "Inspect evidence and reconcile before retrying." } });
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
this.armIdleTimer();
|
|
81
|
-
}
|
|
82
|
-
connectClient(clientId) {
|
|
83
|
-
const now = this.clock.now();
|
|
84
|
-
if (!this.clients.has(clientId)) {
|
|
85
|
-
if (this.clients.size >= (this.options.maxClients ?? 32))
|
|
86
|
-
throw new BrokerProtocolError("REQUEST_CANCELLED", "Broker client limit reached", "Release an inactive Broker client and retry.");
|
|
87
|
-
this.clients.set(clientId, { connectedAt: now, lastActivityAt: now, inFlight: 0 });
|
|
88
|
-
this.emit({ action: "client_connected", clientId, status: "passed" });
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
async accept(raw) {
|
|
92
|
-
if (!this.accepting)
|
|
93
|
-
return this.failure(requestIdOf(raw), "BROKER_STOPPED", "Broker is shutting down", "Reconnect to a new Broker instance.");
|
|
94
|
-
let request;
|
|
95
|
-
try {
|
|
96
|
-
request = validateRequest(raw);
|
|
97
|
-
}
|
|
98
|
-
catch (error) {
|
|
99
|
-
return this.failure(requestIdOf(raw), error);
|
|
100
|
-
}
|
|
101
|
-
const hash = canonicalPayloadHash(request);
|
|
102
|
-
const prior = this.ledger.get(request.requestId);
|
|
103
|
-
if (prior) {
|
|
104
|
-
if (prior.payloadHash !== hash)
|
|
105
|
-
return this.failure(request.requestId, "REQUEST_ID_REPLAYED", "requestId is already bound to different content", "Use request_status or a fresh requestId.");
|
|
106
|
-
const response = this.responseFor(prior);
|
|
107
|
-
await this.flushLifecycle();
|
|
108
|
-
return response;
|
|
109
|
-
}
|
|
110
|
-
try {
|
|
111
|
-
this.connectClient(request.clientId);
|
|
112
|
-
}
|
|
113
|
-
catch (error) {
|
|
114
|
-
return this.failure(request.requestId, error);
|
|
115
|
-
}
|
|
116
|
-
const client = this.clients.get(request.clientId);
|
|
117
|
-
const now = this.clock.now();
|
|
118
|
-
client.lastActivityAt = now;
|
|
119
|
-
client.inFlight += 1;
|
|
120
|
-
this.lastAcceptedRequestAt = now;
|
|
121
|
-
this.lastAcceptedRequestId = request.requestId;
|
|
122
|
-
this.armIdleTimer();
|
|
123
|
-
const entry = {
|
|
124
|
-
requestId: request.requestId, clientId: request.clientId, operation: request.operation,
|
|
125
|
-
pid: request.pid, buildKey: request.buildKey, payloadHash: hash, acceptedAt: now,
|
|
126
|
-
state: "validated", terminal: false, evidenceRefs: [], transitions: [{ state: "validated", timestamp: now, evidenceRefs: [] }],
|
|
127
|
-
...requestCorrelation(request),
|
|
128
|
-
...mutationMetadata(request)
|
|
129
|
-
};
|
|
130
|
-
try {
|
|
131
|
-
this.persistLedger(entry);
|
|
132
|
-
}
|
|
133
|
-
catch (error) {
|
|
134
|
-
client.inFlight = Math.max(0, client.inFlight - 1);
|
|
135
|
-
return this.failure(request.requestId, error, undefined, undefined, request);
|
|
136
|
-
}
|
|
137
|
-
this.ledger.set(request.requestId, entry);
|
|
138
|
-
this.activeRequests.add(request.requestId);
|
|
139
|
-
const pruning = this.pruneLedger();
|
|
140
|
-
if (pruning)
|
|
141
|
-
await pruning;
|
|
142
|
-
this.emit({ action: "request_accepted", clientId: request.clientId, requestId: request.requestId, operation: request.operation, pid: request.pid, buildKey: request.buildKey, sessionId: entry.sessionId ?? null, resourceId: entry.resourceId ?? null, status: "started" });
|
|
143
|
-
let executionSlot = false;
|
|
144
|
-
try {
|
|
145
|
-
await this.acquireExecutionSlot(request.clientId);
|
|
146
|
-
executionSlot = true;
|
|
147
|
-
if (request.operation === "request_status") {
|
|
148
|
-
const response = this.complete(entry, this.requestStatus(String(request.payload.requestId ?? "")));
|
|
149
|
-
await this.flushLifecycle();
|
|
150
|
-
return response;
|
|
151
|
-
}
|
|
152
|
-
if (request.operation === "client_release") {
|
|
153
|
-
await this.releaseClient(request.clientId, request.requestId);
|
|
154
|
-
const residualResourceIds = [...this.resources.values()]
|
|
155
|
-
.filter(resource => (resource.refs.get(request.clientId) ?? 0) > 0)
|
|
156
|
-
.map(resource => resource.resourceId);
|
|
157
|
-
const remainingResourceRefs = [...this.resources.values()]
|
|
158
|
-
.reduce((sum, resource) => sum + (resource.refs.get(request.clientId) ?? 0), 0);
|
|
159
|
-
const lastClientReleased = this.clients.size === 0;
|
|
160
|
-
const response = this.complete(entry, {
|
|
161
|
-
released: residualResourceIds.length === 0 && remainingResourceRefs === 0,
|
|
162
|
-
clientId: request.clientId,
|
|
163
|
-
remainingResourceRefs,
|
|
164
|
-
residualResourceIds,
|
|
165
|
-
brokerRetained: true,
|
|
166
|
-
shutdownReason: null,
|
|
167
|
-
idleDeadline: this.lastAcceptedRequestAt + this.idleTimeoutMs
|
|
168
|
-
});
|
|
169
|
-
await this.flushLifecycle();
|
|
170
|
-
if (lastClientReleased)
|
|
171
|
-
this.armIdleTimer();
|
|
172
|
-
return response;
|
|
173
|
-
}
|
|
174
|
-
if (request.operation === "broker_stop") {
|
|
175
|
-
const response = this.complete(entry, { stopping: true, reason: String(request.payload.reason ?? "requested") });
|
|
176
|
-
await this.shutdown(String(request.payload.reason ?? "requested"), request.requestId);
|
|
177
|
-
await this.flushLifecycle();
|
|
178
|
-
return response;
|
|
179
|
-
}
|
|
180
|
-
const result = await this.executeRuntime(request, {
|
|
181
|
-
registerResource: input => this.registerResource(input),
|
|
182
|
-
acquireResource: (resourceId, clientId) => this.acquireResource(resourceId, clientId),
|
|
183
|
-
releaseResource: (resourceId, clientId, expectedType, reason) => this.releaseResource(resourceId, clientId, reason, expectedType),
|
|
184
|
-
requireOwnedResource: (resourceId, clientId, expectedType) => this.requireOwnedResource(resourceId, clientId, expectedType),
|
|
185
|
-
transition: (state, data) => this.transition(entry, state, data)
|
|
186
|
-
});
|
|
187
|
-
const response = this.complete(entry, result);
|
|
188
|
-
await this.flushLifecycle();
|
|
189
|
-
return response;
|
|
190
|
-
}
|
|
191
|
-
catch (error) {
|
|
192
|
-
const failure = errorJson(error);
|
|
193
|
-
if (!entry.terminal) {
|
|
194
|
-
const failureState = entry.state === "executing" || entry.state === "executed" ? "outcome_unknown" : "before_failed";
|
|
195
|
-
this.transition(entry, failureState, { error: failure });
|
|
196
|
-
}
|
|
197
|
-
else if (entry.state !== "outcome_unknown" && entry.error === undefined) {
|
|
198
|
-
entry.error = failure;
|
|
199
|
-
this.persistLedger(entry);
|
|
200
|
-
}
|
|
201
|
-
const mutation = Boolean(entry.mutationPlanId);
|
|
202
|
-
this.emit({
|
|
203
|
-
action: mutation ? "mutation_failed" : "request_failed",
|
|
204
|
-
clientId: entry.clientId,
|
|
205
|
-
requestId: entry.requestId,
|
|
206
|
-
operation: entry.operation,
|
|
207
|
-
pid: entry.pid,
|
|
208
|
-
buildKey: entry.buildKey,
|
|
209
|
-
sessionId: entry.sessionId ?? null,
|
|
210
|
-
resourceId: entry.resourceId ?? null,
|
|
211
|
-
status: "failed",
|
|
212
|
-
error: entry.error,
|
|
213
|
-
mutationPlanId: entry.mutationPlanId ?? null,
|
|
214
|
-
confirmationNonce: entry.confirmationNonce ?? null,
|
|
215
|
-
rollbackMode: entry.rollbackMode ?? null,
|
|
216
|
-
evidenceRefs: [...entry.evidenceRefs],
|
|
217
|
-
literalResult: entry.result ?? null,
|
|
218
|
-
exitStatus: null,
|
|
219
|
-
nextAction: "Query request_status before retrying.",
|
|
220
|
-
result: mutation ? mutationLifecycleResult(entry, entry.result ?? null, "Query request_status before retrying.") : focusedLifecycleProjection(entry, entry.result, entry.error)
|
|
221
|
-
});
|
|
222
|
-
await this.flushLifecycle();
|
|
223
|
-
return this.failure(request.requestId, error, undefined, undefined, request);
|
|
224
|
-
}
|
|
225
|
-
finally {
|
|
226
|
-
if (executionSlot)
|
|
227
|
-
this.releaseExecutionSlot(request.clientId);
|
|
228
|
-
client.inFlight = Math.max(0, client.inFlight - 1);
|
|
229
|
-
this.activeRequests.delete(request.requestId);
|
|
230
|
-
for (const wake of this.drainWaiters)
|
|
231
|
-
wake();
|
|
232
|
-
if (this.clock.now() - this.lastAcceptedRequestAt >= this.idleTimeoutMs)
|
|
233
|
-
this.onIdleTimer();
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
requestStatus(requestId) {
|
|
237
|
-
const entry = this.ledger.get(requestId);
|
|
238
|
-
if (!entry)
|
|
239
|
-
throw new BrokerProtocolError("REQUEST_NOT_FOUND", `request ${requestId} was not found`, "Check the requestId and Broker instance.");
|
|
240
|
-
const failed = new Set(["before_failed", "rollback_unavailable", "rollback_failed", "irreversible_failed"]);
|
|
241
|
-
return {
|
|
242
|
-
requestId, state: entry.state,
|
|
243
|
-
status: entry.state === "outcome_unknown" ? "outcome_unknown" : entry.state === "completed" || entry.state === "rolled_back" ? "completed" : failed.has(entry.state) ? "failed" : "pending",
|
|
244
|
-
phase: entry.state, terminal: entry.terminal, result: entry.result ?? null, error: entry.error ?? null,
|
|
245
|
-
evidenceRefs: entry.evidenceRefs, sessionId: entry.sessionId ?? null, resourceId: entry.resourceId ?? null
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
registerResource(input) {
|
|
249
|
-
const resourceId = input.resourceId ?? `${input.type}-${randomUUID()}`;
|
|
250
|
-
if (this.resources.has(resourceId))
|
|
251
|
-
throw new Error(`resource ${resourceId} already exists`);
|
|
252
|
-
const ancestors = [];
|
|
253
|
-
if (input.parentResourceId) {
|
|
254
|
-
let parentId = input.parentResourceId;
|
|
255
|
-
while (parentId) {
|
|
256
|
-
const parent = this.requireOwnedResource(parentId, input.ownerClientId);
|
|
257
|
-
if (parent.pid !== input.pid || parent.buildKey !== input.buildKey) {
|
|
258
|
-
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", `parent ${parentId} does not share target scope`);
|
|
259
|
-
}
|
|
260
|
-
ancestors.push(parent);
|
|
261
|
-
parentId = parent.parentResourceId;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
const resource = { ...input, resourceId, refs: new Map([[input.ownerClientId, 1]]), cleanupState: "active" };
|
|
265
|
-
try {
|
|
266
|
-
for (const ancestor of ancestors)
|
|
267
|
-
ancestor.refs.set(input.ownerClientId, (ancestor.refs.get(input.ownerClientId) ?? 0) + 1);
|
|
268
|
-
this.resources.set(resourceId, resource);
|
|
269
|
-
}
|
|
270
|
-
catch (error) {
|
|
271
|
-
for (const ancestor of ancestors.reverse())
|
|
272
|
-
decrementRef(ancestor, input.ownerClientId);
|
|
273
|
-
throw error;
|
|
274
|
-
}
|
|
275
|
-
return resource;
|
|
276
|
-
}
|
|
277
|
-
acquireResource(resourceId, clientId) {
|
|
278
|
-
const resource = this.requireResource(resourceId);
|
|
279
|
-
if (resource.cleanupState !== "active")
|
|
280
|
-
throw new BrokerProtocolError("RESOURCE_ALREADY_RELEASED", `${resourceId} is not active`);
|
|
281
|
-
const acquiredParents = [];
|
|
282
|
-
let parentId = resource.parentResourceId;
|
|
283
|
-
try {
|
|
284
|
-
while (parentId) {
|
|
285
|
-
const parent = this.requireResource(parentId);
|
|
286
|
-
if (parent.cleanupState !== "active" || parent.pid !== resource.pid || parent.buildKey !== resource.buildKey) {
|
|
287
|
-
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", `${resourceId} parent graph is not active in the same scope`);
|
|
288
|
-
}
|
|
289
|
-
parent.refs.set(clientId, (parent.refs.get(clientId) ?? 0) + 1);
|
|
290
|
-
acquiredParents.push(parent);
|
|
291
|
-
parentId = parent.parentResourceId;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
catch (error) {
|
|
295
|
-
for (const parent of acquiredParents.reverse())
|
|
296
|
-
decrementRef(parent, clientId);
|
|
297
|
-
throw error;
|
|
298
|
-
}
|
|
299
|
-
resource.refs.set(clientId, (resource.refs.get(clientId) ?? 0) + 1);
|
|
300
|
-
return resource;
|
|
301
|
-
}
|
|
302
|
-
requireOwnedResource(resourceId, clientId, expectedType) {
|
|
303
|
-
const resource = this.requireResource(resourceId);
|
|
304
|
-
if (resource.cleanupState !== "active")
|
|
305
|
-
throw new BrokerProtocolError("RESOURCE_ALREADY_RELEASED", `${resourceId} is not active`);
|
|
306
|
-
if (!resource.refs.has(clientId))
|
|
307
|
-
throw new BrokerProtocolError("RESOURCE_NOT_OWNER", `${clientId} does not own ${resourceId}`);
|
|
308
|
-
if (expectedType && resource.type !== expectedType)
|
|
309
|
-
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", `${resourceId} is not a ${expectedType} resource`);
|
|
310
|
-
return resource;
|
|
311
|
-
}
|
|
312
|
-
async releaseResource(resourceId, clientId, reason = "client_release", expectedType, outerDeadline = Number.POSITIVE_INFINITY) {
|
|
313
|
-
const resource = this.requireResource(resourceId);
|
|
314
|
-
if (resource.cleanupState === "released" || resource.cleanupState === "cleaning")
|
|
315
|
-
throw new BrokerProtocolError("RESOURCE_ALREADY_RELEASED", `${resourceId} is not available for release`);
|
|
316
|
-
if (expectedType && resource.type !== expectedType)
|
|
317
|
-
throw new BrokerProtocolError("SESSION_TARGET_MISMATCH", `${resourceId} is not a ${expectedType} resource`);
|
|
318
|
-
if (!resource.refs.has(clientId))
|
|
319
|
-
throw new BrokerProtocolError("RESOURCE_NOT_OWNER", `${clientId} does not own ${resourceId}`);
|
|
320
|
-
const count = resource.refs.get(clientId);
|
|
321
|
-
if (count <= 0)
|
|
322
|
-
throw new BrokerProtocolError("RESOURCE_REF_UNDERFLOW", `${resourceId} refcount would underflow`);
|
|
323
|
-
const failures = [];
|
|
324
|
-
const isLastLease = resource.refs.size === 1 && count === 1;
|
|
325
|
-
let releasedLease = false;
|
|
326
|
-
if (isLastLease) {
|
|
327
|
-
const records = [];
|
|
328
|
-
await this.cleanupResource(resource, reason, records, outerDeadline);
|
|
329
|
-
if (resource.cleanupState === "failed") {
|
|
330
|
-
failures.push(new BrokerProtocolError("CLEANUP_TIMEOUT", `${resourceId} cleanup failed: ${resource.cleanupErrors?.map(item => `${item.step}: ${item.error}`).join("; ") ?? "unknown error"}`, "Inspect residual resource state before retrying cleanup."));
|
|
331
|
-
}
|
|
332
|
-
else
|
|
333
|
-
releasedLease = true;
|
|
334
|
-
}
|
|
335
|
-
else {
|
|
336
|
-
decrementRef(resource, clientId);
|
|
337
|
-
releasedLease = true;
|
|
338
|
-
}
|
|
339
|
-
if (resource.parentResourceId && releasedLease) {
|
|
340
|
-
try {
|
|
341
|
-
await this.releaseResource(resource.parentResourceId, clientId, `${reason}:child_ref`, undefined, outerDeadline);
|
|
342
|
-
}
|
|
343
|
-
catch (error) {
|
|
344
|
-
failures.push(error);
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
if (failures.length === 1)
|
|
348
|
-
throw failures[0];
|
|
349
|
-
if (failures.length > 1)
|
|
350
|
-
throw new BrokerProtocolError("CLEANUP_TIMEOUT", failures.map(error => error instanceof Error ? error.message : String(error)).join("; "), "Inspect residual resource state before retrying cleanup.");
|
|
351
|
-
return resource;
|
|
352
|
-
}
|
|
353
|
-
async releaseClient(clientId, excludeRequestId) {
|
|
354
|
-
const owned = [...this.resources.values()].filter(resource => resource.refs.has(clientId));
|
|
355
|
-
owned.sort((a, b) => resourceDepth(b, this.resources) - resourceDepth(a, this.resources));
|
|
356
|
-
const deadline = this.clock.now() + (this.options.sessionCleanupTimeoutMs ?? 2_000);
|
|
357
|
-
const cleanupRecords = [];
|
|
358
|
-
const failedScopes = await this.prepareCaptureScopes(owned, "lease_release", cleanupRecords, deadline);
|
|
359
|
-
if (failedScopes.size > 0) {
|
|
360
|
-
throw new BrokerProtocolError("CLEANUP_TIMEOUT", `capture cleanup failed for ${[...failedScopes].join(",")}`, "Retry client release after inspecting the frozen residual evidence.");
|
|
361
|
-
}
|
|
362
|
-
let cleanupFailure = false;
|
|
363
|
-
for (const resource of owned) {
|
|
364
|
-
const count = resource.refs.get(clientId) ?? 0;
|
|
365
|
-
for (let index = 0; index < count; index += 1) {
|
|
366
|
-
try {
|
|
367
|
-
await this.releaseResource(resource.resourceId, clientId, "lease_release", undefined, deadline);
|
|
368
|
-
}
|
|
369
|
-
catch (error) {
|
|
370
|
-
cleanupFailure = true;
|
|
371
|
-
cleanupRecords.push({ resourceId: resource.resourceId, step: "ref_release", ok: false, error: errorText(error) });
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
for (const scopeId of new Set(owned.map(resource => resource.cleanupScopeId).filter((value) => Boolean(value)))) {
|
|
376
|
-
if (!await this.completeCaptureScope(scopeId, "lease_release", deadline, cleanupRecords))
|
|
377
|
-
cleanupFailure = true;
|
|
378
|
-
}
|
|
379
|
-
if (!await this.releaseRuntimeClient(clientId, deadline, cleanupRecords))
|
|
380
|
-
cleanupFailure = true;
|
|
381
|
-
if (cleanupFailure) {
|
|
382
|
-
throw new BrokerProtocolError("CLEANUP_TIMEOUT", `client release cleanup failed: ${cleanupRecords.filter(item => !item.ok).map(item => `${item.resourceId}:${item.error}`).join("; ")}`, "Inspect residual resource state and retry client release.");
|
|
383
|
-
}
|
|
384
|
-
this.clients.delete(clientId);
|
|
385
|
-
this.emit({ action: "client_released", clientId, status: "passed" });
|
|
386
|
-
}
|
|
387
|
-
async shutdown(reason, excludeRequestId) {
|
|
388
|
-
if (this.shutdownPromise)
|
|
389
|
-
return this.shutdownPromise;
|
|
390
|
-
this.accepting = false;
|
|
391
|
-
if (this.timer !== undefined)
|
|
392
|
-
this.clock.clearTimeout(this.timer);
|
|
393
|
-
this.shutdownPromise = (async () => {
|
|
394
|
-
const result = { reason, cleanupOrder: [] };
|
|
395
|
-
const brokerDeadline = this.clock.now() + (this.options.brokerCleanupTimeoutMs ?? 10_000);
|
|
396
|
-
this.emit({ action: "shutdown_started", status: "started", result: { reason } });
|
|
397
|
-
const preparedScopes = new Set();
|
|
398
|
-
const failedScopes = await this.prepareCaptureScopes([...this.resources.values()], reason, result.cleanupOrder, brokerDeadline, preparedScopes);
|
|
399
|
-
await this.drainInFlight(brokerDeadline, excludeRequestId, result.cleanupOrder);
|
|
400
|
-
for (const scope of await this.prepareCaptureScopes([...this.resources.values()], reason, result.cleanupOrder, brokerDeadline, preparedScopes))
|
|
401
|
-
failedScopes.add(scope);
|
|
402
|
-
const resources = [...this.resources.values()].sort((a, b) => resourceDepth(b, this.resources) - resourceDepth(a, this.resources));
|
|
403
|
-
for (const resource of resources) {
|
|
404
|
-
if (resource.cleanupState !== "active" && resource.cleanupState !== "failed")
|
|
405
|
-
continue;
|
|
406
|
-
if (resource.cleanupScopeId && failedScopes.has(resource.cleanupScopeId))
|
|
407
|
-
continue;
|
|
408
|
-
const blockers = this.unreleasedChildren(resource.resourceId);
|
|
409
|
-
if (blockers.length > 0) {
|
|
410
|
-
this.markResidualParent(resource, blockers);
|
|
411
|
-
result.cleanupOrder.push({ resourceId: resource.resourceId, step: "ref_release", ok: false, error: `CHILD_RESOURCE_RESIDUAL:${blockers.join(",")}` });
|
|
412
|
-
continue;
|
|
413
|
-
}
|
|
414
|
-
await this.cleanupResource(resource, reason, result.cleanupOrder, brokerDeadline);
|
|
415
|
-
}
|
|
416
|
-
for (const scopeId of preparedScopes) {
|
|
417
|
-
if (!failedScopes.has(scopeId))
|
|
418
|
-
await this.completeCaptureScope(scopeId, reason, brokerDeadline, result.cleanupOrder);
|
|
419
|
-
}
|
|
420
|
-
await this.shutdownStep(result, "server_close", brokerDeadline, () => this.options.closeServer?.());
|
|
421
|
-
await this.shutdownStep(result, "runtime_close", brokerDeadline, () => this.options.runtime.close?.());
|
|
422
|
-
await this.shutdownStep(result, "lock_release", brokerDeadline, () => this.options.releaseSingleton?.());
|
|
423
|
-
await this.shutdownStep(result, "on_shutdown", brokerDeadline, () => this.options.onShutdown?.(result, this.cleanupContext(brokerDeadline, brokerDeadline)));
|
|
424
|
-
await this.shutdownStep(result, "ledger_close", brokerDeadline, () => this.ledgerStore?.close());
|
|
425
|
-
this.emit({ action: "shutdown_completed", status: result.cleanupOrder.some(item => !item.ok) ? "partial" : "passed", result: { reason, cleanupSteps: result.cleanupOrder.length } });
|
|
426
|
-
await this.shutdownStep(result, "lifecycle_flush", brokerDeadline, () => this.flushLifecycle());
|
|
427
|
-
return result;
|
|
428
|
-
})();
|
|
429
|
-
return this.shutdownPromise;
|
|
430
|
-
}
|
|
431
|
-
status() {
|
|
432
|
-
return { instanceId: this.instanceId, protocolVersion: BROKER_PROTOCOL_VERSION, startedAt: this.startedAt, uptimeMs: this.clock.now() - this.startedAt, idleTimeoutMs: this.idleTimeoutMs, idleDeadline: this.lastAcceptedRequestAt + this.idleTimeoutMs, lastAcceptedRequestId: this.lastAcceptedRequestId, clients: [...this.clients.entries()].map(([clientId, value]) => ({ clientId, ...value })), inFlight: [...this.clients.values()].reduce((sum, client) => sum + client.inFlight, 0), resources: [...this.resources.values()].map(resource => ({ resourceId: resource.resourceId, type: resource.type, pid: resource.pid, buildKey: resource.buildKey, refcount: [...resource.refs.values()].reduce((sum, count) => sum + count, 0), cleanupState: resource.cleanupState })), ledgerEntries: this.ledger.size, accepting: this.accepting };
|
|
433
|
-
}
|
|
434
|
-
async handleTargetExit(pid, buildKey) {
|
|
435
|
-
const affected = [...this.resources.values()].filter(resource => resource.pid === pid && (buildKey === null || resource.buildKey === buildKey));
|
|
436
|
-
affected.sort((a, b) => resourceDepth(b, this.resources) - resourceDepth(a, this.resources));
|
|
437
|
-
const deadline = this.clock.now() + (this.options.sessionCleanupTimeoutMs ?? 2_000);
|
|
438
|
-
const records = [];
|
|
439
|
-
const failedScopes = await this.prepareCaptureScopes(affected, "target_exit", records, deadline);
|
|
440
|
-
for (const resource of affected) {
|
|
441
|
-
if (resource.cleanupScopeId && failedScopes.has(resource.cleanupScopeId))
|
|
442
|
-
continue;
|
|
443
|
-
const blockers = this.unreleasedChildren(resource.resourceId);
|
|
444
|
-
if (blockers.length > 0) {
|
|
445
|
-
this.markResidualParent(resource, blockers);
|
|
446
|
-
records.push({ resourceId: resource.resourceId, step: "ref_release", ok: false, error: `CHILD_RESOURCE_RESIDUAL:${blockers.join(",")}` });
|
|
447
|
-
continue;
|
|
448
|
-
}
|
|
449
|
-
await this.cleanupResource(resource, "target_exit", records, deadline);
|
|
450
|
-
}
|
|
451
|
-
for (const scopeId of new Set(affected.map(resource => resource.cleanupScopeId).filter((value) => Boolean(value))))
|
|
452
|
-
if (!failedScopes.has(scopeId))
|
|
453
|
-
await this.completeCaptureScope(scopeId, "target_exit", deadline, records);
|
|
454
|
-
this.emit({ action: "attachment_fatal", pid, buildKey, status: records.some(record => !record.ok) ? "partial" : "failed", error: { failureClass: "attachment_fatal", errorCode: "ATTACHMENT_FATAL", scope: "attachment" }, result: { cleanup: records } });
|
|
455
|
-
}
|
|
456
|
-
async handleCaptureFatal(sessionId, pid, buildKey, error) {
|
|
457
|
-
await this.terminateCaptureScope(sessionId, pid, buildKey, "capture_fatal", error);
|
|
458
|
-
}
|
|
459
|
-
async handleCaptureExpiry(sessionId, pid, buildKey) {
|
|
460
|
-
await this.terminateCaptureScope(sessionId, pid, buildKey, "duration_expired");
|
|
461
|
-
}
|
|
462
|
-
async terminateCaptureScope(sessionId, pid, buildKey, reason, error) {
|
|
463
|
-
const affected = [...this.resources.values()].filter(resource => resource.cleanupScopeId === sessionId && resource.pid === pid && resource.buildKey === buildKey);
|
|
464
|
-
const records = [];
|
|
465
|
-
const deadline = this.clock.now() + (this.options.sessionCleanupTimeoutMs ?? 2_000);
|
|
466
|
-
const failedScopes = await this.prepareCaptureScopes(affected, reason, records, deadline);
|
|
467
|
-
if (failedScopes.has(sessionId)) {
|
|
468
|
-
this.emit(reason === "capture_fatal"
|
|
469
|
-
? { action: "capture_fatal", sessionId, pid, buildKey, status: "partial", error: { failureClass: "capture_fatal", errorCode: "CAPTURE_FATAL", scope: "session", message: error instanceof Error ? error.message : error === undefined ? null : String(error) }, result: { cleanup: records } }
|
|
470
|
-
: { action: "duration_expired", sessionId, pid, buildKey, status: "partial", result: { cleanup: records } });
|
|
471
|
-
return;
|
|
472
|
-
}
|
|
473
|
-
affected.sort((a, b) => resourceDepth(b, this.resources) - resourceDepth(a, this.resources));
|
|
474
|
-
const session = affected.find(resource => resource.type === "session");
|
|
475
|
-
const ownerClientId = session?.ownerClientId;
|
|
476
|
-
const attachmentResourceId = session?.parentResourceId;
|
|
477
|
-
if (ownerClientId) {
|
|
478
|
-
for (const resource of affected) {
|
|
479
|
-
if (!resource.refs.has(ownerClientId))
|
|
480
|
-
continue;
|
|
481
|
-
try {
|
|
482
|
-
await this.releaseResource(resource.resourceId, ownerClientId, reason, resource.type, deadline);
|
|
483
|
-
}
|
|
484
|
-
catch (releaseError) {
|
|
485
|
-
records.push({ resourceId: resource.resourceId, step: "ref_release", ok: false, error: releaseError instanceof Error ? releaseError.message : String(releaseError) });
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
if (attachmentResourceId) {
|
|
489
|
-
try {
|
|
490
|
-
await this.releaseResource(attachmentResourceId, ownerClientId, `${reason}:attachment_ref`, "attachment", deadline);
|
|
491
|
-
}
|
|
492
|
-
catch (releaseError) {
|
|
493
|
-
records.push({ resourceId: attachmentResourceId, step: "ref_release", ok: false, error: releaseError instanceof Error ? releaseError.message : String(releaseError) });
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
await this.completeCaptureScope(sessionId, reason, deadline, records);
|
|
498
|
-
if (reason === "capture_fatal") {
|
|
499
|
-
this.emit({
|
|
500
|
-
action: "capture_fatal",
|
|
501
|
-
sessionId,
|
|
502
|
-
pid,
|
|
503
|
-
buildKey,
|
|
504
|
-
status: records.some(record => !record.ok) ? "partial" : "failed",
|
|
505
|
-
error: { failureClass: "capture_fatal", errorCode: "CAPTURE_FATAL", scope: "session", message: error instanceof Error ? error.message : error === undefined ? null : String(error) },
|
|
506
|
-
result: { cleanup: records }
|
|
507
|
-
});
|
|
508
|
-
}
|
|
509
|
-
else {
|
|
510
|
-
this.emit({ action: "duration_expired", sessionId, pid, buildKey, status: records.some(record => !record.ok) ? "partial" : "passed", result: { cleanup: records } });
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
unreleasedChildren(resourceId) {
|
|
514
|
-
return [...this.resources.values()]
|
|
515
|
-
.filter(candidate => candidate.parentResourceId === resourceId && candidate.cleanupState !== "released")
|
|
516
|
-
.map(candidate => candidate.resourceId)
|
|
517
|
-
.sort();
|
|
518
|
-
}
|
|
519
|
-
markResidualParent(resource, blockers) {
|
|
520
|
-
resource.cleanupState = "failed";
|
|
521
|
-
const marker = `CHILD_RESOURCE_RESIDUAL:${blockers.join(",")}`;
|
|
522
|
-
if (!(resource.cleanupErrors ?? []).some(error => error.step === "ref_release" && error.error === marker))
|
|
523
|
-
(resource.cleanupErrors ??= []).push({ step: "ref_release", error: marker });
|
|
524
|
-
}
|
|
525
|
-
transition(entry, state, data = {}) {
|
|
526
|
-
if (entry.state !== state && !ALLOWED_TRANSITIONS[entry.state].has(state))
|
|
527
|
-
throw new BrokerProtocolError("BROKER_FATAL", `invalid ledger transition ${entry.state} -> ${state}`, "Inspect request_status and Broker lifecycle evidence.");
|
|
528
|
-
const previousTimestamp = entry.transitions.at(-1)?.timestamp ?? this.clock.now();
|
|
529
|
-
const timestamp = Math.max(previousTimestamp, this.clock.now());
|
|
530
|
-
entry.state = state;
|
|
531
|
-
if (Object.hasOwn(data, "result"))
|
|
532
|
-
entry.result = data.result;
|
|
533
|
-
if (Object.hasOwn(data, "error"))
|
|
534
|
-
entry.error = data.error;
|
|
535
|
-
if (data.evidenceRefs)
|
|
536
|
-
entry.evidenceRefs.push(...data.evidenceRefs);
|
|
537
|
-
entry.transitions.push({ state, timestamp, result: data.result, error: data.error, evidenceRefs: data.evidenceRefs ? [...data.evidenceRefs] : [] });
|
|
538
|
-
if (TERMINAL.has(state))
|
|
539
|
-
this.makeTerminal(entry);
|
|
540
|
-
this.persistLedger(entry);
|
|
541
|
-
if (state === "executing" && entry.mutationPlanId) {
|
|
542
|
-
const nextAction = "Complete operation and validate after evidence.";
|
|
543
|
-
this.emit({
|
|
544
|
-
action: "mutation_started",
|
|
545
|
-
clientId: entry.clientId,
|
|
546
|
-
requestId: entry.requestId,
|
|
547
|
-
operation: entry.operation,
|
|
548
|
-
pid: entry.pid,
|
|
549
|
-
buildKey: entry.buildKey,
|
|
550
|
-
status: "started",
|
|
551
|
-
mutationPlanId: entry.mutationPlanId,
|
|
552
|
-
confirmationNonce: entry.confirmationNonce ?? null,
|
|
553
|
-
rollbackMode: entry.rollbackMode ?? null,
|
|
554
|
-
evidenceRefs: [...entry.evidenceRefs],
|
|
555
|
-
literalResult: null,
|
|
556
|
-
exitStatus: null,
|
|
557
|
-
nextAction,
|
|
558
|
-
result: mutationLifecycleResult(entry, null, nextAction)
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
complete(entry, result) {
|
|
563
|
-
updateEntryCorrelation(entry, result);
|
|
564
|
-
this.transition(entry, "completed", { result });
|
|
565
|
-
const nextAction = entry.mutationPlanId ? "Reopen evidenceRefs and verify hashes." : undefined;
|
|
566
|
-
this.emit({
|
|
567
|
-
action: entry.mutationPlanId ? "mutation_completed" : "request_completed",
|
|
568
|
-
clientId: entry.clientId,
|
|
569
|
-
requestId: entry.requestId,
|
|
570
|
-
operation: entry.operation,
|
|
571
|
-
pid: entry.pid,
|
|
572
|
-
buildKey: entry.buildKey,
|
|
573
|
-
sessionId: entry.sessionId ?? null,
|
|
574
|
-
resourceId: entry.resourceId ?? null,
|
|
575
|
-
status: "passed",
|
|
576
|
-
mutationPlanId: entry.mutationPlanId ?? null,
|
|
577
|
-
confirmationNonce: entry.confirmationNonce ?? null,
|
|
578
|
-
rollbackMode: entry.rollbackMode ?? null,
|
|
579
|
-
evidenceRefs: [...entry.evidenceRefs],
|
|
580
|
-
literalResult: entry.mutationPlanId ? result : null,
|
|
581
|
-
exitStatus: entry.mutationPlanId ? 0 : undefined,
|
|
582
|
-
nextAction,
|
|
583
|
-
result: entry.mutationPlanId ? mutationLifecycleResult(entry, result, nextAction) : focusedLifecycleProjection(entry, result)
|
|
584
|
-
});
|
|
585
|
-
return { schemaVersion: BROKER_SCHEMA_VERSION, protocolVersion: BROKER_PROTOCOL_VERSION, requestId: entry.requestId, ok: true, result };
|
|
586
|
-
}
|
|
587
|
-
makeTerminal(entry) { entry.terminal = true; entry.terminalAt = this.clock.now(); }
|
|
588
|
-
responseFor(entry) {
|
|
589
|
-
if (entry.error && typeof entry.error === "object" && !Array.isArray(entry.error)) {
|
|
590
|
-
const error = entry.error;
|
|
591
|
-
return this.failure(entry.requestId, typeof error.code === "string" ? error.code : "BROKER_FATAL", typeof error.message === "string" ? error.message : "Broker request failed", typeof error.nextAction === "string" ? error.nextAction : undefined, { operation: entry.operation, pid: entry.pid, buildKey: entry.buildKey });
|
|
592
|
-
}
|
|
593
|
-
return { schemaVersion: 1, protocolVersion: BROKER_PROTOCOL_VERSION, requestId: entry.requestId, ok: true, result: Object.hasOwn(entry, "result") ? entry.result : this.requestStatus(entry.requestId) };
|
|
594
|
-
}
|
|
595
|
-
failure(requestId, codeOrError, message, nextAction, request) {
|
|
596
|
-
const error = codeOrError instanceof BrokerProtocolError ? codeOrError : typeof codeOrError === "string" ? new BrokerProtocolError(codeOrError, message ?? codeOrError, nextAction) : new BrokerProtocolError("BROKER_FATAL", codeOrError instanceof Error ? codeOrError.message : String(codeOrError));
|
|
597
|
-
const metadata = failureMetadata(error.code);
|
|
598
|
-
return { schemaVersion: 1, protocolVersion: BROKER_PROTOCOL_VERSION, requestId, ok: false, error: { code: error.code, message: error.message, nextAction: error.nextAction, ...metadata, ...(request ? { operation: request.operation, pid: request.pid, buildKey: request.buildKey } : {}) } };
|
|
599
|
-
}
|
|
600
|
-
requireResource(resourceId) { const resource = this.resources.get(resourceId); if (!resource)
|
|
601
|
-
throw new BrokerProtocolError("RESOURCE_NOT_FOUND", `${resourceId} was not found`); return resource; }
|
|
602
|
-
async cleanupResource(resource, reason, records, outerDeadline = Number.POSITIVE_INFINITY) {
|
|
603
|
-
if (resource.cleanupState === "released")
|
|
604
|
-
return;
|
|
605
|
-
if (resource.cleanupState === "cleaning")
|
|
606
|
-
return;
|
|
607
|
-
resource.cleanupState = "cleaning";
|
|
608
|
-
const scopeId = resource.cleanupScopeId ?? resource.resourceId;
|
|
609
|
-
const sessionDeadline = this.cleanupDeadlines.get(scopeId) ?? this.clock.now() + (this.options.sessionCleanupTimeoutMs ?? 2_000);
|
|
610
|
-
this.cleanupDeadlines.set(scopeId, sessionDeadline);
|
|
611
|
-
const resourceDeadline = Math.min(outerDeadline, sessionDeadline);
|
|
612
|
-
const steps = resource.cleanupScopeId && this.options.runtime.cleanupCaptureScope
|
|
613
|
-
? ["ref_release"]
|
|
614
|
-
: RELEVANT_STEPS[resource.type];
|
|
615
|
-
for (const step of steps) {
|
|
616
|
-
const cleanupContext = this.cleanupContext(sessionDeadline, outerDeadline);
|
|
617
|
-
try {
|
|
618
|
-
await this.runBeforeDeadline(() => this.options.runtime.cleanup?.(resource, step, reason, cleanupContext) ?? Promise.resolve(), resourceDeadline);
|
|
619
|
-
records.push({ resourceId: resource.resourceId, step, ok: true });
|
|
620
|
-
}
|
|
621
|
-
catch (error) {
|
|
622
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
623
|
-
resource.cleanupState = "failed";
|
|
624
|
-
(resource.cleanupErrors ??= []).push({ step, error: message });
|
|
625
|
-
records.push({ resourceId: resource.resourceId, step, ok: false, error: message });
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
if (resource.cleanupState !== "failed")
|
|
629
|
-
resource.cleanupState = "released";
|
|
630
|
-
if (resource.cleanupState === "released")
|
|
631
|
-
resource.refs.clear();
|
|
632
|
-
if (![...this.resources.values()].some(candidate => (candidate.cleanupScopeId ?? candidate.resourceId) === scopeId && (candidate.cleanupState === "active" || candidate.cleanupState === "cleaning")))
|
|
633
|
-
this.cleanupDeadlines.delete(scopeId);
|
|
634
|
-
}
|
|
635
|
-
cleanupContext(sessionDeadline, brokerDeadline) {
|
|
636
|
-
const effectiveDeadline = Math.min(sessionDeadline, brokerDeadline);
|
|
637
|
-
return {
|
|
638
|
-
sessionDeadline,
|
|
639
|
-
brokerDeadline,
|
|
640
|
-
effectiveDeadline,
|
|
641
|
-
remainingMs: Math.max(0, effectiveDeadline - this.clock.now())
|
|
642
|
-
};
|
|
643
|
-
}
|
|
644
|
-
async prepareCaptureScopes(resources, reason, records, brokerDeadline, preparedScopes = new Set()) {
|
|
645
|
-
const failedScopes = new Set();
|
|
646
|
-
if (!this.options.runtime.cleanupCaptureScope)
|
|
647
|
-
return failedScopes;
|
|
648
|
-
const scopes = [...new Set(resources.map(resource => resource.cleanupScopeId).filter((value) => Boolean(value)))].filter(scope => !preparedScopes.has(scope)).sort();
|
|
649
|
-
for (const scopeId of scopes) {
|
|
650
|
-
preparedScopes.add(scopeId);
|
|
651
|
-
const sessionDeadline = this.cleanupDeadlines.get(scopeId) ?? this.clock.now() + (this.options.sessionCleanupTimeoutMs ?? 2_000);
|
|
652
|
-
this.cleanupDeadlines.set(scopeId, sessionDeadline);
|
|
653
|
-
const context = this.cleanupContext(sessionDeadline, brokerDeadline);
|
|
654
|
-
let result;
|
|
655
|
-
try {
|
|
656
|
-
const prepared = await this.runBeforeDeadline(() => this.options.runtime.cleanupCaptureScope(scopeId, reason, context), context.effectiveDeadline);
|
|
657
|
-
result = prepared ?? { sessionId: scopeId, status: "passed", steps: [] };
|
|
658
|
-
}
|
|
659
|
-
catch (error) {
|
|
660
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
661
|
-
result = { sessionId: scopeId, status: "failed", steps: [{ step: "artifact_freeze", ok: false, error: message }], residualResources: resources.filter(resource => resource.cleanupScopeId === scopeId).map(resource => resource.resourceId) };
|
|
662
|
-
}
|
|
663
|
-
for (const step of result.steps)
|
|
664
|
-
records.push({ resourceId: `capture:${scopeId}`, ...step });
|
|
665
|
-
const finalized = await this.finalizeCaptureScope(scopeId, reason, result, context.effectiveDeadline, records);
|
|
666
|
-
if (result.status !== "passed" || !finalized) {
|
|
667
|
-
failedScopes.add(scopeId);
|
|
668
|
-
for (const resource of resources.filter(candidate => candidate.cleanupScopeId === scopeId)) {
|
|
669
|
-
resource.cleanupState = "failed";
|
|
670
|
-
for (const step of result.steps.filter(candidate => !candidate.ok))
|
|
671
|
-
(resource.cleanupErrors ??= []).push({ step: step.step, error: step.error ?? result.status });
|
|
672
|
-
if (!finalized && !(resource.cleanupErrors ?? []).some(item => item.step === "artifact_freeze" && item.error.includes("finalizeCaptureScope")))
|
|
673
|
-
(resource.cleanupErrors ??= []).push({ step: "artifact_freeze", error: "finalizeCaptureScope failed" });
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
return failedScopes;
|
|
678
|
-
}
|
|
679
|
-
async finalizeCaptureScope(scopeId, reason, result, deadline, records) {
|
|
680
|
-
if (!this.options.runtime.finalizeCaptureScope)
|
|
681
|
-
return true;
|
|
682
|
-
try {
|
|
683
|
-
await this.runBeforeDeadline(() => Promise.resolve(this.options.runtime.finalizeCaptureScope(scopeId, reason, result)), deadline);
|
|
684
|
-
return true;
|
|
685
|
-
}
|
|
686
|
-
catch (error) {
|
|
687
|
-
records.push({ resourceId: `capture:${scopeId}`, step: "artifact_freeze", ok: false, error: `finalizeCaptureScope: ${errorText(error)}` });
|
|
688
|
-
return false;
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
async completeCaptureScope(scopeId, reason, deadline, records) {
|
|
692
|
-
if (!this.options.runtime.completeCaptureScope)
|
|
693
|
-
return true;
|
|
694
|
-
try {
|
|
695
|
-
await this.runBeforeDeadline(() => Promise.resolve(this.options.runtime.completeCaptureScope(scopeId, reason)), deadline);
|
|
696
|
-
return true;
|
|
697
|
-
}
|
|
698
|
-
catch (error) {
|
|
699
|
-
records.push({ resourceId: `capture:${scopeId}`, step: "ref_release", ok: false, error: `completeCaptureScope: ${errorText(error)}` });
|
|
700
|
-
return false;
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
async releaseRuntimeClient(clientId, deadline, records) {
|
|
704
|
-
if (!this.options.runtime.releaseClient)
|
|
705
|
-
return true;
|
|
706
|
-
try {
|
|
707
|
-
await this.runBeforeDeadline(() => Promise.resolve(this.options.runtime.releaseClient(clientId)), deadline);
|
|
708
|
-
return true;
|
|
709
|
-
}
|
|
710
|
-
catch (error) {
|
|
711
|
-
records.push({ resourceId: `client:${clientId}`, step: "ref_release", ok: false, error: `releaseClient: ${errorText(error)}` });
|
|
712
|
-
return false;
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
armIdleTimer() { if (this.timer !== undefined)
|
|
716
|
-
this.clock.clearTimeout(this.timer); this.timer = this.clock.setTimeout(() => this.onIdleTimer(), this.idleTimeoutMs); }
|
|
717
|
-
onIdleTimer() {
|
|
718
|
-
if (!this.accepting)
|
|
719
|
-
return;
|
|
720
|
-
const elapsed = this.clock.now() - this.lastAcceptedRequestAt;
|
|
721
|
-
const inFlight = [...this.clients.values()].some(client => client.inFlight > 0);
|
|
722
|
-
if (elapsed >= this.idleTimeoutMs && !inFlight)
|
|
723
|
-
void this.shutdown("idle_timeout");
|
|
724
|
-
else
|
|
725
|
-
this.timer = this.clock.setTimeout(() => this.onIdleTimer(), Math.max(1, this.idleTimeoutMs - elapsed));
|
|
726
|
-
}
|
|
727
|
-
async acquireExecutionSlot(clientId) {
|
|
728
|
-
const running = this.runningByClient.get(clientId) ?? 0;
|
|
729
|
-
if (running < (this.options.maxInFlightPerClient ?? 32)) {
|
|
730
|
-
this.runningByClient.set(clientId, running + 1);
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
const queue = this.queuesByClient.get(clientId) ?? [];
|
|
734
|
-
if (queue.length >= (this.options.maxQueuedPerClient ?? 128))
|
|
735
|
-
throw new BrokerProtocolError("REQUEST_CANCELLED", "Broker client queue limit reached", "Wait for current requests to finish.");
|
|
736
|
-
await new Promise(resolve => { queue.push(resolve); this.queuesByClient.set(clientId, queue); });
|
|
737
|
-
this.runningByClient.set(clientId, (this.runningByClient.get(clientId) ?? 0) + 1);
|
|
738
|
-
}
|
|
739
|
-
releaseExecutionSlot(clientId) {
|
|
740
|
-
this.runningByClient.set(clientId, Math.max(0, (this.runningByClient.get(clientId) ?? 1) - 1));
|
|
741
|
-
const next = this.queuesByClient.get(clientId)?.shift();
|
|
742
|
-
if (next)
|
|
743
|
-
next();
|
|
744
|
-
}
|
|
745
|
-
pruneLedger() {
|
|
746
|
-
const max = this.options.maxLedgerEntries ?? 10_000;
|
|
747
|
-
const retention = this.options.ledgerRetentionMs ?? 86_400_000;
|
|
748
|
-
if (this.ledger.size <= max)
|
|
749
|
-
return;
|
|
750
|
-
return (async () => {
|
|
751
|
-
for (const [id, entry] of this.ledger) {
|
|
752
|
-
if (this.ledger.size <= max || !entry.terminalAt || this.clock.now() - entry.terminalAt < retention)
|
|
753
|
-
continue;
|
|
754
|
-
this.emit({ action: "ledger_evicted", requestId: id, status: "passed", result: { terminalAt: entry.terminalAt, retentionMs: retention } });
|
|
755
|
-
await this.flushLifecycle();
|
|
756
|
-
this.ledgerStore?.remove(id);
|
|
757
|
-
this.ledger.delete(id);
|
|
758
|
-
}
|
|
759
|
-
})();
|
|
760
|
-
}
|
|
761
|
-
emit(event) {
|
|
762
|
-
const record = { timestamp: this.clock.now(), instanceId: this.instanceId, ...event };
|
|
763
|
-
this.lifecycleTail = this.lifecycleTail.then(async () => {
|
|
764
|
-
try {
|
|
765
|
-
await this.options.lifecycle?.(record);
|
|
766
|
-
}
|
|
767
|
-
catch (error) {
|
|
768
|
-
this.lifecycleFailure ??= error;
|
|
769
|
-
}
|
|
770
|
-
});
|
|
771
|
-
}
|
|
772
|
-
async flushLifecycle() {
|
|
773
|
-
await this.lifecycleTail;
|
|
774
|
-
if (this.lifecycleFailure) {
|
|
775
|
-
const error = this.lifecycleFailure;
|
|
776
|
-
this.lifecycleFailure = undefined;
|
|
777
|
-
throw new BrokerProtocolError("BROKER_FATAL", `lifecycle persistence failed: ${error instanceof Error ? error.message : String(error)}`, "Inspect Broker lifecycle storage and retry after repair.");
|
|
778
|
-
}
|
|
779
|
-
}
|
|
780
|
-
persistLedger(entry) {
|
|
781
|
-
try {
|
|
782
|
-
this.ledgerStore?.persist(entry);
|
|
783
|
-
}
|
|
784
|
-
catch (error) {
|
|
785
|
-
throw new BrokerProtocolError("BROKER_FATAL", `ledger persistence failed: ${error instanceof Error ? error.message : String(error)}`, "Inspect Broker ledger storage and restart after repair.");
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
async runBeforeDeadline(action, deadline) {
|
|
789
|
-
const remaining = deadline - this.clock.now();
|
|
790
|
-
if (remaining <= 0) {
|
|
791
|
-
throw new BrokerProtocolError("CLEANUP_TIMEOUT", "cleanup deadline exhausted");
|
|
792
|
-
}
|
|
793
|
-
let timer;
|
|
794
|
-
try {
|
|
795
|
-
return await Promise.race([action(), new Promise((_, reject) => { timer = this.clock.setTimeout(() => reject(new BrokerProtocolError("CLEANUP_TIMEOUT", "cleanup step timed out")), remaining); })]);
|
|
796
|
-
}
|
|
797
|
-
finally {
|
|
798
|
-
if (timer !== undefined)
|
|
799
|
-
this.clock.clearTimeout(timer);
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
async shutdownStep(result, step, deadline, action) {
|
|
803
|
-
try {
|
|
804
|
-
await this.runBeforeDeadline(() => Promise.resolve(action()), deadline);
|
|
805
|
-
result.cleanupOrder.push({ resourceId: "broker", step, ok: true });
|
|
806
|
-
}
|
|
807
|
-
catch (error) {
|
|
808
|
-
result.cleanupOrder.push({ resourceId: "broker", step, ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
async drainInFlight(deadline, excludeRequestId, records) {
|
|
812
|
-
const pending = () => [...this.activeRequests].some(requestId => requestId !== excludeRequestId);
|
|
813
|
-
const wallDeadline = Date.now() + Math.max(0, deadline - this.clock.now());
|
|
814
|
-
while (pending()) {
|
|
815
|
-
const remaining = deadline - this.clock.now();
|
|
816
|
-
if (remaining <= 0 || Date.now() >= wallDeadline) {
|
|
817
|
-
for (const requestId of this.activeRequests) {
|
|
818
|
-
if (requestId === excludeRequestId)
|
|
819
|
-
continue;
|
|
820
|
-
const entry = this.ledger.get(requestId);
|
|
821
|
-
if (entry && !entry.terminal) {
|
|
822
|
-
try {
|
|
823
|
-
this.transition(entry, "outcome_unknown", { error: { code: "REQUEST_TIMEOUT", message: "Broker shutdown deadline expired while request was in flight", nextAction: "Query request_status before retrying." } });
|
|
824
|
-
}
|
|
825
|
-
catch { /* preserve the original ledger error */ }
|
|
826
|
-
}
|
|
827
|
-
this.requestCancellers.get(requestId)?.();
|
|
828
|
-
}
|
|
829
|
-
records.push({ resourceId: "broker", step: "in_flight_drain", ok: false, error: "CLEANUP_TIMEOUT" });
|
|
830
|
-
return;
|
|
831
|
-
}
|
|
832
|
-
await new Promise(resolve => {
|
|
833
|
-
let timer;
|
|
834
|
-
const wake = () => {
|
|
835
|
-
this.drainWaiters.delete(wake);
|
|
836
|
-
if (timer !== undefined)
|
|
837
|
-
clearTimeout(timer);
|
|
838
|
-
resolve();
|
|
839
|
-
};
|
|
840
|
-
this.drainWaiters.add(wake);
|
|
841
|
-
timer = setTimeout(wake, Math.max(1, Math.min(remaining, wallDeadline - Date.now())));
|
|
842
|
-
});
|
|
843
|
-
}
|
|
844
|
-
records.push({ resourceId: "broker", step: "in_flight_drain", ok: true });
|
|
845
|
-
}
|
|
846
|
-
async executeRuntime(request, context) {
|
|
847
|
-
let cancel;
|
|
848
|
-
const cancelled = new Promise((_, reject) => {
|
|
849
|
-
cancel = () => reject(new BrokerProtocolError("BROKER_STOPPED", "Broker shutdown cancelled the in-flight request", "Query request_status before retrying."));
|
|
850
|
-
});
|
|
851
|
-
this.requestCancellers.set(request.requestId, cancel);
|
|
852
|
-
let runtimePromise;
|
|
853
|
-
try {
|
|
854
|
-
runtimePromise = Promise.resolve(this.options.runtime.execute(request, context));
|
|
855
|
-
}
|
|
856
|
-
catch (error) {
|
|
857
|
-
runtimePromise = Promise.reject(error);
|
|
858
|
-
}
|
|
859
|
-
try {
|
|
860
|
-
return await Promise.race([runtimePromise, cancelled]);
|
|
861
|
-
}
|
|
862
|
-
finally {
|
|
863
|
-
this.requestCancellers.delete(request.requestId);
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
function errorText(error) { return error instanceof Error ? error.message : String(error); }
|
|
868
|
-
function resourceDepth(resource, resources) { let depth = 0; let parent = resource.parentResourceId; while (parent) {
|
|
869
|
-
depth += 1;
|
|
870
|
-
parent = resources.get(parent)?.parentResourceId;
|
|
871
|
-
} return depth; }
|
|
872
|
-
function decrementRef(resource, clientId) {
|
|
873
|
-
const count = resource.refs.get(clientId);
|
|
874
|
-
if (count === undefined)
|
|
875
|
-
throw new BrokerProtocolError("RESOURCE_NOT_OWNER", `${clientId} does not own ${resource.resourceId}`);
|
|
876
|
-
if (count <= 0)
|
|
877
|
-
throw new BrokerProtocolError("RESOURCE_REF_UNDERFLOW", `${resource.resourceId} refcount would underflow`);
|
|
878
|
-
if (count === 1)
|
|
879
|
-
resource.refs.delete(clientId);
|
|
880
|
-
else
|
|
881
|
-
resource.refs.set(clientId, count - 1);
|
|
882
|
-
}
|
|
883
|
-
function requestIdOf(raw) { return raw && typeof raw === "object" && typeof raw.requestId === "string" ? String(raw.requestId) : "unknown"; }
|
|
884
|
-
function errorJson(error) {
|
|
885
|
-
const code = error instanceof BrokerProtocolError ? error.code : "BROKER_FATAL";
|
|
886
|
-
const base = error instanceof BrokerProtocolError ? { code, message: error.message, nextAction: error.nextAction } : { code, message: error instanceof Error ? error.message : String(error), nextAction: null };
|
|
887
|
-
return { ...base, ...failureMetadata(code) };
|
|
888
|
-
}
|
|
889
|
-
function failureMetadata(code) {
|
|
890
|
-
if (code === "CAPTURE_FATAL")
|
|
891
|
-
return { errorCode: code, failureClass: "capture_fatal", scope: "session" };
|
|
892
|
-
if (code === "ATTACHMENT_FATAL")
|
|
893
|
-
return { errorCode: code, failureClass: "attachment_fatal", scope: "attachment" };
|
|
894
|
-
if (code === "BROKER_FATAL")
|
|
895
|
-
return { errorCode: code, failureClass: "broker_fatal", scope: "broker" };
|
|
896
|
-
return {};
|
|
897
|
-
}
|
|
898
|
-
function mutationLifecycleResult(entry, literalResult, nextAction) {
|
|
899
|
-
return {
|
|
900
|
-
planId: entry.mutationPlanId ?? null,
|
|
901
|
-
confirmationNonce: entry.confirmationNonce ?? null,
|
|
902
|
-
rollbackMode: entry.rollbackMode ?? null,
|
|
903
|
-
literalResult,
|
|
904
|
-
evidenceRefs: [...entry.evidenceRefs],
|
|
905
|
-
exitStatus: literalResult === null ? null : 0,
|
|
906
|
-
nextAction
|
|
907
|
-
};
|
|
908
|
-
}
|
|
909
|
-
function mutationMetadata(request) {
|
|
910
|
-
const command = isJsonRecord(request.payload.request) ? request.payload.request : request.payload;
|
|
911
|
-
const contract = isJsonRecord(command.mutation)
|
|
912
|
-
? command.mutation
|
|
913
|
-
: isJsonRecord(command.launch)
|
|
914
|
-
? command.launch
|
|
915
|
-
: undefined;
|
|
916
|
-
if (!contract)
|
|
917
|
-
return {};
|
|
918
|
-
const confirmation = isJsonRecord(contract.confirmation) ? contract.confirmation : undefined;
|
|
919
|
-
const rollback = isJsonRecord(contract.rollback) ? contract.rollback : undefined;
|
|
920
|
-
return {
|
|
921
|
-
...(typeof contract.mutationPlanId === "string" ? { mutationPlanId: contract.mutationPlanId } : {}),
|
|
922
|
-
...(typeof confirmation?.nonce === "string" ? { confirmationNonce: confirmation.nonce } : {}),
|
|
923
|
-
...(typeof rollback?.mode === "string" ? { rollbackMode: rollback.mode } : {})
|
|
924
|
-
};
|
|
925
|
-
}
|
|
926
|
-
function requestCorrelation(request) {
|
|
927
|
-
if (!isFocusOperation(request.operation))
|
|
928
|
-
return {};
|
|
929
|
-
const sessionId = stringField(request.payload, "sessionId");
|
|
930
|
-
const resourceId = stringField(request.payload, "resourceId") ?? (sessionId ? `focus-session:${sessionId}` : undefined);
|
|
931
|
-
return { ...(sessionId ? { sessionId } : {}), ...(resourceId ? { resourceId } : {}) };
|
|
932
|
-
}
|
|
933
|
-
function updateEntryCorrelation(entry, result) {
|
|
934
|
-
if (!isFocusOperation(entry.operation) || !isJsonRecord(result))
|
|
935
|
-
return;
|
|
936
|
-
entry.sessionId = stringField(result, "sessionId") ?? entry.sessionId;
|
|
937
|
-
entry.resourceId = stringField(result, "resourceId") ?? entry.resourceId ?? (entry.sessionId ? `focus-session:${entry.sessionId}` : undefined);
|
|
938
|
-
}
|
|
939
|
-
function focusedLifecycleProjection(entry, result, error) {
|
|
940
|
-
if (!isFocusOperation(entry.operation))
|
|
941
|
-
return undefined;
|
|
942
|
-
const value = isJsonRecord(result) ? result : undefined;
|
|
943
|
-
const errorValue = isJsonRecord(error) ? error : undefined;
|
|
944
|
-
const sessionId = entry.sessionId ?? stringField(value, "sessionId") ?? residualSessionId(errorValue);
|
|
945
|
-
const resourceId = entry.resourceId ?? stringField(value, "resourceId") ?? (sessionId ? `focus-session:${sessionId}` : undefined);
|
|
946
|
-
const projection = {
|
|
947
|
-
requestId: entry.requestId,
|
|
948
|
-
clientId: entry.clientId,
|
|
949
|
-
sessionId: sessionId ?? null,
|
|
950
|
-
resourceId: resourceId ?? null,
|
|
951
|
-
pid: entry.pid,
|
|
952
|
-
buildKey: entry.buildKey
|
|
953
|
-
};
|
|
954
|
-
if (entry.operation.endsWith("_stop")) {
|
|
955
|
-
const cleanupAttempted = booleanField(value, "cleanupAttempted") ?? cleanupFlag(errorValue, "cleanupAttempted") ?? Boolean(error);
|
|
956
|
-
const fullyReleased = booleanField(value, "fullyReleased") ?? cleanupFlag(errorValue, "fullyReleased") ?? false;
|
|
957
|
-
projection.cleanupAttempted = cleanupAttempted;
|
|
958
|
-
projection.fullyReleased = fullyReleased;
|
|
959
|
-
projection.alreadyReleased = booleanField(value, "alreadyReleased") ?? false;
|
|
960
|
-
projection.cleanupStatus = cleanupStatusProjection(value?.cleanupStatus);
|
|
961
|
-
projection.residualSessionId = residualSessionId(errorValue) ?? null;
|
|
962
|
-
projection.residualResourceIds = residualResourceIds(value, errorValue);
|
|
963
|
-
projection.attachmentDetachStatus = attachmentDetachStatus(value, errorValue, fullyReleased);
|
|
964
|
-
}
|
|
965
|
-
return projection;
|
|
966
|
-
}
|
|
967
|
-
function isFocusOperation(operation) {
|
|
968
|
-
return operation.startsWith("wow_focus_") || operation.startsWith("wow_watch_") || operation === "wow_session_checkpoint";
|
|
969
|
-
}
|
|
970
|
-
function stringField(value, key) {
|
|
971
|
-
const field = value?.[key];
|
|
972
|
-
return typeof field === "string" && field.length > 0 ? field : undefined;
|
|
973
|
-
}
|
|
974
|
-
function booleanField(value, key) {
|
|
975
|
-
const field = value?.[key];
|
|
976
|
-
return typeof field === "boolean" ? field : undefined;
|
|
977
|
-
}
|
|
978
|
-
function cleanupFlag(error, name) {
|
|
979
|
-
const direct = booleanField(error, name);
|
|
980
|
-
if (direct !== undefined)
|
|
981
|
-
return direct;
|
|
982
|
-
const message = stringField(error, "message");
|
|
983
|
-
const match = message?.match(new RegExp(`(?:^|[; ])${name}=(true|false)(?:;|$)`));
|
|
984
|
-
return match ? match[1] === "true" : undefined;
|
|
985
|
-
}
|
|
986
|
-
function residualSessionId(error) {
|
|
987
|
-
const direct = stringField(error, "residualSessionId");
|
|
988
|
-
if (direct)
|
|
989
|
-
return direct;
|
|
990
|
-
return stringField(error, "message")?.match(/residualSessionId=([^;\s]+)/)?.[1];
|
|
991
|
-
}
|
|
992
|
-
function residualResourceIds(value, error) {
|
|
993
|
-
const direct = value?.residualResources ?? error?.residualResources;
|
|
994
|
-
if (Array.isArray(direct))
|
|
995
|
-
return direct.filter((item) => typeof item === "string").slice(0, 64);
|
|
996
|
-
const message = stringField(error, "message");
|
|
997
|
-
const encoded = message?.match(/residualResources=(\[[^;]*\])/)?.[1];
|
|
998
|
-
if (!encoded)
|
|
999
|
-
return [];
|
|
1000
|
-
try {
|
|
1001
|
-
const parsed = JSON.parse(encoded);
|
|
1002
|
-
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string").slice(0, 64) : [];
|
|
1003
|
-
}
|
|
1004
|
-
catch {
|
|
1005
|
-
return [];
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
function cleanupStatusProjection(value) {
|
|
1009
|
-
if (!isJsonRecord(value))
|
|
1010
|
-
return null;
|
|
1011
|
-
const projection = {};
|
|
1012
|
-
for (const key of ["hooks", "scripts", "interceptors", "cleanupAttempted", "fullyReleased"]) {
|
|
1013
|
-
const field = value[key];
|
|
1014
|
-
if (typeof field === "number" || typeof field === "boolean" || field === null)
|
|
1015
|
-
projection[key] = field;
|
|
1016
|
-
}
|
|
1017
|
-
if (Array.isArray(value.steps))
|
|
1018
|
-
projection.steps = value.steps.slice(0, 32).map(step => {
|
|
1019
|
-
if (!isJsonRecord(step))
|
|
1020
|
-
return null;
|
|
1021
|
-
return { step: stringField(step, "step") ?? null, status: stringField(step, "status") ?? null, ok: booleanField(step, "ok") ?? null };
|
|
1022
|
-
});
|
|
1023
|
-
if (Array.isArray(value.residualErrors))
|
|
1024
|
-
projection.residualErrorCount = value.residualErrors.length;
|
|
1025
|
-
return projection;
|
|
1026
|
-
}
|
|
1027
|
-
function attachmentDetachStatus(value, error, fullyReleased) {
|
|
1028
|
-
const explicit = stringField(value, "attachmentDetachStatus") ?? stringField(error, "attachmentDetachStatus");
|
|
1029
|
-
if (explicit)
|
|
1030
|
-
return { brokerResourceRelease: fullyReleased ? "complete" : "residual_or_unknown", physicalDetach: explicit, focusManifest: null };
|
|
1031
|
-
const cleanup = isJsonRecord(value?.cleanupStatus) ? value.cleanupStatus : undefined;
|
|
1032
|
-
if (Array.isArray(cleanup?.steps)) {
|
|
1033
|
-
const step = cleanup.steps.find(item => isJsonRecord(item) && (item.step === "detach" || item.step === "detach_deferred_to_broker"));
|
|
1034
|
-
if (isJsonRecord(step))
|
|
1035
|
-
return {
|
|
1036
|
-
brokerResourceRelease: fullyReleased ? "complete" : "residual_or_unknown",
|
|
1037
|
-
physicalDetach: stringField(step, "step") === "detach" ? (stringField(step, "status") ?? "reported") : "not_reported",
|
|
1038
|
-
focusManifest: { step: stringField(step, "step") ?? null, status: stringField(step, "status") ?? null, ok: booleanField(step, "ok") ?? null }
|
|
1039
|
-
};
|
|
1040
|
-
}
|
|
1041
|
-
return { brokerResourceRelease: fullyReleased ? "complete" : "residual_or_unknown", physicalDetach: "not_reported", focusManifest: null };
|
|
1042
|
-
}
|
|
1043
|
-
function isJsonRecord(value) {
|
|
1044
|
-
return value !== null && value !== undefined && typeof value === "object" && !Array.isArray(value);
|
|
1045
|
-
}
|