@ours.network/fleet 1.2.0-nightly.62 → 1.2.0-nightly.64
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/README.md +6 -0
- package/dist/application/conversation-history.js +4 -1
- package/dist/application/conversation-role-observer.d.ts +62 -0
- package/dist/application/conversation-role-observer.js +206 -0
- package/dist/application/role-removal-service.js +1 -1
- package/dist/application/session-control.d.ts +31 -17
- package/dist/application/session-control.js +148 -33
- package/dist/build-info.json +4 -4
- package/dist/notifications/fleet-producer.js +4 -4
- package/dist/notifications/target-cleanup.d.ts +2 -0
- package/dist/notifications/target-cleanup.js +24 -2
- package/dist/ops.js +1 -1
- package/dist/session/acp.d.ts +8 -1
- package/dist/session/acp.js +97 -43
- package/dist/session/arbiter.d.ts +4 -0
- package/dist/session/arbiter.js +18 -0
- package/dist/session/codex-app-server.d.ts +7 -1
- package/dist/session/codex-app-server.js +60 -9
- package/dist/session/control.d.ts +5 -2
- package/dist/session/control.js +145 -20
- package/dist/session/conversation-observation.d.ts +25 -0
- package/dist/session/conversation-observation.js +100 -0
- package/dist/session/conversation-replay.d.ts +50 -0
- package/dist/session/conversation-replay.js +203 -0
- package/dist/session/conversation-store.d.ts +14 -0
- package/dist/session/conversation-store.js +201 -17
- package/dist/session/conversation-types.d.ts +29 -1
- package/dist/session/types.d.ts +6 -0
- package/dist/subscriptions/login.d.ts +1 -0
- package/dist/subscriptions/login.js +30 -5
- package/dist/web/auth.d.ts +2 -0
- package/dist/web/auth.js +10 -1
- package/dist/web/conversation-stream-codec.d.ts +23 -0
- package/dist/web/conversation-stream-codec.js +110 -0
- package/dist/web/conversation-stream.d.ts +25 -0
- package/dist/web/conversation-stream.js +231 -0
- package/dist/web/runtime.js +12 -2
- package/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +2 -0
- package/docs/conversation-stream.md +176 -0
- package/package.json +2 -1
|
@@ -73,13 +73,27 @@ export class ClaudeLogin extends BaseLogin {
|
|
|
73
73
|
child;
|
|
74
74
|
output = '';
|
|
75
75
|
submitted = false;
|
|
76
|
+
continued = false;
|
|
76
77
|
/** Resolves with the CLI exit status once it exits. */
|
|
77
78
|
exited;
|
|
78
79
|
constructor(owner, bins, home, profileId) {
|
|
79
80
|
super('claude', owner, 'paste_code');
|
|
80
|
-
const
|
|
81
|
-
|
|
81
|
+
const darwin = process.platform === 'darwin';
|
|
82
|
+
const args = darwin
|
|
83
|
+
? ['-q', '/dev/null', bins.claude, 'auth', 'login']
|
|
84
|
+
: ['-qefc', `${shellQuote(bins.claude)} auth login`, '/dev/null'];
|
|
85
|
+
// BSD script accepts a real pipe, but rejects Node's socket-backed stdin.
|
|
86
|
+
// Own the pipeline as one process group so cancellation also stops cat.
|
|
87
|
+
const child = spawn(darwin ? '/bin/sh' : bins.script, darwin
|
|
88
|
+
? ['-c', '/bin/cat 3>&- | { "$@" 3>&-; result=$?; printf "%s\\n" "$result" >&3; exit "$result"; }', 'fleet-claude-login', bins.script, ...args] : args, {
|
|
89
|
+
detached: darwin,
|
|
90
|
+
env: profileEnv('CLAUDE_CONFIG_DIR', profileId, home),
|
|
91
|
+
stdio: darwin ? ['pipe', 'pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
|
|
82
92
|
});
|
|
93
|
+
// The terminal has exited; release cat's input so the shell can finish.
|
|
94
|
+
// Only the shell owns fd 3. Final close remains the authoritative status.
|
|
95
|
+
if (darwin)
|
|
96
|
+
child.stdio[3]?.once('data', () => child.stdin.end());
|
|
83
97
|
this.child = child;
|
|
84
98
|
child.stdin.on('error', () => { });
|
|
85
99
|
const onData = (chunk) => {
|
|
@@ -98,14 +112,16 @@ export class ClaudeLogin extends BaseLogin {
|
|
|
98
112
|
}
|
|
99
113
|
}
|
|
100
114
|
// Claude asks for Enter after success; the answer carries no secret.
|
|
101
|
-
if (this.submitted && /press enter/i.test(this.output))
|
|
115
|
+
if (this.submitted && !this.continued && /press enter/i.test(this.output)) {
|
|
116
|
+
this.continued = true;
|
|
102
117
|
child.stdin.write('\r');
|
|
118
|
+
}
|
|
103
119
|
};
|
|
104
120
|
child.stdout.on('data', onData);
|
|
105
121
|
child.stderr.on('data', onData);
|
|
106
122
|
this.exited = new Promise(resolve => {
|
|
107
123
|
child.once('error', () => { this.end('failed', 'could not start the sign-in'); resolve(null); });
|
|
108
|
-
child.once('close', code => resolve(code));
|
|
124
|
+
child.once('close', code => { this.child = undefined; resolve(code); });
|
|
109
125
|
});
|
|
110
126
|
}
|
|
111
127
|
submitCode(code) {
|
|
@@ -118,7 +134,16 @@ export class ClaudeLogin extends BaseLogin {
|
|
|
118
134
|
this.output = '';
|
|
119
135
|
this.child?.stdin.write(`${code}\r`);
|
|
120
136
|
}
|
|
121
|
-
stop() {
|
|
137
|
+
stop() {
|
|
138
|
+
if (process.platform === 'darwin' && this.child?.pid) {
|
|
139
|
+
try {
|
|
140
|
+
process.kill(-this.child.pid, 'SIGTERM');
|
|
141
|
+
}
|
|
142
|
+
catch { /* already exited */ }
|
|
143
|
+
}
|
|
144
|
+
else
|
|
145
|
+
this.child?.kill('SIGTERM');
|
|
146
|
+
}
|
|
122
147
|
}
|
|
123
148
|
/** Codex device-code login through the app-server's supported account API. */
|
|
124
149
|
export class CodexDeviceLogin extends BaseLogin {
|
package/dist/web/auth.d.ts
CHANGED
|
@@ -65,6 +65,8 @@ export declare class WebAuth {
|
|
|
65
65
|
anonymous(request: FastifyRequest): BrowserSession;
|
|
66
66
|
resume(request: FastifyRequest): AuthResult;
|
|
67
67
|
authenticate(request: FastifyRequest, mutation?: boolean): BrowserSession;
|
|
68
|
+
/** Check a transport's opening lease without refreshing cookie idle activity. */
|
|
69
|
+
sessionValid(session: Pick<BrowserSession, 'id' | 'absoluteExpiresAt'>): boolean;
|
|
68
70
|
logout(request: FastifyRequest): void;
|
|
69
71
|
mintTicket(request: FastifyRequest, purpose: Ticket['purpose'], roleId?: string): {
|
|
70
72
|
ticket: string;
|
package/dist/web/auth.js
CHANGED
|
@@ -136,7 +136,7 @@ export class WebAuth {
|
|
|
136
136
|
const id = parseCookies(request.headers.cookie ?? '').ofs_session;
|
|
137
137
|
const session = id ? this.sessions.get(id) : undefined;
|
|
138
138
|
const now = this.now();
|
|
139
|
-
if (!session ||
|
|
139
|
+
if (!session || !this.sessionValid(session)) {
|
|
140
140
|
if (id)
|
|
141
141
|
this.removeSession(id);
|
|
142
142
|
throw new FleetError('unauthorized', 'browser session is missing or expired');
|
|
@@ -150,6 +150,15 @@ export class WebAuth {
|
|
|
150
150
|
session.lastSeenAt = now;
|
|
151
151
|
return session;
|
|
152
152
|
}
|
|
153
|
+
/** Check a transport's opening lease without refreshing cookie idle activity. */
|
|
154
|
+
sessionValid(session) {
|
|
155
|
+
const current = this.sessions.get(session.id), now = this.now();
|
|
156
|
+
if (!current || now >= session.absoluteExpiresAt)
|
|
157
|
+
return false;
|
|
158
|
+
if (session.id.startsWith('workspace:'))
|
|
159
|
+
return Boolean(this.workspaceDevices?.valid(session.id.slice(10)));
|
|
160
|
+
return now < current.absoluteExpiresAt && now - current.lastSeenAt < 30 * 60_000;
|
|
161
|
+
}
|
|
153
162
|
logout(request) {
|
|
154
163
|
const session = this.authenticate(request, true);
|
|
155
164
|
const deviceId = this.sessionDevices.get(session.id);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Public v1 wire contract. Kept free of Node APIs so browser consumers can share it. */
|
|
2
|
+
export declare const MAX_FRAME_BYTES: number;
|
|
3
|
+
export declare const MAX_ASSEMBLY_BYTES: number;
|
|
4
|
+
export declare const MAX_PENDING_BYTES: number;
|
|
5
|
+
export type ConversationRecordType = 'ready' | 'ledger' | 'state' | 'role' | 'resync' | 'keepalive';
|
|
6
|
+
export interface ConversationStreamRecord {
|
|
7
|
+
version: 1;
|
|
8
|
+
type: ConversationRecordType;
|
|
9
|
+
payload: unknown;
|
|
10
|
+
}
|
|
11
|
+
/** A payload is the logical record; SSE/JSON envelopes do not consume its assembly budget. */
|
|
12
|
+
export declare function encodeConversationRecord(type: ConversationRecordType, payload: unknown, id: string): Generator<string>;
|
|
13
|
+
/** Strict incremental decoder: one assembly, no partial publication, UTF-8 split safe. */
|
|
14
|
+
export declare class ConversationStreamDecoder {
|
|
15
|
+
private readonly publish;
|
|
16
|
+
private decoder;
|
|
17
|
+
private pending;
|
|
18
|
+
private assembly?;
|
|
19
|
+
constructor(publish: (record: ConversationStreamRecord) => void);
|
|
20
|
+
push(bytes: Uint8Array): void;
|
|
21
|
+
finish(): void;
|
|
22
|
+
private accept;
|
|
23
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/** Public v1 wire contract. Kept free of Node APIs so browser consumers can share it. */
|
|
2
|
+
export const MAX_FRAME_BYTES = 512 * 1024;
|
|
3
|
+
export const MAX_ASSEMBLY_BYTES = 16 * 1024 * 1024;
|
|
4
|
+
export const MAX_PENDING_BYTES = 8 * 1024 * 1024;
|
|
5
|
+
const utf8 = new TextEncoder();
|
|
6
|
+
const types = new Set(['ready', 'ledger', 'state', 'role', 'resync', 'keepalive']);
|
|
7
|
+
const frame = (value) => `event: conversation\ndata: ${JSON.stringify(value)}\n\n`;
|
|
8
|
+
const identity = (payload) => ({ generation: payload?.sessionGeneration ?? payload?.snapshot?.sessionGeneration,
|
|
9
|
+
revision: payload?.stateRevision ?? payload?.snapshot?.stateRevision ?? payload?.roleRevision });
|
|
10
|
+
/** A payload is the logical record; SSE/JSON envelopes do not consume its assembly budget. */
|
|
11
|
+
export function* encodeConversationRecord(type, payload, id) {
|
|
12
|
+
const json = JSON.stringify(payload), totalBytes = utf8.encode(json).byteLength;
|
|
13
|
+
if (totalBytes > MAX_ASSEMBLY_BYTES)
|
|
14
|
+
throw new Error('conversation record exceeds assembly limit');
|
|
15
|
+
const direct = frame({ version: 1, type, payload });
|
|
16
|
+
if (utf8.encode(direct).byteLength <= MAX_FRAME_BYTES) {
|
|
17
|
+
yield direct;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const position = identity(payload);
|
|
21
|
+
yield frame({ version: 1, type: 'start', id, recordType: type, totalBytes, ...position });
|
|
22
|
+
// At most 48K UTF-16 units: even worst-case JSON escapes fit a 512 KiB frame.
|
|
23
|
+
let index = 0;
|
|
24
|
+
for (let offset = 0; offset < json.length;) {
|
|
25
|
+
let end = Math.min(json.length, offset + 48 * 1024);
|
|
26
|
+
if (end < json.length && /[\uD800-\uDBFF]/.test(json[end - 1]))
|
|
27
|
+
end--;
|
|
28
|
+
yield frame({ version: 1, type: 'chunk', id, index: index++, data: json.slice(offset, end) });
|
|
29
|
+
offset = end;
|
|
30
|
+
}
|
|
31
|
+
yield frame({ version: 1, type: 'commit', id, totalBytes, ...position });
|
|
32
|
+
}
|
|
33
|
+
/** Strict incremental decoder: one assembly, no partial publication, UTF-8 split safe. */
|
|
34
|
+
export class ConversationStreamDecoder {
|
|
35
|
+
publish;
|
|
36
|
+
decoder = new TextDecoder('utf-8', { fatal: true });
|
|
37
|
+
pending = '';
|
|
38
|
+
assembly;
|
|
39
|
+
constructor(publish) {
|
|
40
|
+
this.publish = publish;
|
|
41
|
+
}
|
|
42
|
+
push(bytes) {
|
|
43
|
+
try {
|
|
44
|
+
this.pending += this.decoder.decode(bytes, { stream: true });
|
|
45
|
+
for (;;) {
|
|
46
|
+
const end = this.pending.indexOf('\n\n');
|
|
47
|
+
if (end < 0)
|
|
48
|
+
break;
|
|
49
|
+
const text = this.pending.slice(0, end + 2);
|
|
50
|
+
this.pending = this.pending.slice(end + 2);
|
|
51
|
+
if (utf8.encode(text).byteLength > MAX_FRAME_BYTES)
|
|
52
|
+
throw new Error('frame limit exceeded');
|
|
53
|
+
if (!text.startsWith('event: conversation\ndata: '))
|
|
54
|
+
throw new Error('invalid conversation frame');
|
|
55
|
+
this.accept(JSON.parse(text.slice(26, -2)));
|
|
56
|
+
}
|
|
57
|
+
if (utf8.encode(this.pending).byteLength > MAX_FRAME_BYTES)
|
|
58
|
+
throw new Error('frame limit exceeded');
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
this.pending = '';
|
|
62
|
+
this.assembly = undefined;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
finish() {
|
|
67
|
+
const rest = this.decoder.decode();
|
|
68
|
+
if (rest || this.pending || this.assembly)
|
|
69
|
+
throw new Error('incomplete conversation stream');
|
|
70
|
+
}
|
|
71
|
+
accept(value) {
|
|
72
|
+
if (value?.version !== 1)
|
|
73
|
+
throw new Error('unsupported conversation version');
|
|
74
|
+
if (types.has(value.type)) {
|
|
75
|
+
if (this.assembly)
|
|
76
|
+
throw new Error('overlapping assembly');
|
|
77
|
+
this.publish(value);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (value.type === 'start') {
|
|
81
|
+
if (this.assembly)
|
|
82
|
+
throw new Error('overlapping assembly');
|
|
83
|
+
if (typeof value.id !== 'string' || !types.has(value.recordType) || !Number.isSafeInteger(value.totalBytes) || value.totalBytes < 1 || value.totalBytes > MAX_ASSEMBLY_BYTES)
|
|
84
|
+
throw new Error('invalid assembly length/type');
|
|
85
|
+
this.assembly = { id: value.id, type: value.recordType, total: value.totalBytes, bytes: 0, index: 0, parts: [], generation: value.generation, revision: value.revision };
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const assembly = this.assembly;
|
|
89
|
+
if (!assembly || value.id !== assembly.id)
|
|
90
|
+
throw new Error('assembly identity mismatch');
|
|
91
|
+
if (value.type === 'chunk') {
|
|
92
|
+
if (value.index !== assembly.index++ || typeof value.data !== 'string')
|
|
93
|
+
throw new Error('assembly chunk order');
|
|
94
|
+
if (!value.data.length)
|
|
95
|
+
throw new Error('assembly empty chunk');
|
|
96
|
+
assembly.bytes += utf8.encode(value.data).byteLength;
|
|
97
|
+
if (assembly.bytes > assembly.total)
|
|
98
|
+
throw new Error('assembly length exceeded');
|
|
99
|
+
assembly.parts.push(value.data);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (value.type !== 'commit' || value.totalBytes !== assembly.total || assembly.bytes !== assembly.total)
|
|
103
|
+
throw new Error('assembly length mismatch');
|
|
104
|
+
const payload = JSON.parse(assembly.parts.join('')), position = identity(payload);
|
|
105
|
+
if (value.generation !== assembly.generation || value.revision !== assembly.revision || position.generation !== assembly.generation || position.revision !== assembly.revision)
|
|
106
|
+
throw new Error('assembly revision mismatch');
|
|
107
|
+
this.assembly = undefined;
|
|
108
|
+
this.publish({ version: 1, type: assembly.type, payload });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Writable } from 'node:stream';
|
|
2
|
+
import { type ConversationRecordType } from './conversation-stream-codec.js';
|
|
3
|
+
/** One active logical record; only waiting encoded output consumes the pending budget. */
|
|
4
|
+
export declare class ConversationStreamWriter {
|
|
5
|
+
private readonly output;
|
|
6
|
+
private readonly failed;
|
|
7
|
+
private queue;
|
|
8
|
+
private active?;
|
|
9
|
+
private waiting;
|
|
10
|
+
private ended;
|
|
11
|
+
private sequence;
|
|
12
|
+
pendingBytes: number;
|
|
13
|
+
get busy(): boolean;
|
|
14
|
+
constructor(output: Writable, failed: (reason: string) => void);
|
|
15
|
+
private error;
|
|
16
|
+
private drain;
|
|
17
|
+
send(type: ConversationRecordType, payload: unknown): boolean;
|
|
18
|
+
private flush;
|
|
19
|
+
private fail;
|
|
20
|
+
close(): void;
|
|
21
|
+
}
|
|
22
|
+
import type { FastifyInstance } from 'fastify';
|
|
23
|
+
import type { WebServices } from './server.js';
|
|
24
|
+
import type { WebAuth } from './auth.js';
|
|
25
|
+
export declare function registerConversationStream(app: FastifyInstance, services: WebServices, auth: WebAuth): void;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { encodeConversationRecord, MAX_PENDING_BYTES } from './conversation-stream-codec.js';
|
|
2
|
+
/** One active logical record; only waiting encoded output consumes the pending budget. */
|
|
3
|
+
export class ConversationStreamWriter {
|
|
4
|
+
output;
|
|
5
|
+
failed;
|
|
6
|
+
queue = [];
|
|
7
|
+
active;
|
|
8
|
+
waiting = false;
|
|
9
|
+
ended = false;
|
|
10
|
+
sequence = 0;
|
|
11
|
+
pendingBytes = 0;
|
|
12
|
+
get busy() { return Boolean(this.active); }
|
|
13
|
+
constructor(output, failed) {
|
|
14
|
+
this.output = output;
|
|
15
|
+
this.failed = failed;
|
|
16
|
+
output.on('drain', this.drain);
|
|
17
|
+
output.once('error', this.error);
|
|
18
|
+
}
|
|
19
|
+
error = () => this.fail('transport-error');
|
|
20
|
+
drain = () => { this.waiting = false; this.flush(); };
|
|
21
|
+
send(type, payload) {
|
|
22
|
+
if (this.ended)
|
|
23
|
+
return false;
|
|
24
|
+
const id = String(++this.sequence);
|
|
25
|
+
try {
|
|
26
|
+
if (!this.active) {
|
|
27
|
+
this.active = encodeConversationRecord(type, payload, id);
|
|
28
|
+
this.flush();
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
for (const frame of encodeConversationRecord(type, payload, id))
|
|
33
|
+
bytes += Buffer.byteLength(frame);
|
|
34
|
+
if (this.pendingBytes + bytes > MAX_PENDING_BYTES) {
|
|
35
|
+
this.fail('output-overflow');
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
this.queue.push({ type, payload, id, bytes });
|
|
39
|
+
this.pendingBytes += bytes;
|
|
40
|
+
}
|
|
41
|
+
return !this.ended;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
this.fail('record-too-large');
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
flush() {
|
|
49
|
+
if (this.ended || this.waiting)
|
|
50
|
+
return;
|
|
51
|
+
try {
|
|
52
|
+
while (this.active) {
|
|
53
|
+
const next = this.active.next();
|
|
54
|
+
if (!next.done) {
|
|
55
|
+
if (!this.output.write(next.value)) {
|
|
56
|
+
this.waiting = true;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const queued = this.queue.shift();
|
|
62
|
+
this.active = queued ? encodeConversationRecord(queued.type, queued.payload, queued.id) : undefined;
|
|
63
|
+
if (queued)
|
|
64
|
+
this.pendingBytes -= queued.bytes;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
this.fail('record-too-large');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
fail(reason) {
|
|
73
|
+
if (this.ended)
|
|
74
|
+
return;
|
|
75
|
+
// A resync is only useful at a frame boundary with writable capacity.
|
|
76
|
+
if (!this.waiting && !this.output.destroyed) {
|
|
77
|
+
const frame = encodeConversationRecord('resync', { reason, capability: 'degraded', retry: reason !== 'record-too-large' }, 'terminal').next().value;
|
|
78
|
+
if (frame)
|
|
79
|
+
this.output.write(frame);
|
|
80
|
+
}
|
|
81
|
+
const blocked = this.waiting;
|
|
82
|
+
this.close();
|
|
83
|
+
this.failed(reason);
|
|
84
|
+
if (blocked)
|
|
85
|
+
this.output.destroy();
|
|
86
|
+
}
|
|
87
|
+
close() {
|
|
88
|
+
if (this.ended)
|
|
89
|
+
return;
|
|
90
|
+
this.ended = true;
|
|
91
|
+
this.active = undefined;
|
|
92
|
+
this.queue = [];
|
|
93
|
+
this.pendingBytes = 0;
|
|
94
|
+
this.output.off('drain', this.drain);
|
|
95
|
+
this.output.off('error', this.error);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
import { FleetError } from '../application/errors.js';
|
|
99
|
+
export function registerConversationStream(app, services, auth) {
|
|
100
|
+
const connections = new Set();
|
|
101
|
+
app.addHook('preClose', async () => { for (const close of connections)
|
|
102
|
+
close(); });
|
|
103
|
+
app.get('/api/v1/roles/:id/conversation/events', async (request, reply) => {
|
|
104
|
+
const browser = auth.authenticate(request);
|
|
105
|
+
const generation = request.query.expectedGeneration, rawAfter = request.query.after ?? '0', after = Number(rawAfter);
|
|
106
|
+
if (typeof generation !== 'string' || !generation.trim() || generation.length > 512 || !/^\d+$/.test(rawAfter) || !Number.isSafeInteger(after))
|
|
107
|
+
throw new FleetError('invalid_request', 'expectedGeneration and a nonnegative integer after cursor are required');
|
|
108
|
+
if (!services.observeRole)
|
|
109
|
+
throw new FleetError('capability_unavailable', 'Complete conversation observation is unavailable');
|
|
110
|
+
let ended = false, owner, follow;
|
|
111
|
+
let binding, timer;
|
|
112
|
+
let epoch = 0, revision = -1, lifetime, available = false;
|
|
113
|
+
let abort = new AbortController();
|
|
114
|
+
const close = () => {
|
|
115
|
+
if (ended)
|
|
116
|
+
return;
|
|
117
|
+
ended = true;
|
|
118
|
+
epoch++;
|
|
119
|
+
abort.abort();
|
|
120
|
+
if (timer)
|
|
121
|
+
clearInterval(timer);
|
|
122
|
+
binding?.();
|
|
123
|
+
writer.close();
|
|
124
|
+
follow?.close();
|
|
125
|
+
follow = undefined;
|
|
126
|
+
owner?.dispose();
|
|
127
|
+
connections.delete(close);
|
|
128
|
+
reply.raw.end();
|
|
129
|
+
};
|
|
130
|
+
const writer = new ConversationStreamWriter(reply.raw, close);
|
|
131
|
+
const resync = (reason, retry = true) => writer.send('resync', { reason, retry, capability: 'degraded' });
|
|
132
|
+
const valid = () => { if (auth.sessionValid(browser))
|
|
133
|
+
return true; writer.send('resync', { reason: 'auth-expired', retry: browser.rotating ? 'authenticate' : 'reauthenticate', capability: 'auth-expired' }); close(); return false; };
|
|
134
|
+
const stopFollow = () => { epoch++; abort.abort(); abort = new AbortController(); follow?.close(); follow = undefined; };
|
|
135
|
+
const open = async () => {
|
|
136
|
+
const current = ++epoch, signal = abort.signal;
|
|
137
|
+
const active = () => !ended && current === epoch && !signal.aborted;
|
|
138
|
+
try {
|
|
139
|
+
const control = await services.session(request.params.id);
|
|
140
|
+
if (!active() || !valid())
|
|
141
|
+
return;
|
|
142
|
+
const descriptor = await control.describe(owner);
|
|
143
|
+
if (!active() || !valid())
|
|
144
|
+
return;
|
|
145
|
+
if (!control.followConversation || !descriptor.features.includes('conversation_live_complete')) {
|
|
146
|
+
resync('capability-unavailable', false);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const handle = await control.followConversation({ controller: false, expectedGeneration: generation, after, signal, roleObservation: owner,
|
|
150
|
+
onReplay: page => { if (active() && valid()) {
|
|
151
|
+
for (const event of page.events)
|
|
152
|
+
writer.send('ledger', event);
|
|
153
|
+
writer.send('ledger', { coveredThrough: page.coveredThrough });
|
|
154
|
+
} },
|
|
155
|
+
onReady: ready => {
|
|
156
|
+
if (active() && valid()) {
|
|
157
|
+
writer.send('state', ready.snapshot);
|
|
158
|
+
writer.send('ready', { sessionGeneration: ready.snapshot.sessionGeneration, stateRevision: ready.snapshot.stateRevision,
|
|
159
|
+
ledgerBarrier: ready.snapshot.ledgerBarrier, coveredThrough: ready.coveredThrough, capability: 'complete', authorizedUntil: browser.absoluteExpiresAt });
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
onEvent: event => { if (active() && valid()) {
|
|
163
|
+
writer.send('ledger', event);
|
|
164
|
+
writer.send('ledger', { coveredThrough: event.seq });
|
|
165
|
+
} },
|
|
166
|
+
onState: state => { if (active() && valid())
|
|
167
|
+
writer.send('state', state); },
|
|
168
|
+
onClose: reason => { if (active()) {
|
|
169
|
+
epoch++;
|
|
170
|
+
abort.abort();
|
|
171
|
+
follow = undefined;
|
|
172
|
+
resync(reason ?? 'control-closed', !reason?.includes('assembly limit'));
|
|
173
|
+
} },
|
|
174
|
+
});
|
|
175
|
+
if (!active())
|
|
176
|
+
handle.close();
|
|
177
|
+
else
|
|
178
|
+
follow = handle;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
if (active())
|
|
182
|
+
resync('observation-unavailable');
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
const changed = (state) => {
|
|
186
|
+
if (ended || state.roleRevision <= revision)
|
|
187
|
+
return;
|
|
188
|
+
revision = state.roleRevision;
|
|
189
|
+
const { stateDir: _private, ...publicState } = state;
|
|
190
|
+
writer.send('role', publicState);
|
|
191
|
+
const replaced = lifetime !== undefined && lifetime !== state.roleLifetime;
|
|
192
|
+
const nextAvailable = state.availability === 'available' && state.health === 'healthy';
|
|
193
|
+
if (replaced || !nextAvailable)
|
|
194
|
+
stopFollow();
|
|
195
|
+
if (state.health !== 'healthy')
|
|
196
|
+
resync(state.failure ?? 'role-observation-unavailable', false);
|
|
197
|
+
else if (replaced)
|
|
198
|
+
resync('role-replaced');
|
|
199
|
+
const shouldOpen = nextAvailable && (!available || replaced);
|
|
200
|
+
lifetime = state.roleLifetime;
|
|
201
|
+
available = nextAvailable;
|
|
202
|
+
if (shouldOpen)
|
|
203
|
+
void open();
|
|
204
|
+
};
|
|
205
|
+
reply.hijack();
|
|
206
|
+
for (const [name, value] of Object.entries(reply.getHeaders()))
|
|
207
|
+
if (value !== undefined)
|
|
208
|
+
reply.raw.setHeader(name, Array.isArray(value) ? value : String(value));
|
|
209
|
+
reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' });
|
|
210
|
+
reply.raw.flushHeaders();
|
|
211
|
+
reply.raw.once('close', close);
|
|
212
|
+
connections.add(close);
|
|
213
|
+
binding = auth.bindTransport(browser, () => { writer.send('resync', { reason: 'auth-expired', capability: 'auth-expired', retry: browser.rotating ? 'authenticate' : 'reauthenticate' }); close(); });
|
|
214
|
+
try {
|
|
215
|
+
// Factory callbacks can be synchronous; defer handling until the owner can be borrowed.
|
|
216
|
+
owner = services.observeRole(request.params.id, state => queueMicrotask(() => changed(state)));
|
|
217
|
+
if (ended) {
|
|
218
|
+
owner.dispose();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
changed(owner.snapshot());
|
|
222
|
+
timer = setInterval(() => { if (valid())
|
|
223
|
+
writer.send('keepalive', { at: Date.now() }); }, 1_000);
|
|
224
|
+
timer.unref();
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
resync('role-observation-unavailable', false);
|
|
228
|
+
close();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
package/dist/web/runtime.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ConversationRoleObserver } from '../application/conversation-role-observer.js';
|
|
1
2
|
import { replaceFileAtomically } from '../atomic-file.js';
|
|
2
3
|
import { validateAccountOrigin } from '../account-origin.js';
|
|
3
4
|
import { startWorkspaceTunnel } from './workspace-tunnel.js';
|
|
@@ -110,6 +111,13 @@ export async function startWebConsole(options) {
|
|
|
110
111
|
},
|
|
111
112
|
});
|
|
112
113
|
const events = new FleetEventBus();
|
|
114
|
+
const roleMutations = new Set();
|
|
115
|
+
const signalRoleMutation = () => { for (const listener of roleMutations)
|
|
116
|
+
listener(); };
|
|
117
|
+
const observeRole = (roleId, listener) => new ConversationRoleObserver({
|
|
118
|
+
roleId, configPath: options.configPath,
|
|
119
|
+
subscribeMutations: changed => { roleMutations.add(changed); return () => { roleMutations.delete(changed); }; },
|
|
120
|
+
}, listener);
|
|
113
121
|
const audit = new AuditSink();
|
|
114
122
|
const watchdogConfigProvider = cachedConfigProvider(options.configPath);
|
|
115
123
|
const log = options.log ?? (() => { });
|
|
@@ -149,6 +157,7 @@ export async function startWebConsole(options) {
|
|
|
149
157
|
? 'ready' : detail?.status.overall === 'attention' ? 'attention' : 'unknown';
|
|
150
158
|
},
|
|
151
159
|
onProgress: action => {
|
|
160
|
+
signalRoleMutation();
|
|
152
161
|
events.publish('creation.changed', action, action.roleId);
|
|
153
162
|
void audit.record({
|
|
154
163
|
roleId: action.roleId, action: `creation.${action.state}`,
|
|
@@ -164,6 +173,7 @@ export async function startWebConsole(options) {
|
|
|
164
173
|
repository, ops, configPath: options.configPath,
|
|
165
174
|
status: async (roleId) => (await query.detail(roleId)).status,
|
|
166
175
|
onProgress: receipt => {
|
|
176
|
+
signalRoleMutation();
|
|
167
177
|
events.publish('action.changed', receipt, receipt.roleId);
|
|
168
178
|
void audit.record({
|
|
169
179
|
roleId: receipt.roleId, action: `lifecycle.${receipt.action}`,
|
|
@@ -185,7 +195,7 @@ export async function startWebConsole(options) {
|
|
|
185
195
|
let server;
|
|
186
196
|
try {
|
|
187
197
|
server = await buildWebServer({
|
|
188
|
-
query, repository, logs, commands, creation, removal, audit, events, watchdogs, configuration,
|
|
198
|
+
query, repository, logs, commands, creation, removal, audit, events, watchdogs, configuration, observeRole,
|
|
189
199
|
taskRooms: new TaskRoomApplicationService(options.configPath, { binPath: () => options.binPath }),
|
|
190
200
|
roomLayouts: new RoomLayoutDefinitions(options.configPath),
|
|
191
201
|
layoutBindings: new LayoutBindingGrants(new RoomLayoutService(options.configPath).supervisor()),
|
|
@@ -218,7 +228,7 @@ export async function startWebConsole(options) {
|
|
|
218
228
|
const dir = repository.stateDir(role);
|
|
219
229
|
if (!dir)
|
|
220
230
|
throw new FleetError('control_unavailable', 'role state directory is unavailable');
|
|
221
|
-
return new RoleSessionControlAdapter(dir);
|
|
231
|
+
return new RoleSessionControlAdapter(dir, undefined, undefined, listener => observeRole(roleId, listener));
|
|
222
232
|
}
|
|
223
233
|
throw new FleetError('capability_unavailable', 'role session backend is unavailable');
|
|
224
234
|
},
|
package/dist/web/server.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConversationRoleObservation, ConversationRoleState } from '../application/conversation-role-observer.js';
|
|
1
2
|
import { type InitAnswers } from '../init-wizard.js';
|
|
2
3
|
import { SupervisorOursTools } from '../application/supervisor-ours-tools.js';
|
|
3
4
|
import { type FastifyInstance } from 'fastify';
|
|
@@ -23,6 +24,7 @@ import type { OfferedModels } from '../subscriptions/cli.js';
|
|
|
23
24
|
import type { SubscriptionService } from '../subscriptions/service.js';
|
|
24
25
|
import type { LayoutBindingGrants } from '../rooms-tasks/layout-binding-grants.js';
|
|
25
26
|
export interface WebServices {
|
|
27
|
+
observeRole?(roleId: string, listener: (state: ConversationRoleState) => void): ConversationRoleObservation;
|
|
26
28
|
layoutBindings?: Pick<LayoutBindingGrants, 'control'>;
|
|
27
29
|
query: FleetQueryService;
|
|
28
30
|
repository: RoleRepository;
|
package/dist/web/server.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { registerConversationStream } from './conversation-stream.js';
|
|
1
2
|
import { conversationHasAttachment } from '../application/conversation-history.js';
|
|
2
3
|
import { readDeliveredFile } from '../file-delivery/store.js';
|
|
3
4
|
import { WorkspaceDeviceAuthError } from './workspace-devices.js';
|
|
@@ -88,6 +89,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
88
89
|
reply.header('X-Ours-Request-Id', request.id);
|
|
89
90
|
reply.code(statusFor(fleetError.code)).send({ error: fleetError.toJSON() });
|
|
90
91
|
});
|
|
92
|
+
registerConversationStream(app, services, auth);
|
|
91
93
|
app.post('/api/v1/auth/exchange', async (request, reply) => {
|
|
92
94
|
const { session, device } = auth.exchange(request);
|
|
93
95
|
setAuthCookies(reply, session.id, device.token, auth.secureCookies);
|