@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
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface PendingMessageClaim {
|
|
2
|
+
wireId: string;
|
|
3
|
+
seq: number;
|
|
4
|
+
claimedAt: number;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Body-free crash journal for the SQLite getMessages read boundary.
|
|
8
|
+
*
|
|
9
|
+
* A claim lands before getMessages marks its exact oldest-first batch read.
|
|
10
|
+
* After a crash, getHistoryItem can therefore recover the body by wire ID
|
|
11
|
+
* without fleet ever duplicating message plaintext in its own state.
|
|
12
|
+
*/
|
|
13
|
+
export declare class MessageRecoveryState {
|
|
14
|
+
private readonly path;
|
|
15
|
+
private readonly limit;
|
|
16
|
+
private pending;
|
|
17
|
+
private corrupt;
|
|
18
|
+
constructor(path: string, limit?: number);
|
|
19
|
+
integrity(): boolean;
|
|
20
|
+
list(): PendingMessageClaim[];
|
|
21
|
+
claim(items: PendingMessageClaim[]): void;
|
|
22
|
+
pruneHandled(handled: (wireId: string) => boolean): number;
|
|
23
|
+
private assertHealthy;
|
|
24
|
+
private persist;
|
|
25
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { chmodSync, existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
3
|
+
const MAX_PENDING_MESSAGES = 5_000;
|
|
4
|
+
const MAX_WIRE_ID_CHARS = 1_024;
|
|
5
|
+
/**
|
|
6
|
+
* Body-free crash journal for the SQLite getMessages read boundary.
|
|
7
|
+
*
|
|
8
|
+
* A claim lands before getMessages marks its exact oldest-first batch read.
|
|
9
|
+
* After a crash, getHistoryItem can therefore recover the body by wire ID
|
|
10
|
+
* without fleet ever duplicating message plaintext in its own state.
|
|
11
|
+
*/
|
|
12
|
+
export class MessageRecoveryState {
|
|
13
|
+
path;
|
|
14
|
+
limit;
|
|
15
|
+
pending = [];
|
|
16
|
+
corrupt = false;
|
|
17
|
+
constructor(path, limit = MAX_PENDING_MESSAGES) {
|
|
18
|
+
this.path = path;
|
|
19
|
+
this.limit = limit;
|
|
20
|
+
if (!existsSync(path))
|
|
21
|
+
return;
|
|
22
|
+
try {
|
|
23
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
24
|
+
if (raw.version !== 1 || !Array.isArray(raw.pending)
|
|
25
|
+
|| raw.pending.length > limit || !raw.pending.every(validClaim)
|
|
26
|
+
|| new Set(raw.pending.map(item => item.wireId)).size !== raw.pending.length)
|
|
27
|
+
throw new Error('invalid message recovery state');
|
|
28
|
+
this.pending = raw.pending.map(item => ({ ...item }));
|
|
29
|
+
chmodSync(path, 0o600);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
this.corrupt = true;
|
|
33
|
+
this.pending = [];
|
|
34
|
+
try {
|
|
35
|
+
chmodSync(path, 0o600);
|
|
36
|
+
}
|
|
37
|
+
catch { /* retain the evidence */ }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
integrity() { return !this.corrupt; }
|
|
41
|
+
list() {
|
|
42
|
+
this.assertHealthy();
|
|
43
|
+
return this.pending.map(item => ({ ...item }));
|
|
44
|
+
}
|
|
45
|
+
claim(items) {
|
|
46
|
+
this.assertHealthy();
|
|
47
|
+
if (!items.length)
|
|
48
|
+
return;
|
|
49
|
+
if (!items.every(validClaim))
|
|
50
|
+
throw new Error('invalid message recovery claim');
|
|
51
|
+
const next = this.pending.map(item => ({ ...item }));
|
|
52
|
+
const byWire = new Map(next.map(item => [item.wireId, item]));
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
const existing = byWire.get(item.wireId);
|
|
55
|
+
if (existing) {
|
|
56
|
+
if (existing.seq !== item.seq)
|
|
57
|
+
throw new Error('message recovery wire ID changed sequence');
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (next.length >= this.limit)
|
|
61
|
+
throw new Error('too many pending message recoveries');
|
|
62
|
+
const copy = { ...item };
|
|
63
|
+
next.push(copy);
|
|
64
|
+
byWire.set(copy.wireId, copy);
|
|
65
|
+
}
|
|
66
|
+
if (next.length === this.pending.length)
|
|
67
|
+
return;
|
|
68
|
+
const previous = this.pending;
|
|
69
|
+
this.pending = next;
|
|
70
|
+
try {
|
|
71
|
+
this.persist();
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
this.pending = previous;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
pruneHandled(handled) {
|
|
79
|
+
this.assertHealthy();
|
|
80
|
+
const next = this.pending.filter(item => !handled(item.wireId));
|
|
81
|
+
const removed = this.pending.length - next.length;
|
|
82
|
+
if (!removed)
|
|
83
|
+
return 0;
|
|
84
|
+
const previous = this.pending;
|
|
85
|
+
this.pending = next;
|
|
86
|
+
try {
|
|
87
|
+
this.persist();
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
this.pending = previous;
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
return removed;
|
|
94
|
+
}
|
|
95
|
+
assertHealthy() {
|
|
96
|
+
if (this.corrupt)
|
|
97
|
+
throw new Error('message recovery state is corrupt');
|
|
98
|
+
}
|
|
99
|
+
persist() {
|
|
100
|
+
replaceFileAtomically(this.path, JSON.stringify({
|
|
101
|
+
version: 1, pending: this.pending,
|
|
102
|
+
}) + '\n', 0o600);
|
|
103
|
+
chmodSync(this.path, 0o600);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function validClaim(value) {
|
|
107
|
+
if (!value || typeof value !== 'object')
|
|
108
|
+
return false;
|
|
109
|
+
const item = value;
|
|
110
|
+
return typeof item.wireId === 'string' && item.wireId.length >= 1
|
|
111
|
+
&& Array.from(item.wireId).length <= MAX_WIRE_ID_CHARS
|
|
112
|
+
&& Number.isSafeInteger(item.seq) && item.seq >= 1
|
|
113
|
+
&& Number.isSafeInteger(item.claimedAt) && item.claimedAt >= 0;
|
|
114
|
+
}
|
|
@@ -22,13 +22,6 @@ export declare const ownerNotices: {
|
|
|
22
22
|
receivedStarted: () => string;
|
|
23
23
|
receivedQueued: (queuedBehind: number) => string;
|
|
24
24
|
receivedInterrupting: () => string;
|
|
25
|
-
/**
|
|
26
|
-
* The honest answer when the agent is mid-task and pre-empting it would have
|
|
27
|
-
* corrupted the conversation. Says "not started yet" rather than borrowing
|
|
28
|
-
* `receivedInterrupting`'s claim that something was cancelled for this
|
|
29
|
-
* request.
|
|
30
|
-
*/
|
|
31
|
-
receivedDeferred: () => string;
|
|
32
25
|
status: (role: string, snapshot: SessionSnapshot) => string;
|
|
33
26
|
interrupted: (role: string) => string;
|
|
34
27
|
/** The turn IS cancelled — say how, without implying the owner must retry. */
|
|
@@ -26,15 +26,6 @@ export const ownerNotices = {
|
|
|
26
26
|
receivedInterrupting: () => "ℹ️ Message received. The agent's previous task was interrupted to prioritize "
|
|
27
27
|
+ 'this request, and it is now working on a response. '
|
|
28
28
|
+ 'The response will arrive in this channel when ready.',
|
|
29
|
-
/**
|
|
30
|
-
* The honest answer when the agent is mid-task and pre-empting it would have
|
|
31
|
-
* corrupted the conversation. Says "not started yet" rather than borrowing
|
|
32
|
-
* `receivedInterrupting`'s claim that something was cancelled for this
|
|
33
|
-
* request.
|
|
34
|
-
*/
|
|
35
|
-
receivedDeferred: () => 'ℹ️ Message received and held. The agent is in the middle of a task that '
|
|
36
|
-
+ 'cannot be interrupted safely; this request starts as soon as that work '
|
|
37
|
-
+ 'reaches a stopping point. The response will arrive in this channel when ready.',
|
|
38
29
|
status: (role, snapshot) => `📊 ${role} status: ${snapshot.readiness}; session is ${snapshot.alive ? 'online' : 'offline'}.`,
|
|
39
30
|
interrupted: (role) => `🛑 Interrupt sent to ${role}'s active turn.`,
|
|
40
31
|
/** The turn IS cancelled — say how, without implying the owner must retry. */
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { OursClient, type AttachOursClientOptions, type NotificationEvent } from '@ours.network/sdk/client';
|
|
2
|
+
/** Any failure of a daemon operation. Never carries a message body or a token. */
|
|
3
|
+
export declare class OursDaemonError extends Error {
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The daemon accepted the call and answered "not sent". The MCP surface reported
|
|
7
|
+
* exactly these verdicts as tool errors, so they must keep throwing here: the
|
|
8
|
+
* owner channel books a resolved send as delivered, and a silently-swallowed
|
|
9
|
+
* refusal would be recorded as a delivered message that never left the host.
|
|
10
|
+
*/
|
|
11
|
+
export declare class OursSendRefusedError extends OursDaemonError {
|
|
12
|
+
}
|
|
13
|
+
type Res<M extends keyof OursClient> = OursClient[M] extends (...args: never[]) => infer R ? Awaited<R> : never;
|
|
14
|
+
export type OursContactsView = Res<'listContacts'>;
|
|
15
|
+
export type OursInviteResult = Res<'generateInvite'>;
|
|
16
|
+
export type OursAddContactResult = Res<'addContact'>;
|
|
17
|
+
export type OursIncomingMessage = Res<'listIncomingMessages'>[number];
|
|
18
|
+
export type OursMessagesPayload = Res<'getMessages'>;
|
|
19
|
+
export type OursInboundMessage = OursMessagesPayload['messages'][number];
|
|
20
|
+
export type OursHistoryMessage = NonNullable<Res<'getHistoryItem'>>;
|
|
21
|
+
export type OursIncomingFile = Res<'listIncomingFiles'>[number];
|
|
22
|
+
export type OursRetrievedFiles = Res<'getFiles'>;
|
|
23
|
+
export type OursRetrievedFile = OursRetrievedFiles['files'][number];
|
|
24
|
+
export type OursHistoryFile = NonNullable<Res<'getFileInfo'>>;
|
|
25
|
+
export type OursNotificationEvent = NotificationEvent;
|
|
26
|
+
export declare class OursWatchDeadlineError extends OursDaemonError {
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The daemon operations the owner channel needs, one typed method each.
|
|
30
|
+
*
|
|
31
|
+
* This interface deliberately has no generic `callTool(name, args): unknown`
|
|
32
|
+
* escape hatch. The legacy MCP connector answered every
|
|
33
|
+
* tool with `{content:[{type:'text',...}]}` and no `structuredContent`, the
|
|
34
|
+
* transport fell back to returning the daemon's English sentence — which the
|
|
35
|
+
* channel then pattern-matched (an invite blob sliced out of a prose sentence,
|
|
36
|
+
* a bind conflict detected with /currently bound to another live session/i).
|
|
37
|
+
* With no untyped result there is nothing left to pattern-match.
|
|
38
|
+
*/
|
|
39
|
+
export interface OursOps {
|
|
40
|
+
/** Prepare the transport. Must be called before any operation. */
|
|
41
|
+
start(): Promise<void>;
|
|
42
|
+
/** Bind this session's identity. Throws `BOUND_ELSEWHERE` when it is held live. */
|
|
43
|
+
bindIdentity(name: string): Promise<void>;
|
|
44
|
+
listContacts(): Promise<OursContactsView>;
|
|
45
|
+
generateInvite(name?: string): Promise<OursInviteResult>;
|
|
46
|
+
addContact(a: {
|
|
47
|
+
invite: string;
|
|
48
|
+
name?: string;
|
|
49
|
+
}): Promise<OursAddContactResult>;
|
|
50
|
+
listIncomingMessages(): Promise<OursIncomingMessage[]>;
|
|
51
|
+
getMessages(limit: number): Promise<OursMessagesPayload>;
|
|
52
|
+
getHistoryItem(wireId: string): Promise<OursHistoryMessage | null>;
|
|
53
|
+
watchNotifications(identity: string, options?: {
|
|
54
|
+
since?: number | 'tip';
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
}): AsyncGenerator<OursNotificationEvent, void, undefined>;
|
|
57
|
+
listIncomingFiles(): Promise<OursIncomingFile[]>;
|
|
58
|
+
getFileInfo(wireId: string): Promise<OursHistoryFile | null>;
|
|
59
|
+
getFiles(wireIds: string[]): Promise<OursRetrievedFiles>;
|
|
60
|
+
/**
|
|
61
|
+
* The bytes of an already-retrieved file. Transport only — the caller owns
|
|
62
|
+
* where they land, so path safety stays with the attachment code that already
|
|
63
|
+
* enforces it (`writeRecoveredAttachment`).
|
|
64
|
+
*/
|
|
65
|
+
fetchFile(wireId: string): Promise<Uint8Array>;
|
|
66
|
+
sendMessage(a: {
|
|
67
|
+
contact: string;
|
|
68
|
+
text: string;
|
|
69
|
+
replyToWireId?: string;
|
|
70
|
+
}): Promise<void>;
|
|
71
|
+
sendFile(a: {
|
|
72
|
+
contact: string;
|
|
73
|
+
path: string;
|
|
74
|
+
filename: string;
|
|
75
|
+
replyToWireId?: string;
|
|
76
|
+
}): Promise<void>;
|
|
77
|
+
/** Release the daemon lease and stop. Never throws. */
|
|
78
|
+
close(): Promise<void>;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The typed error code of a daemon operation, or undefined when the failure was
|
|
82
|
+
* not one (transport, abort, programming error). `instanceof` is checked first;
|
|
83
|
+
* the structural fallback keeps a duplicated SDK copy in a consumer's tree from
|
|
84
|
+
* silently demoting a real daemon verdict to "unknown transport failure".
|
|
85
|
+
*/
|
|
86
|
+
export declare function oursErrorCode(error: unknown): string | undefined;
|
|
87
|
+
/** The identity is bound by another live session; a predecessor may still be releasing it. */
|
|
88
|
+
export declare const OURS_BOUND_ELSEWHERE = "BOUND_ELSEWHERE";
|
|
89
|
+
export interface OursSdkClientDeps {
|
|
90
|
+
/** Test seam; production uses the SDK's coherence-checking application attach path. */
|
|
91
|
+
attachClient?(options: AttachOursClientOptions): OursClient | Promise<OursClient>;
|
|
92
|
+
/** Underlying transport and deadline seams for deterministic half-open tests. */
|
|
93
|
+
fetch?: typeof globalThis.fetch;
|
|
94
|
+
notificationRequestDeadlineMs?: number;
|
|
95
|
+
readFile?(path: string): Promise<Uint8Array>;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The owner-channel's daemon client: one `OursClient` over the local ours HTTP
|
|
99
|
+
* API, owning exactly one identity binding.
|
|
100
|
+
*
|
|
101
|
+
* Lease lifetime. The lease token IS the session, so each channel instance mints
|
|
102
|
+
* its own and hands it back in `close()`. That replaces the connector proxy's
|
|
103
|
+
* shell-PID fence, which existed because a supervised attempt had to make its
|
|
104
|
+
* lease reclaimable while the supervisor itself stayed alive: an explicit
|
|
105
|
+
* release does that deterministically, and `clientPid` still covers the case
|
|
106
|
+
* where the whole supervisor dies without unwinding.
|
|
107
|
+
*/
|
|
108
|
+
export declare class OursSdkClient implements OursOps {
|
|
109
|
+
private readonly env;
|
|
110
|
+
private readonly log;
|
|
111
|
+
private readonly deps;
|
|
112
|
+
private client?;
|
|
113
|
+
private readonly leaseToken;
|
|
114
|
+
constructor(env?: Record<string, string>, log?: (line: string) => void, deps?: OursSdkClientDeps);
|
|
115
|
+
start(): Promise<void>;
|
|
116
|
+
bindIdentity(name: string): Promise<void>;
|
|
117
|
+
listContacts(): Promise<OursContactsView>;
|
|
118
|
+
generateInvite(name?: string): Promise<OursInviteResult>;
|
|
119
|
+
addContact(a: {
|
|
120
|
+
invite: string;
|
|
121
|
+
name?: string;
|
|
122
|
+
}): Promise<OursAddContactResult>;
|
|
123
|
+
listIncomingMessages(): Promise<OursIncomingMessage[]>;
|
|
124
|
+
getMessages(limit: number): Promise<OursMessagesPayload>;
|
|
125
|
+
getHistoryItem(wireId: string): Promise<OursHistoryMessage | null>;
|
|
126
|
+
watchNotifications(identity: string, options?: {
|
|
127
|
+
since?: number | 'tip';
|
|
128
|
+
signal?: AbortSignal;
|
|
129
|
+
}): AsyncGenerator<OursNotificationEvent, void, undefined>;
|
|
130
|
+
listIncomingFiles(): Promise<OursIncomingFile[]>;
|
|
131
|
+
getFileInfo(wireId: string): Promise<OursHistoryFile | null>;
|
|
132
|
+
getFiles(wireIds: string[]): Promise<OursRetrievedFiles>;
|
|
133
|
+
fetchFile(wireId: string): Promise<Uint8Array>;
|
|
134
|
+
sendMessage(a: {
|
|
135
|
+
contact: string;
|
|
136
|
+
text: string;
|
|
137
|
+
replyToWireId?: string;
|
|
138
|
+
}): Promise<void>;
|
|
139
|
+
sendFile(a: {
|
|
140
|
+
contact: string;
|
|
141
|
+
path: string;
|
|
142
|
+
filename: string;
|
|
143
|
+
replyToWireId?: string;
|
|
144
|
+
}): Promise<void>;
|
|
145
|
+
close(): Promise<void>;
|
|
146
|
+
private ops;
|
|
147
|
+
}
|
|
148
|
+
export {};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { extname } from 'node:path';
|
|
4
|
+
import { OursError, attachOursClient, } from '@ours.network/sdk/client';
|
|
5
|
+
/** Any failure of a daemon operation. Never carries a message body or a token. */
|
|
6
|
+
export class OursDaemonError extends Error {
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The daemon accepted the call and answered "not sent". The MCP surface reported
|
|
10
|
+
* exactly these verdicts as tool errors, so they must keep throwing here: the
|
|
11
|
+
* owner channel books a resolved send as delivered, and a silently-swallowed
|
|
12
|
+
* refusal would be recorded as a delivered message that never left the host.
|
|
13
|
+
*/
|
|
14
|
+
export class OursSendRefusedError extends OursDaemonError {
|
|
15
|
+
}
|
|
16
|
+
// The daemon normally returns a quiet long-poll within 25 seconds. Keep the
|
|
17
|
+
// client-side fence comfortably above it so ordinary quiet periods do not
|
|
18
|
+
// recycle a healthy stream, while a half-open socket still heals on its own.
|
|
19
|
+
const NOTIFICATION_REQUEST_DEADLINE_MS = 120_000;
|
|
20
|
+
// SDK sendFile({path}) inferred these common types before reading the path in
|
|
21
|
+
// the daemon process. Staged uploads move that read into fleet, so preserve the
|
|
22
|
+
// same advertised MIME rather than silently turning every attachment into an
|
|
23
|
+
// octet stream. Importing the SDK root just for its helper would also pull the
|
|
24
|
+
// daemon runtime into this client-only process.
|
|
25
|
+
const FILE_MIME_BY_EXTENSION = {
|
|
26
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
27
|
+
'.webp': 'image/webp', '.svg': 'image/svg+xml', '.bmp': 'image/bmp', '.ico': 'image/x-icon',
|
|
28
|
+
'.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown',
|
|
29
|
+
'.json': 'application/json', '.csv': 'text/csv', '.html': 'text/html',
|
|
30
|
+
'.xml': 'application/xml', '.zip': 'application/zip', '.gz': 'application/gzip',
|
|
31
|
+
'.tar': 'application/x-tar', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
|
|
32
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime',
|
|
33
|
+
};
|
|
34
|
+
function mimeFromFilename(filename) {
|
|
35
|
+
return FILE_MIME_BY_EXTENSION[extname(filename).toLowerCase()] ?? 'application/octet-stream';
|
|
36
|
+
}
|
|
37
|
+
export class OursWatchDeadlineError extends OursDaemonError {
|
|
38
|
+
}
|
|
39
|
+
function notificationRequest(input) {
|
|
40
|
+
const url = typeof input === 'string' || input instanceof URL ? String(input) : input.url;
|
|
41
|
+
return /\/identities\/[^/]+\/notifications(?:\?|$)/.test(url);
|
|
42
|
+
}
|
|
43
|
+
function notificationDeadlineFetch(fetchImpl, deadlineMs) {
|
|
44
|
+
return async (input, init) => {
|
|
45
|
+
if (!notificationRequest(input))
|
|
46
|
+
return fetchImpl(input, init);
|
|
47
|
+
const ctrl = new AbortController();
|
|
48
|
+
const upstream = init?.signal;
|
|
49
|
+
let rejectFence;
|
|
50
|
+
const fence = new Promise((_resolve, reject) => { rejectFence = reject; });
|
|
51
|
+
const abortUpstream = () => {
|
|
52
|
+
const reason = upstream?.reason
|
|
53
|
+
?? new DOMException('ours notification request aborted', 'AbortError');
|
|
54
|
+
ctrl.abort(reason);
|
|
55
|
+
// Shutdown must not wait for the deadline when a custom fetch ignores
|
|
56
|
+
// AbortSignal either.
|
|
57
|
+
rejectFence(reason);
|
|
58
|
+
};
|
|
59
|
+
if (upstream?.aborted)
|
|
60
|
+
abortUpstream();
|
|
61
|
+
else
|
|
62
|
+
upstream?.addEventListener('abort', abortUpstream, { once: true });
|
|
63
|
+
const timer = setTimeout(() => {
|
|
64
|
+
const error = new OursWatchDeadlineError('ours notification request deadline exceeded');
|
|
65
|
+
ctrl.abort(error);
|
|
66
|
+
// Do not rely on a custom or half-open fetch implementation to honor
|
|
67
|
+
// AbortSignal: the fence itself must always settle the request.
|
|
68
|
+
rejectFence(error);
|
|
69
|
+
}, deadlineMs);
|
|
70
|
+
timer.unref?.();
|
|
71
|
+
try {
|
|
72
|
+
return await Promise.race([
|
|
73
|
+
fetchImpl(input, { ...init, signal: ctrl.signal }),
|
|
74
|
+
fence,
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
upstream?.removeEventListener('abort', abortUpstream);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The typed error code of a daemon operation, or undefined when the failure was
|
|
85
|
+
* not one (transport, abort, programming error). `instanceof` is checked first;
|
|
86
|
+
* the structural fallback keeps a duplicated SDK copy in a consumer's tree from
|
|
87
|
+
* silently demoting a real daemon verdict to "unknown transport failure".
|
|
88
|
+
*/
|
|
89
|
+
export function oursErrorCode(error) {
|
|
90
|
+
if (error instanceof OursError)
|
|
91
|
+
return error.code;
|
|
92
|
+
if (error instanceof Error && error.name === 'OursError') {
|
|
93
|
+
const code = error.code;
|
|
94
|
+
if (typeof code === 'string')
|
|
95
|
+
return code;
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
/** The identity is bound by another live session; a predecessor may still be releasing it. */
|
|
100
|
+
export const OURS_BOUND_ELSEWHERE = 'BOUND_ELSEWHERE';
|
|
101
|
+
/**
|
|
102
|
+
* The owner-channel's daemon client: one `OursClient` over the local ours HTTP
|
|
103
|
+
* API, owning exactly one identity binding.
|
|
104
|
+
*
|
|
105
|
+
* Lease lifetime. The lease token IS the session, so each channel instance mints
|
|
106
|
+
* its own and hands it back in `close()`. That replaces the connector proxy's
|
|
107
|
+
* shell-PID fence, which existed because a supervised attempt had to make its
|
|
108
|
+
* lease reclaimable while the supervisor itself stayed alive: an explicit
|
|
109
|
+
* release does that deterministically, and `clientPid` still covers the case
|
|
110
|
+
* where the whole supervisor dies without unwinding.
|
|
111
|
+
*/
|
|
112
|
+
export class OursSdkClient {
|
|
113
|
+
env;
|
|
114
|
+
log;
|
|
115
|
+
deps;
|
|
116
|
+
client;
|
|
117
|
+
leaseToken = `ours-fleet-owner-${process.pid}-${randomUUID()}`;
|
|
118
|
+
constructor(env = {}, log = () => undefined, deps = {}) {
|
|
119
|
+
this.env = env;
|
|
120
|
+
this.log = log;
|
|
121
|
+
this.deps = deps;
|
|
122
|
+
}
|
|
123
|
+
async start() {
|
|
124
|
+
if (this.client)
|
|
125
|
+
return;
|
|
126
|
+
const environment = { ...process.env, ...this.env };
|
|
127
|
+
// SDK 2's supported application path resolves endpoint, state root, and
|
|
128
|
+
// token as one coherent selection, proves the daemon's state root before
|
|
129
|
+
// sending credentials, and only then constructs the client.
|
|
130
|
+
const options = {
|
|
131
|
+
env: environment,
|
|
132
|
+
leaseToken: this.leaseToken,
|
|
133
|
+
clientPid: process.pid,
|
|
134
|
+
fetch: notificationDeadlineFetch(this.deps.fetch ?? globalThis.fetch, this.deps.notificationRequestDeadlineMs ?? NOTIFICATION_REQUEST_DEADLINE_MS),
|
|
135
|
+
};
|
|
136
|
+
this.client = await (this.deps.attachClient?.(options) ?? attachOursClient(options));
|
|
137
|
+
}
|
|
138
|
+
async bindIdentity(name) {
|
|
139
|
+
// force is pinned off: the owner channel never evicts another live session
|
|
140
|
+
// from an identity, it waits for the bounded handoff window and then fails.
|
|
141
|
+
await this.ops().chooseIdentity({ name, force: false });
|
|
142
|
+
}
|
|
143
|
+
async listContacts() {
|
|
144
|
+
return this.ops().listContacts();
|
|
145
|
+
}
|
|
146
|
+
async generateInvite(name) {
|
|
147
|
+
return this.ops().generateInvite(name ? { name } : {});
|
|
148
|
+
}
|
|
149
|
+
async addContact(a) {
|
|
150
|
+
return this.ops().addContact({ invite: a.invite, ...(a.name ? { name: a.name } : {}) });
|
|
151
|
+
}
|
|
152
|
+
async listIncomingMessages() {
|
|
153
|
+
return this.ops().listIncomingMessages();
|
|
154
|
+
}
|
|
155
|
+
async getMessages(limit) {
|
|
156
|
+
return this.ops().getMessages({ limit });
|
|
157
|
+
}
|
|
158
|
+
async getHistoryItem(wireId) {
|
|
159
|
+
return this.ops().getHistoryItem({ wire_id: wireId });
|
|
160
|
+
}
|
|
161
|
+
watchNotifications(identity, options) {
|
|
162
|
+
return this.ops().watchNotifications(identity, options);
|
|
163
|
+
}
|
|
164
|
+
async listIncomingFiles() {
|
|
165
|
+
return this.ops().listIncomingFiles();
|
|
166
|
+
}
|
|
167
|
+
async getFileInfo(wireId) {
|
|
168
|
+
return this.ops().getFileInfo({ wire_id: wireId });
|
|
169
|
+
}
|
|
170
|
+
async getFiles(wireIds) {
|
|
171
|
+
return this.ops().getFiles({ wire_ids: wireIds });
|
|
172
|
+
}
|
|
173
|
+
async fetchFile(wireId) {
|
|
174
|
+
// save_file has no SDK operation on purpose: that daemon route only reports
|
|
175
|
+
// that a too-old connector reached it. The bytes of a retrieved file are
|
|
176
|
+
// already on disk, so read them back and let the caller write them as this
|
|
177
|
+
// process's own OS user.
|
|
178
|
+
return this.ops().fetchFile(wireId);
|
|
179
|
+
}
|
|
180
|
+
async sendMessage(a) {
|
|
181
|
+
const verdict = await this.ops().sendMessage({
|
|
182
|
+
contact: a.contact, text: a.text,
|
|
183
|
+
...(a.replyToWireId ? { reply_to_wire_id: a.replyToWireId } : {}),
|
|
184
|
+
});
|
|
185
|
+
// Legacy parity: only `refused` was a tool error. `migrating`,
|
|
186
|
+
// `deferred` and `e2e` are accepted-and-queued outcomes that it reported as
|
|
187
|
+
// success, so they must not become failures here.
|
|
188
|
+
if (verdict.kind === 'refused')
|
|
189
|
+
throw new OursSendRefusedError('the daemon refused the message: the contact\'s end-to-end session must be '
|
|
190
|
+
+ 're-established after an upgrade; it was not sent and not downgraded');
|
|
191
|
+
}
|
|
192
|
+
async sendFile(a) {
|
|
193
|
+
// The shared daemon may be owned by a different OS process/user and must
|
|
194
|
+
// never be expected to open fleet-private outbox paths. SDK 2 stages bytes
|
|
195
|
+
// read by THIS process, then sends only the opaque upload receipt.
|
|
196
|
+
const bytes = await (this.deps.readFile?.(a.path) ?? readFile(a.path));
|
|
197
|
+
const staged = await this.ops().uploadFile(bytes, {
|
|
198
|
+
filename: a.filename, mime: mimeFromFilename(a.filename),
|
|
199
|
+
});
|
|
200
|
+
const verdict = await this.ops().sendFile({
|
|
201
|
+
contact: a.contact, upload_id: staged.upload_id, filename: a.filename,
|
|
202
|
+
...(a.replyToWireId ? { reply_to_wire_id: a.replyToWireId } : {}),
|
|
203
|
+
});
|
|
204
|
+
// Legacy parity treated `migrating` as an error for
|
|
205
|
+
// files and as success for messages: files are not auto-queued behind a
|
|
206
|
+
// migration, so "queued" would be a false delivery claim.
|
|
207
|
+
if (verdict.kind === 'refused' || verdict.kind === 'migrating')
|
|
208
|
+
throw new OursSendRefusedError(`the daemon did not send the file (${verdict.kind}): the contact's end-to-end `
|
|
209
|
+
+ 'session must be re-established after an upgrade; files are not queued');
|
|
210
|
+
}
|
|
211
|
+
async close() {
|
|
212
|
+
const client = this.client;
|
|
213
|
+
this.client = undefined;
|
|
214
|
+
if (!client)
|
|
215
|
+
return;
|
|
216
|
+
// Handing the lease back is what lets a successor bind this identity without
|
|
217
|
+
// waiting for the supervisor to exit. A failure here is not fatal — the
|
|
218
|
+
// daemon still reclaims the lease when this process dies.
|
|
219
|
+
try {
|
|
220
|
+
await client.releaseLease();
|
|
221
|
+
}
|
|
222
|
+
catch (error) {
|
|
223
|
+
this.log(`lease release failed: ${error?.message ?? String(error)}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
ops() {
|
|
227
|
+
if (!this.client)
|
|
228
|
+
throw new OursDaemonError('ours daemon client is not started');
|
|
229
|
+
return this.client;
|
|
230
|
+
}
|
|
231
|
+
}
|
package/dist/resolved-plan.js
CHANGED
|
@@ -18,7 +18,6 @@ export function resolvedPlan(cfg) {
|
|
|
18
18
|
schemaVersion: RESOLVED_PLAN_SCHEMA_VERSION,
|
|
19
19
|
sourceFiles: [...cfg.files],
|
|
20
20
|
startStaggerMs: cfg.startStaggerMs,
|
|
21
|
-
harnesses: cfg.harnessPlugins,
|
|
22
21
|
diagnostics: cfg.diagnostics.map(diagnostic => ({ ...diagnostic })),
|
|
23
22
|
roles: cfg.roles.map(resolvedRolePlan),
|
|
24
23
|
loops: cfg.loops.map(loop => sortedObject({
|
package/dist/runner.d.ts
CHANGED
|
@@ -35,15 +35,6 @@ export interface RunnerDeps {
|
|
|
35
35
|
}
|
|
36
36
|
/** Environment injected only into the managed harness process. */
|
|
37
37
|
export declare function managedFleetProxyEnv(role: ResolvedRole, stateDir: string): Record<string, string>;
|
|
38
|
-
/**
|
|
39
|
-
* The environment a managed harness child actually receives, checked at the one
|
|
40
|
-
* point where it is composed. `role.env` deliberately wins over harness prep,
|
|
41
|
-
* which is exactly how a stale fleet-wide model pin used to outrank the model
|
|
42
|
-
* the role was spawned with — so the model pin is verified here rather than
|
|
43
|
-
* trusted, and a disagreement stops the launch instead of being reported as a
|
|
44
|
-
* success (see src/model-env.ts).
|
|
45
|
-
*/
|
|
46
|
-
export declare function harnessChildEnv(role: ResolvedRole, launchEnv: Record<string, string> | undefined, stateDir: string): Record<string, string>;
|
|
47
38
|
/**
|
|
48
39
|
* Record who owns wake delivery for this run. Returning true means a fleet
|
|
49
40
|
* monitor is taking ownership back from a native harness and must start at the
|
|
@@ -70,23 +61,6 @@ export declare function readExitRecord(path: string): ExitRecord | null;
|
|
|
70
61
|
export declare const RESTART_LEDGER_FILE = ".restart-ledger.json";
|
|
71
62
|
/** Consecutive immediate failures tolerated before the agent is held down. */
|
|
72
63
|
export declare const RESTART_FAIL_THRESHOLD = 5;
|
|
73
|
-
/**
|
|
74
|
-
* How the previous supervisor process ended.
|
|
75
|
-
*
|
|
76
|
-
* `abrupt` is the case the ledger used to miss entirely: an OOM-kill or any
|
|
77
|
-
* other external signal takes the supervisor down before it can write anything,
|
|
78
|
-
* the service manager restarts the unit, and every durable indicator still
|
|
79
|
-
* describes the run that died. A health check reading them reported "no
|
|
80
|
-
* restarts" for a role that had died and come back.
|
|
81
|
-
*/
|
|
82
|
-
export interface TerminationRecord {
|
|
83
|
-
class: 'clean' | 'abrupt' | 'unknown';
|
|
84
|
-
detail: string;
|
|
85
|
-
/** When the SURVIVING process observed it, not when it happened. */
|
|
86
|
-
observedAt: string;
|
|
87
|
-
/** Start time of the run that ended, when it was recorded. */
|
|
88
|
-
runStartedAt?: string;
|
|
89
|
-
}
|
|
90
64
|
export interface RestartLedger {
|
|
91
65
|
version: 1;
|
|
92
66
|
consecutiveImmediateFailures: number;
|
|
@@ -98,29 +72,7 @@ export interface RestartLedger {
|
|
|
98
72
|
updatedAt: string;
|
|
99
73
|
/** When the circuit opened, for the held-down status line. */
|
|
100
74
|
openedAt?: string;
|
|
101
|
-
/** How the previous supervisor process ended, including abnormal exits. */
|
|
102
|
-
lastTermination?: TerminationRecord;
|
|
103
|
-
/** Supervisor processes that died without closing their run marker. */
|
|
104
|
-
abruptTerminations?: number;
|
|
105
|
-
/** Start of the supervisor run that owns this state directory now. */
|
|
106
|
-
supervisorStartedAt?: string;
|
|
107
75
|
}
|
|
108
|
-
/**
|
|
109
|
-
* Carried across a supervisor process's life so its successor can tell an
|
|
110
|
-
* orderly exit from a kill. Present on disk == "a supervisor believed it was
|
|
111
|
-
* running"; the next start finding one that is not its own is proof the
|
|
112
|
-
* previous process died without getting to write anything.
|
|
113
|
-
*/
|
|
114
|
-
export declare const RUN_MARKER_FILE = ".supervisor-run.json";
|
|
115
|
-
/**
|
|
116
|
-
* Claim this state directory for the current supervisor process and report how
|
|
117
|
-
* the previous one ended. Runs BEFORE the first attempt, which is the whole
|
|
118
|
-
* point: after an abrupt kill nothing else writes until an attempt finishes,
|
|
119
|
-
* and an attempt can take minutes.
|
|
120
|
-
*/
|
|
121
|
-
export declare function claimSupervisorRun(dir: string, startedAt: string, pid?: number): TerminationRecord;
|
|
122
|
-
/** Orderly exit: the successor must not read this run as a kill. */
|
|
123
|
-
export declare function releaseSupervisorRun(dir: string): void;
|
|
124
76
|
/** Bounded exponential backoff for the nth consecutive immediate failure. */
|
|
125
77
|
export declare function backoffFor(consecutiveFailures: number): number;
|
|
126
78
|
/** Read a role's restart ledger; a missing or corrupt one starts clean. */
|