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 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
- let entries;
175
- try { entries = await fs.readdir(sessionsRoot, { withFileTypes: true }); }
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
- const start = rotation % ids.length;
186
- for (let index = 0; index < ids.length && deltas.length < 16 && remaining > 0; index++) {
187
- const position = (start + index) % ids.length;
188
- rotation = position + 1;
189
- const sessionId = ids[position];
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
- const deltas = await collect();
281
- if (!stillEnabled()) return false;
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 (!await stillEnabled()) return false;
286
- await acceptAcks(reply.acks, deltas);
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 (!await stillEnabled()) return false;
386
+ if (!stillEnabled()) return false;
294
387
  await options.applyProfile(command.profile, { signal });
295
- if (!await stillEnabled()) return false;
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 true;
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
  }