@ours.network/fleet 1.1.5 → 1.2.0-nightly.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -21
- package/dist/agent-ours/bridge.d.ts +3 -0
- package/dist/agent-ours/bridge.js +178 -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 +364 -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/agent-recovery-gate.js +1 -1
- package/dist/application/legacy-supervisor-identity.d.ts +7 -0
- package/dist/application/legacy-supervisor-identity.js +43 -0
- package/dist/application/supervisor-ours-tools.d.ts +159 -0
- package/dist/application/supervisor-ours-tools.js +121 -0
- package/dist/application/task-room-service.js +55 -19
- package/dist/briefing.js +10 -59
- package/dist/build-info.json +6 -5
- package/dist/capabilities.d.ts +2 -1
- package/dist/capabilities.js +2 -0
- package/dist/cli.js +75 -8
- package/dist/client-profile.d.ts +18 -0
- package/dist/client-profile.js +130 -0
- package/dist/config.d.ts +1 -0
- package/dist/creation.d.ts +1 -1
- package/dist/creation.js +13 -21
- package/dist/daemon-recovery.d.ts +2 -0
- package/dist/daemon-recovery.js +53 -2
- package/dist/docs.d.ts +3 -3
- package/dist/docs.js +32 -23
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +70 -14
- package/dist/fleet-command-audit.js +3 -2
- 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/codex.d.ts +3 -2
- package/dist/harness/codex.js +8 -3
- package/dist/harness/hermes-session.js +7 -1
- package/dist/init-wizard.d.ts +11 -6
- package/dist/init-wizard.js +33 -0
- package/dist/monitor.d.ts +34 -3
- package/dist/monitor.js +174 -74
- package/dist/owner-channel/channel.d.ts +3 -0
- package/dist/owner-channel/channel.js +28 -6
- package/dist/owner-channel/ours-client.d.ts +12 -5
- package/dist/owner-channel/ours-client.js +48 -12
- package/dist/rooms-tasks/archived-absence.d.ts +8 -0
- package/dist/rooms-tasks/archived-absence.js +48 -0
- package/dist/rooms-tasks/cli.js +1 -1
- package/dist/rooms-tasks/close.d.ts +3 -0
- package/dist/rooms-tasks/close.js +95 -17
- package/dist/rooms-tasks/cowork-adapter.d.ts +4 -0
- package/dist/rooms-tasks/cowork-adapter.js +10 -2
- package/dist/rooms-tasks/cowork-http.d.ts +3 -0
- package/dist/rooms-tasks/cowork-http.js +70 -0
- package/dist/rooms-tasks/deletion.js +40 -3
- package/dist/rooms-tasks/member-startup.d.ts +1 -0
- package/dist/rooms-tasks/member-startup.js +2 -0
- package/dist/rooms-tasks/provision.d.ts +11 -0
- package/dist/rooms-tasks/provision.js +138 -26
- package/dist/rooms-tasks/room-state.js +13 -0
- package/dist/rooms-tasks/task-state.d.ts +13 -3
- package/dist/rooms-tasks/task-state.js +42 -6
- package/dist/rooms-tasks/types.d.ts +12 -0
- package/dist/rooms-tasks/workspace-artifacts.d.ts +6 -0
- package/dist/rooms-tasks/workspace-artifacts.js +93 -0
- package/dist/rooms-tasks/workspace.d.ts +26 -0
- package/dist/rooms-tasks/workspace.js +195 -0
- package/dist/runner.d.ts +14 -0
- package/dist/runner.js +540 -494
- package/dist/session/codex-app-server-transport.js +1 -1
- package/dist/session/codex-app-server.js +31 -5
- package/dist/spawn.js +19 -16
- package/dist/startup-readiness.d.ts +13 -0
- package/dist/startup-readiness.js +38 -0
- package/dist/supervisor/launchd.js +8 -1
- package/dist/supervisor/systemd.js +5 -0
- 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/dist/web/server.d.ts +2 -0
- package/dist/web/server.js +18 -1
- package/docs/task-workspaces.md +98 -0
- package/package.json +8 -4
- package/presets/fleet/roles/Coordinator.yaml +22 -0
|
@@ -419,7 +419,9 @@ export class OwnerChannel {
|
|
|
419
419
|
]), deadlineAt);
|
|
420
420
|
if (superseded())
|
|
421
421
|
throw new Error('owner recovery epoch superseded');
|
|
422
|
-
|
|
422
|
+
// A daemon reconnect is not terminal ownership evidence. Keep the same
|
|
423
|
+
// owner ID and its temporary/permanent bindings while replacing transport.
|
|
424
|
+
await this.recoveryStage('close', this.client.close({ releaseLease: false }), deadlineAt);
|
|
423
425
|
if (superseded())
|
|
424
426
|
throw new Error('owner recovery epoch superseded');
|
|
425
427
|
try {
|
|
@@ -450,7 +452,7 @@ export class OwnerChannel {
|
|
|
450
452
|
// its disposal; the recorded quiescence debt gates the next retry.
|
|
451
453
|
if (!this.recoveryQuiescence) {
|
|
452
454
|
try {
|
|
453
|
-
await this.recoveryStage('close_after_failure', this.client.close(), deadlineAt);
|
|
455
|
+
await this.recoveryStage('close_after_failure', this.client.close({ releaseLease: false }), deadlineAt);
|
|
454
456
|
}
|
|
455
457
|
catch (closeError) {
|
|
456
458
|
this.logError('recovery client close failed', closeError);
|
|
@@ -1121,6 +1123,13 @@ export class OwnerChannel {
|
|
|
1121
1123
|
const retrieved = unread.length
|
|
1122
1124
|
? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
|
|
1123
1125
|
: [];
|
|
1126
|
+
// getFiles returns daemon-local paths, which need not exist in Fleet's
|
|
1127
|
+
// filesystem (for example with an HTTP daemon in a container). Fetch
|
|
1128
|
+
// through the bound client and keep the daemon's integrity metadata for
|
|
1129
|
+
// admission below; never trust or remap the returned filesystem path.
|
|
1130
|
+
for (const file of retrieved) {
|
|
1131
|
+
file.path = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
|
|
1132
|
+
}
|
|
1124
1133
|
for (const file of historyRecovered) {
|
|
1125
1134
|
if (!group.recovery)
|
|
1126
1135
|
throw new Error('unexpected read attachment without recovery route');
|
|
@@ -1134,8 +1143,7 @@ export class OwnerChannel {
|
|
|
1134
1143
|
await mkdir(outbox, { recursive: true, mode: 0o700 });
|
|
1135
1144
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
1136
1145
|
const queued = await queueSessionPrompt(this.options.session, this.ownerAttachmentPrompt(sender, originWireId, requestId, admitted, group.caption), {
|
|
1137
|
-
|
|
1138
|
-
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
1146
|
+
...this.ownerPromptPolicy(),
|
|
1139
1147
|
origin: { kind: 'owner', requestId,
|
|
1140
1148
|
...(group.caption ? { displayText: String(group.caption.text ?? '') } : {}) },
|
|
1141
1149
|
});
|
|
@@ -1261,8 +1269,7 @@ export class OwnerChannel {
|
|
|
1261
1269
|
const activityCursor = this.latestEventSeq(this.options.session.eventsSince(0));
|
|
1262
1270
|
try {
|
|
1263
1271
|
queued = await queueSessionPrompt(this.options.session, this.ownerPrompt(sender, text, wireId), {
|
|
1264
|
-
|
|
1265
|
-
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
1272
|
+
...this.ownerPromptPolicy(),
|
|
1266
1273
|
origin: { kind: 'owner', requestId, displayText: text },
|
|
1267
1274
|
});
|
|
1268
1275
|
}
|
|
@@ -1307,6 +1314,14 @@ export class OwnerChannel {
|
|
|
1307
1314
|
this.completionTasks.add(task);
|
|
1308
1315
|
return true;
|
|
1309
1316
|
}
|
|
1317
|
+
ownerPromptPolicy() {
|
|
1318
|
+
if (this.options.startupPending?.())
|
|
1319
|
+
return { interrupt: false, steer: true };
|
|
1320
|
+
return {
|
|
1321
|
+
interrupt: this.options.config.interrupt,
|
|
1322
|
+
...(this.options.config.interrupt ? { interruptSource: 'owner' } : {}),
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1310
1325
|
/**
|
|
1311
1326
|
* Deterministic command path: the message never becomes an agent prompt.
|
|
1312
1327
|
* Authorization already happened — the managed-agent relay branch and the
|
|
@@ -1760,6 +1775,13 @@ export class OwnerChannel {
|
|
|
1760
1775
|
const retrieved = unread.length
|
|
1761
1776
|
? parseRetrievedAttachments(await this.client.getFiles(unread.map(file => file.wireId)), unread)
|
|
1762
1777
|
: [];
|
|
1778
|
+
// getFiles returns daemon-local paths, which need not exist in Fleet's
|
|
1779
|
+
// filesystem (for example with an HTTP daemon in a container). Fetch
|
|
1780
|
+
// through the bound client and keep the daemon's integrity metadata for
|
|
1781
|
+
// admission below; never trust or remap the returned filesystem path.
|
|
1782
|
+
for (const file of retrieved) {
|
|
1783
|
+
file.path = await writeRecoveredAttachment(requestDir, file.wireId, await this.client.fetchFile(file.wireId));
|
|
1784
|
+
}
|
|
1763
1785
|
for (const file of historyRecovered) {
|
|
1764
1786
|
if (!group.recovery)
|
|
1765
1787
|
throw new Error('unexpected read attachment without recovery route');
|
|
@@ -92,8 +92,10 @@ export interface OursOps {
|
|
|
92
92
|
filename: string;
|
|
93
93
|
replyToWireId?: string;
|
|
94
94
|
}): Promise<void>;
|
|
95
|
-
/**
|
|
96
|
-
close(
|
|
95
|
+
/** Dispose transport; terminal release is default and rejects if cleanup is incomplete. */
|
|
96
|
+
close(options?: {
|
|
97
|
+
releaseLease?: boolean;
|
|
98
|
+
}): Promise<void>;
|
|
97
99
|
}
|
|
98
100
|
/**
|
|
99
101
|
* The typed error code of a daemon operation, or undefined when the failure was
|
|
@@ -120,14 +122,17 @@ export interface OursSdkClientDeps {
|
|
|
120
122
|
* its own and hands it back in `close()`. That replaces the connector proxy's
|
|
121
123
|
* shell-PID fence, which existed because a supervised attempt had to make its
|
|
122
124
|
* lease reclaimable while the supervisor itself stayed alive: an explicit
|
|
123
|
-
* release does that deterministically
|
|
124
|
-
*
|
|
125
|
+
* terminal release does that deterministically. Recovery disposes only the
|
|
126
|
+
* transport and retains this owner ID and its bindings. Explicit daemon identity selects external
|
|
127
|
+
* ownership in either layout; PID-based legacy attachment remains temporary.
|
|
128
|
+
* Failed external release preserves unknown ownership until exact terminal evidence.
|
|
125
129
|
*/
|
|
126
130
|
export declare class OursSdkClient implements OursOps {
|
|
127
131
|
private readonly env;
|
|
128
132
|
private readonly log;
|
|
129
133
|
private readonly deps;
|
|
130
134
|
private client?;
|
|
135
|
+
private terminalReleasePending;
|
|
131
136
|
private readonly leaseToken;
|
|
132
137
|
constructor(env?: Record<string, string>, log?: (line: string) => void, deps?: OursSdkClientDeps);
|
|
133
138
|
start(): Promise<void>;
|
|
@@ -161,7 +166,9 @@ export declare class OursSdkClient implements OursOps {
|
|
|
161
166
|
filename: string;
|
|
162
167
|
replyToWireId?: string;
|
|
163
168
|
}): Promise<void>;
|
|
164
|
-
close(
|
|
169
|
+
close(options?: {
|
|
170
|
+
releaseLease?: boolean;
|
|
171
|
+
}): Promise<void>;
|
|
165
172
|
private ops;
|
|
166
173
|
}
|
|
167
174
|
export {};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import { extname } from 'node:path';
|
|
3
|
+
import { extname, join } from 'node:path';
|
|
4
|
+
import { readDaemonConfig, resolveEndpoint } from '../monitor.js';
|
|
5
|
+
import { readClientProfile } from '../client-profile.js';
|
|
4
6
|
import { OursError, attachOursClient, } from '@ours.network/sdk/client';
|
|
5
7
|
/** Any failure of a daemon operation. Never carries a message body or a token. */
|
|
6
8
|
export class OursDaemonError extends Error {
|
|
@@ -106,14 +108,18 @@ export const OURS_BOUND_ELSEWHERE = 'BOUND_ELSEWHERE';
|
|
|
106
108
|
* its own and hands it back in `close()`. That replaces the connector proxy's
|
|
107
109
|
* shell-PID fence, which existed because a supervised attempt had to make its
|
|
108
110
|
* lease reclaimable while the supervisor itself stayed alive: an explicit
|
|
109
|
-
* release does that deterministically
|
|
110
|
-
*
|
|
111
|
+
* terminal release does that deterministically. Recovery disposes only the
|
|
112
|
+
* transport and retains this owner ID and its bindings. Explicit daemon identity selects external
|
|
113
|
+
* ownership in either layout; PID-based legacy attachment remains temporary.
|
|
114
|
+
* Failed external release preserves unknown ownership until exact terminal evidence.
|
|
111
115
|
*/
|
|
112
116
|
export class OursSdkClient {
|
|
113
117
|
env;
|
|
114
118
|
log;
|
|
115
119
|
deps;
|
|
116
120
|
client;
|
|
121
|
+
// Disposing a transport does not discharge this owner's terminal cleanup.
|
|
122
|
+
terminalReleasePending = false;
|
|
117
123
|
leaseToken = `ours-fleet-owner-${process.pid}-${randomUUID()}`;
|
|
118
124
|
constructor(env = {}, log = () => undefined, deps = {}) {
|
|
119
125
|
this.env = env;
|
|
@@ -124,16 +130,28 @@ export class OursSdkClient {
|
|
|
124
130
|
if (this.client)
|
|
125
131
|
return;
|
|
126
132
|
const environment = { ...process.env, ...this.env };
|
|
127
|
-
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
const
|
|
133
|
+
const profile = readClientProfile(environment);
|
|
134
|
+
// Reuse existing connection configuration. Identity selection checks the
|
|
135
|
+
// daemon capability before reading the client's protected token delivery file.
|
|
136
|
+
const endpoint = !profile && environment.OURS_DAEMON_ID
|
|
137
|
+
? resolveEndpoint(environment, false) : undefined;
|
|
138
|
+
const token = endpoint ? environment.OURS_API_TOKEN?.trim() || readDaemonConfig(environment).apiToken : undefined;
|
|
139
|
+
const options = profile ? {
|
|
140
|
+
endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
141
|
+
credentialPath: profile.credentialPath,
|
|
142
|
+
sessionMode: 'external', leaseToken: this.leaseToken, env: {},
|
|
143
|
+
} : endpoint ? {
|
|
144
|
+
endpoint: endpoint.origin, expectedInstanceId: environment.OURS_DAEMON_ID,
|
|
145
|
+
sessionMode: 'external', leaseToken: this.leaseToken,
|
|
146
|
+
...(token ? { token } : { credentialPath: join(endpoint.stateDir, 'daemon-token') }),
|
|
147
|
+
} : {
|
|
131
148
|
env: environment,
|
|
132
149
|
leaseToken: this.leaseToken,
|
|
133
150
|
clientPid: process.pid,
|
|
134
151
|
fetch: notificationDeadlineFetch(this.deps.fetch ?? globalThis.fetch, this.deps.notificationRequestDeadlineMs ?? NOTIFICATION_REQUEST_DEADLINE_MS),
|
|
135
152
|
};
|
|
136
153
|
this.client = await (this.deps.attachClient?.(options) ?? attachOursClient(options));
|
|
154
|
+
this.terminalReleasePending = true;
|
|
137
155
|
}
|
|
138
156
|
async bindIdentity(name) {
|
|
139
157
|
// force is pinned off: the owner channel never evicts another live session
|
|
@@ -162,7 +180,10 @@ export class OursSdkClient {
|
|
|
162
180
|
return this.ops().getHistoryItem({ wire_id: wireId });
|
|
163
181
|
}
|
|
164
182
|
watchNotifications(identity, options) {
|
|
165
|
-
return this.ops().watchNotifications(identity,
|
|
183
|
+
return this.ops().watchNotifications(identity, {
|
|
184
|
+
...options,
|
|
185
|
+
requestTimeoutMs: this.deps.notificationRequestDeadlineMs ?? NOTIFICATION_REQUEST_DEADLINE_MS,
|
|
186
|
+
});
|
|
166
187
|
}
|
|
167
188
|
async listIncomingFiles() {
|
|
168
189
|
return this.ops().listIncomingFiles();
|
|
@@ -211,19 +232,34 @@ export class OursSdkClient {
|
|
|
211
232
|
throw new OursSendRefusedError(`the daemon did not send the file (${verdict.kind}): the contact's end-to-end `
|
|
212
233
|
+ 'session must be re-established after an upgrade; files are not queued');
|
|
213
234
|
}
|
|
214
|
-
async close() {
|
|
235
|
+
async close(options = {}) {
|
|
236
|
+
const terminal = options.releaseLease !== false;
|
|
237
|
+
// A failed recovery may have disposed its client before normal shutdown.
|
|
238
|
+
// Reattach the same owner through ordinary verified selection; no rebind is
|
|
239
|
+
// needed to release it, and a failed attach must not be reported as closed.
|
|
240
|
+
if (terminal && !this.client && this.terminalReleasePending)
|
|
241
|
+
await this.start();
|
|
215
242
|
const client = this.client;
|
|
216
243
|
this.client = undefined;
|
|
217
244
|
if (!client)
|
|
218
245
|
return;
|
|
219
246
|
// Handing the lease back is what lets a successor bind this identity without
|
|
220
|
-
// waiting for the supervisor to exit.
|
|
221
|
-
//
|
|
247
|
+
// waiting for the supervisor to exit. External failure remains unknown;
|
|
248
|
+
// it is not permission to infer owner death or fall back to daemon PID checks.
|
|
222
249
|
try {
|
|
223
|
-
|
|
250
|
+
if (terminal) {
|
|
251
|
+
const result = await client.releaseLease();
|
|
252
|
+
if (result.failed > 0)
|
|
253
|
+
throw new OursDaemonError('daemon lease release incomplete');
|
|
254
|
+
this.terminalReleasePending = false;
|
|
255
|
+
}
|
|
224
256
|
}
|
|
225
257
|
catch (error) {
|
|
226
258
|
this.log(`lease release failed: ${error?.message ?? String(error)}`);
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
await client.close();
|
|
227
263
|
}
|
|
228
264
|
}
|
|
229
265
|
ops() {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ArchivedMemberAbsence, RoomMemberSeat } from './types.js';
|
|
2
|
+
/** Revalidate live facts; a persisted timestamp is never cleanup authority. */
|
|
3
|
+
export declare function verifyArchivedAbsence(proof: ArchivedMemberAbsence): Promise<void>;
|
|
4
|
+
/** After the verified archive has been consumed by explicit workspace deletion,
|
|
5
|
+
* still fence replacement live state and a recreated daemon identity on every retry.
|
|
6
|
+
*/
|
|
7
|
+
export declare function verifyArchivedMemberStillAbsent(proof: ArchivedMemberAbsence): Promise<void>;
|
|
8
|
+
export declare function proveArchivedAbsence(seat: RoomMemberSeat): Promise<ArchivedMemberAbsence>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { agentDir } from '../paths.js';
|
|
4
|
+
import { tempArchiveForLaunch, tempArchiveForCreationAction, tempSupervisorLiveness } from '../temp-lifecycle.js';
|
|
5
|
+
import { assertMemberIdentityAbsent } from './close.js';
|
|
6
|
+
/** Revalidate live facts; a persisted timestamp is never cleanup authority. */
|
|
7
|
+
export async function verifyArchivedAbsence(proof) {
|
|
8
|
+
const absent = () => {
|
|
9
|
+
if (existsSync(agentDir(proof.name, true)))
|
|
10
|
+
throw new Error('Archived member has replacement live state');
|
|
11
|
+
};
|
|
12
|
+
absent();
|
|
13
|
+
const archive = tempArchiveForLaunch(proof.name, proof.launch_id);
|
|
14
|
+
const action = tempArchiveForCreationAction(proof.name, proof.action_id);
|
|
15
|
+
if (!archive || archive !== proof.archive_path || action?.path !== archive
|
|
16
|
+
|| action.launchId !== proof.launch_id
|
|
17
|
+
|| readFileSync(join(archive, '.identity'), 'utf8').trim() !== proof.name)
|
|
18
|
+
throw new Error('Archived member absence ownership proof mismatch');
|
|
19
|
+
if (await tempSupervisorLiveness(archive) !== 'stopped')
|
|
20
|
+
throw new Error('Archived member supervisor absence is not proven');
|
|
21
|
+
await verifyArchivedMemberStillAbsent(proof);
|
|
22
|
+
}
|
|
23
|
+
/** After the verified archive has been consumed by explicit workspace deletion,
|
|
24
|
+
* still fence replacement live state and a recreated daemon identity on every retry.
|
|
25
|
+
*/
|
|
26
|
+
export async function verifyArchivedMemberStillAbsent(proof) {
|
|
27
|
+
const absent = () => {
|
|
28
|
+
if (existsSync(agentDir(proof.name, true)))
|
|
29
|
+
throw new Error('Archived member has replacement live state');
|
|
30
|
+
};
|
|
31
|
+
absent();
|
|
32
|
+
await assertMemberIdentityAbsent({
|
|
33
|
+
role_name: proof.name, slot: 'archived', cowork_role: 'archived', seat_state: 'removed',
|
|
34
|
+
});
|
|
35
|
+
absent();
|
|
36
|
+
}
|
|
37
|
+
export async function proveArchivedAbsence(seat) {
|
|
38
|
+
if (seat.identity_cid || seat.retirement?.phase !== 'identity_absent'
|
|
39
|
+
|| !seat.launch?.launch_id || !seat.launch.action_id
|
|
40
|
+
|| seat.launch.launch_id !== seat.retirement.launch_id || !seat.retirement.archive_path)
|
|
41
|
+
throw new Error('Missing exact archived member absence evidence');
|
|
42
|
+
const proof = {
|
|
43
|
+
name: seat.role_name, launch_id: seat.launch.launch_id, action_id: seat.launch.action_id,
|
|
44
|
+
archive_path: seat.retirement.archive_path, checked_at: new Date().toISOString(),
|
|
45
|
+
};
|
|
46
|
+
await verifyArchivedAbsence(proof);
|
|
47
|
+
return proof;
|
|
48
|
+
}
|
package/dist/rooms-tasks/cli.js
CHANGED
|
@@ -1152,7 +1152,7 @@ export function registerTaskCommands(parent, cOpt) {
|
|
|
1152
1152
|
const app = taskRoomService(opts.configuration);
|
|
1153
1153
|
for (;;) {
|
|
1154
1154
|
const before = app.taskProvisioningOutcome(id);
|
|
1155
|
-
if (before.kind !== 'in_progress') {
|
|
1155
|
+
if (before.kind !== 'in_progress' || before.next_action) {
|
|
1156
1156
|
await presentDetachedProvisioningOutcome(before);
|
|
1157
1157
|
return;
|
|
1158
1158
|
}
|
|
@@ -2,6 +2,8 @@ import { withFileLock } from '../atomic-file.js';
|
|
|
2
2
|
import { type TempLifecycleDeps } from '../temp-lifecycle.js';
|
|
3
3
|
import { type CoworkAdapter } from './cowork-adapter.js';
|
|
4
4
|
import type { RoomMemberSeat, RoomOrchestrationRecord } from './types.js';
|
|
5
|
+
export declare const CLOSE_LOCK_STALE_MS: number;
|
|
6
|
+
export declare function roomCloseLockPath(roomId: string): string;
|
|
5
7
|
export interface RoomCloseDeps {
|
|
6
8
|
inspectMember?(seat: RoomMemberSeat): Promise<{
|
|
7
9
|
launchId: string;
|
|
@@ -19,6 +21,7 @@ export declare function waitForLivenessAbsent(role: string, launchId: string, li
|
|
|
19
21
|
/** Report whether any daemon identity — under any name — carries this exact CID. */
|
|
20
22
|
export declare function identityCidPresent(cid: string): Promise<boolean>;
|
|
21
23
|
export declare function removeExactMemberIdentity(seat: RoomMemberSeat): Promise<void>;
|
|
24
|
+
export declare function assertMemberIdentityAbsent(seat: RoomMemberSeat): Promise<void>;
|
|
22
25
|
/** One forward-only room close saga shared by every Fleet entry point. */
|
|
23
26
|
export declare function acceptManagedRoomClose(roomId: string): Promise<RoomOrchestrationRecord>;
|
|
24
27
|
export declare function recordManagedRoomCloseError(roomId: string, error: string, recoveryHint: string): Promise<RoomOrchestrationRecord>;
|
|
@@ -1,16 +1,19 @@
|
|
|
1
|
+
import { deleteWorkspace, assertWorkspaceDeletable } from './workspace.js';
|
|
2
|
+
import { collectWorkspaceArchives } from './workspace-artifacts.js';
|
|
1
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
4
|
import { join } from 'node:path';
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
|
4
6
|
import { attachOursClient } from '@ours.network/sdk/client';
|
|
5
7
|
import { withFileLock } from '../atomic-file.js';
|
|
6
8
|
import { agentDir, stateRoot } from '../paths.js';
|
|
7
|
-
import {
|
|
9
|
+
import { readClientProfile } from '../client-profile.js';
|
|
10
|
+
import { readTempSupervisor, secureStoppedTempArchive, stopTempSupervisor, tempSupervisorLiveness, tempArchiveForLaunch, tempArchiveForCreationAction, } from '../temp-lifecycle.js';
|
|
8
11
|
import { CoworkProtocolError } from './cowork-adapter.js';
|
|
9
12
|
import { advanceMemberRetirement, advanceRoomClose, beginRoomClose, closeRoom, deleteRoomRecord, getRoomRecord, listRoomRecords, setRoomCloseError, } from './room-state.js';
|
|
10
|
-
const CLOSE_LOCK_STALE_MS = 5 * 60_000;
|
|
13
|
+
export const CLOSE_LOCK_STALE_MS = 5 * 60_000;
|
|
11
14
|
const STOP_POLLS = 50;
|
|
12
15
|
const STOP_POLL_MS = 100;
|
|
13
|
-
function roomCloseLockPath(roomId) {
|
|
16
|
+
export function roomCloseLockPath(roomId) {
|
|
14
17
|
return join(stateRoot(), 'locks', 'room-close', encodeURIComponent(roomId));
|
|
15
18
|
}
|
|
16
19
|
function errorText(error) {
|
|
@@ -54,16 +57,28 @@ export async function waitForLivenessAbsent(role, launchId, lifecycleDeps = {})
|
|
|
54
57
|
throw new Error(`temporary role '${role}' did not reach proven stopped liveness`);
|
|
55
58
|
}
|
|
56
59
|
async function withIdentityClient(work) {
|
|
57
|
-
const
|
|
60
|
+
const profile = readClientProfile(process.env);
|
|
61
|
+
const leaseToken = `ours-fleet-room-close-${process.pid}-${randomUUID()}`;
|
|
62
|
+
const client = await attachOursClient(profile ? {
|
|
63
|
+
endpoint: profile.endpoint,
|
|
64
|
+
expectedInstanceId: profile.expectedInstanceId,
|
|
65
|
+
credentialPath: profile.credentialPath,
|
|
66
|
+
sessionMode: 'external', env: {}, leaseToken,
|
|
67
|
+
} : {
|
|
58
68
|
env: process.env,
|
|
59
|
-
leaseToken
|
|
69
|
+
leaseToken,
|
|
60
70
|
clientPid: process.pid,
|
|
61
71
|
});
|
|
62
72
|
try {
|
|
63
73
|
return await work(client);
|
|
64
74
|
}
|
|
65
75
|
finally {
|
|
66
|
-
|
|
76
|
+
try {
|
|
77
|
+
await client.releaseLease().catch(() => { });
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await client.close();
|
|
81
|
+
}
|
|
67
82
|
}
|
|
68
83
|
}
|
|
69
84
|
function listedIdentity(rows, name) {
|
|
@@ -106,11 +121,57 @@ export async function removeExactMemberIdentity(seat) {
|
|
|
106
121
|
}
|
|
107
122
|
});
|
|
108
123
|
}
|
|
124
|
+
export async function assertMemberIdentityAbsent(seat) {
|
|
125
|
+
await withIdentityClient(async (client) => {
|
|
126
|
+
const rows = await client.listIdentities();
|
|
127
|
+
if (rows.some(row => row.name === seat.role_name ||
|
|
128
|
+
(seat.identity_cid && 'cid' in row && row.cid.toLowerCase() === seat.identity_cid.toLowerCase()))) {
|
|
129
|
+
throw new Error(`room member '${seat.role_name}' identity absence is not proven; refusing retirement`);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
109
133
|
async function retireMember(roomId, seat, deps) {
|
|
110
134
|
let room = getRoomRecord(roomId);
|
|
111
135
|
let current = room.member_seats.find(candidate => candidate.role_name === seat.role_name);
|
|
112
136
|
let retirement = current.retirement;
|
|
137
|
+
if (retirement?.phase === 'identity_absent') {
|
|
138
|
+
if (current.launch?.launch_id && current.launch.launch_id !== retirement.launch_id) {
|
|
139
|
+
if (existsSync(agentDir(current.role_name, true)))
|
|
140
|
+
throw new Error(`room member '${current.role_name}' has a replacement launch after retirement; retire that exact temporary role before retrying`);
|
|
141
|
+
await assertMemberIdentityAbsent(current);
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
113
145
|
if (!retirement) {
|
|
146
|
+
if (!existsSync(agentDir(current.role_name, true)) && current.launch?.launch_id && current.launch.action_id) {
|
|
147
|
+
const archived = tempArchiveForLaunch(current.role_name, current.launch.launch_id);
|
|
148
|
+
const created = tempArchiveForCreationAction(current.role_name, current.launch.action_id);
|
|
149
|
+
if (archived && created?.path === archived && created.launchId === current.launch.launch_id) {
|
|
150
|
+
if (readFileSync(join(archived, '.identity'), 'utf8').trim() !== current.role_name)
|
|
151
|
+
throw new Error(`room member '${current.role_name}' archive identity mismatch`);
|
|
152
|
+
if (await tempSupervisorLiveness(archived) !== 'stopped')
|
|
153
|
+
throw new Error(`room member '${current.role_name}' archived supervisor is not proven stopped`);
|
|
154
|
+
// A terminated launch can archive itself before room retirement begins.
|
|
155
|
+
// Accept its exact durable provenance only when no identity needs removal.
|
|
156
|
+
await assertMemberIdentityAbsent(current);
|
|
157
|
+
const latest = getRoomRecord(roomId)?.member_seats.find(seat => seat.role_name === current.role_name);
|
|
158
|
+
if (existsSync(agentDir(current.role_name, true))
|
|
159
|
+
|| latest?.launch?.launch_id !== current.launch.launch_id
|
|
160
|
+
|| latest?.launch?.action_id !== current.launch.action_id
|
|
161
|
+
|| latest?.identity_cid !== current.identity_cid)
|
|
162
|
+
throw new Error(`room member '${current.role_name}' changed during archived retirement proof`);
|
|
163
|
+
advanceMemberRetirement(roomId, current.role_name, 'identity_absent', current.launch.launch_id, archived);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (current.launch?.state === 'failed' && !existsSync(agentDir(current.role_name, true))) {
|
|
168
|
+
// A failure before applyRole (for example invite-secret validation) has
|
|
169
|
+
// no supervisor to stop or archive. Settle only proven absence; this
|
|
170
|
+
// path never removes an identity based on missing local evidence.
|
|
171
|
+
await assertMemberIdentityAbsent(current);
|
|
172
|
+
advanceMemberRetirement(roomId, current.role_name, 'identity_absent', 'failed-launch-absent');
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
114
175
|
if (current.launch?.state === 'pending' && current.launch.attempt === 0) {
|
|
115
176
|
if (existsSync(agentDir(current.role_name, true))) {
|
|
116
177
|
throw new Error(`never-launched room member '${current.role_name}' unexpectedly has Fleet temp state`);
|
|
@@ -159,8 +220,6 @@ export async function closeManagedRoom(input) {
|
|
|
159
220
|
try {
|
|
160
221
|
if (room.close?.phase === 'retire_members') {
|
|
161
222
|
for (const seat of room.member_seats) {
|
|
162
|
-
if (seat.retirement?.phase === 'identity_absent')
|
|
163
|
-
continue;
|
|
164
223
|
await retireMember(input.roomId, seat, deps);
|
|
165
224
|
}
|
|
166
225
|
room = advanceRoomClose(input.roomId, 'close_cowork');
|
|
@@ -180,6 +239,9 @@ export async function closeManagedRoom(input) {
|
|
|
180
239
|
export async function deleteLegacyClosedRooms(input) {
|
|
181
240
|
const deleted = [];
|
|
182
241
|
for (const room of listRoomRecords({ state: 'closed' })) {
|
|
242
|
+
// This compatibility sweep is retirement, never explicit workspace deletion.
|
|
243
|
+
if (room.workspace)
|
|
244
|
+
continue;
|
|
183
245
|
try {
|
|
184
246
|
await input.cowork.deleteRoom(room.room_id);
|
|
185
247
|
}
|
|
@@ -197,13 +259,29 @@ export async function deleteLegacyClosedRooms(input) {
|
|
|
197
259
|
/** Retire live resources through the existing cursor, then delete retained state. */
|
|
198
260
|
export async function deleteManagedRoom(input) {
|
|
199
261
|
await closeManagedRoom(input);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
262
|
+
return withFileLock(roomCloseLockPath(input.roomId), async () => {
|
|
263
|
+
try {
|
|
264
|
+
try {
|
|
265
|
+
await input.cowork.deleteRoom(input.roomId);
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
// An earlier explicit delete may have removed the remote room before
|
|
269
|
+
// local filesystem cleanup failed. Never restore it or repeat creation.
|
|
270
|
+
if (!(error instanceof CoworkProtocolError && error.code === 'not_found'))
|
|
271
|
+
throw error;
|
|
272
|
+
}
|
|
273
|
+
const retained = getRoomRecord(input.roomId);
|
|
274
|
+
if (retained?.workspace && !retained.task_id) {
|
|
275
|
+
assertWorkspaceDeletable(retained.workspace, 'room', input.roomId);
|
|
276
|
+
collectWorkspaceArchives(retained.workspace);
|
|
277
|
+
deleteWorkspace(retained.workspace, 'room', input.roomId);
|
|
278
|
+
}
|
|
279
|
+
deleteRoomRecord(input.roomId);
|
|
280
|
+
return { room_id: input.roomId, deleted: true };
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
setRoomCloseError(input.roomId, errorText(error), `Fix the cleanup error and explicitly retry room delete ${input.roomId} ${input.roomId}.`);
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
}, {}, CLOSE_LOCK_STALE_MS);
|
|
209
287
|
}
|
|
@@ -58,6 +58,10 @@ export interface CoworkAdapter {
|
|
|
58
58
|
briefing: string;
|
|
59
59
|
quiet_membership?: boolean;
|
|
60
60
|
anonymous?: boolean;
|
|
61
|
+
activation_requirements?: Array<{
|
|
62
|
+
role: string;
|
|
63
|
+
count: number;
|
|
64
|
+
}>;
|
|
61
65
|
}): Promise<CoworkRoomCreateResult>;
|
|
62
66
|
acceptInvite(roomId: string, invite: string, opts: {
|
|
63
67
|
role: string;
|
|
@@ -8,6 +8,8 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import { join, resolve } from 'node:path';
|
|
10
10
|
import { createConnection } from 'node:net';
|
|
11
|
+
import { readClientProfile } from '../client-profile.js';
|
|
12
|
+
import { coworkHttpCall } from './cowork-http.js';
|
|
11
13
|
const RPC_VERSION = 1;
|
|
12
14
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
13
15
|
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
@@ -275,10 +277,15 @@ function rpcCall(socketPath, method, params, timeoutMs, connect) {
|
|
|
275
277
|
});
|
|
276
278
|
}
|
|
277
279
|
export function createCoworkAdapter(options = {}) {
|
|
278
|
-
const
|
|
280
|
+
const env = options.env ?? process.env;
|
|
281
|
+
const explicitLocal = options.socketPath !== undefined || options.configPath !== undefined
|
|
282
|
+
|| env.OURS_COWORK_CONFIG !== undefined || !!env.OURS_COWORK_STATE_DIR;
|
|
283
|
+
const profile = explicitLocal ? undefined : readClientProfile({ ...env, HOME: options.home ?? env.HOME });
|
|
284
|
+
const remote = profile?.serverUrl ? profile : undefined;
|
|
285
|
+
const socketPath = remote ? undefined : resolveCoworkSocketPath(options);
|
|
279
286
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
280
287
|
const connect = options.connect ?? ((path) => createConnection(path));
|
|
281
|
-
const call = (method, params) => rpcCall(socketPath, method, params, timeoutMs, connect);
|
|
288
|
+
const call = (method, params) => remote ? coworkHttpCall(remote, method, params, timeoutMs) : rpcCall(socketPath, method, params, timeoutMs, connect);
|
|
282
289
|
return {
|
|
283
290
|
async available() {
|
|
284
291
|
try {
|
|
@@ -296,6 +303,7 @@ export function createCoworkAdapter(options = {}) {
|
|
|
296
303
|
briefing: opts.briefing,
|
|
297
304
|
...(opts.quiet_membership === undefined ? {} : { quiet_membership: opts.quiet_membership }),
|
|
298
305
|
...(opts.anonymous === undefined ? {} : { anonymous: opts.anonymous }),
|
|
306
|
+
...(opts.activation_requirements === undefined ? {} : { activation_requirements: opts.activation_requirements }),
|
|
299
307
|
}), 'room.create');
|
|
300
308
|
if (!result.identity_cid)
|
|
301
309
|
throw new CoworkProtocolError('room.create', 'created room did not establish an identity CID');
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { ExplicitClientProfile } from '../client-profile.js';
|
|
2
|
+
/** Same RPC protocol as the local socket; never replay a possibly delivered mutation. */
|
|
3
|
+
export declare function coworkHttpCall(profile: ExplicitClientProfile, method: string, params: Record<string, unknown>, timeoutMs: number): Promise<unknown>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { attachOursClient } from '@ours.network/sdk/client';
|
|
3
|
+
import { readPrivateFile } from '@ours.network/sdk/connector';
|
|
4
|
+
import { CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
|
|
5
|
+
/** Same RPC protocol as the local socket; never replay a possibly delivered mutation. */
|
|
6
|
+
export async function coworkHttpCall(profile, method, params, timeoutMs) {
|
|
7
|
+
const id = randomUUID();
|
|
8
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
9
|
+
const body = JSON.stringify({ version: 1, id, method, params });
|
|
10
|
+
if (Buffer.byteLength(body) > 1024 * 1024)
|
|
11
|
+
throw new CoworkProtocolError(method, 'request exceeded 1 MiB');
|
|
12
|
+
try {
|
|
13
|
+
const client = await attachOursClient({ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
|
|
14
|
+
credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken: `fleet-cowork-check-${id}`, env: {}, requestSignal: signal });
|
|
15
|
+
await client.close();
|
|
16
|
+
const token = readPrivateFile(profile.credentialPath, 4096).toString('utf8').trim();
|
|
17
|
+
const response = await fetch(profile.serverUrl + '/cowork/management/rpc', {
|
|
18
|
+
method: 'POST', redirect: 'error', signal,
|
|
19
|
+
headers: { 'content-type': 'application/json', 'x-ours-api-token': token },
|
|
20
|
+
body,
|
|
21
|
+
});
|
|
22
|
+
if (response.status === 401 || response.status === 403) {
|
|
23
|
+
await response.body?.cancel();
|
|
24
|
+
throw new CoworkProtocolError(method, 'server credential was rejected', 'unauthorized');
|
|
25
|
+
}
|
|
26
|
+
const reader = response.body?.getReader();
|
|
27
|
+
if (!reader)
|
|
28
|
+
throw new CoworkProtocolError(method, 'empty HTTP response');
|
|
29
|
+
const chunks = [];
|
|
30
|
+
let size = 0;
|
|
31
|
+
try {
|
|
32
|
+
for (;;) {
|
|
33
|
+
const { done, value } = await reader.read();
|
|
34
|
+
if (done)
|
|
35
|
+
break;
|
|
36
|
+
size += value.length;
|
|
37
|
+
if (size > 4 * 1024 * 1024)
|
|
38
|
+
throw new CoworkProtocolError(method, 'response exceeded 4 MiB');
|
|
39
|
+
chunks.push(value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await reader.cancel();
|
|
44
|
+
}
|
|
45
|
+
let rpc;
|
|
46
|
+
try {
|
|
47
|
+
rpc = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new CoworkProtocolError(method, 'server returned malformed JSON');
|
|
51
|
+
}
|
|
52
|
+
if (!rpc || Array.isArray(rpc) || rpc.version !== 1 || rpc.id !== id
|
|
53
|
+
|| Object.hasOwn(rpc, 'result') === Object.hasOwn(rpc, 'error'))
|
|
54
|
+
throw new CoworkProtocolError(method, 'server returned an invalid RPC response');
|
|
55
|
+
if (Object.hasOwn(rpc, 'error')) {
|
|
56
|
+
const error = rpc.error;
|
|
57
|
+
if (!error || typeof error.code !== 'string' || typeof error.message !== 'string')
|
|
58
|
+
throw new CoworkProtocolError(method, 'server returned an invalid RPC error');
|
|
59
|
+
throw new CoworkProtocolError(method, error.message, error.code);
|
|
60
|
+
}
|
|
61
|
+
if (!response.ok)
|
|
62
|
+
throw new CoworkProtocolError(method, `management answered HTTP ${response.status}`);
|
|
63
|
+
return rpc.result;
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
if (error instanceof CoworkProtocolError)
|
|
67
|
+
throw error;
|
|
68
|
+
throw new CoworkUnavailableError('Cowork HTTP management failed; check the selected server and credential', { cause: error });
|
|
69
|
+
}
|
|
70
|
+
}
|