@livedesk/hub 0.1.40 → 0.1.42
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/package.json +2 -2
- package/src/agents/agent-device-scope.js +29 -29
- package/src/agents/agent-manager.js +103 -103
- package/src/agents/agent-permission-store.js +78 -78
- package/src/agents/agent-runtime-error.js +9 -9
- package/src/agents/agent-settings.js +71 -71
- package/src/agents/codex-agent-runtime.js +594 -594
- package/src/agents/codex-mcp-server.js +100 -100
- package/src/filesystem/directory-reader.js +187 -187
- package/src/filesystem/hub-filesystem.js +78 -78
- package/src/filesystem/path-registry.js +78 -78
- package/src/filesystem/roots.js +115 -115
- package/src/frame-packet-contract.mjs +427 -427
- package/src/http/hub-ui-session.js +119 -119
- package/src/live-capture-transition-retry.mjs +297 -297
- package/src/live-desk-update.js +167 -108
- package/src/live-stream-monitor-contract.js +38 -38
- package/src/lzo1x.js +109 -109
- package/src/mode4-atlas-pool.js +137 -137
- package/src/mode4-atlas-sizing.js +32 -32
- package/src/mode4-atlas-worker.js +599 -599
- package/src/mode4-atlas.js +306 -306
- package/src/remote-audio-subscription-contract.mjs +109 -109
- package/src/remote-audio-subscription-contract.test.mjs +72 -72
- package/src/remote-hub.js +140 -111
- package/src/server.js +196 -180
- package/src/settings/effective-device-policy.js +16 -16
- package/src/transport/udp-protocol.js +453 -453
|
@@ -1,297 +1,297 @@
|
|
|
1
|
-
function finitePositive(value, fallback) {
|
|
2
|
-
const number = Number(value);
|
|
3
|
-
return Number.isFinite(number) && number > 0 ? number : fallback;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
function clamp(value, minimum, maximum) {
|
|
7
|
-
return Math.max(minimum, Math.min(maximum, value));
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Owns the Hub-to-browser retry that follows an exact native capture stop.
|
|
12
|
-
*
|
|
13
|
-
* Contract:
|
|
14
|
-
* - one owner per WebSocket/device intent generation;
|
|
15
|
-
* - the native stop promise is the event source (no polling loop);
|
|
16
|
-
* - retries and total elapsed time are both bounded;
|
|
17
|
-
* - supersede, close, success, and terminal failure synchronously release every timer.
|
|
18
|
-
*/
|
|
19
|
-
export function createLiveCaptureTransitionRetryCoordinator(options = {}) {
|
|
20
|
-
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
21
|
-
const setTimer = typeof options.setTimer === 'function' ? options.setTimer : setTimeout;
|
|
22
|
-
const clearTimer = typeof options.clearTimer === 'function' ? options.clearTimer : clearTimeout;
|
|
23
|
-
const maxAttempts = Math.max(1, Math.floor(finitePositive(options.maxAttempts, 3)));
|
|
24
|
-
const minimumRetryAfterMs = Math.max(1, finitePositive(options.minimumRetryAfterMs, 25));
|
|
25
|
-
const maximumRetryAfterMs = Math.max(
|
|
26
|
-
minimumRetryAfterMs,
|
|
27
|
-
finitePositive(options.maximumRetryAfterMs, 250)
|
|
28
|
-
);
|
|
29
|
-
const minimumTotalMs = Math.max(1, finitePositive(options.minimumTotalMs, 2_000));
|
|
30
|
-
const maximumTotalMs = Math.max(minimumTotalMs, finitePositive(options.maximumTotalMs, 30_000));
|
|
31
|
-
const completionMarginMs = Math.max(0, Number(options.completionMarginMs ?? 2_000) || 0);
|
|
32
|
-
const clientStates = new WeakMap();
|
|
33
|
-
const activeOwners = new Set();
|
|
34
|
-
|
|
35
|
-
function stateFor(client) {
|
|
36
|
-
if (!client || (typeof client !== 'object' && typeof client !== 'function')) {
|
|
37
|
-
throw new TypeError('A capture transition retry requires an exact client owner.');
|
|
38
|
-
}
|
|
39
|
-
let state = clientStates.get(client);
|
|
40
|
-
if (!state) {
|
|
41
|
-
state = {
|
|
42
|
-
intentGenerations: new Map(),
|
|
43
|
-
owners: new Map()
|
|
44
|
-
};
|
|
45
|
-
clientStates.set(client, state);
|
|
46
|
-
}
|
|
47
|
-
return state;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function ownerIsCurrent(owner) {
|
|
51
|
-
if (!owner || owner.cancelled) {
|
|
52
|
-
return false;
|
|
53
|
-
}
|
|
54
|
-
const state = clientStates.get(owner.client);
|
|
55
|
-
return !!state
|
|
56
|
-
&& state.owners.get(owner.deviceId) === owner
|
|
57
|
-
&& state.intentGenerations.get(owner.deviceId) === owner.intentGeneration;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function releaseOwner(owner, reason = 'completed') {
|
|
61
|
-
if (!owner || owner.cancelled) {
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
owner.cancelled = true;
|
|
65
|
-
owner.cancelReason = reason;
|
|
66
|
-
owner.waitToken += 1;
|
|
67
|
-
if (owner.timer) {
|
|
68
|
-
clearTimer(owner.timer);
|
|
69
|
-
owner.timer = null;
|
|
70
|
-
}
|
|
71
|
-
const state = clientStates.get(owner.client);
|
|
72
|
-
if (state?.owners.get(owner.deviceId) === owner) {
|
|
73
|
-
state.owners.delete(owner.deviceId);
|
|
74
|
-
}
|
|
75
|
-
activeOwners.delete(owner);
|
|
76
|
-
return true;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function failOwner(owner, error, details = {}) {
|
|
80
|
-
if (!ownerIsCurrent(owner)) {
|
|
81
|
-
return false;
|
|
82
|
-
}
|
|
83
|
-
const onTerminal = owner.onTerminal;
|
|
84
|
-
const terminal = {
|
|
85
|
-
deviceId: owner.deviceId,
|
|
86
|
-
intentGeneration: owner.intentGeneration,
|
|
87
|
-
attemptCount: owner.attemptCount,
|
|
88
|
-
deadlineAtEpochMs: owner.deadlineAtEpochMs,
|
|
89
|
-
error,
|
|
90
|
-
...details
|
|
91
|
-
};
|
|
92
|
-
releaseOwner(owner, error);
|
|
93
|
-
try {
|
|
94
|
-
onTerminal?.(terminal);
|
|
95
|
-
} catch {
|
|
96
|
-
// The retry owner is already terminal. UI notification failure cannot
|
|
97
|
-
// resurrect it or retain a timer.
|
|
98
|
-
}
|
|
99
|
-
return true;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function beginIntent(client, deviceId) {
|
|
103
|
-
const normalizedDeviceId = String(deviceId || '').trim();
|
|
104
|
-
if (!normalizedDeviceId) {
|
|
105
|
-
throw new TypeError('A capture transition retry requires a device id.');
|
|
106
|
-
}
|
|
107
|
-
const state = stateFor(client);
|
|
108
|
-
releaseOwner(state.owners.get(normalizedDeviceId), 'superseded');
|
|
109
|
-
const current = Number(state.intentGenerations.get(normalizedDeviceId) || 0);
|
|
110
|
-
const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
|
|
111
|
-
state.intentGenerations.set(normalizedDeviceId, next);
|
|
112
|
-
return next;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function complete(client, deviceId, intentGeneration, reason = 'completed') {
|
|
116
|
-
const normalizedDeviceId = String(deviceId || '').trim();
|
|
117
|
-
const state = clientStates.get(client);
|
|
118
|
-
const owner = state?.owners.get(normalizedDeviceId);
|
|
119
|
-
if (!owner
|
|
120
|
-
|| (Number.isSafeInteger(Number(intentGeneration))
|
|
121
|
-
&& Number(intentGeneration) > 0
|
|
122
|
-
&& owner.intentGeneration !== Number(intentGeneration))) {
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
return releaseOwner(owner, reason);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function cancelClient(client, reason = 'client-closed') {
|
|
129
|
-
const state = clientStates.get(client);
|
|
130
|
-
if (!state) {
|
|
131
|
-
return 0;
|
|
132
|
-
}
|
|
133
|
-
let cancelled = 0;
|
|
134
|
-
for (const owner of [...state.owners.values()]) {
|
|
135
|
-
if (releaseOwner(owner, reason)) {
|
|
136
|
-
cancelled += 1;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
state.intentGenerations.clear();
|
|
140
|
-
clientStates.delete(client);
|
|
141
|
-
return cancelled;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function schedule(client, request = {}) {
|
|
145
|
-
const deviceId = String(request.deviceId || '').trim();
|
|
146
|
-
const intentGeneration = Number(request.intentGeneration || 0);
|
|
147
|
-
const result = request.result && typeof request.result === 'object'
|
|
148
|
-
? request.result
|
|
149
|
-
: {};
|
|
150
|
-
const state = stateFor(client);
|
|
151
|
-
if (!deviceId
|
|
152
|
-
|| !Number.isSafeInteger(intentGeneration)
|
|
153
|
-
|| intentGeneration <= 0
|
|
154
|
-
|| state.intentGenerations.get(deviceId) !== intentGeneration
|
|
155
|
-
|| result.error !== 'CAPTURE_TRANSITION_IN_PROGRESS') {
|
|
156
|
-
return false;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
let owner = state.owners.get(deviceId);
|
|
160
|
-
if (owner && owner.intentGeneration !== intentGeneration) {
|
|
161
|
-
releaseOwner(owner, 'superseded');
|
|
162
|
-
owner = null;
|
|
163
|
-
}
|
|
164
|
-
const transitionTimeoutMs = clamp(
|
|
165
|
-
finitePositive(result.transitionTimeoutMs, 11_000),
|
|
166
|
-
minimumRetryAfterMs,
|
|
167
|
-
maximumTotalMs
|
|
168
|
-
);
|
|
169
|
-
if (!owner) {
|
|
170
|
-
const totalWindowMs = clamp(
|
|
171
|
-
transitionTimeoutMs + completionMarginMs,
|
|
172
|
-
minimumTotalMs,
|
|
173
|
-
maximumTotalMs
|
|
174
|
-
);
|
|
175
|
-
owner = {
|
|
176
|
-
client,
|
|
177
|
-
deviceId,
|
|
178
|
-
intentGeneration,
|
|
179
|
-
attemptCount: 0,
|
|
180
|
-
deadlineAtEpochMs: now() + totalWindowMs,
|
|
181
|
-
timer: null,
|
|
182
|
-
phase: 'created',
|
|
183
|
-
cancelled: false,
|
|
184
|
-
cancelReason: '',
|
|
185
|
-
waitToken: 0,
|
|
186
|
-
retry: request.retry,
|
|
187
|
-
onTerminal: request.onTerminal,
|
|
188
|
-
transitionTimeoutMs
|
|
189
|
-
};
|
|
190
|
-
state.owners.set(deviceId, owner);
|
|
191
|
-
activeOwners.add(owner);
|
|
192
|
-
} else {
|
|
193
|
-
owner.retry = request.retry;
|
|
194
|
-
owner.onTerminal = request.onTerminal;
|
|
195
|
-
owner.transitionTimeoutMs = transitionTimeoutMs;
|
|
196
|
-
if (owner.timer) {
|
|
197
|
-
clearTimer(owner.timer);
|
|
198
|
-
owner.timer = null;
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
if (owner.attemptCount >= maxAttempts) {
|
|
203
|
-
failOwner(owner, 'CAPTURE_TRANSITION_ATTEMPT_LIMIT');
|
|
204
|
-
return false;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const retryAfterMs = clamp(
|
|
208
|
-
finitePositive(result.retryAfterMs, minimumRetryAfterMs),
|
|
209
|
-
minimumRetryAfterMs,
|
|
210
|
-
maximumRetryAfterMs
|
|
211
|
-
);
|
|
212
|
-
const waitToken = ++owner.waitToken;
|
|
213
|
-
owner.phase = 'waiting-stop';
|
|
214
|
-
const transitionPromise = result.transitionPromise
|
|
215
|
-
&& typeof result.transitionPromise.then === 'function'
|
|
216
|
-
? result.transitionPromise
|
|
217
|
-
: Promise.resolve({
|
|
218
|
-
captureStopConfirmed: false,
|
|
219
|
-
evidenceUnavailable: true
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
void Promise.resolve(transitionPromise).then(
|
|
223
|
-
settlement => {
|
|
224
|
-
if (!ownerIsCurrent(owner) || owner.waitToken !== waitToken) {
|
|
225
|
-
return;
|
|
226
|
-
}
|
|
227
|
-
const remainingMs = owner.deadlineAtEpochMs - now();
|
|
228
|
-
const captureStopConfirmed = settlement?.captureStopConfirmed === true;
|
|
229
|
-
const requiredRetryBudgetMs = captureStopConfirmed
|
|
230
|
-
? retryAfterMs
|
|
231
|
-
: owner.transitionTimeoutMs + retryAfterMs;
|
|
232
|
-
if (remainingMs < requiredRetryBudgetMs) {
|
|
233
|
-
failOwner(owner, 'CAPTURE_TRANSITION_TIMEOUT', {
|
|
234
|
-
captureStopConfirmed,
|
|
235
|
-
remainingMs: Math.max(0, remainingMs)
|
|
236
|
-
});
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
owner.phase = 'retry-delay';
|
|
240
|
-
owner.timer = setTimer(() => {
|
|
241
|
-
if (!ownerIsCurrent(owner) || owner.waitToken !== waitToken) {
|
|
242
|
-
return;
|
|
243
|
-
}
|
|
244
|
-
owner.timer = null;
|
|
245
|
-
owner.phase = 'retrying';
|
|
246
|
-
owner.attemptCount += 1;
|
|
247
|
-
const retryContext = {
|
|
248
|
-
deviceId: owner.deviceId,
|
|
249
|
-
intentGeneration: owner.intentGeneration,
|
|
250
|
-
attemptCount: owner.attemptCount,
|
|
251
|
-
deadlineAtEpochMs: owner.deadlineAtEpochMs,
|
|
252
|
-
captureStopConfirmed
|
|
253
|
-
};
|
|
254
|
-
void Promise.resolve()
|
|
255
|
-
.then(() => owner.retry?.(retryContext))
|
|
256
|
-
.then(() => {
|
|
257
|
-
if (ownerIsCurrent(owner) && owner.phase === 'retrying') {
|
|
258
|
-
failOwner(owner, 'CAPTURE_TRANSITION_RETRY_NOT_SETTLED');
|
|
259
|
-
}
|
|
260
|
-
})
|
|
261
|
-
.catch(error => {
|
|
262
|
-
failOwner(owner, 'CAPTURE_TRANSITION_RETRY_FAILED', {
|
|
263
|
-
detail: error instanceof Error ? error.message : String(error || '')
|
|
264
|
-
});
|
|
265
|
-
});
|
|
266
|
-
}, Math.min(retryAfterMs, remainingMs));
|
|
267
|
-
owner.timer?.unref?.();
|
|
268
|
-
},
|
|
269
|
-
error => {
|
|
270
|
-
failOwner(owner, 'CAPTURE_TRANSITION_WAIT_FAILED', {
|
|
271
|
-
detail: error instanceof Error ? error.message : String(error || '')
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
);
|
|
275
|
-
return true;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function snapshot() {
|
|
279
|
-
const owners = [...activeOwners].filter(ownerIsCurrent);
|
|
280
|
-
return {
|
|
281
|
-
activeOwnerCount: owners.length,
|
|
282
|
-
timerCount: owners.filter(owner => !!owner.timer).length,
|
|
283
|
-
waitingStopCount: owners.filter(owner => owner.phase === 'waiting-stop').length,
|
|
284
|
-
retryingCount: owners.filter(owner => owner.phase === 'retrying').length,
|
|
285
|
-
attemptCount: owners.reduce((total, owner) => total + owner.attemptCount, 0),
|
|
286
|
-
terminal: owners.length === 0
|
|
287
|
-
};
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
return Object.freeze({
|
|
291
|
-
beginIntent,
|
|
292
|
-
schedule,
|
|
293
|
-
complete,
|
|
294
|
-
cancelClient,
|
|
295
|
-
snapshot
|
|
296
|
-
});
|
|
297
|
-
}
|
|
1
|
+
function finitePositive(value, fallback) {
|
|
2
|
+
const number = Number(value);
|
|
3
|
+
return Number.isFinite(number) && number > 0 ? number : fallback;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function clamp(value, minimum, maximum) {
|
|
7
|
+
return Math.max(minimum, Math.min(maximum, value));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Owns the Hub-to-browser retry that follows an exact native capture stop.
|
|
12
|
+
*
|
|
13
|
+
* Contract:
|
|
14
|
+
* - one owner per WebSocket/device intent generation;
|
|
15
|
+
* - the native stop promise is the event source (no polling loop);
|
|
16
|
+
* - retries and total elapsed time are both bounded;
|
|
17
|
+
* - supersede, close, success, and terminal failure synchronously release every timer.
|
|
18
|
+
*/
|
|
19
|
+
export function createLiveCaptureTransitionRetryCoordinator(options = {}) {
|
|
20
|
+
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
21
|
+
const setTimer = typeof options.setTimer === 'function' ? options.setTimer : setTimeout;
|
|
22
|
+
const clearTimer = typeof options.clearTimer === 'function' ? options.clearTimer : clearTimeout;
|
|
23
|
+
const maxAttempts = Math.max(1, Math.floor(finitePositive(options.maxAttempts, 3)));
|
|
24
|
+
const minimumRetryAfterMs = Math.max(1, finitePositive(options.minimumRetryAfterMs, 25));
|
|
25
|
+
const maximumRetryAfterMs = Math.max(
|
|
26
|
+
minimumRetryAfterMs,
|
|
27
|
+
finitePositive(options.maximumRetryAfterMs, 250)
|
|
28
|
+
);
|
|
29
|
+
const minimumTotalMs = Math.max(1, finitePositive(options.minimumTotalMs, 2_000));
|
|
30
|
+
const maximumTotalMs = Math.max(minimumTotalMs, finitePositive(options.maximumTotalMs, 30_000));
|
|
31
|
+
const completionMarginMs = Math.max(0, Number(options.completionMarginMs ?? 2_000) || 0);
|
|
32
|
+
const clientStates = new WeakMap();
|
|
33
|
+
const activeOwners = new Set();
|
|
34
|
+
|
|
35
|
+
function stateFor(client) {
|
|
36
|
+
if (!client || (typeof client !== 'object' && typeof client !== 'function')) {
|
|
37
|
+
throw new TypeError('A capture transition retry requires an exact client owner.');
|
|
38
|
+
}
|
|
39
|
+
let state = clientStates.get(client);
|
|
40
|
+
if (!state) {
|
|
41
|
+
state = {
|
|
42
|
+
intentGenerations: new Map(),
|
|
43
|
+
owners: new Map()
|
|
44
|
+
};
|
|
45
|
+
clientStates.set(client, state);
|
|
46
|
+
}
|
|
47
|
+
return state;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function ownerIsCurrent(owner) {
|
|
51
|
+
if (!owner || owner.cancelled) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
const state = clientStates.get(owner.client);
|
|
55
|
+
return !!state
|
|
56
|
+
&& state.owners.get(owner.deviceId) === owner
|
|
57
|
+
&& state.intentGenerations.get(owner.deviceId) === owner.intentGeneration;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function releaseOwner(owner, reason = 'completed') {
|
|
61
|
+
if (!owner || owner.cancelled) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
owner.cancelled = true;
|
|
65
|
+
owner.cancelReason = reason;
|
|
66
|
+
owner.waitToken += 1;
|
|
67
|
+
if (owner.timer) {
|
|
68
|
+
clearTimer(owner.timer);
|
|
69
|
+
owner.timer = null;
|
|
70
|
+
}
|
|
71
|
+
const state = clientStates.get(owner.client);
|
|
72
|
+
if (state?.owners.get(owner.deviceId) === owner) {
|
|
73
|
+
state.owners.delete(owner.deviceId);
|
|
74
|
+
}
|
|
75
|
+
activeOwners.delete(owner);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function failOwner(owner, error, details = {}) {
|
|
80
|
+
if (!ownerIsCurrent(owner)) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const onTerminal = owner.onTerminal;
|
|
84
|
+
const terminal = {
|
|
85
|
+
deviceId: owner.deviceId,
|
|
86
|
+
intentGeneration: owner.intentGeneration,
|
|
87
|
+
attemptCount: owner.attemptCount,
|
|
88
|
+
deadlineAtEpochMs: owner.deadlineAtEpochMs,
|
|
89
|
+
error,
|
|
90
|
+
...details
|
|
91
|
+
};
|
|
92
|
+
releaseOwner(owner, error);
|
|
93
|
+
try {
|
|
94
|
+
onTerminal?.(terminal);
|
|
95
|
+
} catch {
|
|
96
|
+
// The retry owner is already terminal. UI notification failure cannot
|
|
97
|
+
// resurrect it or retain a timer.
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function beginIntent(client, deviceId) {
|
|
103
|
+
const normalizedDeviceId = String(deviceId || '').trim();
|
|
104
|
+
if (!normalizedDeviceId) {
|
|
105
|
+
throw new TypeError('A capture transition retry requires a device id.');
|
|
106
|
+
}
|
|
107
|
+
const state = stateFor(client);
|
|
108
|
+
releaseOwner(state.owners.get(normalizedDeviceId), 'superseded');
|
|
109
|
+
const current = Number(state.intentGenerations.get(normalizedDeviceId) || 0);
|
|
110
|
+
const next = Number.isSafeInteger(current) && current > 0 ? current + 1 : 1;
|
|
111
|
+
state.intentGenerations.set(normalizedDeviceId, next);
|
|
112
|
+
return next;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function complete(client, deviceId, intentGeneration, reason = 'completed') {
|
|
116
|
+
const normalizedDeviceId = String(deviceId || '').trim();
|
|
117
|
+
const state = clientStates.get(client);
|
|
118
|
+
const owner = state?.owners.get(normalizedDeviceId);
|
|
119
|
+
if (!owner
|
|
120
|
+
|| (Number.isSafeInteger(Number(intentGeneration))
|
|
121
|
+
&& Number(intentGeneration) > 0
|
|
122
|
+
&& owner.intentGeneration !== Number(intentGeneration))) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
return releaseOwner(owner, reason);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function cancelClient(client, reason = 'client-closed') {
|
|
129
|
+
const state = clientStates.get(client);
|
|
130
|
+
if (!state) {
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
let cancelled = 0;
|
|
134
|
+
for (const owner of [...state.owners.values()]) {
|
|
135
|
+
if (releaseOwner(owner, reason)) {
|
|
136
|
+
cancelled += 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
state.intentGenerations.clear();
|
|
140
|
+
clientStates.delete(client);
|
|
141
|
+
return cancelled;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function schedule(client, request = {}) {
|
|
145
|
+
const deviceId = String(request.deviceId || '').trim();
|
|
146
|
+
const intentGeneration = Number(request.intentGeneration || 0);
|
|
147
|
+
const result = request.result && typeof request.result === 'object'
|
|
148
|
+
? request.result
|
|
149
|
+
: {};
|
|
150
|
+
const state = stateFor(client);
|
|
151
|
+
if (!deviceId
|
|
152
|
+
|| !Number.isSafeInteger(intentGeneration)
|
|
153
|
+
|| intentGeneration <= 0
|
|
154
|
+
|| state.intentGenerations.get(deviceId) !== intentGeneration
|
|
155
|
+
|| result.error !== 'CAPTURE_TRANSITION_IN_PROGRESS') {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let owner = state.owners.get(deviceId);
|
|
160
|
+
if (owner && owner.intentGeneration !== intentGeneration) {
|
|
161
|
+
releaseOwner(owner, 'superseded');
|
|
162
|
+
owner = null;
|
|
163
|
+
}
|
|
164
|
+
const transitionTimeoutMs = clamp(
|
|
165
|
+
finitePositive(result.transitionTimeoutMs, 11_000),
|
|
166
|
+
minimumRetryAfterMs,
|
|
167
|
+
maximumTotalMs
|
|
168
|
+
);
|
|
169
|
+
if (!owner) {
|
|
170
|
+
const totalWindowMs = clamp(
|
|
171
|
+
transitionTimeoutMs + completionMarginMs,
|
|
172
|
+
minimumTotalMs,
|
|
173
|
+
maximumTotalMs
|
|
174
|
+
);
|
|
175
|
+
owner = {
|
|
176
|
+
client,
|
|
177
|
+
deviceId,
|
|
178
|
+
intentGeneration,
|
|
179
|
+
attemptCount: 0,
|
|
180
|
+
deadlineAtEpochMs: now() + totalWindowMs,
|
|
181
|
+
timer: null,
|
|
182
|
+
phase: 'created',
|
|
183
|
+
cancelled: false,
|
|
184
|
+
cancelReason: '',
|
|
185
|
+
waitToken: 0,
|
|
186
|
+
retry: request.retry,
|
|
187
|
+
onTerminal: request.onTerminal,
|
|
188
|
+
transitionTimeoutMs
|
|
189
|
+
};
|
|
190
|
+
state.owners.set(deviceId, owner);
|
|
191
|
+
activeOwners.add(owner);
|
|
192
|
+
} else {
|
|
193
|
+
owner.retry = request.retry;
|
|
194
|
+
owner.onTerminal = request.onTerminal;
|
|
195
|
+
owner.transitionTimeoutMs = transitionTimeoutMs;
|
|
196
|
+
if (owner.timer) {
|
|
197
|
+
clearTimer(owner.timer);
|
|
198
|
+
owner.timer = null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (owner.attemptCount >= maxAttempts) {
|
|
203
|
+
failOwner(owner, 'CAPTURE_TRANSITION_ATTEMPT_LIMIT');
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const retryAfterMs = clamp(
|
|
208
|
+
finitePositive(result.retryAfterMs, minimumRetryAfterMs),
|
|
209
|
+
minimumRetryAfterMs,
|
|
210
|
+
maximumRetryAfterMs
|
|
211
|
+
);
|
|
212
|
+
const waitToken = ++owner.waitToken;
|
|
213
|
+
owner.phase = 'waiting-stop';
|
|
214
|
+
const transitionPromise = result.transitionPromise
|
|
215
|
+
&& typeof result.transitionPromise.then === 'function'
|
|
216
|
+
? result.transitionPromise
|
|
217
|
+
: Promise.resolve({
|
|
218
|
+
captureStopConfirmed: false,
|
|
219
|
+
evidenceUnavailable: true
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
void Promise.resolve(transitionPromise).then(
|
|
223
|
+
settlement => {
|
|
224
|
+
if (!ownerIsCurrent(owner) || owner.waitToken !== waitToken) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const remainingMs = owner.deadlineAtEpochMs - now();
|
|
228
|
+
const captureStopConfirmed = settlement?.captureStopConfirmed === true;
|
|
229
|
+
const requiredRetryBudgetMs = captureStopConfirmed
|
|
230
|
+
? retryAfterMs
|
|
231
|
+
: owner.transitionTimeoutMs + retryAfterMs;
|
|
232
|
+
if (remainingMs < requiredRetryBudgetMs) {
|
|
233
|
+
failOwner(owner, 'CAPTURE_TRANSITION_TIMEOUT', {
|
|
234
|
+
captureStopConfirmed,
|
|
235
|
+
remainingMs: Math.max(0, remainingMs)
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
owner.phase = 'retry-delay';
|
|
240
|
+
owner.timer = setTimer(() => {
|
|
241
|
+
if (!ownerIsCurrent(owner) || owner.waitToken !== waitToken) {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
owner.timer = null;
|
|
245
|
+
owner.phase = 'retrying';
|
|
246
|
+
owner.attemptCount += 1;
|
|
247
|
+
const retryContext = {
|
|
248
|
+
deviceId: owner.deviceId,
|
|
249
|
+
intentGeneration: owner.intentGeneration,
|
|
250
|
+
attemptCount: owner.attemptCount,
|
|
251
|
+
deadlineAtEpochMs: owner.deadlineAtEpochMs,
|
|
252
|
+
captureStopConfirmed
|
|
253
|
+
};
|
|
254
|
+
void Promise.resolve()
|
|
255
|
+
.then(() => owner.retry?.(retryContext))
|
|
256
|
+
.then(() => {
|
|
257
|
+
if (ownerIsCurrent(owner) && owner.phase === 'retrying') {
|
|
258
|
+
failOwner(owner, 'CAPTURE_TRANSITION_RETRY_NOT_SETTLED');
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
.catch(error => {
|
|
262
|
+
failOwner(owner, 'CAPTURE_TRANSITION_RETRY_FAILED', {
|
|
263
|
+
detail: error instanceof Error ? error.message : String(error || '')
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}, Math.min(retryAfterMs, remainingMs));
|
|
267
|
+
owner.timer?.unref?.();
|
|
268
|
+
},
|
|
269
|
+
error => {
|
|
270
|
+
failOwner(owner, 'CAPTURE_TRANSITION_WAIT_FAILED', {
|
|
271
|
+
detail: error instanceof Error ? error.message : String(error || '')
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
);
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function snapshot() {
|
|
279
|
+
const owners = [...activeOwners].filter(ownerIsCurrent);
|
|
280
|
+
return {
|
|
281
|
+
activeOwnerCount: owners.length,
|
|
282
|
+
timerCount: owners.filter(owner => !!owner.timer).length,
|
|
283
|
+
waitingStopCount: owners.filter(owner => owner.phase === 'waiting-stop').length,
|
|
284
|
+
retryingCount: owners.filter(owner => owner.phase === 'retrying').length,
|
|
285
|
+
attemptCount: owners.reduce((total, owner) => total + owner.attemptCount, 0),
|
|
286
|
+
terminal: owners.length === 0
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return Object.freeze({
|
|
291
|
+
beginIntent,
|
|
292
|
+
schedule,
|
|
293
|
+
complete,
|
|
294
|
+
cancelClient,
|
|
295
|
+
snapshot
|
|
296
|
+
});
|
|
297
|
+
}
|