@ours.network/fleet 1.2.0-nightly.63 → 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 +3 -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/session-control.d.ts +31 -17
- package/dist/application/session-control.js +148 -33
- package/dist/build-info.json +4 -4
- 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/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
|
@@ -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);
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# Conversation event stream (wire version 1)
|
|
2
|
+
|
|
3
|
+
`GET /api/v1/roles/:id/conversation/events?expectedGeneration=GENERATION&after=CURSOR`
|
|
4
|
+
uses the existing Fleet route boundary, including `/fleet/api/v1/...` through the
|
|
5
|
+
prefix gateway. Use authenticated `fetch`, then read the response body. Cookies
|
|
6
|
+
work for standalone and same-origin frame access; the hosted App sends its current
|
|
7
|
+
workspace access bearer in `Authorization`. Query parameters are data, never
|
|
8
|
+
credentials. `after` is a nonnegative safe integer; generation is required.
|
|
9
|
+
No new WebSocket path, controller lease, notification acknowledgement, work
|
|
10
|
+
submission, or read receipt is introduced.
|
|
11
|
+
|
|
12
|
+
## Framing
|
|
13
|
+
|
|
14
|
+
Content type is `text/event-stream; charset=utf-8`, with `Cache-Control: no-store`
|
|
15
|
+
and `X-Accel-Buffering: no`. Every frame is exactly:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
event: conversation
|
|
19
|
+
data: {"version":1,"type":"TYPE","payload":...}
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The six logical types are:
|
|
24
|
+
|
|
25
|
+
- `role`: complete public role replacement (`roleId`, `roleRevision`,
|
|
26
|
+
`roleLifetime`, `availability`, `health`, optional backend/fallback/failure).
|
|
27
|
+
Local state paths are omitted. This ordering is independent of session state.
|
|
28
|
+
- `ledger`: either a single durable conversation event, or a coverage marker
|
|
29
|
+
`{coveredThrough:number}`. All events covered by a marker precede it. Replay
|
|
30
|
+
can legitimately advance coverage without visible events. Never advance a
|
|
31
|
+
recovery cursor from maximum rendered event sequence alone.
|
|
32
|
+
- `ready`: `{sessionGeneration,stateRevision,ledgerBarrier,coveredThrough,
|
|
33
|
+
capability:"complete",authorizedUntil}`. The coherent initial snapshot arrives
|
|
34
|
+
as a preceding `state` record, keeping its full 16 MiB payload budget. Hold
|
|
35
|
+
initial state/replay as provisional until matching ready; validate generation,
|
|
36
|
+
stateRevision and ledgerBarrier against the staged state and coverage against
|
|
37
|
+
delivered replay. Only successful private replay certifies `coveredThrough`;
|
|
38
|
+
the snapshot barrier alone does not. Disconnect between state and ready must
|
|
39
|
+
not publish live initial state or advance the certified cursor. Reconnect with
|
|
40
|
+
same-generation certified coverage. State revision and event sequence differ.
|
|
41
|
+
- `state`: complete conversation observation replacement, including
|
|
42
|
+
`sessionGeneration`, `stateRevision` and `ledgerBarrier`. Do not merge it as a
|
|
43
|
+
patch or use its barrier as coverage.
|
|
44
|
+
- `resync`: `{reason,retry,capability}`. Clear partial assembly and resynchronize
|
|
45
|
+
explicitly or enter supported fallback. `retry:false` means persistent
|
|
46
|
+
unsupported/oversize observation: do not loop reconnects. `capability` is
|
|
47
|
+
`degraded`, except `auth-expired` below.
|
|
48
|
+
- `keepalive`: `{at:number}`. Transport liveness only; it cannot certify role,
|
|
49
|
+
session, replay, or private-control health.
|
|
50
|
+
|
|
51
|
+
A large logical payload uses `start`, ordered `chunk`, then `commit` frames,
|
|
52
|
+
without the `payload` field:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{"version":1,"type":"start","id":"3","recordType":"state","totalBytes":1048576,"generation":"g","revision":7}
|
|
56
|
+
{"version":1,"type":"chunk","id":"3","index":0,"data":"a slice of the serialized payload JSON"}
|
|
57
|
+
{"version":1,"type":"commit","id":"3","totalBytes":1048576,"generation":"g","revision":7}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Chunks contain JSON text, **not base64**. Concatenate `data`, UTF-8 count it,
|
|
61
|
+
then JSON-parse only after commit. The optional generation/revision are derived
|
|
62
|
+
from payload sessionGeneration/stateRevision or roleRevision;
|
|
63
|
+
commit and reconstructed payload must match start. Reject duplicate/out-of-order
|
|
64
|
+
chunks, empty chunks, mismatched identities/revisions/lengths, overlapping assemblies, invalid
|
|
65
|
+
UTF-8, over-budget frames, and an incomplete end. Never publish partial snapshots.
|
|
66
|
+
`ConversationStreamDecoder` implements these framing checks without Node APIs.
|
|
67
|
+
It is not a domain reducer: consumers must additionally enforce current workspace,
|
|
68
|
+
generation and monotonic state/role revisions before applying records.
|
|
69
|
+
|
|
70
|
+
## Limits and ownership
|
|
71
|
+
|
|
72
|
+
Encoded frames, including SSE and JSON overhead, are at most 512 KiB. Payload JSON
|
|
73
|
+
is at most 16 MiB; one assembly is allowed at a time. The encoder splits at safe
|
|
74
|
+
UTF-16 boundaries into at most 48 Ki code units, so JSON escaping still fits the
|
|
75
|
+
encoded-frame bound. The active logical record is streamed progressively while
|
|
76
|
+
waiting encoded records are capped at 8 MiB per web connection. A valid 16 MiB
|
|
77
|
+
ledger event is sent independently of its coverage marker so the page envelope
|
|
78
|
+
does not consume its logical-record budget. Oversize records explicitly degrade.
|
|
79
|
+
The writer waits for socket drain before writing another frame; overflow sends a
|
|
80
|
+
resync when writable, otherwise closes. Ledger delivery has no batching delay
|
|
81
|
+
(therefore no intentional wait beyond the required 50 ms); network backpressure
|
|
82
|
+
can delay actual receipt.
|
|
83
|
+
|
|
84
|
+
The private supervisor separately owns its 8 MiB live replay backlog and writes
|
|
85
|
+
64 KiB slices under socket backpressure. The adapter bounds each raw NDJSON line
|
|
86
|
+
at 16 MiB plus the maximum computed serialized replay/ready/state/event envelope
|
|
87
|
+
(replay has an empty events array; ready omits its null placeholder; coverage uses
|
|
88
|
+
`Number.MAX_SAFE_INTEGER`). This admits one exact 16 MiB event;
|
|
89
|
+
malformed or oversized passive input closes the stream. The receiving browser
|
|
90
|
+
owns its 16 MiB assembly limit. A parsed record, serialized UTF-16 string, encoded
|
|
91
|
+
frame, and socket buffers can coexist: these are logical budgets, not an RSS cap.
|
|
92
|
+
Aggregate memory scales with connections, as do role watchers and private sockets.
|
|
93
|
+
There is no global subscriber cap. HTTP/1 browsers commonly share about six
|
|
94
|
+
connections per origin; multiple tabs, pane consumers and voice can exhaust that
|
|
95
|
+
pool. Dispose surplus consumers; HTTP/2 deployment and browser pooling vary.
|
|
96
|
+
|
|
97
|
+
Each web connection owns one role observer, including while the role is missing.
|
|
98
|
+
Session describe/follow borrow it. Missing/replaced roles stop the old follow;
|
|
99
|
+
reappearance can open another follow, still bound to the requested generation.
|
|
100
|
+
Abort, revocation, server shutdown and late connector completion release owners
|
|
101
|
+
idempotently. Pane observation does not acquire or release voice's independent
|
|
102
|
+
controller. Private heartbeats remain serialized every two seconds; six seconds
|
|
103
|
+
without one ends private health even if SSE keepalives continue.
|
|
104
|
+
|
|
105
|
+
Fleet emits browser keepalives every second. Each App pane/voice attempt independently
|
|
106
|
+
bounds opening through complete ready to ten seconds, regardless of incoming activity.
|
|
107
|
+
After ready, nonempty byte chunks (including keepalives/comments and partial records)
|
|
108
|
+
reset a ten-second silence deadline. Elapsed checks before activity and on browser
|
|
109
|
+
visibility/pageshow resume give no hidden grace; suspended code expires at its first
|
|
110
|
+
subsequent check. Expiry retires health and aborts the old attempt before bounded
|
|
111
|
+
recovery/fallback. These browser clocks do not renew or replace credential deadlines.
|
|
112
|
+
Comment-only/blank SSE frames are transport activity and publish no domain record.
|
|
113
|
+
|
|
114
|
+
## Authentication, fallback and filesystem assumptions
|
|
115
|
+
|
|
116
|
+
The stream is bound to its **opening** credential deadline. Rotating access lasts
|
|
117
|
+
at most five minutes; it is not renewable in place. On `auth-expired`, obtain the
|
|
118
|
+
renewed access credential and reconnect using only same-generation certified
|
|
119
|
+
coverage. Count this normal auth reconnect separately from degraded operation.
|
|
120
|
+
Fixed credentials use their device/sunset deadline. Cookie idle expiry is checked
|
|
121
|
+
without updating lastSeenAt, including the exact 30-minute boundary; ordinary
|
|
122
|
+
requests still update activity. Absolute expiry and revocation also end streams.
|
|
123
|
+
The terminal resync uses `capability:"auth-expired"`, with retry `"authenticate"`
|
|
124
|
+
for rotating credentials or `"reauthenticate"` for cookies/fixed credentials.
|
|
125
|
+
Actions after expiry must reauthenticate; heartbeats cannot refresh session policy.
|
|
126
|
+
|
|
127
|
+
`conversation_live_complete` describes current intersection of healthy role
|
|
128
|
+
observation and private replay/heartbeat capability. It never certifies requested
|
|
129
|
+
history. REST remains for initial latest history, user-requested older pages,
|
|
130
|
+
explicit recovery, and older/unsupported/degraded server fallback. Healthy active
|
|
131
|
+
consumers must not periodically fetch role details or history.
|
|
132
|
+
|
|
133
|
+
Complete role delivery relies on native directory notifications for fleet config,
|
|
134
|
+
role manifests and session files. Qualified native host filesystems are the
|
|
135
|
+
intended deployment (macOS local APFS or Linux local filesystems with functional
|
|
136
|
+
inotify). Watch errors, unexpected closure, unsupported watching, or invalid
|
|
137
|
+
current role reads withdraw completeness. Silent event loss is not detectable
|
|
138
|
+
without polling and is not promised away. Docker must expose the same state files
|
|
139
|
+
and private socket to the Fleet process, with working directory-change delivery
|
|
140
|
+
across the selected bind mount/volume. Docker Desktop host-to-VM mounts, network
|
|
141
|
+
filesystems and remote/FUSE mounts require deployment-specific notification tests;
|
|
142
|
+
use degraded REST fallback when those assumptions cannot be established. No ACP
|
|
143
|
+
library changes are required.
|
|
144
|
+
|
|
145
|
+
## Admission receipt provenance
|
|
146
|
+
|
|
147
|
+
Prompt admission responses additionally expose optional `sessionGeneration`: the
|
|
148
|
+
original durable admission generation. Both backend session generations are immutable
|
|
149
|
+
for the admitting lifetime. Cached receipts retain that generation, and reopened command
|
|
150
|
+
indexes derive it from the original `prompt.admitted` event, never a replacement session.
|
|
151
|
+
Historical receipts without provable generation leave the field absent.
|
|
152
|
+
|
|
153
|
+
This permits an explicitly retried pre-change `{text, commandId}` browser draft to settle
|
|
154
|
+
only when matching receipt identity and original generation validate against its captured
|
|
155
|
+
current owner. Missing/old provenance keeps a visible unresolved draft and its idempotency
|
|
156
|
+
ID. Existing generation-bound drafts retain their original fences. `eventCursor` and causal
|
|
157
|
+
coverage/inventory settlement remain unchanged; provenance is not a new settlement watermark.
|
|
158
|
+
|
|
159
|
+
## Cross-repository qualification
|
|
160
|
+
|
|
161
|
+
`test/web/fixtures/conversation-stack.ts` shares the real HTTP and private-control
|
|
162
|
+
stack with the legacy conversation tests and the matching App checkout's optional
|
|
163
|
+
`tests/fleet-live-data.integration.mjs`. ACP uses the existing child-process protocol
|
|
164
|
+
fixture; native Codex uses the existing app-server protocol driver. Both exercise
|
|
165
|
+
actual session, ledger, control adapter, HTTP framing and App decoder/reconciler/UI.
|
|
166
|
+
Role availability changes are controlled fixture inputs; this does not qualify native
|
|
167
|
+
filesystem event delivery on an arbitrary mount.
|
|
168
|
+
|
|
169
|
+
Run Fleet's full tests and typecheck after integrated changes. Retain the SSE,
|
|
170
|
+
workspace-auth-provenance and workspace-tunnel suites for frame/hosted authentication,
|
|
171
|
+
expiry/revocation, delayed-open disposal and bounded slow consumers. App reports
|
|
172
|
+
healthy content requests separately from bootstrap, paging, credential reconnect,
|
|
173
|
+
explicit recovery and unsupported-host fallback. DOM mutation and fixture latency
|
|
174
|
+
measurements have no claim about real-provider inference or physical-device audio.
|
|
175
|
+
The App and Fleet changes require coordinated upstream delivery; source qualification
|
|
176
|
+
does not establish that installed or serving packages contain them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "1.2.0-nightly.
|
|
3
|
+
"version": "1.2.0-nightly.64",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"README.md",
|
|
21
21
|
"docs/task-workspaces.md",
|
|
22
22
|
"docs/live-voice.md",
|
|
23
|
+
"docs/conversation-stream.md",
|
|
23
24
|
"docs/room-layouts.md",
|
|
24
25
|
"docs/validation/managed-cli-permissions.md",
|
|
25
26
|
"docs/fleet-migration.md",
|