@ours.network/fleet 0.18.0 → 0.19.0-nightly.1
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 +57 -121
- package/dist/application/fleet-query-service.js +0 -12
- package/dist/application/role-creation-service.js +10 -7
- package/dist/application/types.d.ts +0 -11
- package/dist/briefing.js +6 -15
- package/dist/build-info.json +6 -7
- package/dist/capabilities.d.ts +1 -3
- package/dist/capabilities.js +0 -3
- package/dist/cli.js +7 -83
- package/dist/config.d.ts +3 -11
- package/dist/config.js +15 -40
- package/dist/creation.d.ts +38 -22
- package/dist/creation.js +111 -24
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +46 -120
- package/dist/doctor.d.ts +5 -1
- package/dist/doctor.js +18 -11
- package/dist/fleet-proxy.d.ts +0 -5
- package/dist/harness/acp-agent.js +6 -11
- package/dist/harness/claude-code.js +11 -204
- package/dist/harness/codex.d.ts +1 -4
- package/dist/harness/codex.js +12 -74
- package/dist/harness/types.d.ts +4 -54
- package/dist/index.d.ts +0 -2
- package/dist/index.js +0 -1
- package/dist/loops/manager.d.ts +1 -30
- package/dist/loops/manager.js +6 -69
- package/dist/loops/state.d.ts +0 -18
- package/dist/loops/state.js +0 -4
- package/dist/monitor.js +1 -1
- package/dist/ops.d.ts +3 -0
- package/dist/ops.js +8 -3
- package/dist/owner-channel/attachments.d.ts +25 -2
- package/dist/owner-channel/attachments.js +61 -5
- package/dist/owner-channel/channel.d.ts +29 -30
- package/dist/owner-channel/channel.js +291 -291
- package/dist/owner-channel/message-recovery.d.ts +25 -0
- package/dist/owner-channel/message-recovery.js +114 -0
- package/dist/owner-channel/notices.d.ts +0 -7
- package/dist/owner-channel/notices.js +0 -9
- package/dist/owner-channel/ours-client.d.ts +148 -0
- package/dist/owner-channel/ours-client.js +231 -0
- package/dist/resolved-plan.js +0 -1
- package/dist/runner.d.ts +0 -48
- package/dist/runner.js +94 -236
- package/dist/session/acp.d.ts +0 -104
- package/dist/session/acp.js +10 -213
- package/dist/session/conversation-normalizer.d.ts +0 -6
- package/dist/session/conversation-normalizer.js +10 -153
- package/dist/session/conversation-types.d.ts +4 -23
- package/dist/session/types.d.ts +0 -35
- package/dist/spawn.js +26 -33
- package/dist/supervisor/systemd.js +29 -2
- package/dist/watchdog/briefing.js +0 -7
- package/dist/watchdog/run.js +3 -3
- package/dist/web-app/assets/{TerminalView-C_G1ID2P.js → TerminalView-BAVk1Bot.js} +1 -1
- package/dist/web-app/assets/{index-BCBK78hw.js → index-C3S-xFRU.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +1 -7
- package/dist/worklog.js +39 -191
- package/package.json +3 -1
- package/dist/harness-plugins.d.ts +0 -48
- package/dist/harness-plugins.js +0 -309
- package/dist/model-env.d.ts +0 -71
- package/dist/model-env.js +0 -106
- package/dist/owner-channel/mcp.d.ts +0 -24
- package/dist/owner-channel/mcp.js +0 -145
- package/dist/session/activity.d.ts +0 -31
- package/dist/session/activity.js +0 -48
package/dist/loops/manager.js
CHANGED
|
@@ -39,7 +39,7 @@ export class ScheduledLoopManager {
|
|
|
39
39
|
}
|
|
40
40
|
start() {
|
|
41
41
|
if (!this.store.fresh)
|
|
42
|
-
this.
|
|
42
|
+
this.skipRestartMisses();
|
|
43
43
|
this.schedule();
|
|
44
44
|
}
|
|
45
45
|
async stop() {
|
|
@@ -143,15 +143,10 @@ export class ScheduledLoopManager {
|
|
|
143
143
|
async attempt(definition, state, scheduledAt) {
|
|
144
144
|
const runId = `sl_${randomUUID()}`;
|
|
145
145
|
const origin = { kind: 'scheduled-loop', loop: definition.name, runId };
|
|
146
|
-
|
|
147
|
-
// an attempt that ends `skipped_busy` or `unavailable` reported it to
|
|
148
|
-
// nobody, so it has to still be there for the attempt that succeeds.
|
|
149
|
-
const gap = state.missedGap;
|
|
150
|
-
const prompt = this.envelope(definition, runId, scheduledAt, gap);
|
|
146
|
+
const prompt = this.envelope(definition, runId, scheduledAt);
|
|
151
147
|
let claimed = false;
|
|
152
148
|
const result = await this.arbiter.tryScheduled(prompt, origin, () => {
|
|
153
149
|
claimed = true;
|
|
154
|
-
state.missedGap = null;
|
|
155
150
|
state.activeRunId = runId;
|
|
156
151
|
state.lastRunId = runId;
|
|
157
152
|
state.lastStartedAt = new Date(this.deps.now()).toISOString();
|
|
@@ -270,15 +265,7 @@ export class ScheduledLoopManager {
|
|
|
270
265
|
state.nextScheduledAt = new Date(next).toISOString();
|
|
271
266
|
state.nextDueAt = new Date(next + deterministicJitter(this.role, definition.name, next, definition.jitterMs)).toISOString();
|
|
272
267
|
}
|
|
273
|
-
/**
|
|
274
|
-
* Coalesce a backlog into one skip. The counters alone say how many
|
|
275
|
-
* occurrences were lost but never when or for how long, so the window is
|
|
276
|
-
* recorded too and carried on the state until a run is actually told about it
|
|
277
|
-
* — a dropped pass has to stay visible to the next one, not just to whoever
|
|
278
|
-
* was reading the log at the time.
|
|
279
|
-
*/
|
|
280
268
|
skipMissed(definition, state, now) {
|
|
281
|
-
const from = state.nextScheduledAt;
|
|
282
269
|
let missed = 0;
|
|
283
270
|
while (Date.parse(state.nextDueAt) <= now) {
|
|
284
271
|
this.advance(definition, state);
|
|
@@ -288,43 +275,14 @@ export class ScheduledLoopManager {
|
|
|
288
275
|
state.counts.skippedMissed = increment(state.counts.skippedMissed, missed);
|
|
289
276
|
state.lastOutcome = 'skipped_missed';
|
|
290
277
|
state.lastFinishedAt = new Date(now).toISOString();
|
|
291
|
-
// Successive outages before any run lands merge into one gap: the earliest
|
|
292
|
-
// start wins, so the window always spans the whole silence.
|
|
293
|
-
const previous = state.missedGap;
|
|
294
|
-
state.missedGap = {
|
|
295
|
-
count: increment(previous?.count ?? 0, missed),
|
|
296
|
-
fromAt: previous?.fromAt ?? from,
|
|
297
|
-
throughAt: state.lastScheduledAt ?? from,
|
|
298
|
-
detectedAt: new Date(now).toISOString(),
|
|
299
|
-
};
|
|
300
278
|
this.store.persist();
|
|
301
|
-
this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed}
|
|
302
|
-
+ `gap=${from}..${state.missedGap.throughAt} `
|
|
303
|
-
+ `unreported=${state.missedGap.count}`);
|
|
279
|
+
this.deps.log(`[${this.role}] loop ${definition.name} skipped_missed count=${missed}`);
|
|
304
280
|
}
|
|
305
|
-
|
|
306
|
-
* Restart is not, by itself, a reason to lose an occurrence a running manager
|
|
307
|
-
* would still have run. `poll` tolerates lateness up to one full interval and
|
|
308
|
-
* runs the tick late; this path used to drop anything already due however
|
|
309
|
-
* recently, so a role restarted seconds after its own tick came due lost it
|
|
310
|
-
* outright. For an oversight role that is precisely the pass which would have
|
|
311
|
-
* recorded why it restarted, so the failure erased its own witness.
|
|
312
|
-
*
|
|
313
|
-
* The tolerance is the only thing shared with `poll`. A backlog at least one
|
|
314
|
-
* interval deep is still coalesced into a single skip and never replayed —
|
|
315
|
-
* after a long outage exactly one occurrence survives, and `schedule` then
|
|
316
|
-
* arms it through the ordinary path rather than firing a burst here.
|
|
317
|
-
*
|
|
318
|
-
* Running the survivor late cannot outpace the configured cadence: `advance`
|
|
319
|
-
* moves the cursor by exactly one `intervalMs` per occurrence from the nominal
|
|
320
|
-
* time, so a loop that keeps restarting still runs at most once per interval.
|
|
321
|
-
*/
|
|
322
|
-
skipRestartBacklog() {
|
|
281
|
+
skipRestartMisses() {
|
|
323
282
|
const now = this.deps.now();
|
|
324
283
|
for (const definition of this.definitions.values()) {
|
|
325
284
|
const state = this.store.state.loops[definition.name];
|
|
326
|
-
if (definition.enabled && !state.operatorDisabled
|
|
327
|
-
&& now >= Date.parse(state.nextDueAt) + definition.intervalMs)
|
|
285
|
+
if (definition.enabled && !state.operatorDisabled && Date.parse(state.nextDueAt) <= now)
|
|
328
286
|
this.skipMissed(definition, state, now);
|
|
329
287
|
}
|
|
330
288
|
}
|
|
@@ -382,38 +340,17 @@ export class ScheduledLoopManager {
|
|
|
382
340
|
this.deps.clearTimer(this.timer);
|
|
383
341
|
this.arm(backoffMs(this.pollFailures));
|
|
384
342
|
}
|
|
385
|
-
|
|
386
|
-
* The envelope is the only channel a scheduled pass has for learning about
|
|
387
|
-
* the passes that did not happen. A gap stated here is what lets an oversight
|
|
388
|
-
* role report its own outage instead of resuming as if nothing was missed.
|
|
389
|
-
*/
|
|
390
|
-
envelope(definition, runId, scheduledAt, gap) {
|
|
391
|
-
const lateBy = Math.max(0, this.deps.now() - scheduledAt);
|
|
343
|
+
envelope(definition, runId, scheduledAt) {
|
|
392
344
|
return [
|
|
393
345
|
'[fleet-loop]',
|
|
394
346
|
`loop: ${definition.name}`,
|
|
395
347
|
`run: ${runId}`,
|
|
396
348
|
`scheduled_at: ${new Date(scheduledAt).toISOString()}`,
|
|
397
|
-
...(lateBy > 0 ? [`started_late_by_ms: ${lateBy}`] : []),
|
|
398
|
-
...(gap ? [
|
|
399
|
-
`missed_occurrences: ${gap.count}`,
|
|
400
|
-
`missed_window: ${gap.fromAt}..${gap.throughAt}`,
|
|
401
|
-
`missed_gap_ms: ${Math.max(0, Date.parse(gap.detectedAt) - Date.parse(gap.fromAt))}`,
|
|
402
|
-
] : []),
|
|
403
349
|
'origin: local-trusted-config',
|
|
404
350
|
'',
|
|
405
351
|
'This is a scheduled internal maintenance turn, not an owner message and not ordinary ours mail.',
|
|
406
352
|
'Perform one bounded pass. Do not wait for the next tick. Do not report to an owner unless your',
|
|
407
353
|
'configured policy and an existing authenticated proactive-report route authorize a material report.',
|
|
408
|
-
// Same single route as the owner-request prompt, and for the same reason.
|
|
409
|
-
'To send a file, call ours `send_file` with the recipient and the path — to your owner-channel',
|
|
410
|
-
'identity if this role has one, otherwise directly to the contact who should receive it.',
|
|
411
|
-
'A file written anywhere else is not delivered and nothing will report that it was not.',
|
|
412
|
-
...(gap ? ['',
|
|
413
|
-
'This loop did not run for the window above: those occurrences were coalesced away while the role',
|
|
414
|
-
'was unavailable, and this pass is the first since. Treat the gap as part of what you are reporting',
|
|
415
|
-
'on — it is the record of your own outage, and no later pass will be told about it.',
|
|
416
|
-
] : []),
|
|
417
354
|
'',
|
|
418
355
|
definition.prompt,
|
|
419
356
|
].join('\n');
|
package/dist/loops/state.d.ts
CHANGED
|
@@ -8,29 +8,11 @@ export interface LoopCounts {
|
|
|
8
8
|
skippedBusy: number;
|
|
9
9
|
skippedMissed: number;
|
|
10
10
|
}
|
|
11
|
-
/**
|
|
12
|
-
* A coalesced run of occurrences that were never submitted, held until a run
|
|
13
|
-
* actually starts and can be told about it. Without it a dropped occurrence
|
|
14
|
-
* survives only as a counter, which says how many were lost but never when or
|
|
15
|
-
* for how long — and an oversight role cannot report an outage it cannot date.
|
|
16
|
-
*/
|
|
17
|
-
export interface LoopMissedGap {
|
|
18
|
-
/** Occurrences coalesced away, summed across every skip since the last run. */
|
|
19
|
-
count: number;
|
|
20
|
-
/** Nominal time of the earliest occurrence in the gap. */
|
|
21
|
-
fromAt: string;
|
|
22
|
-
/** Nominal time of the latest occurrence in the gap. */
|
|
23
|
-
throughAt: string;
|
|
24
|
-
/** When the manager noticed — the end of the outage, not of the last skip. */
|
|
25
|
-
detectedAt: string;
|
|
26
|
-
}
|
|
27
11
|
export interface LoopRuntimeState {
|
|
28
12
|
definitionHash: string;
|
|
29
13
|
promptHash: string;
|
|
30
14
|
enabled: boolean;
|
|
31
15
|
operatorDisabled: boolean;
|
|
32
|
-
/** Unreported gap, cleared by the first run that carries it. */
|
|
33
|
-
missedGap: LoopMissedGap | null;
|
|
34
16
|
nextScheduledAt: string;
|
|
35
17
|
nextDueAt: string;
|
|
36
18
|
lastScheduledAt: string | null;
|
package/dist/loops/state.js
CHANGED
|
@@ -100,9 +100,6 @@ export class ScheduledLoopStateStore {
|
|
|
100
100
|
if (old?.definitionHash === definition.definitionHash) {
|
|
101
101
|
next[definition.name] = {
|
|
102
102
|
...old, promptHash: definition.promptHash, enabled: definition.enabled,
|
|
103
|
-
// A file written before this field existed restores as undefined; an
|
|
104
|
-
// unreported gap is absent, not lost, so normalize rather than trust.
|
|
105
|
-
missedGap: old.missedGap ?? null,
|
|
106
103
|
};
|
|
107
104
|
}
|
|
108
105
|
else {
|
|
@@ -122,7 +119,6 @@ export class ScheduledLoopStateStore {
|
|
|
122
119
|
activeRunId: old?.activeRunId ?? null,
|
|
123
120
|
counts: old?.counts ?? zeroCounts(), lastError: old?.lastError ?? null,
|
|
124
121
|
operatorDisabled: old?.operatorDisabled ?? false,
|
|
125
|
-
missedGap: old?.missedGap ?? null,
|
|
126
122
|
};
|
|
127
123
|
}
|
|
128
124
|
if (recoverActive && next[definition.name].activeRunId) {
|
package/dist/monitor.js
CHANGED
|
@@ -42,7 +42,7 @@ class AuthError extends Error {
|
|
|
42
42
|
}
|
|
43
43
|
/** Path to the daemon config the MCP client uses: OURS_CONFIG ?? real ~/.ours/config.json. */
|
|
44
44
|
const daemonConfigPath = (env) => env.OURS_CONFIG ?? join(homedir(), '.ours', 'config.json');
|
|
45
|
-
/**
|
|
45
|
+
/** Preserve the daemon's legacy env integer semantics: parseInt, invalid → absent. */
|
|
46
46
|
function envInt(env, name) {
|
|
47
47
|
const raw = env[name];
|
|
48
48
|
if (raw === undefined)
|
package/dist/ops.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { FleetConfig, ResolvedRole } from './config.js';
|
|
2
2
|
import type { InstallOutcome as BackendInstallOutcome, SupervisorBackend } from './supervisor/types.js';
|
|
3
3
|
import { type Exec } from './exec.js';
|
|
4
|
+
import { type IdentityProvisioner } from './creation.js';
|
|
4
5
|
/** An install outcome tagged with the role it belongs to. */
|
|
5
6
|
export interface InstallOutcome extends BackendInstallOutcome {
|
|
6
7
|
role: string;
|
|
@@ -14,6 +15,8 @@ export interface OpsDeps {
|
|
|
14
15
|
/** Test seam for exact detached-supervisor signaling/liveness. */
|
|
15
16
|
kill?(pid: number, signal: NodeJS.Signals | 0): void;
|
|
16
17
|
sleep?(ms: number): Promise<void>;
|
|
18
|
+
/** Permanent identity reconciliation seam; temporary roles never use this lifecycle. */
|
|
19
|
+
identityProvisioner?: IdentityProvisioner;
|
|
17
20
|
/**
|
|
18
21
|
* Called the INSTANT a registration is created, before anything else can
|
|
19
22
|
* fail. A creation transaction that learns about registrations only from
|
package/dist/ops.js
CHANGED
|
@@ -8,6 +8,7 @@ import { generateBriefing } from './briefing.js';
|
|
|
8
8
|
import { resetRestartLedger } from './runner.js';
|
|
9
9
|
import { archiveTempState, stopTempSupervisor, tempSupervisorLiveness, } from './temp-lifecycle.js';
|
|
10
10
|
import { realExec } from './exec.js';
|
|
11
|
+
import { daemonIdentityProvisioner, reconcilePermanentRoleIdentities, } from './creation.js';
|
|
11
12
|
// Launch staggering now lives at the harness-launch point (the runner's start
|
|
12
13
|
// gate, driven by `start_stagger_ms`), so it covers systemd host-boot too — not
|
|
13
14
|
// just the `up`/`restart` command loop below. The old in-loop FLEET_START_STAGGER
|
|
@@ -16,7 +17,7 @@ import { realExec } from './exec.js';
|
|
|
16
17
|
/** Materialize a role's state dir from config: briefing + markers. Returns the dir. */
|
|
17
18
|
export function applyRole(role, opts = {}) {
|
|
18
19
|
const adapter = getAdapter(role.harness);
|
|
19
|
-
const errs = adapter.validateOptions(role.harness_options
|
|
20
|
+
const errs = adapter.validateOptions(role.harness_options);
|
|
20
21
|
if (errs.length)
|
|
21
22
|
throw new Error(`role '${role.name}': ` + errs.map(e => `${e.path}: ${e.message}`).join('; '));
|
|
22
23
|
const dir = agentDir(role.name, opts.temp === true);
|
|
@@ -54,7 +55,8 @@ function selectRoles(cfg, names) {
|
|
|
54
55
|
export async function up(cfg, names, deps, configPath, identityGuarantee) {
|
|
55
56
|
const outcomes = [];
|
|
56
57
|
for (const role of selectRoles(cfg, names)) {
|
|
57
|
-
const
|
|
58
|
+
const guarantee = await reconcilePermanentRoleIdentities(role, deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log, identityGuarantee);
|
|
59
|
+
const dir = applyRole(role, { configPath, identityGuarantee: guarantee });
|
|
58
60
|
// Only a *definite* stop boots fresh so the role reads the briefing we just
|
|
59
61
|
// wrote. A running, restarting, or unprobeable role keeps its context —
|
|
60
62
|
// guessing "stopped" from an unanswered probe silently discards a live
|
|
@@ -221,7 +223,10 @@ export async function down(cfg, names, deps) {
|
|
|
221
223
|
/** Re-sync from config + bounce. mode 'keep' resumes context; 'fresh' wipes it. */
|
|
222
224
|
export async function restartRoles(cfg, names, deps, mode, configPath) {
|
|
223
225
|
for (const role of selectRoles(cfg, names)) {
|
|
224
|
-
const
|
|
226
|
+
const guarantee = await reconcilePermanentRoleIdentities(role, deps.identityProvisioner ?? daemonIdentityProvisioner(), deps.log);
|
|
227
|
+
const dir = applyRole(role, {
|
|
228
|
+
fresh: mode === 'fresh', configPath, identityGuarantee: guarantee,
|
|
229
|
+
});
|
|
225
230
|
resetRestartLedger(dir); // explicit restart closes the circuit
|
|
226
231
|
await deps.backend.restart(role.name);
|
|
227
232
|
deps.log(mode === 'fresh'
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { type FileHandle } from 'node:fs/promises';
|
|
1
2
|
import type { OwnerAttachmentConfig } from '../config.js';
|
|
3
|
+
import type { OursIncomingFile, OursRetrievedFiles } from './ours-client.js';
|
|
2
4
|
export interface AttachmentReplyRef {
|
|
3
5
|
wire_id: string;
|
|
4
6
|
sentence?: number;
|
|
@@ -42,8 +44,14 @@ export interface AdmittedAttachment {
|
|
|
42
44
|
kind: 'file' | 'voice_message';
|
|
43
45
|
transcription?: Omit<VoiceTranscription, 'audioPath'>;
|
|
44
46
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Admit the daemon's file listing. The rows are typed now, but every field is
|
|
49
|
+
* still re-validated here: sender CID, wire id, sizes and ids all cross the
|
|
50
|
+
* trust boundary and decide routing, so a daemon-side shape change must drop a
|
|
51
|
+
* row rather than produce a half-built attachment.
|
|
52
|
+
*/
|
|
53
|
+
export declare function parseIncomingAttachments(raw: OursIncomingFile[] | undefined): IncomingAttachment[];
|
|
54
|
+
export declare function parseRetrievedAttachments(raw: OursRetrievedFiles | undefined, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
|
|
47
55
|
export declare function validateAttachmentSelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
|
|
48
56
|
/**
|
|
49
57
|
* Managed-agent -> owner egress limits. This intentionally does not consult
|
|
@@ -54,6 +62,21 @@ export declare function prepareAttachmentDirectory(root: string, requestId: stri
|
|
|
54
62
|
export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig, options?: {
|
|
55
63
|
mimePolicy?: 'strict' | 'report-only';
|
|
56
64
|
}): Promise<AdmittedAttachment[]>;
|
|
65
|
+
/** Injectable short-write seam, so partial writes are provably handled. */
|
|
66
|
+
export interface AttachmentWriteDeps {
|
|
67
|
+
write?(handle: FileHandle, bytes: Uint8Array, offset: number): Promise<number>;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Land crash-recovered file bytes inside an already-prepared request directory.
|
|
71
|
+
*
|
|
72
|
+
* The MCP path handed the daemon a `dest_path` and let its connector write the
|
|
73
|
+
* file. Nothing writes on our behalf any more, so this owns both halves of that
|
|
74
|
+
* contract: the destination is DERIVED from a validated wire id inside `dir`
|
|
75
|
+
* rather than accepted from a caller, and the file is published by link-after-
|
|
76
|
+
* fsync, so a crash or a short write can never leave a partial file where the
|
|
77
|
+
* admission step would read it as complete.
|
|
78
|
+
*/
|
|
79
|
+
export declare function writeRecoveredAttachment(dir: string, wireId: string, bytes: Uint8Array, deps?: AttachmentWriteDeps): Promise<string>;
|
|
57
80
|
export declare function recoveredAttachment(file: IncomingAttachment, path: string): Promise<RetrievedAttachment>;
|
|
58
81
|
export declare function removeRequestDirectory(path: string): Promise<void>;
|
|
59
82
|
export declare function cleanupAttachmentRoot(root: string, now: number, retentionMs: number, limit?: number): Promise<number>;
|
|
@@ -6,8 +6,14 @@ import { replaceFileAtomically } from '../atomic-file.js';
|
|
|
6
6
|
const WIRE = /^[A-Fa-f0-9]{64}$/;
|
|
7
7
|
const CID = /^[A-Fa-f0-9]{64}$/;
|
|
8
8
|
const MAX_PENDING_REQUESTS = 32;
|
|
9
|
+
/**
|
|
10
|
+
* Admit the daemon's file listing. The rows are typed now, but every field is
|
|
11
|
+
* still re-validated here: sender CID, wire id, sizes and ids all cross the
|
|
12
|
+
* trust boundary and decide routing, so a daemon-side shape change must drop a
|
|
13
|
+
* row rather than produce a half-built attachment.
|
|
14
|
+
*/
|
|
9
15
|
export function parseIncomingAttachments(raw) {
|
|
10
|
-
const values = raw
|
|
16
|
+
const values = raw;
|
|
11
17
|
if (!Array.isArray(values))
|
|
12
18
|
return [];
|
|
13
19
|
const out = [];
|
|
@@ -40,12 +46,12 @@ export function parseIncomingAttachments(raw) {
|
|
|
40
46
|
export function parseRetrievedAttachments(raw, expected, recovered = false) {
|
|
41
47
|
const values = raw?.files;
|
|
42
48
|
if (!Array.isArray(values) || values.length !== expected.length)
|
|
43
|
-
throw new Error('ours
|
|
49
|
+
throw new Error('the ours daemon returned an incomplete selected attachment set');
|
|
44
50
|
const byWire = new Map(expected.map(file => [file.wireId, file]));
|
|
45
51
|
const out = [];
|
|
46
52
|
for (const value of values) {
|
|
47
53
|
if (!value || typeof value !== 'object')
|
|
48
|
-
throw new Error('ours
|
|
54
|
+
throw new Error('the ours daemon returned invalid attachment metadata');
|
|
49
55
|
const file = value;
|
|
50
56
|
const wireId = String(file.wire_id ?? '');
|
|
51
57
|
const listed = byWire.get(wireId);
|
|
@@ -58,7 +64,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
|
|
|
58
64
|
|| !Number.isSafeInteger(size) || size !== listed.size || mime !== listed.mime
|
|
59
65
|
|| kind !== listed.kind || !/^[a-f0-9]{64}$/.test(sha256)
|
|
60
66
|
|| typeof file.path !== 'string' || !file.path)
|
|
61
|
-
throw new Error('
|
|
67
|
+
throw new Error('selected attachment provenance or integrity metadata mismatched');
|
|
62
68
|
out.push({
|
|
63
69
|
...listed, filename: safeField(file.filename, 255), mime, size, path: file.path, sha256, kind,
|
|
64
70
|
...(recovered ? {} : parseTranscription(file.transcription, wireId)),
|
|
@@ -66,7 +72,7 @@ export function parseRetrievedAttachments(raw, expected, recovered = false) {
|
|
|
66
72
|
byWire.delete(wireId);
|
|
67
73
|
}
|
|
68
74
|
if (byWire.size)
|
|
69
|
-
throw new Error('ours
|
|
75
|
+
throw new Error('the ours daemon omitted a selected attachment');
|
|
70
76
|
return out;
|
|
71
77
|
}
|
|
72
78
|
function parseTranscription(value, wireId) {
|
|
@@ -219,6 +225,56 @@ export async function admitAttachments(files, dir, config, options = {}) {
|
|
|
219
225
|
}
|
|
220
226
|
return admitted;
|
|
221
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Land crash-recovered file bytes inside an already-prepared request directory.
|
|
230
|
+
*
|
|
231
|
+
* The MCP path handed the daemon a `dest_path` and let its connector write the
|
|
232
|
+
* file. Nothing writes on our behalf any more, so this owns both halves of that
|
|
233
|
+
* contract: the destination is DERIVED from a validated wire id inside `dir`
|
|
234
|
+
* rather than accepted from a caller, and the file is published by link-after-
|
|
235
|
+
* fsync, so a crash or a short write can never leave a partial file where the
|
|
236
|
+
* admission step would read it as complete.
|
|
237
|
+
*/
|
|
238
|
+
export async function writeRecoveredAttachment(dir, wireId, bytes, deps = {}) {
|
|
239
|
+
if (!WIRE.test(wireId))
|
|
240
|
+
throw new Error('recovered attachment wire id is not a 64-hex value');
|
|
241
|
+
const dirStat = await lstat(dir);
|
|
242
|
+
if (!dirStat.isDirectory() || dirStat.isSymbolicLink())
|
|
243
|
+
throw new Error('recovered attachment directory is not a safe directory');
|
|
244
|
+
const write = deps.write
|
|
245
|
+
?? ((handle, buffer, offset) => handle.write(buffer, offset, buffer.length - offset)
|
|
246
|
+
.then(result => result.bytesWritten));
|
|
247
|
+
const finalPath = join(dir, `.recovered-${wireId}-${randomUUID()}`);
|
|
248
|
+
const tmp = join(dir, `.${basename(finalPath)}.${randomUUID()}.tmp`);
|
|
249
|
+
const handle = await open(tmp, 'wx', 0o600);
|
|
250
|
+
try {
|
|
251
|
+
for (let written = 0; written < bytes.length;) {
|
|
252
|
+
const advanced = await write(handle, bytes, written);
|
|
253
|
+
if (advanced <= 0)
|
|
254
|
+
throw new Error(`recovered attachment write made no progress at byte ${written}`);
|
|
255
|
+
written += advanced;
|
|
256
|
+
}
|
|
257
|
+
await handle.sync();
|
|
258
|
+
}
|
|
259
|
+
catch (error) {
|
|
260
|
+
await handle.close().catch(() => undefined);
|
|
261
|
+
await rm(tmp, { force: true });
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
await handle.close();
|
|
265
|
+
// link publishes the finished bytes under a name that never existed in a
|
|
266
|
+
// partial state; the temp is only ever removed after it succeeded.
|
|
267
|
+
try {
|
|
268
|
+
await link(tmp, finalPath);
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
await rm(tmp, { force: true });
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
await rm(tmp, { force: true });
|
|
275
|
+
await chmod(finalPath, 0o600);
|
|
276
|
+
return finalPath;
|
|
277
|
+
}
|
|
222
278
|
export async function recoveredAttachment(file, path) {
|
|
223
279
|
const stat = await lstat(path);
|
|
224
280
|
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== file.size)
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
1
|
import { type OwnerChannelConfig } from '../config.js';
|
|
3
|
-
import { type FetchLike } from '../monitor.js';
|
|
4
2
|
import { type SessionHandle } from '../session/types.js';
|
|
5
3
|
import { type OwnerFleetOps } from './commands.js';
|
|
6
4
|
import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
|
|
7
|
-
import { type
|
|
5
|
+
import { type OursOps } from './ours-client.js';
|
|
8
6
|
import { type OwnerUpdatePhase } from './notices.js';
|
|
9
7
|
import { type OwnerEntry } from './state.js';
|
|
10
8
|
import { type OwnerTaskPhase } from './tasks.js';
|
|
@@ -17,15 +15,8 @@ export interface OwnerChannelOptions {
|
|
|
17
15
|
session: SessionHandle;
|
|
18
16
|
stateDir: string;
|
|
19
17
|
env?: Record<string, string>;
|
|
20
|
-
command?: string;
|
|
21
18
|
log(line: string): void;
|
|
22
|
-
client?:
|
|
23
|
-
/** Legacy child-process test seam; production uses the direct notification API. */
|
|
24
|
-
watch?: (identity: string) => ChildProcessWithoutNullStreams;
|
|
25
|
-
/** Test seam for the production direct notification long-poll. */
|
|
26
|
-
watchFetch?: FetchLike;
|
|
27
|
-
/** Test seam for the long-poll stall bound; production uses OWNER_WATCH_STALL_MS. */
|
|
28
|
-
watchStallMs?: number;
|
|
19
|
+
client?: OursOps;
|
|
29
20
|
/** Test seam; production uses the detached ours-fleet CLI (`fleetCliOps`). */
|
|
30
21
|
fleet?: OwnerFleetOps;
|
|
31
22
|
/** Forwarded to fleet CLI invocations spawned for owner commands. */
|
|
@@ -118,7 +109,13 @@ export type { OwnerUpdatePhase } from './notices.js';
|
|
|
118
109
|
export interface OwnerContact {
|
|
119
110
|
cid: string;
|
|
120
111
|
name: string;
|
|
112
|
+
/** Structural, from which daemon collection the row came: established or pending. */
|
|
121
113
|
status: string;
|
|
114
|
+
/**
|
|
115
|
+
* Retained for the `ours-fleet owner contact list` column. The daemon's typed
|
|
116
|
+
* contact view has no such field, so it is always absent; it is not inferred
|
|
117
|
+
* from anything a contact controls.
|
|
118
|
+
*/
|
|
122
119
|
kind?: string;
|
|
123
120
|
human?: {
|
|
124
121
|
cid?: string;
|
|
@@ -136,6 +133,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
136
133
|
private readonly authorizations;
|
|
137
134
|
private readonly conversations;
|
|
138
135
|
private readonly tasks;
|
|
136
|
+
private readonly messageRecovery;
|
|
139
137
|
private readonly attachmentRecovery;
|
|
140
138
|
private readonly attachmentConfig;
|
|
141
139
|
private readonly attachmentRoot;
|
|
@@ -144,7 +142,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
144
142
|
* (a crash must replay them) but must not be queued twice while live.
|
|
145
143
|
*/
|
|
146
144
|
private readonly inFlight;
|
|
147
|
-
/** Wires already NACKed to the managed agent, so a
|
|
145
|
+
/** Wires already NACKed to the managed agent, so a history replay stays quiet. */
|
|
148
146
|
private readonly relayNacks;
|
|
149
147
|
/**
|
|
150
148
|
* fleet.yaml declares the restart baseline; `/comments on|off` changes only
|
|
@@ -155,7 +153,6 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
155
153
|
private readonly commentsBaseline;
|
|
156
154
|
private commentsEnabled;
|
|
157
155
|
private stopping;
|
|
158
|
-
private watchProcess?;
|
|
159
156
|
private watchTask?;
|
|
160
157
|
private watchAbort?;
|
|
161
158
|
private drainTask?;
|
|
@@ -174,6 +171,12 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
174
171
|
manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
|
|
175
172
|
notifyFleetSpawn(event: ManagedFleetSpawnResult): Promise<void>;
|
|
176
173
|
private manageNow;
|
|
174
|
+
/**
|
|
175
|
+
* The daemon reports established contacts and pending introductions as two
|
|
176
|
+
* separate collections, so the status is structural rather than a word parsed
|
|
177
|
+
* out of a rendered line. Nothing here can be spoofed by a contact's own
|
|
178
|
+
* display name.
|
|
179
|
+
*/
|
|
177
180
|
private contacts;
|
|
178
181
|
private contact;
|
|
179
182
|
private assertCid;
|
|
@@ -187,6 +190,15 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
187
190
|
private safeTaskReport;
|
|
188
191
|
private safeProactiveMessage;
|
|
189
192
|
private drainAll;
|
|
193
|
+
/**
|
|
194
|
+
* Claim the exact oldest unread SQLite batch before marking it read.
|
|
195
|
+
* The journal contains only wire IDs and sequence numbers; bodies remain in
|
|
196
|
+
* the daemon's persistent history and are recovered with getHistoryItem.
|
|
197
|
+
*/
|
|
198
|
+
private claimMessages;
|
|
199
|
+
private messageClaim;
|
|
200
|
+
private historyMessage;
|
|
201
|
+
private attachmentMetadata;
|
|
190
202
|
private attachmentGroups;
|
|
191
203
|
private handleAttachmentGroup;
|
|
192
204
|
private handle;
|
|
@@ -224,7 +236,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
224
236
|
private managedAttachmentReplyWire;
|
|
225
237
|
/**
|
|
226
238
|
* One bounded NACK per wire: an unroutable or refused relay must be visible
|
|
227
|
-
* to the authenticated agent, while its
|
|
239
|
+
* to the authenticated agent, while its history replays stay quiet. NACK
|
|
228
240
|
* delivery is best-effort — it must never make the failure worse.
|
|
229
241
|
*/
|
|
230
242
|
private nackManagedAgent;
|
|
@@ -238,16 +250,6 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
238
250
|
private warnOwnerOfUnauthorizedSender;
|
|
239
251
|
private effectiveOwners;
|
|
240
252
|
private authorizationIntegrity;
|
|
241
|
-
/**
|
|
242
|
-
* Report what the session actually did with the prompt, not what the config
|
|
243
|
-
* asked for. `interrupt: true` used to be reported as "your request
|
|
244
|
-
* interrupted the previous task" unconditionally; the session now answers
|
|
245
|
-
* whether anything was cancelled, whether the request is queued behind
|
|
246
|
-
* earlier prompts, or whether it is held until the current task reaches a
|
|
247
|
-
* safe stopping point. Backends that report no delivery state keep the old
|
|
248
|
-
* queuedBehind-based wording.
|
|
249
|
-
*/
|
|
250
|
-
private acceptanceNotice;
|
|
251
253
|
private complete;
|
|
252
254
|
private commentsState;
|
|
253
255
|
/** Model-authored commentary only; raw protocol/tool data never reaches here. */
|
|
@@ -267,15 +269,12 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
267
269
|
private progressPhase;
|
|
268
270
|
private watchLoop;
|
|
269
271
|
/**
|
|
270
|
-
* `recovered` distinguishes a first-ever start
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* durable cursor was ever lost.
|
|
272
|
+
* `recovered` distinguishes a first-ever start from unreadable persisted
|
|
273
|
+
* diagnostics. Notification correctness does not depend on this state: every
|
|
274
|
+
* establishment drains and then replays SDK hints from offset zero.
|
|
274
275
|
*/
|
|
275
276
|
private readWatchState;
|
|
276
277
|
private writeWatchState;
|
|
277
|
-
/** Compatibility path for injected child-process tests; production is direct. */
|
|
278
|
-
private legacyWatchLoop;
|
|
279
278
|
private errorText;
|
|
280
279
|
private logError;
|
|
281
280
|
}
|