neoctl-web 0.1.8 → 0.1.10
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/control-sync.mjs +120 -26
- package/dist/assets/index-DGNbGP2p.js +123 -0
- package/dist/index.html +1 -1
- package/package.json +2 -2
- package/dist/assets/index-BihhJN9D.js +0 -122
package/control-sync.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
|
+
import { setTimeout as wait } from 'node:timers/promises';
|
|
4
5
|
import { execFile } from 'node:child_process';
|
|
5
6
|
import { createHash, randomUUID } from 'node:crypto';
|
|
6
7
|
import { seal, open } from './control-protocol.mjs';
|
|
@@ -131,7 +132,14 @@ export function createControlSync(options = {}) {
|
|
|
131
132
|
const fetchImpl = options.fetchImpl || fetch;
|
|
132
133
|
const pollMs = Math.min(30_000, Math.max(1, Number(options.pollMs) || 1000));
|
|
133
134
|
const timeoutMs = Math.min(30_000, Math.max(1, Number(options.timeoutMs) || 8000));
|
|
135
|
+
const fastPollMs = Math.min(30_000, Math.max(500, Number(options.fastPollMs) || 500));
|
|
136
|
+
const scanLimit = Math.min(1024, Math.max(2, Math.floor(Number(options.scanLimit) || 64)));
|
|
137
|
+
const indexTtlMs = Math.min(60_000, Math.max(1, Number(options.indexTtlMs) || 30_000));
|
|
134
138
|
let state, pairing, identity, inFlight, controller, timer;
|
|
139
|
+
let registryCache, registryStamp, indexAt = 0, lastSyncAt = 0, backlog = false;
|
|
140
|
+
const active = new Map();
|
|
141
|
+
const activeTtlMs = Math.min(60_000, Math.max(1, Number(options.activeTtlMs) || 30_000));
|
|
142
|
+
let serverReportingBlocked = false, reportingProbed = false;
|
|
135
143
|
let enrolled = false;
|
|
136
144
|
let stopped = false, started = false, failures = 0, rotation = 0, lastDiagnostic = '';
|
|
137
145
|
|
|
@@ -170,29 +178,62 @@ export function createControlSync(options = {}) {
|
|
|
170
178
|
await atomicJson(stateFile, state);
|
|
171
179
|
await diagnose('TRANSCRIPT_CONFLICT');
|
|
172
180
|
}
|
|
181
|
+
async function sessionIndex() {
|
|
182
|
+
// Stat every cycle, parse/sort on change or TTL. Index registered IDs directly;
|
|
183
|
+
// unrelated Engine/CLI history never adds filesystem scan work.
|
|
184
|
+
try {
|
|
185
|
+
const stat = await fs.stat(registryFile, { bigint: true });
|
|
186
|
+
const stamp = [stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].join(':');
|
|
187
|
+
if (!registryCache || stamp !== registryStamp || Date.now() - indexAt >= indexTtlMs) {
|
|
188
|
+
const registry = JSON.parse(await fs.readFile(registryFile, 'utf8'));
|
|
189
|
+
if (!registry || typeof registry !== 'object' || Array.isArray(registry)) throw new Error('REGISTRY_INVALID');
|
|
190
|
+
const ids = Object.keys(registry).filter(validId).sort();
|
|
191
|
+
registryCache = { ids, members: new Set(ids) };
|
|
192
|
+
registryStamp = stamp;
|
|
193
|
+
indexAt = Date.now();
|
|
194
|
+
for (const id of active.keys()) if (!registryCache.members.has(id)) active.delete(id);
|
|
195
|
+
}
|
|
196
|
+
return registryCache.ids;
|
|
197
|
+
} catch {
|
|
198
|
+
registryCache = undefined;
|
|
199
|
+
registryStamp = undefined;
|
|
200
|
+
active.clear();
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
173
204
|
async function collect() {
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
177
|
-
// Fail closed: shared Engine storage may contain unrelated CLI history.
|
|
178
|
-
let registry;
|
|
179
|
-
try { registry = JSON.parse(await fs.readFile(registryFile, 'utf8')); } catch { return []; }
|
|
180
|
-
if (!registry || typeof registry !== 'object' || Array.isArray(registry)) return [];
|
|
181
|
-
const ids = entries.filter((entry) => entry.isDirectory() && validId(entry.name) && Object.hasOwn(registry, entry.name)).map((entry) => entry.name).sort();
|
|
205
|
+
const ids = await sessionIndex();
|
|
206
|
+
backlog = false;
|
|
182
207
|
if (!ids.length) return [];
|
|
183
208
|
const deltas = [];
|
|
184
209
|
let remaining = RAW_BUDGET;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
210
|
+
// Interleave a rotating active queue and cold sweep, with bounded disk work.
|
|
211
|
+
const visited = new Set();
|
|
212
|
+
let cold = 0;
|
|
213
|
+
const hot = [];
|
|
214
|
+
for (const id of active.keys()) { hot.push(id); if (hot.length >= Math.ceil(scanLimit / 2)) break; }
|
|
215
|
+
let hotIndex = 0;
|
|
216
|
+
for (let index = 0; index < scanLimit && deltas.length < 16 && remaining > 0 && stillEnabled(); index++) {
|
|
217
|
+
let sessionId = ids.length > scanLimit && index % 2 === 0 ? hot[hotIndex++] : undefined;
|
|
218
|
+
if (!sessionId) {
|
|
219
|
+
if (cold >= ids.length) break;
|
|
220
|
+
sessionId = ids[rotation % ids.length];
|
|
221
|
+
rotation = (rotation + 1) % ids.length;
|
|
222
|
+
cold++;
|
|
223
|
+
}
|
|
224
|
+
if (visited.has(sessionId)) continue;
|
|
225
|
+
visited.add(sessionId);
|
|
226
|
+
const activeUntil = active.get(sessionId);
|
|
227
|
+
active.delete(sessionId);
|
|
228
|
+
if (activeUntil > Date.now()) active.set(sessionId, activeUntil);
|
|
190
229
|
const cursor = state.cursors[sessionId] || { offset: 0 };
|
|
191
|
-
if (cursor.blocked) continue;
|
|
230
|
+
if (cursor.blocked) { active.delete(sessionId); continue; }
|
|
192
231
|
if (!Number.isSafeInteger(cursor.offset) || cursor.offset < 0) { await conflict(sessionId); continue; }
|
|
193
232
|
const filename = path.join(sessionsRoot, sessionId, FILE);
|
|
194
233
|
let handle;
|
|
195
234
|
try {
|
|
235
|
+
const parent = await fs.lstat(path.dirname(filename));
|
|
236
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) continue;
|
|
196
237
|
const link = await fs.lstat(filename);
|
|
197
238
|
if (!link.isFile() || link.isSymbolicLink()) continue;
|
|
198
239
|
handle = await fs.open(filename, 'r');
|
|
@@ -206,16 +247,21 @@ export function createControlSync(options = {}) {
|
|
|
206
247
|
const newline = data.lastIndexOf(10);
|
|
207
248
|
if (newline >= 0) data = data.subarray(0, newline + 1);
|
|
208
249
|
else if (data.length < CHUNK) data = Buffer.alloc(0);
|
|
250
|
+
if (!data.length) continue; // Empty/unfinished tails consume no delta slots.
|
|
251
|
+
active.set(sessionId, Date.now() + activeTtlMs);
|
|
252
|
+
if (stat.size > cursor.offset + data.length) backlog = true;
|
|
209
253
|
remaining -= data.length;
|
|
210
254
|
deltas.push({ sessionId, file: FILE, offset: cursor.offset, data: data.toString('base64') });
|
|
211
255
|
} catch (error) {
|
|
212
256
|
if (error.code !== 'ENOENT') await diagnose('TRANSCRIPT_READ_FAILED');
|
|
213
257
|
} finally { await handle?.close().catch(() => {}); }
|
|
214
258
|
}
|
|
259
|
+
if (remaining === 0 || deltas.length === 16) backlog = true;
|
|
215
260
|
return deltas;
|
|
216
261
|
}
|
|
217
262
|
async function acceptAcks(acks, deltas) {
|
|
218
263
|
if (!Array.isArray(acks)) throw new Error('ACK_INVALID');
|
|
264
|
+
if (!acks.length) return;
|
|
219
265
|
const next = structuredClone(state);
|
|
220
266
|
const sent = new Map(deltas.map((delta) => [delta.sessionId, delta]));
|
|
221
267
|
const seen = new Set();
|
|
@@ -224,6 +270,13 @@ export function createControlSync(options = {}) {
|
|
|
224
270
|
if (!delta || ack.file !== FILE || seen.has(ack.sessionId) || !Number.isSafeInteger(ack.offset) || ack.offset < 0 || ack.offset > delta.offset + Buffer.from(delta.data, 'base64').length) throw new Error('ACK_INVALID');
|
|
225
271
|
seen.add(ack.sessionId);
|
|
226
272
|
if (state.cursors[ack.sessionId]?.blocked) continue;
|
|
273
|
+
if (ack.error === 'QUOTA_EXCEEDED' && ack.retryable === false) {
|
|
274
|
+
next.cursors[ack.sessionId] = { ...state.cursors[ack.sessionId], offset: state.cursors[ack.sessionId]?.offset ?? delta.offset, blocked: true };
|
|
275
|
+
active.delete(ack.sessionId);
|
|
276
|
+
await diagnose('SESSION_QUOTA_EXCEEDED');
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (ack.error) throw new Error('ACK_INVALID');
|
|
227
280
|
if (ack.conflict === true) {
|
|
228
281
|
await conflict(ack.sessionId);
|
|
229
282
|
next.cursors[ack.sessionId] = state.cursors[ack.sessionId];
|
|
@@ -240,7 +293,7 @@ export function createControlSync(options = {}) {
|
|
|
240
293
|
next.cursors[ack.sessionId] = { offset: ack.offset, anchor: await anchorAt(handle, ack.offset) };
|
|
241
294
|
} finally { await handle?.close().catch(() => {}); }
|
|
242
295
|
}
|
|
243
|
-
await atomicJson(stateFile, next);
|
|
296
|
+
if (JSON.stringify(next) !== JSON.stringify(state)) await atomicJson(stateFile, next);
|
|
244
297
|
state = next;
|
|
245
298
|
}
|
|
246
299
|
async function exchange(endpoint, payload) {
|
|
@@ -249,11 +302,18 @@ export function createControlSync(options = {}) {
|
|
|
249
302
|
if (Buffer.byteLength(body) > MAX_PACKET) throw new Error('PACKET_LIMIT');
|
|
250
303
|
if (!stillEnabled()) throw new Error('STOPPED');
|
|
251
304
|
controller = new AbortController();
|
|
305
|
+
// All sync requests (including manual ticks and immediate ACK) share spacing.
|
|
306
|
+
if (endpoint === 'sync') {
|
|
307
|
+
const delay = Math.max(0, 500 - (Date.now() - lastSyncAt));
|
|
308
|
+
if (delay) await wait(delay, undefined, { signal: controller.signal });
|
|
309
|
+
if (!stillEnabled()) throw new Error('STOPPED');
|
|
310
|
+
lastSyncAt = Date.now();
|
|
311
|
+
}
|
|
252
312
|
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]);
|
|
253
313
|
const response = await fetchImpl(`${pairing.url}/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, signal, redirect: 'error' });
|
|
254
314
|
if (!response.ok) {
|
|
255
315
|
await response.body?.cancel().catch(() => {});
|
|
256
|
-
if (endpoint === 'sync' && [401, 403, 404].includes(response.status)) enrolled = false;
|
|
316
|
+
if (endpoint === 'sync' && [401, 403, 404].includes(response.status)) { enrolled = false; reportingProbed = false; }
|
|
257
317
|
throw new Error('CONTROL_HTTP_FAILED');
|
|
258
318
|
}
|
|
259
319
|
// Bounded response reading also covers chunked responses.
|
|
@@ -277,28 +337,62 @@ export function createControlSync(options = {}) {
|
|
|
277
337
|
if (!stillEnabled()) return false;
|
|
278
338
|
enrolled = true;
|
|
279
339
|
}
|
|
280
|
-
|
|
281
|
-
|
|
340
|
+
// First sync after launch/re-enrollment is control-only. Never inspect local
|
|
341
|
+
// transcripts until an authenticated response establishes reporting policy.
|
|
342
|
+
// A cycle is bounded to probe + regular sync + one immediate ACK (at most 3).
|
|
343
|
+
if (!reportingProbed) {
|
|
344
|
+
const hadPendingAck = state.ackCommandId && state.ackCommandPending !== false;
|
|
345
|
+
const applied = await syncOnce([]);
|
|
346
|
+
if (!stillEnabled()) return false;
|
|
347
|
+
if (applied) { await syncOnce([]); return stillEnabled(); }
|
|
348
|
+
if (serverReportingBlocked || hadPendingAck) return true;
|
|
349
|
+
}
|
|
350
|
+
// Persisted pending ACK has priority over transcript scans after restart.
|
|
351
|
+
if (state.ackCommandId && state.ackCommandPending !== false) {
|
|
352
|
+
const applied = await syncOnce([]);
|
|
353
|
+
if (applied && stillEnabled()) await syncOnce([]);
|
|
354
|
+
} else {
|
|
355
|
+
const deltas = serverReportingBlocked ? [] : await collect();
|
|
356
|
+
if (!stillEnabled()) return false;
|
|
357
|
+
const applied = await syncOnce(deltas);
|
|
358
|
+
// One extra request only; never recurse on commands returned by the ACK.
|
|
359
|
+
if (applied && stillEnabled()) await syncOnce([]);
|
|
360
|
+
}
|
|
361
|
+
return stillEnabled();
|
|
362
|
+
}
|
|
363
|
+
async function syncOnce(deltas) {
|
|
282
364
|
const payload = { requestId: randomUUID(), sentAt: Date.now(), device: identity, deltas };
|
|
283
|
-
if (state.ackCommandId) payload.ackCommandId = state.ackCommandId;
|
|
365
|
+
if (state.ackCommandId && state.ackCommandPending !== false) payload.ackCommandId = state.ackCommandId;
|
|
284
366
|
const { reply, signal } = await exchange('sync', payload);
|
|
285
|
-
if (!
|
|
286
|
-
|
|
367
|
+
if (!stillEnabled()) return false;
|
|
368
|
+
if (reply.reportingBlocked !== undefined && typeof reply.reportingBlocked !== 'boolean') throw new Error('REPORTING_POLICY_INVALID');
|
|
369
|
+
serverReportingBlocked = reply.reportingBlocked === true; // Legacy servers default false.
|
|
370
|
+
reportingProbed = true;
|
|
371
|
+
if (serverReportingBlocked) backlog = false;
|
|
372
|
+
// In-flight bytes may have been sent before learning the policy. The server
|
|
373
|
+
// discards them; never advance or freeze a cursor using a blocked response.
|
|
374
|
+
if (!serverReportingBlocked) await acceptAcks(reply.acks, deltas);
|
|
375
|
+
if (payload.ackCommandId) {
|
|
376
|
+
const next = { ...state, ackCommandPending: false };
|
|
377
|
+
await atomicJson(stateFile, next);
|
|
378
|
+
state = next;
|
|
379
|
+
}
|
|
287
380
|
if (reply.command) {
|
|
288
381
|
const command = reply.command;
|
|
289
382
|
if (!validId(command.id)) throw new Error('COMMAND_INVALID');
|
|
290
383
|
if (command.id !== state.ackCommandId) {
|
|
291
384
|
loginProfile(command.profile);
|
|
292
385
|
if (!options.applyProfile) throw new Error('LOGIN_UNAVAILABLE');
|
|
293
|
-
if (!
|
|
386
|
+
if (!stillEnabled()) return false;
|
|
294
387
|
await options.applyProfile(command.profile, { signal });
|
|
295
|
-
if (!
|
|
296
|
-
const next = { ...state, ackCommandId: command.id };
|
|
388
|
+
if (!stillEnabled()) return false;
|
|
389
|
+
const next = { ...state, ackCommandId: command.id, ackCommandPending: true };
|
|
297
390
|
await atomicJson(stateFile, next);
|
|
298
391
|
state = next;
|
|
392
|
+
return true;
|
|
299
393
|
}
|
|
300
394
|
}
|
|
301
|
-
return
|
|
395
|
+
return false;
|
|
302
396
|
}
|
|
303
397
|
function tick() {
|
|
304
398
|
if (inFlight) return inFlight;
|
|
@@ -314,7 +408,7 @@ export function createControlSync(options = {}) {
|
|
|
314
408
|
if (stopped || !started || !enabled) return;
|
|
315
409
|
timer = setTimeout(async () => {
|
|
316
410
|
await tick();
|
|
317
|
-
schedule(Math.min(30_000, pollMs * 2 ** failures));
|
|
411
|
+
schedule(failures ? Math.min(30_000, Math.max(500, pollMs) * 2 ** failures) : backlog ? fastPollMs : pollMs);
|
|
318
412
|
}, delay);
|
|
319
413
|
timer.unref?.();
|
|
320
414
|
}
|