@ours.network/fleet 1.2.0-nightly.3 → 1.2.0-nightly.5
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 +32 -19
- package/dist/agent-ours/bridge.d.ts +3 -0
- package/dist/agent-ours/bridge.js +169 -0
- package/dist/agent-ours/controller.d.ts +14 -0
- package/dist/agent-ours/controller.js +60 -0
- package/dist/agent-ours/file-rpc.d.ts +23 -0
- package/dist/agent-ours/file-rpc.js +123 -0
- package/dist/agent-ours/harness.d.ts +14 -0
- package/dist/agent-ours/harness.js +64 -0
- package/dist/agent-ours/mcp-endpoint.d.ts +15 -0
- package/dist/agent-ours/mcp-endpoint.js +111 -0
- package/dist/agent-ours/runtime.d.ts +53 -0
- package/dist/agent-ours/runtime.js +247 -0
- package/dist/agent-ours/service.d.ts +23 -0
- package/dist/agent-ours/service.js +352 -0
- package/dist/agent-ours/state.d.ts +45 -0
- package/dist/agent-ours/state.js +95 -0
- package/dist/agent-ours/wire.d.ts +14 -0
- package/dist/agent-ours/wire.js +55 -0
- package/dist/briefing.js +10 -59
- package/dist/build-info.json +5 -4
- package/dist/capabilities.d.ts +2 -1
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +15 -2
- package/dist/client-profile.d.ts +1 -0
- package/dist/client-profile.js +18 -3
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +3 -14
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +15 -20
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +28 -9
- package/dist/fleet-command-audit.js +1 -1
- package/dist/harness/agent-session.d.ts +2 -0
- package/dist/harness/claude-code-session.js +4 -2
- package/dist/harness/codex-session.js +9 -2
- package/dist/harness/hermes-session.js +7 -1
- package/dist/rooms-tasks/cowork-adapter.js +9 -2
- package/dist/rooms-tasks/cowork-http.d.ts +3 -0
- package/dist/rooms-tasks/cowork-http.js +70 -0
- package/dist/rooms-tasks/provision.js +9 -2
- package/dist/runner.d.ts +4 -0
- package/dist/runner.js +522 -492
- package/dist/session/codex-app-server-transport.js +1 -1
- package/dist/spawn.js +12 -16
- package/dist/temp-supervisor-recovery.d.ts +3 -0
- package/dist/temp-supervisor-recovery.js +46 -0
- package/dist/watchdog/briefing.js +3 -16
- package/dist/watchdog/run.js +19 -5
- package/package.json +6 -5
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { attachOursClient } from '@ours.network/sdk/client';
|
|
5
|
+
import { ApplicationIdentityStore } from '@ours.network/mcp/application-identities';
|
|
6
|
+
import { readClientProfile } from '../client-profile.js';
|
|
7
|
+
import { stateRoot } from '../paths.js';
|
|
8
|
+
import { createCoworkAdapter } from '../rooms-tasks/cowork-adapter.js';
|
|
9
|
+
import { RuntimeController } from './controller.js';
|
|
10
|
+
import { atomicPrivateWrite, binderKey } from './state.js';
|
|
11
|
+
import { AgentOursRuntime } from './runtime.js';
|
|
12
|
+
import { startMcpEndpoint } from './mcp-endpoint.js';
|
|
13
|
+
export const privateRuntimeRoot = () => join(stateRoot(), 'private-ours');
|
|
14
|
+
/** Only trusted launch orchestration may write this descriptor, never the child. */
|
|
15
|
+
export function storeRoomSecret(role) {
|
|
16
|
+
const startup = role.roomMemberStartup;
|
|
17
|
+
if (!startup?.invite)
|
|
18
|
+
return;
|
|
19
|
+
const root = join(privateRuntimeRoot(), 'room-inputs');
|
|
20
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
21
|
+
const path = join(root, binderKey(startup.room_identity_cid, role.name) + '.json');
|
|
22
|
+
if (existsSync(path)) {
|
|
23
|
+
const old = JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
+
if (old.room_id !== startup.room_id ||
|
|
25
|
+
old.invite_id !== startup.invite_id ||
|
|
26
|
+
old.identity_name !== startup.identity_name)
|
|
27
|
+
throw Error('ROOM_SECRET_COLLISION');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
atomicPrivateWrite(path, startup);
|
|
31
|
+
}
|
|
32
|
+
export function storeTemporaryLaunch(role, action) {
|
|
33
|
+
const root = join(privateRuntimeRoot(), 'launches');
|
|
34
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
35
|
+
atomicPrivateWrite(join(root, binderKey('temporary', role.name) + '.json'), {
|
|
36
|
+
role: role.name,
|
|
37
|
+
identity: role.identity,
|
|
38
|
+
action,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export async function prepareManagedAgent(role, stateDir, temporary) {
|
|
42
|
+
if (role.owner_channel?.identity === role.identity)
|
|
43
|
+
throw Error('OWNER_AGENT_IDENTITY_COLLISION');
|
|
44
|
+
const profile = readClientProfile({ ...process.env, ...role.env });
|
|
45
|
+
if (!profile)
|
|
46
|
+
throw Error('MANAGED_OURS_REQUIRES_EXPLICIT_DAEMON_PROFILE');
|
|
47
|
+
const root = privateRuntimeRoot(), key = binderKey(profile.expectedInstanceId, role.identity), dir = join(root, key);
|
|
48
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
49
|
+
const instancePath = join(dir, 'instance.json');
|
|
50
|
+
const launch = temporary
|
|
51
|
+
? JSON.parse(readFileSync(join(root, 'launches', binderKey('temporary', role.name) + '.json'), 'utf8'))
|
|
52
|
+
: undefined;
|
|
53
|
+
if (launch &&
|
|
54
|
+
(launch.role !== role.name ||
|
|
55
|
+
launch.identity !== role.identity ||
|
|
56
|
+
typeof launch.action !== 'string'))
|
|
57
|
+
throw Error('INVALID_TEMP_LAUNCH');
|
|
58
|
+
let instance;
|
|
59
|
+
if (existsSync(instancePath)) {
|
|
60
|
+
const old = JSON.parse(readFileSync(instancePath, 'utf8'));
|
|
61
|
+
if (old.role !== role.name || old.temporary !== temporary)
|
|
62
|
+
throw Error('IDENTITY_ASSIGNED_TO_OTHER_ROLE');
|
|
63
|
+
instance = old.instance;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
instance = randomUUID();
|
|
67
|
+
try {
|
|
68
|
+
writeFileSync(instancePath, JSON.stringify({ instance, role: role.name, temporary }), {
|
|
69
|
+
flag: 'wx',
|
|
70
|
+
mode: 0o600,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code !== 'EEXIST')
|
|
75
|
+
throw error;
|
|
76
|
+
const old = JSON.parse(readFileSync(instancePath, 'utf8'));
|
|
77
|
+
if (old.role !== role.name || old.temporary !== temporary)
|
|
78
|
+
throw Error('IDENTITY_ASSIGNED_TO_OTHER_ROLE');
|
|
79
|
+
instance = old.instance;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const controller = await RuntimeController.acquire(root, profile.expectedInstanceId, role.identity, instance);
|
|
83
|
+
let closeClient;
|
|
84
|
+
let partialEndpoint;
|
|
85
|
+
try {
|
|
86
|
+
let prior = controller.journal.read();
|
|
87
|
+
if (prior?.phase === 'RELEASED') {
|
|
88
|
+
if (temporary && launch.action === prior.action)
|
|
89
|
+
throw Error('TERMINAL_TEMP_REQUIRES_NEW_LAUNCH');
|
|
90
|
+
instance = controller.successor();
|
|
91
|
+
atomicPrivateWrite(instancePath, { instance, role: role.name, temporary });
|
|
92
|
+
if (temporary && existsSync(join(dir, 'identity-pin.json')))
|
|
93
|
+
unlinkSync(join(dir, 'identity-pin.json'));
|
|
94
|
+
prior = undefined;
|
|
95
|
+
}
|
|
96
|
+
const generation = prior ? controller.resumeGeneration() : 1;
|
|
97
|
+
const tokenPath = join(dir, 'owner.json');
|
|
98
|
+
let token;
|
|
99
|
+
if (existsSync(tokenPath)) {
|
|
100
|
+
const owner = JSON.parse(readFileSync(tokenPath, 'utf8'));
|
|
101
|
+
if (owner.instance !== instance || typeof owner.token !== 'string')
|
|
102
|
+
throw Error('OWNER_RECORD_MISMATCH');
|
|
103
|
+
token = owner.token;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
if (prior)
|
|
107
|
+
throw Error('OWNER_RECORD_MISSING');
|
|
108
|
+
token = randomBytes(32).toString('hex');
|
|
109
|
+
atomicPrivateWrite(tokenPath, { instance, token });
|
|
110
|
+
}
|
|
111
|
+
const client = await attachOursClient({
|
|
112
|
+
endpoint: profile.endpoint,
|
|
113
|
+
expectedInstanceId: profile.expectedInstanceId,
|
|
114
|
+
credentialPath: profile.credentialPath,
|
|
115
|
+
sessionMode: 'external',
|
|
116
|
+
leaseToken: token,
|
|
117
|
+
requiredCapabilities: ['external-sessions-v1', 'root-first-identities-v1'],
|
|
118
|
+
});
|
|
119
|
+
closeClient = () => client.close();
|
|
120
|
+
const pinPath = join(dir, 'identity-pin.json');
|
|
121
|
+
const pin = existsSync(pinPath) ? JSON.parse(readFileSync(pinPath, 'utf8')) : undefined;
|
|
122
|
+
if (pin &&
|
|
123
|
+
(pin.daemon !== profile.expectedInstanceId ||
|
|
124
|
+
pin.name !== role.identity ||
|
|
125
|
+
typeof pin.cid !== 'string'))
|
|
126
|
+
throw Error('INVALID_IDENTITY_PIN');
|
|
127
|
+
const runtime = new AgentOursRuntime({
|
|
128
|
+
instance,
|
|
129
|
+
generation,
|
|
130
|
+
daemon: profile.expectedInstanceId,
|
|
131
|
+
name: role.identity,
|
|
132
|
+
lifetime: temporary ? 'temporary' : 'permanent',
|
|
133
|
+
action: launch?.action ?? prior?.action ?? randomUUID(),
|
|
134
|
+
expectedCid: pin?.cid,
|
|
135
|
+
allowCreate: true,
|
|
136
|
+
bio: role.bio ?? '',
|
|
137
|
+
}, {
|
|
138
|
+
client,
|
|
139
|
+
journal: controller.journal,
|
|
140
|
+
assertFence: controller.assertFence,
|
|
141
|
+
now: Date.now,
|
|
142
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
143
|
+
});
|
|
144
|
+
let room;
|
|
145
|
+
if (role.roomMemberStartup) {
|
|
146
|
+
const startup = role.roomMemberStartup;
|
|
147
|
+
const path = join(root, 'room-inputs', binderKey(startup.room_identity_cid, role.name) + '.json');
|
|
148
|
+
const cowork = createCoworkAdapter();
|
|
149
|
+
room = {
|
|
150
|
+
id: startup.room_id,
|
|
151
|
+
cid: startup.room_identity_cid,
|
|
152
|
+
seat: startup.invite_id,
|
|
153
|
+
action: startup.invite_id,
|
|
154
|
+
redeem: async (attached) => {
|
|
155
|
+
const secret = JSON.parse(readFileSync(path, 'utf8'));
|
|
156
|
+
if (secret.room_id !== startup.room_id ||
|
|
157
|
+
secret.invite_id !== startup.invite_id ||
|
|
158
|
+
secret.identity_name !== role.identity)
|
|
159
|
+
throw Error('ROOM_SECRET_MISMATCH');
|
|
160
|
+
return attached.addContact({ invite: secret.invite });
|
|
161
|
+
},
|
|
162
|
+
observe: async (agentCid) => {
|
|
163
|
+
const [contacts, observed] = await Promise.all([
|
|
164
|
+
client.listContacts(),
|
|
165
|
+
cowork.getRoom(startup.room_id),
|
|
166
|
+
]);
|
|
167
|
+
if (!observed || observed.identity_cid !== startup.room_identity_cid)
|
|
168
|
+
return 'mismatch';
|
|
169
|
+
const seat = observed.seats.find((s) => s.invite_id === startup.invite_id);
|
|
170
|
+
if (seat && (seat.identity_cid !== agentCid || seat.role !== startup.role))
|
|
171
|
+
return 'mismatch';
|
|
172
|
+
return seat?.seat_state === 'active' &&
|
|
173
|
+
contacts.contacts.some((c) => c.container_id === startup.room_identity_cid)
|
|
174
|
+
? 'established'
|
|
175
|
+
: 'pending';
|
|
176
|
+
},
|
|
177
|
+
discardSecret: async () => {
|
|
178
|
+
if (existsSync(path))
|
|
179
|
+
unlinkSync(path);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
await runtime.prepare(room);
|
|
184
|
+
if (role.roomMemberStartup) {
|
|
185
|
+
const startup = role.roomMemberStartup;
|
|
186
|
+
atomicPrivateWrite(join(root, 'room-inputs', binderKey(startup.room_identity_cid, role.name) + '.ready.json'), { room: startup.room_id, invite: startup.invite_id, cid: runtime.snapshot.cid, generation });
|
|
187
|
+
}
|
|
188
|
+
if (!pin)
|
|
189
|
+
atomicPrivateWrite(pinPath, {
|
|
190
|
+
daemon: profile.expectedInstanceId,
|
|
191
|
+
name: role.identity,
|
|
192
|
+
cid: runtime.snapshot.cid,
|
|
193
|
+
});
|
|
194
|
+
const identities = new ApplicationIdentityStore({ instanceId: profile.expectedInstanceId }, { path: join(dir, 'application.json') });
|
|
195
|
+
await identities.add(role.identity);
|
|
196
|
+
const bridgeDir = join(stateDir, '.ours-bridge');
|
|
197
|
+
mkdirSync(bridgeDir, { recursive: true, mode: 0o700 });
|
|
198
|
+
const capability = randomBytes(32).toString('hex'), socket = join(bridgeDir, `g${generation}.sock`);
|
|
199
|
+
const endpoint = await startMcpEndpoint({
|
|
200
|
+
socket,
|
|
201
|
+
capability,
|
|
202
|
+
generation,
|
|
203
|
+
runtime,
|
|
204
|
+
client,
|
|
205
|
+
identities,
|
|
206
|
+
remoteDaemonFiles: true,
|
|
207
|
+
});
|
|
208
|
+
partialEndpoint = endpoint;
|
|
209
|
+
const descriptor = join(bridgeDir, 'descriptor.json');
|
|
210
|
+
atomicPrivateWrite(descriptor, { socket, capability, generation });
|
|
211
|
+
return {
|
|
212
|
+
runtime,
|
|
213
|
+
descriptor,
|
|
214
|
+
privatePaths: [root, profile.configPath, profile.credentialPath],
|
|
215
|
+
close: async (terminal) => {
|
|
216
|
+
try {
|
|
217
|
+
await endpoint.close();
|
|
218
|
+
if (terminal)
|
|
219
|
+
await runtime.terminal();
|
|
220
|
+
else
|
|
221
|
+
await runtime.suspend();
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
try {
|
|
225
|
+
await client.close();
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
controller.unlock();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
try {
|
|
236
|
+
await partialEndpoint?.close();
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
try {
|
|
240
|
+
await closeClient?.();
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
controller.unlock();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/** Explicit trusted inventory migration: read and pin existing permanent CID, never bind. */
|
|
250
|
+
export async function preparePermanentAssignment(role) {
|
|
251
|
+
const profile = readClientProfile({ ...process.env, ...role.env });
|
|
252
|
+
if (!profile)
|
|
253
|
+
throw Error('MANAGED_OURS_REQUIRES_EXPLICIT_DAEMON_PROFILE');
|
|
254
|
+
const client = await attachOursClient({
|
|
255
|
+
endpoint: profile.endpoint,
|
|
256
|
+
expectedInstanceId: profile.expectedInstanceId,
|
|
257
|
+
credentialPath: profile.credentialPath,
|
|
258
|
+
sessionMode: 'local',
|
|
259
|
+
requiredCapabilities: ['local-pid-v1', 'root-first-identities-v1'],
|
|
260
|
+
});
|
|
261
|
+
try {
|
|
262
|
+
const rows = await client.listIdentities(), row = rows.find((r) => r.name === role.identity);
|
|
263
|
+
if (!row)
|
|
264
|
+
return 'unverified';
|
|
265
|
+
if (!('cid' in row) || row.kind !== 'role' || row.temp)
|
|
266
|
+
throw Error('PERMANENT_ASSIGNMENT_NOT_A_ROLE');
|
|
267
|
+
const dir = join(privateRuntimeRoot(), binderKey(profile.expectedInstanceId, role.identity));
|
|
268
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
269
|
+
const path = join(dir, 'identity-pin.json');
|
|
270
|
+
if (existsSync(path)) {
|
|
271
|
+
const old = JSON.parse(readFileSync(path, 'utf8'));
|
|
272
|
+
if (old.cid !== row.cid ||
|
|
273
|
+
old.daemon !== profile.expectedInstanceId ||
|
|
274
|
+
old.name !== role.identity)
|
|
275
|
+
throw Error('PERMANENT_ASSIGNMENT_PIN_MISMATCH');
|
|
276
|
+
}
|
|
277
|
+
else
|
|
278
|
+
atomicPrivateWrite(path, {
|
|
279
|
+
daemon: profile.expectedInstanceId,
|
|
280
|
+
name: role.identity,
|
|
281
|
+
cid: row.cid,
|
|
282
|
+
});
|
|
283
|
+
return 'verified';
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
await client.close();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
export function readRoomReadiness(roomCid, name) {
|
|
290
|
+
try {
|
|
291
|
+
return JSON.parse(readFileSync(join(privateRuntimeRoot(), 'room-inputs', binderKey(roomCid, name) + '.ready.json'), 'utf8'));
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
if (error.code === 'ENOENT')
|
|
295
|
+
return;
|
|
296
|
+
throw error;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/** Explicit logical-instance termination, independent of harness/bridge transport. */
|
|
300
|
+
export async function releaseManagedAgent(role) {
|
|
301
|
+
const profile = readClientProfile({ ...process.env, ...role.env });
|
|
302
|
+
if (!profile)
|
|
303
|
+
throw Error('MANAGED_OURS_REQUIRES_EXPLICIT_DAEMON_PROFILE');
|
|
304
|
+
const root = privateRuntimeRoot(), dir = join(root, binderKey(profile.expectedInstanceId, role.identity));
|
|
305
|
+
if (!existsSync(join(dir, 'instance.json')))
|
|
306
|
+
return;
|
|
307
|
+
const instance = JSON.parse(readFileSync(join(dir, 'instance.json'), 'utf8'));
|
|
308
|
+
if (instance.role !== role.name)
|
|
309
|
+
throw Error('IDENTITY_ASSIGNED_TO_OTHER_ROLE');
|
|
310
|
+
const controller = await RuntimeController.acquire(root, profile.expectedInstanceId, role.identity, instance.instance);
|
|
311
|
+
try {
|
|
312
|
+
const state = controller.journal.read();
|
|
313
|
+
if (!state || state.phase === 'RELEASED')
|
|
314
|
+
return;
|
|
315
|
+
const owner = JSON.parse(readFileSync(join(dir, 'owner.json'), 'utf8'));
|
|
316
|
+
if (owner.instance !== state.instance)
|
|
317
|
+
throw Error('OWNER_RECORD_MISMATCH');
|
|
318
|
+
const client = await attachOursClient({
|
|
319
|
+
endpoint: profile.endpoint,
|
|
320
|
+
expectedInstanceId: profile.expectedInstanceId,
|
|
321
|
+
credentialPath: profile.credentialPath,
|
|
322
|
+
sessionMode: 'external',
|
|
323
|
+
leaseToken: owner.token,
|
|
324
|
+
requiredCapabilities: ['external-sessions-v1', 'root-first-identities-v1'],
|
|
325
|
+
});
|
|
326
|
+
try {
|
|
327
|
+
const runtime = new AgentOursRuntime({
|
|
328
|
+
instance: state.instance,
|
|
329
|
+
generation: state.generation,
|
|
330
|
+
daemon: state.daemon,
|
|
331
|
+
name: state.name,
|
|
332
|
+
lifetime: state.lifetime,
|
|
333
|
+
action: state.action,
|
|
334
|
+
allowCreate: false,
|
|
335
|
+
bio: '',
|
|
336
|
+
}, {
|
|
337
|
+
client,
|
|
338
|
+
journal: controller.journal,
|
|
339
|
+
assertFence: controller.assertFence,
|
|
340
|
+
now: Date.now,
|
|
341
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
342
|
+
});
|
|
343
|
+
await runtime.terminal();
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
await client.close();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
controller.unlock();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type Phase = 'PREPARING' | 'OWNED' | 'ROOM_PENDING' | 'READY' | 'SERVING' | 'RECOVERING' | 'QUIESCING' | 'TERMINAL_INTENT' | 'RELEASED' | 'FAILED' | 'CLEANUP_PENDING';
|
|
2
|
+
export interface RuntimeState {
|
|
3
|
+
version: 1;
|
|
4
|
+
instance: string;
|
|
5
|
+
generation: number;
|
|
6
|
+
daemon: string;
|
|
7
|
+
name: string;
|
|
8
|
+
cid?: string;
|
|
9
|
+
lifetime: 'permanent' | 'temporary';
|
|
10
|
+
action: string;
|
|
11
|
+
phase: Phase;
|
|
12
|
+
revision: number;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
admissionIntent?: {
|
|
15
|
+
id: string;
|
|
16
|
+
cid: string;
|
|
17
|
+
seat: string;
|
|
18
|
+
action: string;
|
|
19
|
+
agentCid: string;
|
|
20
|
+
};
|
|
21
|
+
releaseAck?: {
|
|
22
|
+
released: string[];
|
|
23
|
+
closed: string[];
|
|
24
|
+
attempted: number;
|
|
25
|
+
notified: number;
|
|
26
|
+
failed: number;
|
|
27
|
+
};
|
|
28
|
+
room?: {
|
|
29
|
+
id: string;
|
|
30
|
+
cid: string;
|
|
31
|
+
seat: string;
|
|
32
|
+
agentCid: string;
|
|
33
|
+
action: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export declare function atomicPrivateWrite(path: string, value: unknown): void;
|
|
37
|
+
export declare function binderKey(daemon: string, identity: string): string;
|
|
38
|
+
/** Supervisor-owned durable state; retained across harness reconnects. */
|
|
39
|
+
export declare class RuntimeJournal {
|
|
40
|
+
readonly privateDir: string;
|
|
41
|
+
readonly path: string;
|
|
42
|
+
constructor(privateDir: string);
|
|
43
|
+
read(): RuntimeState | undefined;
|
|
44
|
+
commit(next: RuntimeState, expectedRevision?: number): void;
|
|
45
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
const phases = [
|
|
5
|
+
'PREPARING',
|
|
6
|
+
'OWNED',
|
|
7
|
+
'ROOM_PENDING',
|
|
8
|
+
'READY',
|
|
9
|
+
'SERVING',
|
|
10
|
+
'RECOVERING',
|
|
11
|
+
'QUIESCING',
|
|
12
|
+
'TERMINAL_INTENT',
|
|
13
|
+
'RELEASED',
|
|
14
|
+
'FAILED',
|
|
15
|
+
'CLEANUP_PENDING',
|
|
16
|
+
];
|
|
17
|
+
export function atomicPrivateWrite(path, value) {
|
|
18
|
+
const tmp = `${path}.${randomUUID()}.tmp`;
|
|
19
|
+
const fd = openSync(tmp, 'wx', 0o600);
|
|
20
|
+
try {
|
|
21
|
+
writeFileSync(fd, JSON.stringify(value) + '\n');
|
|
22
|
+
fsyncSync(fd);
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
closeSync(fd);
|
|
26
|
+
}
|
|
27
|
+
renameSync(tmp, path);
|
|
28
|
+
const parent = openSync(join(path, '..'), 'r');
|
|
29
|
+
try {
|
|
30
|
+
fsyncSync(parent);
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
closeSync(parent);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function binderKey(daemon, identity) {
|
|
37
|
+
return createHash('sha256')
|
|
38
|
+
.update(JSON.stringify([daemon, identity]))
|
|
39
|
+
.digest('hex');
|
|
40
|
+
}
|
|
41
|
+
/** Supervisor-owned durable state; retained across harness reconnects. */
|
|
42
|
+
export class RuntimeJournal {
|
|
43
|
+
privateDir;
|
|
44
|
+
path;
|
|
45
|
+
constructor(privateDir) {
|
|
46
|
+
this.privateDir = privateDir;
|
|
47
|
+
mkdirSync(privateDir, { recursive: true, mode: 0o700 });
|
|
48
|
+
this.path = join(privateDir, 'state.json');
|
|
49
|
+
}
|
|
50
|
+
read() {
|
|
51
|
+
let raw;
|
|
52
|
+
try {
|
|
53
|
+
raw = readFileSync(this.path, 'utf8');
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error.code === 'ENOENT')
|
|
57
|
+
return;
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
const s = JSON.parse(raw);
|
|
61
|
+
if (s.version !== 1 ||
|
|
62
|
+
!s.instance ||
|
|
63
|
+
!s.daemon ||
|
|
64
|
+
!s.name ||
|
|
65
|
+
!s.action ||
|
|
66
|
+
!phases.includes(s.phase) ||
|
|
67
|
+
!Number.isSafeInteger(s.generation) ||
|
|
68
|
+
s.generation < 1 ||
|
|
69
|
+
!Number.isSafeInteger(s.revision) ||
|
|
70
|
+
!['permanent', 'temporary'].includes(s.lifetime))
|
|
71
|
+
throw Error('CORRUPT_RUNTIME_JOURNAL');
|
|
72
|
+
return s;
|
|
73
|
+
}
|
|
74
|
+
commit(next, expectedRevision) {
|
|
75
|
+
const current = this.read();
|
|
76
|
+
if (current?.revision !== expectedRevision)
|
|
77
|
+
throw Error('STALE_RUNTIME_REVISION');
|
|
78
|
+
if (current &&
|
|
79
|
+
(current.instance !== next.instance ||
|
|
80
|
+
current.daemon !== next.daemon ||
|
|
81
|
+
current.name !== next.name ||
|
|
82
|
+
current.lifetime !== next.lifetime ||
|
|
83
|
+
next.generation < current.generation))
|
|
84
|
+
throw Error('RUNTIME_IDENTITY_MISMATCH');
|
|
85
|
+
if (current &&
|
|
86
|
+
['TERMINAL_INTENT', 'CLEANUP_PENDING', 'RELEASED'].includes(current.phase) &&
|
|
87
|
+
!['TERMINAL_INTENT', 'CLEANUP_PENDING', 'RELEASED'].includes(next.phase))
|
|
88
|
+
throw Error('RETIRED_RUNTIME');
|
|
89
|
+
atomicPrivateWrite(this.path, {
|
|
90
|
+
...next,
|
|
91
|
+
revision: (expectedRevision ?? 0) + 1,
|
|
92
|
+
updatedAt: new Date().toISOString(),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Socket } from 'node:net';
|
|
2
|
+
export declare const MAX_FRAME_BYTES: number;
|
|
3
|
+
export declare const MAX_CHUNK_BYTES: number;
|
|
4
|
+
/** One bounded framed channel. No request replay after disconnect. */
|
|
5
|
+
export declare class Wire {
|
|
6
|
+
readonly socket: Socket;
|
|
7
|
+
private buffer;
|
|
8
|
+
private ended;
|
|
9
|
+
onFrame: (frame: unknown) => void;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
constructor(socket: Socket);
|
|
12
|
+
send(frame: unknown): Promise<void>;
|
|
13
|
+
close(): void;
|
|
14
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const MAX_FRAME_BYTES = 1024 * 1024;
|
|
2
|
+
export const MAX_CHUNK_BYTES = 64 * 1024;
|
|
3
|
+
/** One bounded framed channel. No request replay after disconnect. */
|
|
4
|
+
export class Wire {
|
|
5
|
+
socket;
|
|
6
|
+
buffer = Buffer.alloc(0);
|
|
7
|
+
ended = false;
|
|
8
|
+
onFrame = () => { };
|
|
9
|
+
onClose = () => { };
|
|
10
|
+
constructor(socket) {
|
|
11
|
+
this.socket = socket;
|
|
12
|
+
socket.on('data', (chunk) => {
|
|
13
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
14
|
+
for (;;) {
|
|
15
|
+
const end = this.buffer.indexOf(10);
|
|
16
|
+
if (end < 0) {
|
|
17
|
+
if (this.buffer.length > MAX_FRAME_BYTES)
|
|
18
|
+
socket.destroy();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (end > MAX_FRAME_BYTES) {
|
|
22
|
+
socket.destroy();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const line = this.buffer.subarray(0, end);
|
|
26
|
+
this.buffer = this.buffer.subarray(end + 1);
|
|
27
|
+
try {
|
|
28
|
+
this.onFrame(JSON.parse(line.toString('utf8')));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
socket.destroy();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
socket.on('error', () => socket.destroy());
|
|
37
|
+
socket.on('close', () => {
|
|
38
|
+
if (!this.ended) {
|
|
39
|
+
this.ended = true;
|
|
40
|
+
this.onClose();
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
async send(frame) {
|
|
45
|
+
if (this.ended || this.socket.destroyed)
|
|
46
|
+
throw Error('BRIDGE_DISCONNECTED');
|
|
47
|
+
const bytes = Buffer.from(JSON.stringify(frame) + '\n');
|
|
48
|
+
if (bytes.length > MAX_FRAME_BYTES)
|
|
49
|
+
throw Error('FRAME_TOO_LARGE');
|
|
50
|
+
await new Promise((resolve, reject) => this.socket.write(bytes, (error) => (error ? reject(error) : resolve())));
|
|
51
|
+
}
|
|
52
|
+
close() {
|
|
53
|
+
this.socket.destroy();
|
|
54
|
+
}
|
|
55
|
+
}
|