ai-or-die 0.1.79 → 0.1.80
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/base-bridge.js +6 -1
- package/src/claude-bridge.js +77 -7
- package/src/control/event-bus.js +144 -27
- package/src/control/routes.js +49 -3
- package/src/control/session-status.js +93 -15
- package/src/server.js +681 -129
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-or-die",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.80",
|
|
4
4
|
"description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"test:browser": "npx playwright test --config e2e/playwright.config.js",
|
|
18
18
|
"soak": "node test/longevity/harness/cli.js",
|
|
19
19
|
"test:longevity-smoke": "mocha --exit --timeout 120000 test/longevity/smoke.test.js",
|
|
20
|
-
"test:longevity:server": "mocha --exit --timeout 120000 --recursive --extension .test.js test/longevity/event-loop test/longevity/disk test/longevity/process test/longevity/browser-sampler.test.js test/longevity/cli.test.js test/longevity/gate-evaluator-directional.test.js test/longevity/gate-evaluator-vacuous.test.js test/longevity/resume.test.js test/longevity/smoke.test.js",
|
|
20
|
+
"test:longevity:server": "mocha --exit --timeout 120000 --recursive --extension .test.js test/longevity/event-loop test/longevity/disk test/longevity/process test/longevity/browser-sampler.test.js test/longevity/cli.test.js test/longevity/gate-evaluator-directional.test.js test/longevity/gate-evaluator-vacuous.test.js test/longevity/event-loop-tolerance.test.js test/longevity/resume.test.js test/longevity/smoke.test.js",
|
|
21
21
|
"test:longevity:browser": "playwright test --config test/longevity/playwright.config.js",
|
|
22
22
|
"test:longevity": "npm run test:longevity:server && npm run test:longevity:browser",
|
|
23
23
|
"build:bundle": "node scripts/build-sea.js bundle",
|
package/src/base-bridge.js
CHANGED
|
@@ -275,6 +275,8 @@ class BaseBridge {
|
|
|
275
275
|
const {
|
|
276
276
|
workingDir = process.cwd(),
|
|
277
277
|
dangerouslySkipPermissions = false,
|
|
278
|
+
permissionMode = undefined,
|
|
279
|
+
agentArgs = undefined,
|
|
278
280
|
onOutput = () => {},
|
|
279
281
|
onExit = () => {},
|
|
280
282
|
onError = () => {},
|
|
@@ -292,7 +294,10 @@ class BaseBridge {
|
|
|
292
294
|
console.log(`WARNING: Using ${this.dangerousFlag} flag`);
|
|
293
295
|
}
|
|
294
296
|
|
|
295
|
-
|
|
297
|
+
// F10: forward permissionMode/agentArgs so tool-specific buildArgs (claude)
|
|
298
|
+
// can emit --permission-mode + caller passthrough flags. Subclasses that
|
|
299
|
+
// don't override buildArgs (terminal/codex) ignore these.
|
|
300
|
+
const args = this.buildArgs({ dangerouslySkipPermissions, permissionMode, agentArgs });
|
|
296
301
|
|
|
297
302
|
const env = {
|
|
298
303
|
...process.env,
|
package/src/claude-bridge.js
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
const BaseBridge = require('./base-bridge');
|
|
2
|
+
const { TRUST_PROMPT_REGEX } = require('./control/session-status');
|
|
3
|
+
|
|
4
|
+
// claude's --permission-mode allowlist (F10). The fleet/control plane forwards one
|
|
5
|
+
// of these; an unknown value is rejected up front rather than passed to claude (a
|
|
6
|
+
// flag silently ignored by a skewed claude is a dishonest "mode set").
|
|
7
|
+
const VALID_PERMISSION_MODES = ['plan', 'acceptEdits', 'default', 'bypassPermissions'];
|
|
8
|
+
|
|
9
|
+
function invalidArgument(message) {
|
|
10
|
+
const err = new Error(message);
|
|
11
|
+
err.code = 'INVALID_ARGUMENT';
|
|
12
|
+
return err;
|
|
13
|
+
}
|
|
2
14
|
|
|
3
15
|
// Claude is ALWAYS launched through github-router (never the raw `claude`
|
|
4
16
|
// binary): `npx -y github-router@latest claude --browse [claude flags]`. This
|
|
@@ -53,22 +65,79 @@ class ClaudeBridge extends BaseBridge {
|
|
|
53
65
|
this._trustPromptHandled = new Map();
|
|
54
66
|
}
|
|
55
67
|
|
|
68
|
+
/**
|
|
69
|
+
* F10 — the ONE canonical translation layer from a high-level launch intent
|
|
70
|
+
* (permissionMode + caller agentArgs) to claude's argv, appended AFTER the
|
|
71
|
+
* github-router launcher prefix (`npx -y github-router@latest claude --browse`).
|
|
72
|
+
*
|
|
73
|
+
* claude is launched through github-router, whose `claude` subcommand emits
|
|
74
|
+
* `--dangerously-skip-permissions` by default and DROPS it when a non-bypass
|
|
75
|
+
* `--permission-mode` is present. So here we only need to forward the mode flag;
|
|
76
|
+
* github-router reconciles the dangerous flag on its side.
|
|
77
|
+
*
|
|
78
|
+
* Conflict policy: `agentArgs` may NOT itself carry
|
|
79
|
+
* `--permission-mode` or `--dangerously-skip-permissions` — that would emit
|
|
80
|
+
* duplicate/conflicting flags. We throw INVALID_ARGUMENT so the create fails
|
|
81
|
+
* cleanly instead of launching an ambiguous claude. An unknown `permissionMode`
|
|
82
|
+
* is likewise rejected.
|
|
83
|
+
*/
|
|
84
|
+
buildArgs(options = {}) {
|
|
85
|
+
const prefix = this._prefixArgs || [];
|
|
86
|
+
const out = [...prefix];
|
|
87
|
+
const { permissionMode, agentArgs, dangerouslySkipPermissions } = options;
|
|
88
|
+
|
|
89
|
+
if (agentArgs != null && !Array.isArray(agentArgs)) {
|
|
90
|
+
throw invalidArgument('agentArgs must be an array of strings');
|
|
91
|
+
}
|
|
92
|
+
const extra = Array.isArray(agentArgs) ? agentArgs.map((a) => String(a)) : [];
|
|
93
|
+
for (const a of extra) {
|
|
94
|
+
if (/^--permission-mode(=|$)/.test(a) || a === '--dangerously-skip-permissions') {
|
|
95
|
+
throw invalidArgument(
|
|
96
|
+
`agentArgs may not contain '${a}'; use permissionMode for permission control`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (permissionMode != null && permissionMode !== '') {
|
|
102
|
+
const mode = String(permissionMode);
|
|
103
|
+
if (!VALID_PERMISSION_MODES.includes(mode)) {
|
|
104
|
+
throw invalidArgument(
|
|
105
|
+
`Unknown permissionMode '${mode}' (expected one of: ${VALID_PERMISSION_MODES.join(', ')})`
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
// permissionMode is the source of truth: emit the canonical flag and let
|
|
109
|
+
// github-router drop --dangerously-skip-permissions for non-bypass modes.
|
|
110
|
+
out.push('--permission-mode', mode);
|
|
111
|
+
} else if (dangerouslySkipPermissions && this.dangerousFlag) {
|
|
112
|
+
// Back-compat: no explicit mode → honor the legacy skip-permissions toggle.
|
|
113
|
+
out.push(this.dangerousFlag);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
out.push(...extra);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
56
120
|
processOutput(sessionId, ptyProcess, dataBuffer) {
|
|
57
121
|
if (this._trustPromptHandled.get(sessionId)) return;
|
|
58
122
|
// Strip ANSI first: claude's Ink TUI interleaves escape codes between words,
|
|
59
|
-
// so a clean-string
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
123
|
+
// so a clean-string match misses the folder-trust modal (and the exact wording
|
|
124
|
+
// shifts between claude versions). Match the shared TRUST_PROMPT_REGEX on the
|
|
125
|
+
// de-ANSI'd buffer — but ONLY act when the modal's numbered "1. / 2." choice
|
|
126
|
+
// list is ALSO present. That structural guard (mirroring awaitingFromScreen)
|
|
127
|
+
// prevents a false positive from claude merely PRINTING a trust phrase in prose
|
|
128
|
+
// / a file / a commit message, which would otherwise inject a spurious keystroke
|
|
129
|
+
// into the live PTY. Then send the EXPLICIT "1" choice + Enter (not a bare Enter,
|
|
130
|
+
// which could land on a wrong default) — matching _controlMapResponseKeys. The
|
|
63
131
|
// per-session guard makes this a one-shot. This is what lets HEADLESS /
|
|
64
132
|
// fleet-spawned claude sessions (no human to click) get past trust.
|
|
65
133
|
const plain = String(dataBuffer || '').replace(/\x1b\[[0-9;?]*[A-Za-z]/g, '');
|
|
66
|
-
|
|
134
|
+
const numbered = /\b1\.\s/.test(plain) && /\b2\.\s/.test(plain);
|
|
135
|
+
if (numbered && TRUST_PROMPT_REGEX.test(plain)) {
|
|
67
136
|
this._trustPromptHandled.set(sessionId, true);
|
|
68
137
|
console.log(`Auto-accepting trust prompt for session ${sessionId}`);
|
|
69
138
|
setTimeout(() => {
|
|
70
|
-
try { ptyProcess.write('\r'); } catch (_) { /* pty may have exited */ }
|
|
71
|
-
console.log(`Sent Enter to accept trust prompt for session ${sessionId}`);
|
|
139
|
+
try { ptyProcess.write('1\r'); } catch (_) { /* pty may have exited */ }
|
|
140
|
+
console.log(`Sent "1" + Enter to accept trust prompt for session ${sessionId}`);
|
|
72
141
|
}, 500);
|
|
73
142
|
}
|
|
74
143
|
}
|
|
@@ -81,3 +150,4 @@ class ClaudeBridge extends BaseBridge {
|
|
|
81
150
|
|
|
82
151
|
module.exports = ClaudeBridge;
|
|
83
152
|
module.exports.resolveClaudeLauncher = resolveClaudeLauncher;
|
|
153
|
+
module.exports.VALID_PERMISSION_MODES = VALID_PERMISSION_MODES;
|
package/src/control/event-bus.js
CHANGED
|
@@ -11,16 +11,39 @@
|
|
|
11
11
|
// - EPOCH: a per-process id. An instance restart mints a new epoch, so a
|
|
12
12
|
// client holding a pre-restart cursor is told `gap: restart` instead of
|
|
13
13
|
// silently resuming a reset sequence (the ABA problem).
|
|
14
|
-
// - GAP REPORTING: when
|
|
15
|
-
// returns `gap: overflow` so the client resyncs (via
|
|
16
|
-
//
|
|
17
|
-
// - The cursor is `{ epoch, seq }`; the model never threads it
|
|
18
|
-
// layer manages it per-client (contract v1).
|
|
14
|
+
// - GAP REPORTING: when retention rolls past a client's cursor, `since()`
|
|
15
|
+
// returns `gap: overflow` so the client resyncs (via snapshot) instead of
|
|
16
|
+
// believing nothing happened.
|
|
17
|
+
// - The cursor is `{ epoch, seq }`; the model never threads it - the fleet
|
|
18
|
+
// layer manages it per-client (contract v1). Cursors are PER-WATCHER:
|
|
19
|
+
// `since()` / `waitFor()` are pure reads parameterised by the caller's
|
|
20
|
+
// cursor, with NO shared global watcher position, so many concurrent
|
|
21
|
+
// watchers each resume from their own `{epoch,seq}` without interfering
|
|
22
|
+
// (F22).
|
|
23
|
+
//
|
|
24
|
+
// RETENTION IS PER SESSION (F15). The naive single global ring let one chatty
|
|
25
|
+
// session roll the buffer and evict another session's last `turn_ended`, forcing
|
|
26
|
+
// an avoidable resync. Instead each session keeps its OWN bounded ring, so a busy
|
|
27
|
+
// session only evicts its own old events - never another session's turn boundary.
|
|
28
|
+
// A global monotonic `seq` still orders events across sessions for the cursor,
|
|
29
|
+
// and `_maxEvictedSeq` (the highest seq ever dropped from ANY ring) drives
|
|
30
|
+
// overflow detection: a cursor older than it has provably missed an event. Whole
|
|
31
|
+
// idle-session rings are evicted (LRU) only past a large session cap, also
|
|
32
|
+
// bumping `_maxEvictedSeq` so the drop is never silent.
|
|
19
33
|
|
|
20
34
|
const { EventEmitter } = require('events');
|
|
21
35
|
const crypto = require('crypto');
|
|
22
36
|
|
|
23
|
-
|
|
37
|
+
// Per-SESSION ring depth. ~85 turns of busy/idle/turn_ended history per session,
|
|
38
|
+
// so a session's last turn_ended survives any realistic fan-out burst by OTHER
|
|
39
|
+
// sessions. (Back-compat: the old `maxEvents` option maps to this.)
|
|
40
|
+
const DEFAULT_MAX_EVENTS_PER_SESSION = 256;
|
|
41
|
+
// Whole-session bucket cap. Past this many distinct sessions, the least-recently
|
|
42
|
+
// active session's ring is evicted wholesale (its events are old/dead). Sized for
|
|
43
|
+
// the scale bar (100+ live sessions) with generous headroom for exited ones.
|
|
44
|
+
const DEFAULT_MAX_SESSIONS = 1024;
|
|
45
|
+
// Bucket key for session-less events (defensive; current emitters always pass an id).
|
|
46
|
+
const GLOBAL_KEY = '__global__';
|
|
24
47
|
|
|
25
48
|
// Event kinds the control plane emits (mirrors the contract's await_turn `kind`).
|
|
26
49
|
const EVENT_KINDS = Object.freeze([
|
|
@@ -38,18 +61,28 @@ class ControlEventBus extends EventEmitter {
|
|
|
38
61
|
constructor(options = {}) {
|
|
39
62
|
super();
|
|
40
63
|
this.setMaxListeners(0); // many concurrent long-polls
|
|
41
|
-
|
|
64
|
+
// `maxEvents` is the legacy single-ring cap; it now sizes the PER-SESSION ring
|
|
65
|
+
// (a chatty session evicts only its own old events, never another's turn_ended).
|
|
66
|
+
this._maxPerSession = options.maxEventsPerSession || options.maxEvents || DEFAULT_MAX_EVENTS_PER_SESSION;
|
|
67
|
+
this._maxSessions = options.maxSessions || DEFAULT_MAX_SESSIONS;
|
|
42
68
|
// A fresh, unguessable epoch per process. Math.random/Date are fine here
|
|
43
69
|
// (normal server code), but a crypto id avoids cross-process collisions.
|
|
44
70
|
this._epoch = options.epoch || crypto.randomBytes(8).toString('hex');
|
|
45
71
|
this._seq = 0; // last assigned seq; first event is seq 1
|
|
46
|
-
this.
|
|
72
|
+
this._buckets = new Map(); // sessionKey -> [{ seq, sessionId, kind, at, detail? }] ascending; Map order = LRU
|
|
73
|
+
this._maxEvictedSeq = 0; // highest seq dropped from ANY ring (GLOBAL overflow floor, for unfiltered watchers)
|
|
74
|
+
this._evictedBySession = new Map(); // sessionKey -> highest seq evicted from THAT bucket (filter-aware overflow)
|
|
47
75
|
}
|
|
48
76
|
|
|
49
77
|
get epoch() {
|
|
50
78
|
return this._epoch;
|
|
51
79
|
}
|
|
52
80
|
|
|
81
|
+
/** Per-session ring depth (the retention contract surfaced via /capabilities). */
|
|
82
|
+
get maxEventsPerSession() {
|
|
83
|
+
return this._maxPerSession;
|
|
84
|
+
}
|
|
85
|
+
|
|
53
86
|
/** Append an event, assign it the next seq, and notify long-poll waiters. */
|
|
54
87
|
append(sessionId, kind, detail) {
|
|
55
88
|
if (!EVENT_KINDS.includes(kind)) {
|
|
@@ -57,22 +90,79 @@ class ControlEventBus extends EventEmitter {
|
|
|
57
90
|
}
|
|
58
91
|
const event = { seq: ++this._seq, sessionId: sessionId || null, kind, at: Date.now() };
|
|
59
92
|
if (detail !== undefined) event.detail = detail;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
93
|
+
|
|
94
|
+
const key = event.sessionId || GLOBAL_KEY;
|
|
95
|
+
let bucket = this._buckets.get(key);
|
|
96
|
+
if (bucket) {
|
|
97
|
+
this._buckets.delete(key); // re-insert to mark this session most-recently-active (LRU order)
|
|
98
|
+
} else {
|
|
99
|
+
bucket = [];
|
|
100
|
+
}
|
|
101
|
+
bucket.push(event);
|
|
102
|
+
if (bucket.length > this._maxPerSession) {
|
|
103
|
+
// Evict THIS session's oldest events only - never another session's.
|
|
104
|
+
const dropped = bucket.splice(0, bucket.length - this._maxPerSession);
|
|
105
|
+
this._bumpEvictedFor(key, dropped[dropped.length - 1].seq);
|
|
63
106
|
}
|
|
107
|
+
this._buckets.set(key, bucket);
|
|
108
|
+
|
|
109
|
+
// Whole-session eviction: past the session cap, drop the least-recently-active
|
|
110
|
+
// session's ring entirely (its events are stale). Bump the floor so a watcher
|
|
111
|
+
// holding a cursor into that range gets an overflow gap, not a silent loss.
|
|
112
|
+
while (this._buckets.size > this._maxSessions) {
|
|
113
|
+
const oldestKey = this._buckets.keys().next().value;
|
|
114
|
+
const oldest = this._buckets.get(oldestKey);
|
|
115
|
+
this._buckets.delete(oldestKey);
|
|
116
|
+
if (oldest && oldest.length) this._bumpEvictedFor(oldestKey, oldest[oldest.length - 1].seq);
|
|
117
|
+
}
|
|
118
|
+
|
|
64
119
|
this.emit('event', event);
|
|
65
120
|
return event;
|
|
66
121
|
}
|
|
67
122
|
|
|
123
|
+
_bumpEvicted(seq) {
|
|
124
|
+
if (seq > this._maxEvictedSeq) this._maxEvictedSeq = seq;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Bump BOTH the global overflow floor and the per-bucket evicted watermark. */
|
|
128
|
+
_bumpEvictedFor(key, seq) {
|
|
129
|
+
this._bumpEvicted(seq);
|
|
130
|
+
const cur = this._evictedBySession.get(key) || 0;
|
|
131
|
+
if (seq > cur) this._evictedBySession.set(key, seq);
|
|
132
|
+
}
|
|
133
|
+
|
|
68
134
|
/** The cursor a fresh consumer should start from (i.e. "only new events"). */
|
|
69
135
|
headCursor() {
|
|
70
136
|
return { epoch: this._epoch, seq: this._seq };
|
|
71
137
|
}
|
|
72
138
|
|
|
73
139
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
140
|
+
* Retained events with seq strictly greater than `minSeq`, merged across all
|
|
141
|
+
* session rings and returned ascending by seq. Each ring is already ascending,
|
|
142
|
+
* so we walk it from the tail and stop once we pass `minSeq` - only the new tail
|
|
143
|
+
* of each session is collected, not the whole fleet history.
|
|
144
|
+
*/
|
|
145
|
+
_collectAfter(minSeq) {
|
|
146
|
+
const out = [];
|
|
147
|
+
for (const bucket of this._buckets.values()) {
|
|
148
|
+
for (let i = bucket.length - 1; i >= 0; i--) {
|
|
149
|
+
if (bucket[i].seq <= minSeq) break;
|
|
150
|
+
out.push(bucket[i]);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
out.sort((a, b) => a.seq - b.seq);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** All retained events, ascending by seq (diagnostics / restart resync). */
|
|
158
|
+
listEvents() {
|
|
159
|
+
return this._collectAfter(0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Events strictly after `cursor`, plus any gap markers. Pure read - no shared
|
|
164
|
+
* cursor state, so concurrent watchers are independent (F22).
|
|
165
|
+
* @param {?{epoch:string, seq:number}} cursor omitted/null -> start at head (no replay)
|
|
76
166
|
* @param {?{sessionIds?:string[], kinds?:string[]}} [filter]
|
|
77
167
|
*/
|
|
78
168
|
since(cursor, filter) {
|
|
@@ -86,18 +176,34 @@ class ControlEventBus extends EventEmitter {
|
|
|
86
176
|
// The instance restarted under this consumer. Surface a gap and hand back
|
|
87
177
|
// everything we currently retain so the consumer can resync.
|
|
88
178
|
gaps.push({ reason: 'restart' });
|
|
89
|
-
events = this.
|
|
179
|
+
events = this._collectAfter(0);
|
|
90
180
|
} else {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
181
|
+
// Overflow is FILTER-AWARE: a session-filtered watcher must only see overflow
|
|
182
|
+
// caused by eviction in a session IT watches, not by another chatty session's
|
|
183
|
+
// eviction. Unfiltered watchers use the global floor.
|
|
184
|
+
const overflowFloor = (filter && Array.isArray(filter.sessionIds) && filter.sessionIds.length)
|
|
185
|
+
? Math.max(0, ...filter.sessionIds.map((id) => this._evictedBySession.get(id || GLOBAL_KEY) || 0))
|
|
186
|
+
: this._maxEvictedSeq;
|
|
187
|
+
if (cursor.seq < overflowFloor) {
|
|
188
|
+
// At least one event the watcher cares about was evicted: it must resync
|
|
189
|
+
// via the snapshot endpoint.
|
|
190
|
+
gaps.push({ reason: 'overflow', fromSeq: cursor.seq, toSeq: overflowFloor });
|
|
95
191
|
}
|
|
96
|
-
|
|
192
|
+
// Collect AFTER the gap boundary so returned events never contradict the gap
|
|
193
|
+
// (an event with seq inside [cursor.seq, overflowFloor] would overlap the
|
|
194
|
+
// claimed-lost range). With no overflow this is just cursor.seq - unchanged.
|
|
195
|
+
events = this._collectAfter(Math.max(cursor.seq, overflowFloor));
|
|
97
196
|
}
|
|
98
197
|
|
|
99
198
|
const filtered = applyFilter(events, filter);
|
|
100
|
-
|
|
199
|
+
// Advance the cursor to the global head (`_seq`) past EVERY event seen, so a
|
|
200
|
+
// filtered watcher never re-scans and a stale cursor can't re-trigger overflow.
|
|
201
|
+
// The newest event is always retained (a ring evicts only its own oldest, and
|
|
202
|
+
// the just-active bucket is never the LRU whole-session victim), so in every
|
|
203
|
+
// reachable state `events[last].seq === _seq` whenever anything is newer than
|
|
204
|
+
// the cursor; setting it unconditionally removes that fragile invariant-reasoning
|
|
205
|
+
// with zero behaviour change.
|
|
206
|
+
const maxSeq = this._seq;
|
|
101
207
|
return { events: filtered, gaps, cursor: { epoch: this._epoch, seq: maxSeq }, more: false };
|
|
102
208
|
}
|
|
103
209
|
|
|
@@ -108,8 +214,17 @@ class ControlEventBus extends EventEmitter {
|
|
|
108
214
|
* @returns {Promise<{events:Array, gaps:Array, cursor:object, more:boolean}>}
|
|
109
215
|
*/
|
|
110
216
|
waitFor(cursor, timeoutMs, filter) {
|
|
111
|
-
|
|
112
|
-
|
|
217
|
+
// A fresh watcher (no cursor) must still RECEIVE the event that wakes the
|
|
218
|
+
// long-poll. since(undefined) returns []; without anchoring to the current
|
|
219
|
+
// head, the woken poll would resolve since(undefined) again and drop the very
|
|
220
|
+
// event it woke for. Anchor to head: only events AFTER wait-start are returned
|
|
221
|
+
// (no history replay), and the waking event is delivered.
|
|
222
|
+
const baseCursor = cursor || this.headCursor();
|
|
223
|
+
const immediate = this.since(baseCursor, filter);
|
|
224
|
+
// Return immediately when there's already something to deliver, OR when this is
|
|
225
|
+
// a non-blocking poll (timeoutMs <= 0): a 0-timeout caller wants "what's there
|
|
226
|
+
// now", and entering the wait with no armed timer (FIX D) would otherwise hang.
|
|
227
|
+
if (immediate.events.length || immediate.gaps.length || timeoutMs <= 0) return Promise.resolve(immediate);
|
|
113
228
|
|
|
114
229
|
return new Promise((resolve) => {
|
|
115
230
|
let done = false;
|
|
@@ -117,14 +232,16 @@ class ControlEventBus extends EventEmitter {
|
|
|
117
232
|
if (done) return;
|
|
118
233
|
done = true;
|
|
119
234
|
this.removeListener('event', onEvent);
|
|
120
|
-
clearTimeout(timer);
|
|
121
|
-
resolve(this.since(
|
|
235
|
+
if (timer) clearTimeout(timer);
|
|
236
|
+
resolve(this.since(baseCursor, filter));
|
|
122
237
|
};
|
|
123
238
|
const onEvent = (event) => {
|
|
124
239
|
if (matches(event, filter)) finish();
|
|
125
240
|
};
|
|
126
|
-
|
|
127
|
-
|
|
241
|
+
// FIX D: only arm the timer when a positive timeout is given; setTimeout(0)
|
|
242
|
+
// would resolve on the next macrotask and turn the long-poll into a hot loop.
|
|
243
|
+
const timer = timeoutMs > 0 ? setTimeout(finish, timeoutMs) : null;
|
|
244
|
+
if (timer && timer.unref) timer.unref();
|
|
128
245
|
this.on('event', onEvent);
|
|
129
246
|
});
|
|
130
247
|
}
|
|
@@ -146,4 +263,4 @@ function applyFilter(events, filter) {
|
|
|
146
263
|
return events.filter((e) => matches(e, filter));
|
|
147
264
|
}
|
|
148
265
|
|
|
149
|
-
module.exports = { ControlEventBus, EVENT_KINDS,
|
|
266
|
+
module.exports = { ControlEventBus, EVENT_KINDS, DEFAULT_MAX_EVENTS_PER_SESSION, DEFAULT_MAX_SESSIONS };
|
package/src/control/routes.js
CHANGED
|
@@ -67,16 +67,18 @@ function summaryFromStatus(id, session, status) {
|
|
|
67
67
|
|
|
68
68
|
function parseCursor(raw) {
|
|
69
69
|
if (!raw) return undefined;
|
|
70
|
-
// Accept either "epoch:seq" or a base64url JSON blob.
|
|
70
|
+
// Accept either "epoch:seq" or a base64url JSON blob. A valid seq is a
|
|
71
|
+
// non-negative safe integer (FIX C: reject -5, 1.5, NaN so a present-but-invalid
|
|
72
|
+
// cursor is a client bug surfaced as 400, not a silent fresh tail-follow).
|
|
71
73
|
if (typeof raw === 'string' && raw.includes(':')) {
|
|
72
74
|
const idx = raw.lastIndexOf(':');
|
|
73
75
|
const epoch = raw.slice(0, idx);
|
|
74
76
|
const seq = Number(raw.slice(idx + 1));
|
|
75
|
-
if (epoch && Number.
|
|
77
|
+
if (epoch && Number.isSafeInteger(seq) && seq >= 0) return { epoch, seq };
|
|
76
78
|
}
|
|
77
79
|
try {
|
|
78
80
|
const obj = JSON.parse(Buffer.from(String(raw), 'base64').toString('utf8'));
|
|
79
|
-
if (obj && typeof obj.epoch === 'string' && Number.
|
|
81
|
+
if (obj && typeof obj.epoch === 'string' && Number.isSafeInteger(obj.seq) && obj.seq >= 0) return obj;
|
|
80
82
|
} catch {
|
|
81
83
|
/* fall through */
|
|
82
84
|
}
|
|
@@ -157,17 +159,52 @@ function createControlRouter(deps) {
|
|
|
157
159
|
if (isRateLimitExempt(req) || !rateLimit.max || !rateLimit.windowMs) return next();
|
|
158
160
|
const limited = checkRateLimit(rateLimitBuckets, rateLimit, rateLimitIdentity(req));
|
|
159
161
|
if (limited) {
|
|
162
|
+
// F21: a CLASSIFIABLE 429 so the fleet client can back off precisely rather
|
|
163
|
+
// than hammer. Stable error.code='RATE_LIMITED' + retryAfterMs in the body,
|
|
164
|
+
// and the standard Retry-After header (whole seconds, min 1).
|
|
165
|
+
const retryAfterSec = Math.max(1, Math.ceil(limited.retryAfterMs / 1000));
|
|
166
|
+
res.set('Retry-After', String(retryAfterSec));
|
|
160
167
|
return res.status(429).json({
|
|
161
168
|
error: {
|
|
162
169
|
code: 'RATE_LIMITED',
|
|
163
170
|
message: 'Too many control requests; retry after the current rate-limit window',
|
|
164
171
|
retryAfterMs: limited.retryAfterMs,
|
|
172
|
+
retryAfterSec,
|
|
165
173
|
},
|
|
166
174
|
});
|
|
167
175
|
}
|
|
168
176
|
next();
|
|
169
177
|
});
|
|
170
178
|
|
|
179
|
+
// GET /capabilities — F19 cross-repo capability negotiation. The fleet client
|
|
180
|
+
// reads this ONCE per instance and fails closed on a missing capability, so a
|
|
181
|
+
// newer client never silently assumes an older instance supports a field/event.
|
|
182
|
+
router.get('/capabilities', (req, res, next) => {
|
|
183
|
+
try {
|
|
184
|
+
res.json(deps.capabilities ? deps.capabilities() : { capabilities: [], controlVersion: '0' });
|
|
185
|
+
} catch (err) {
|
|
186
|
+
next(err);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
// GET /snapshot — F15 atomic batch resync. Returns every session's derived
|
|
191
|
+
// status PLUS the event cursor, captured atomically (cursor first, then
|
|
192
|
+
// statuses), so after a gap/overflow the controller resyncs in ONE call and
|
|
193
|
+
// resumes the long-poll from `cursor` with zero lost events (a boundary event
|
|
194
|
+
// may be redelivered — safe/idempotent — but never dropped).
|
|
195
|
+
router.get('/snapshot', async (req, res, next) => {
|
|
196
|
+
try {
|
|
197
|
+
const snap = deps.snapshot ? await deps.snapshot() : { sessions: [], cursor: null, capturedAt: Date.now() };
|
|
198
|
+
res.json({
|
|
199
|
+
sessions: snap.sessions || [],
|
|
200
|
+
cursor: snap.cursor ? encodeCursor(snap.cursor) : null,
|
|
201
|
+
capturedAt: snap.capturedAt,
|
|
202
|
+
});
|
|
203
|
+
} catch (err) {
|
|
204
|
+
next(err);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
171
208
|
// GET /sessions — list with derived status (light).
|
|
172
209
|
router.get('/sessions', async (req, res, next) => {
|
|
173
210
|
try {
|
|
@@ -215,6 +252,11 @@ function createControlRouter(deps) {
|
|
|
215
252
|
if (err && err.code === 'INVALID_WORKDIR') {
|
|
216
253
|
return res.status(403).json({ error: { code: 'CAPABILITY_DENIED', message: err.message } });
|
|
217
254
|
}
|
|
255
|
+
if (err && err.code) {
|
|
256
|
+
return res.status(err.statusCode || statusForErrorCode(err.code)).json({
|
|
257
|
+
error: { code: err.code, message: err.message },
|
|
258
|
+
});
|
|
259
|
+
}
|
|
218
260
|
next(err);
|
|
219
261
|
}
|
|
220
262
|
});
|
|
@@ -305,6 +347,10 @@ function createControlRouter(deps) {
|
|
|
305
347
|
router.get('/events', async (req, res, next) => {
|
|
306
348
|
try {
|
|
307
349
|
const cursor = parseCursor(req.query.cursor);
|
|
350
|
+
// FIX C: ABSENT cursor → fresh watcher (ok); PRESENT-but-INVALID → client bug.
|
|
351
|
+
if (req.query.cursor && cursor === undefined) {
|
|
352
|
+
return res.status(400).json({ error: { code: 'INVALID_ARGUMENT', message: 'invalid cursor' } });
|
|
353
|
+
}
|
|
308
354
|
const timeoutMs = clampInt(req.query.timeoutMs, DEFAULT_EVENTS_TIMEOUT_MS, 0, MAX_EVENTS_TIMEOUT_MS);
|
|
309
355
|
const filter = {};
|
|
310
356
|
if (req.query.sessionIds) filter.sessionIds = String(req.query.sessionIds).split(',').filter(Boolean);
|