@ours.network/fleet 0.12.0 โ 0.13.0
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 +184 -0
- package/dist/briefing.js +10 -0
- package/dist/cli.js +404 -1
- package/dist/config.d.ts +18 -2
- package/dist/config.js +67 -3
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +111 -0
- package/dist/duration.js +7 -3
- package/dist/loops/config.d.ts +30 -0
- package/dist/loops/config.js +135 -0
- package/dist/loops/manager.d.ts +48 -0
- package/dist/loops/manager.js +237 -0
- package/dist/loops/state.d.ts +54 -0
- package/dist/loops/state.js +148 -0
- package/dist/monitor.js +26 -2
- package/dist/owner-channel/attachments.d.ts +74 -0
- package/dist/owner-channel/attachments.js +378 -0
- package/dist/owner-channel/channel.d.ts +114 -2
- package/dist/owner-channel/channel.js +622 -43
- package/dist/owner-channel/notices.d.ts +21 -0
- package/dist/owner-channel/notices.js +66 -0
- package/dist/owner-channel/state.d.ts +34 -0
- package/dist/owner-channel/state.js +148 -1
- package/dist/owner-channel/tasks.d.ts +62 -0
- package/dist/owner-channel/tasks.js +246 -0
- package/dist/resolved-plan.js +11 -0
- package/dist/runner.js +86 -7
- package/dist/session/acp.d.ts +3 -2
- package/dist/session/acp.js +67 -25
- package/dist/session/arbiter.d.ts +42 -0
- package/dist/session/arbiter.js +72 -0
- package/dist/session/control.d.ts +12 -1
- package/dist/session/control.js +56 -3
- package/dist/session/types.d.ts +26 -2
- package/dist/session/types.js +5 -2
- package/dist/supervisor/systemd.js +12 -2
- package/package.json +1 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { SessionSnapshot, TurnOutcome } from '../session/types.js';
|
|
2
|
+
import type { OwnerTaskPhase } from './tasks.js';
|
|
3
|
+
export type OwnerUpdatePhase = 'working' | 'approval' | 'blocked';
|
|
4
|
+
export type OwnerProgressPhase = 'starting request' | 'waiting behind earlier requests' | 'working on request' | 'planning next step' | 'using tools' | 'reviewing tool results' | 'drafting response' | 'waiting for approval' | 'resuming after permission decision' | 'recovering from session error';
|
|
5
|
+
export declare const ownerNotices: {
|
|
6
|
+
receivedStarted: () => string;
|
|
7
|
+
receivedQueued: (queuedBehind: number) => string;
|
|
8
|
+
receivedInterrupting: () => string;
|
|
9
|
+
status: (role: string, snapshot: SessionSnapshot) => string;
|
|
10
|
+
interrupted: (role: string) => string;
|
|
11
|
+
interruptFailed: (role: string) => string;
|
|
12
|
+
attachmentRejected: (reason: string) => string;
|
|
13
|
+
attachmentFailed: () => string;
|
|
14
|
+
deliveryFailed: (role: string) => string;
|
|
15
|
+
progress: (elapsedMs: number, phase: OwnerProgressPhase, started: number, completed: number, activityUpdates?: number) => string;
|
|
16
|
+
authoredUpdate: (phase: OwnerUpdatePhase, message: string) => string;
|
|
17
|
+
taskReport: (phase: OwnerTaskPhase, message: string) => string;
|
|
18
|
+
completedWithoutText: () => string;
|
|
19
|
+
terminal: (outcome: TurnOutcome) => "โ
Request completed." | "๐ Request was cancelled before completion." | "โ ๏ธ The agent declined this request." | "โ ๏ธ Request failed before completion." | "โ ๏ธ Request ended without a confirmed completion.";
|
|
20
|
+
chunk: (part: number, total: number) => string;
|
|
21
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const duration = (elapsedMs) => {
|
|
2
|
+
const seconds = Math.max(0, Math.floor(elapsedMs / 1_000));
|
|
3
|
+
const hours = Math.floor(seconds / 3_600);
|
|
4
|
+
const minutes = Math.floor((seconds % 3_600) / 60);
|
|
5
|
+
const remainder = seconds % 60;
|
|
6
|
+
return [hours ? `${hours}h` : '', minutes ? `${minutes}m` : '', remainder ? `${remainder}s` : '']
|
|
7
|
+
.filter(Boolean).join(' ') || '0s';
|
|
8
|
+
};
|
|
9
|
+
const count = (value, singular, plural = `${singular}s`) => `${value} ${value === 1 ? singular : plural}`;
|
|
10
|
+
const joinCounts = (parts) => parts.length < 2
|
|
11
|
+
? parts[0] ?? ''
|
|
12
|
+
: `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}`;
|
|
13
|
+
export const ownerNotices = {
|
|
14
|
+
receivedStarted: () => 'โน๏ธ Message received. The agent has started working on this request now. '
|
|
15
|
+
+ 'The response will arrive in this channel when ready.',
|
|
16
|
+
receivedQueued: (queuedBehind) => `โน๏ธ Message received. The agent is finishing ${queuedBehind} earlier request(s) first; `
|
|
17
|
+
+ 'this request will start as soon as they complete. '
|
|
18
|
+
+ 'The response will arrive in this channel when ready.',
|
|
19
|
+
receivedInterrupting: () => "โน๏ธ Message received. The agent's previous task was interrupted to prioritize "
|
|
20
|
+
+ 'this request, and it is now working on a response. '
|
|
21
|
+
+ 'The response will arrive in this channel when ready.',
|
|
22
|
+
status: (role, snapshot) => `๐ ${role} status: ${snapshot.readiness}; session is ${snapshot.alive ? 'online' : 'offline'}.`,
|
|
23
|
+
interrupted: (role) => `๐ Interrupt sent to ${role}'s active turn.`,
|
|
24
|
+
interruptFailed: (role) => `โ ๏ธ Could not interrupt ${role}'s active turn.`,
|
|
25
|
+
attachmentRejected: (reason) => `โ ๏ธ Attachment rejected: ${reason}.`,
|
|
26
|
+
attachmentFailed: () => 'โ ๏ธ Could not securely retrieve or admit this attachment request.',
|
|
27
|
+
deliveryFailed: (role) => `โ ๏ธ Could not deliver this request to ${role}.`,
|
|
28
|
+
progress: (elapsedMs, phase, started, completed, activityUpdates = 0) => {
|
|
29
|
+
const counts = [];
|
|
30
|
+
if (started)
|
|
31
|
+
counts.push(`${count(started, 'tool action')} started`);
|
|
32
|
+
if (completed)
|
|
33
|
+
counts.push(`${count(completed, 'tool action')} completed`);
|
|
34
|
+
if (activityUpdates)
|
|
35
|
+
counts.push(`${count(activityUpdates, counts.length ? 'additional activity update' : 'activity update')} observed`);
|
|
36
|
+
const activity = counts.length
|
|
37
|
+
? `${joinCounts(counts)} since the last update.`
|
|
38
|
+
: 'no new reportable activity since the last update.';
|
|
39
|
+
return `โณ Working for ${duration(elapsedMs)} ยท ${phase} ยท ${activity}`;
|
|
40
|
+
},
|
|
41
|
+
authoredUpdate: (phase, message) => {
|
|
42
|
+
switch (phase) {
|
|
43
|
+
case 'working': return `๐ Update: ${message}`;
|
|
44
|
+
case 'approval': return `๐ Approval needed: ${message}`;
|
|
45
|
+
case 'blocked': return `๐ง Blocked: ${message}`;
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
taskReport: (phase, message) => {
|
|
49
|
+
switch (phase) {
|
|
50
|
+
case 'progress': return `๐ Follow-up: ${message}`;
|
|
51
|
+
case 'done': return `โ
Follow-up complete: ${message}`;
|
|
52
|
+
case 'blocked': return `๐ง Follow-up blocked: ${message}`;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
completedWithoutText: () => 'โ
Request completed, but the agent returned no text.',
|
|
56
|
+
terminal: (outcome) => {
|
|
57
|
+
switch (outcome) {
|
|
58
|
+
case 'completed': return 'โ
Request completed.';
|
|
59
|
+
case 'cancelled': return '๐ Request was cancelled before completion.';
|
|
60
|
+
case 'refused': return 'โ ๏ธ The agent declined this request.';
|
|
61
|
+
case 'failed': return 'โ ๏ธ Request failed before completion.';
|
|
62
|
+
case 'inconclusive': return 'โ ๏ธ Request ended without a confirmed completion.';
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
chunk: (part, total) => `โน๏ธ Response part ${part} of ${total}:\n`,
|
|
66
|
+
};
|
|
@@ -8,3 +8,37 @@ export declare class OwnerChannelState {
|
|
|
8
8
|
has(wireId: string): boolean;
|
|
9
9
|
remember(wireId: string): void;
|
|
10
10
|
}
|
|
11
|
+
export type OwnerSource = 'baseline' | 'dynamic';
|
|
12
|
+
export interface OwnerEntry {
|
|
13
|
+
cid: string;
|
|
14
|
+
source: OwnerSource;
|
|
15
|
+
effective: boolean;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Configured owners remain the declared baseline. This durable overlay adds
|
|
19
|
+
* owners or revokes either source without rewriting fleet.yaml. A corrupt
|
|
20
|
+
* overlay authorizes nobody and refuses mutation until the operator repairs or
|
|
21
|
+
* removes the bad file; silently falling back could resurrect a revoked owner.
|
|
22
|
+
*/
|
|
23
|
+
export declare class OwnerAuthorizationState {
|
|
24
|
+
private readonly path;
|
|
25
|
+
private readonly baseline;
|
|
26
|
+
private added;
|
|
27
|
+
private revoked;
|
|
28
|
+
private audit;
|
|
29
|
+
private corruptReason?;
|
|
30
|
+
constructor(path: string, baseline: string[]);
|
|
31
|
+
integrity(): {
|
|
32
|
+
ok: boolean;
|
|
33
|
+
error?: string;
|
|
34
|
+
};
|
|
35
|
+
effective(): Set<string>;
|
|
36
|
+
entries(): OwnerEntry[];
|
|
37
|
+
authorize(cid: string): OwnerEntry;
|
|
38
|
+
revoke(cid: string): OwnerEntry;
|
|
39
|
+
private assertHealthy;
|
|
40
|
+
private record;
|
|
41
|
+
private snapshot;
|
|
42
|
+
private restore;
|
|
43
|
+
private persist;
|
|
44
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
3
4
|
/** Durable bounded dedupe containing wire IDs only โ never message or reply plaintext. */
|
|
4
5
|
export class OwnerChannelState {
|
|
5
6
|
path;
|
|
@@ -35,3 +36,149 @@ export class OwnerChannelState {
|
|
|
35
36
|
renameSync(tmp, this.path);
|
|
36
37
|
}
|
|
37
38
|
}
|
|
39
|
+
const MAX_OVERLAY_CIDS = 1_000;
|
|
40
|
+
const MAX_AUDIT_ENTRIES = 500;
|
|
41
|
+
/**
|
|
42
|
+
* Configured owners remain the declared baseline. This durable overlay adds
|
|
43
|
+
* owners or revokes either source without rewriting fleet.yaml. A corrupt
|
|
44
|
+
* overlay authorizes nobody and refuses mutation until the operator repairs or
|
|
45
|
+
* removes the bad file; silently falling back could resurrect a revoked owner.
|
|
46
|
+
*/
|
|
47
|
+
export class OwnerAuthorizationState {
|
|
48
|
+
path;
|
|
49
|
+
baseline;
|
|
50
|
+
added = new Set();
|
|
51
|
+
revoked = new Set();
|
|
52
|
+
audit = [];
|
|
53
|
+
corruptReason;
|
|
54
|
+
constructor(path, baseline) {
|
|
55
|
+
this.path = path;
|
|
56
|
+
this.baseline = new Set(baseline);
|
|
57
|
+
if (!existsSync(path))
|
|
58
|
+
return;
|
|
59
|
+
try {
|
|
60
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
61
|
+
if (raw.version !== 1 || !Array.isArray(raw.added) || !Array.isArray(raw.revoked)
|
|
62
|
+
|| !Array.isArray(raw.audit))
|
|
63
|
+
throw new Error('unsupported or incomplete state');
|
|
64
|
+
const validCid = (cid) => typeof cid === 'string' && /^[A-Fa-f0-9]{64}$/.test(cid);
|
|
65
|
+
if (!raw.added.every(validCid) || !raw.revoked.every(validCid)
|
|
66
|
+
|| raw.added.length + raw.revoked.length > MAX_OVERLAY_CIDS
|
|
67
|
+
|| raw.audit.length > MAX_AUDIT_ENTRIES
|
|
68
|
+
|| raw.audit.some(entry => !entry || typeof entry !== 'object'
|
|
69
|
+
|| !validCid(entry.cid)
|
|
70
|
+
|| !['authorize', 'revoke'].includes(entry.action)
|
|
71
|
+
|| typeof entry.at !== 'string'))
|
|
72
|
+
throw new Error('invalid or unbounded state');
|
|
73
|
+
this.added = new Set(raw.added);
|
|
74
|
+
this.revoked = new Set(raw.revoked);
|
|
75
|
+
this.audit = raw.audit;
|
|
76
|
+
if (this.added.size !== raw.added.length || this.revoked.size !== raw.revoked.length)
|
|
77
|
+
throw new Error('duplicate overlay CID');
|
|
78
|
+
if ([...this.added].some(cid => this.revoked.has(cid)))
|
|
79
|
+
throw new Error('CID appears in both added and revoked overlays');
|
|
80
|
+
chmodSync(path, 0o600);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
this.corruptReason = 'invalid persisted authorization overlay';
|
|
84
|
+
this.added.clear();
|
|
85
|
+
this.revoked.clear();
|
|
86
|
+
this.audit = [];
|
|
87
|
+
try {
|
|
88
|
+
chmodSync(path, 0o600);
|
|
89
|
+
}
|
|
90
|
+
catch { /* still fail closed */ }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
integrity() {
|
|
94
|
+
return this.corruptReason ? { ok: false, error: this.corruptReason } : { ok: true };
|
|
95
|
+
}
|
|
96
|
+
effective() {
|
|
97
|
+
if (this.corruptReason)
|
|
98
|
+
return new Set();
|
|
99
|
+
const effective = new Set([...this.baseline, ...this.added]);
|
|
100
|
+
for (const cid of this.revoked)
|
|
101
|
+
effective.delete(cid);
|
|
102
|
+
return effective;
|
|
103
|
+
}
|
|
104
|
+
entries() {
|
|
105
|
+
const effective = this.effective();
|
|
106
|
+
return [...new Set([...this.baseline, ...this.added, ...this.revoked])]
|
|
107
|
+
.sort().map(cid => ({
|
|
108
|
+
cid,
|
|
109
|
+
source: this.baseline.has(cid) ? 'baseline' : 'dynamic',
|
|
110
|
+
effective: effective.has(cid),
|
|
111
|
+
}));
|
|
112
|
+
}
|
|
113
|
+
authorize(cid) {
|
|
114
|
+
this.assertHealthy();
|
|
115
|
+
if (this.effective().has(cid))
|
|
116
|
+
throw new Error(`owner '${cid}' is already authorized`);
|
|
117
|
+
const rollback = this.snapshot();
|
|
118
|
+
if (this.baseline.has(cid))
|
|
119
|
+
this.revoked.delete(cid);
|
|
120
|
+
else {
|
|
121
|
+
if (this.added.size + this.revoked.size >= MAX_OVERLAY_CIDS)
|
|
122
|
+
throw new Error(`owner authorization overlay is limited to ${MAX_OVERLAY_CIDS} CIDs`);
|
|
123
|
+
this.added.add(cid);
|
|
124
|
+
this.revoked.delete(cid);
|
|
125
|
+
}
|
|
126
|
+
this.record('authorize', cid);
|
|
127
|
+
try {
|
|
128
|
+
this.persist();
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
this.restore(rollback);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
return { cid, source: this.baseline.has(cid) ? 'baseline' : 'dynamic', effective: true };
|
|
135
|
+
}
|
|
136
|
+
revoke(cid) {
|
|
137
|
+
this.assertHealthy();
|
|
138
|
+
const effective = this.effective();
|
|
139
|
+
if (!effective.has(cid))
|
|
140
|
+
throw new Error(`owner '${cid}' is not authorized`);
|
|
141
|
+
if (effective.size === 1)
|
|
142
|
+
throw new Error('refusing to revoke the last effective owner');
|
|
143
|
+
const rollback = this.snapshot();
|
|
144
|
+
if (this.baseline.has(cid))
|
|
145
|
+
this.revoked.add(cid);
|
|
146
|
+
else
|
|
147
|
+
this.added.delete(cid);
|
|
148
|
+
this.record('revoke', cid);
|
|
149
|
+
try {
|
|
150
|
+
this.persist();
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
this.restore(rollback);
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
156
|
+
return { cid, source: this.baseline.has(cid) ? 'baseline' : 'dynamic', effective: false };
|
|
157
|
+
}
|
|
158
|
+
assertHealthy() {
|
|
159
|
+
if (this.corruptReason)
|
|
160
|
+
throw new Error(`owner authorization state is corrupt; refusing mutation: ${this.corruptReason}`);
|
|
161
|
+
}
|
|
162
|
+
record(action, cid) {
|
|
163
|
+
this.audit.push({ at: new Date().toISOString(), action, cid });
|
|
164
|
+
this.audit = this.audit.slice(-MAX_AUDIT_ENTRIES);
|
|
165
|
+
}
|
|
166
|
+
snapshot() {
|
|
167
|
+
return { added: new Set(this.added), revoked: new Set(this.revoked), audit: [...this.audit] };
|
|
168
|
+
}
|
|
169
|
+
restore(snapshot) {
|
|
170
|
+
this.added = snapshot.added;
|
|
171
|
+
this.revoked = snapshot.revoked;
|
|
172
|
+
this.audit = snapshot.audit;
|
|
173
|
+
}
|
|
174
|
+
persist() {
|
|
175
|
+
const state = {
|
|
176
|
+
version: 1,
|
|
177
|
+
added: [...this.added].sort(),
|
|
178
|
+
revoked: [...this.revoked].sort(),
|
|
179
|
+
audit: this.audit,
|
|
180
|
+
};
|
|
181
|
+
replaceFileAtomically(this.path, JSON.stringify(state) + '\n', 0o600);
|
|
182
|
+
chmodSync(this.path, 0o600);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export type OwnerTaskPhase = 'progress' | 'done' | 'blocked';
|
|
2
|
+
export type OwnerTaskTerminalState = 'closed' | 'expired' | 'revoked';
|
|
3
|
+
export interface OwnerTaskRoute {
|
|
4
|
+
requestId: string;
|
|
5
|
+
contact: string;
|
|
6
|
+
wireId: string;
|
|
7
|
+
}
|
|
8
|
+
interface PendingReport {
|
|
9
|
+
digest: string;
|
|
10
|
+
phase: OwnerTaskPhase;
|
|
11
|
+
chars: number;
|
|
12
|
+
bytes: number;
|
|
13
|
+
}
|
|
14
|
+
export interface OwnerTaskRecord extends OwnerTaskRoute {
|
|
15
|
+
id: string;
|
|
16
|
+
createdAt: number;
|
|
17
|
+
expiresAt: number;
|
|
18
|
+
status: 'open' | 'sending' | 'uncertain';
|
|
19
|
+
sequence: number;
|
|
20
|
+
reportCount: number;
|
|
21
|
+
lastReportAt?: number;
|
|
22
|
+
digests: string[];
|
|
23
|
+
pending?: PendingReport;
|
|
24
|
+
}
|
|
25
|
+
export declare const OWNER_TASK_TTL_MS: number;
|
|
26
|
+
export declare const OWNER_TASK_MAX_OPEN = 32;
|
|
27
|
+
export declare const OWNER_TASK_MAX_PER_OWNER = 8;
|
|
28
|
+
export declare const OWNER_TASK_MAX_REPORTS = 20;
|
|
29
|
+
export declare const OWNER_TASK_REPORT_MIN_INTERVAL_MS = 5000;
|
|
30
|
+
/**
|
|
31
|
+
* Durable proactive-task routing. It deliberately stores no report body: only
|
|
32
|
+
* the authenticated route, bounded hashes/counters, and delivery state.
|
|
33
|
+
*/
|
|
34
|
+
export declare class OwnerTaskState {
|
|
35
|
+
private readonly path;
|
|
36
|
+
private tasks;
|
|
37
|
+
private tombstones;
|
|
38
|
+
private corruptReason?;
|
|
39
|
+
constructor(path: string);
|
|
40
|
+
integrity(): {
|
|
41
|
+
ok: boolean;
|
|
42
|
+
error?: string;
|
|
43
|
+
};
|
|
44
|
+
open(route: OwnerTaskRoute, now?: number): OwnerTaskRecord;
|
|
45
|
+
route(taskId: string, now?: number): OwnerTaskRecord;
|
|
46
|
+
beginReport(taskId: string, phase: OwnerTaskPhase, digest: string, chars: number, bytes: number, now?: number): OwnerTaskRecord;
|
|
47
|
+
delivered(taskId: string, digest: string, terminal: boolean, now?: number): number;
|
|
48
|
+
uncertain(taskId: string, digest: string): void;
|
|
49
|
+
revoke(contact: string, now?: number): number;
|
|
50
|
+
cleanup(now?: number, effectiveOwners?: Set<string>): number;
|
|
51
|
+
private findOpen;
|
|
52
|
+
private remove;
|
|
53
|
+
private mutate;
|
|
54
|
+
private persist;
|
|
55
|
+
private assertHealthy;
|
|
56
|
+
private validTask;
|
|
57
|
+
private validPending;
|
|
58
|
+
private validTombstone;
|
|
59
|
+
}
|
|
60
|
+
export declare const ownerTaskDigest: (phase: OwnerTaskPhase, message: string) => string;
|
|
61
|
+
export declare const ownerTaskAuditId: (value: string) => string;
|
|
62
|
+
export {};
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
4
|
+
export const OWNER_TASK_TTL_MS = 7 * 24 * 60 * 60 * 1_000;
|
|
5
|
+
export const OWNER_TASK_MAX_OPEN = 32;
|
|
6
|
+
export const OWNER_TASK_MAX_PER_OWNER = 8;
|
|
7
|
+
export const OWNER_TASK_MAX_REPORTS = 20;
|
|
8
|
+
export const OWNER_TASK_REPORT_MIN_INTERVAL_MS = 5_000;
|
|
9
|
+
const MAX_TOMBSTONES = 256;
|
|
10
|
+
const TOMBSTONE_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
11
|
+
const HEX_64 = /^[a-f0-9]{64}$/;
|
|
12
|
+
const CID = /^[A-Fa-f0-9]{64}$/;
|
|
13
|
+
/**
|
|
14
|
+
* Durable proactive-task routing. It deliberately stores no report body: only
|
|
15
|
+
* the authenticated route, bounded hashes/counters, and delivery state.
|
|
16
|
+
*/
|
|
17
|
+
export class OwnerTaskState {
|
|
18
|
+
path;
|
|
19
|
+
tasks = [];
|
|
20
|
+
tombstones = [];
|
|
21
|
+
corruptReason;
|
|
22
|
+
constructor(path) {
|
|
23
|
+
this.path = path;
|
|
24
|
+
if (!existsSync(path))
|
|
25
|
+
return;
|
|
26
|
+
try {
|
|
27
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
28
|
+
if (raw.version !== 1 || !Array.isArray(raw.tasks) || !Array.isArray(raw.tombstones)
|
|
29
|
+
|| raw.tasks.length > OWNER_TASK_MAX_OPEN || raw.tombstones.length > MAX_TOMBSTONES
|
|
30
|
+
|| !raw.tasks.every(task => this.validTask(task))
|
|
31
|
+
|| !raw.tombstones.every(tombstone => this.validTombstone(tombstone)))
|
|
32
|
+
throw new Error('invalid or unbounded task state');
|
|
33
|
+
const ids = [...raw.tasks.map(task => task.id), ...raw.tombstones.map(item => item.id)];
|
|
34
|
+
if (new Set(ids).size !== ids.length)
|
|
35
|
+
throw new Error('duplicate task ID');
|
|
36
|
+
this.tasks = raw.tasks.map(task => ({ ...task, digests: [...task.digests] }));
|
|
37
|
+
this.tombstones = raw.tombstones.map(item => ({ ...item }));
|
|
38
|
+
// A crash or lost response after the durable pre-send marker makes the
|
|
39
|
+
// result unknowable. Never resend it automatically and risk duplication.
|
|
40
|
+
let recovered = false;
|
|
41
|
+
for (const task of this.tasks) {
|
|
42
|
+
if (task.status === 'sending') {
|
|
43
|
+
task.status = 'uncertain';
|
|
44
|
+
recovered = true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
chmodSync(path, 0o600);
|
|
48
|
+
if (recovered)
|
|
49
|
+
this.persist();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
this.tasks = [];
|
|
53
|
+
this.tombstones = [];
|
|
54
|
+
this.corruptReason = 'invalid persisted owner task state';
|
|
55
|
+
try {
|
|
56
|
+
chmodSync(path, 0o600);
|
|
57
|
+
}
|
|
58
|
+
catch { /* remain fail-closed */ }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
integrity() {
|
|
62
|
+
return this.corruptReason ? { ok: false, error: this.corruptReason } : { ok: true };
|
|
63
|
+
}
|
|
64
|
+
open(route, now = Date.now()) {
|
|
65
|
+
this.assertHealthy();
|
|
66
|
+
if (!HEX_64.test(route.requestId) || !CID.test(route.contact)
|
|
67
|
+
|| typeof route.wireId !== 'string' || route.wireId.length < 1 || route.wireId.length > 1_024)
|
|
68
|
+
throw new Error('owner task route is invalid or exceeds its bounds');
|
|
69
|
+
this.cleanup(now);
|
|
70
|
+
if (this.tasks.length >= OWNER_TASK_MAX_OPEN)
|
|
71
|
+
throw new Error(`owner channel is limited to ${OWNER_TASK_MAX_OPEN} open tasks`);
|
|
72
|
+
if (this.tasks.filter(task => task.contact === route.contact).length >= OWNER_TASK_MAX_PER_OWNER)
|
|
73
|
+
throw new Error(`an owner is limited to ${OWNER_TASK_MAX_PER_OWNER} open tasks per role`);
|
|
74
|
+
let id;
|
|
75
|
+
do {
|
|
76
|
+
id = randomBytes(32).toString('hex');
|
|
77
|
+
} while (this.tasks.some(task => task.id === id) || this.tombstones.some(item => item.id === id));
|
|
78
|
+
const task = {
|
|
79
|
+
id, ...route, createdAt: now, expiresAt: now + OWNER_TASK_TTL_MS,
|
|
80
|
+
status: 'open', sequence: 0, reportCount: 0, digests: [],
|
|
81
|
+
};
|
|
82
|
+
this.mutate(() => { this.tasks.push(task); });
|
|
83
|
+
return { ...task, digests: [] };
|
|
84
|
+
}
|
|
85
|
+
route(taskId, now = Date.now()) {
|
|
86
|
+
this.assertHealthy();
|
|
87
|
+
this.cleanup(now);
|
|
88
|
+
const task = this.findOpen(taskId);
|
|
89
|
+
return {
|
|
90
|
+
...task, digests: [...task.digests],
|
|
91
|
+
...(task.pending ? { pending: { ...task.pending } } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
beginReport(taskId, phase, digest, chars, bytes, now = Date.now()) {
|
|
95
|
+
this.assertHealthy();
|
|
96
|
+
this.cleanup(now);
|
|
97
|
+
const task = this.findOpen(taskId);
|
|
98
|
+
if (task.status === 'uncertain')
|
|
99
|
+
throw new Error('task report delivery outcome is uncertain; refusing to resend or reorder reports');
|
|
100
|
+
if (task.status !== 'open')
|
|
101
|
+
throw new Error('task report is already being delivered');
|
|
102
|
+
if (task.digests.includes(digest))
|
|
103
|
+
throw new Error('duplicate task report refused');
|
|
104
|
+
if (task.reportCount >= OWNER_TASK_MAX_REPORTS)
|
|
105
|
+
throw new Error(`owner task is limited to ${OWNER_TASK_MAX_REPORTS} reports`);
|
|
106
|
+
if (task.lastReportAt !== undefined && now - task.lastReportAt < OWNER_TASK_REPORT_MIN_INTERVAL_MS)
|
|
107
|
+
throw new Error(`owner task reports are rate-limited to one every ${OWNER_TASK_REPORT_MIN_INTERVAL_MS}ms`);
|
|
108
|
+
this.mutate(() => {
|
|
109
|
+
task.status = 'sending';
|
|
110
|
+
task.pending = { digest, phase, chars, bytes };
|
|
111
|
+
});
|
|
112
|
+
return { ...task, digests: [...task.digests], pending: { ...task.pending } };
|
|
113
|
+
}
|
|
114
|
+
delivered(taskId, digest, terminal, now = Date.now()) {
|
|
115
|
+
this.assertHealthy();
|
|
116
|
+
const task = this.tasks.find(item => item.id === taskId);
|
|
117
|
+
if (!task || task.status !== 'sending' || task.pending?.digest !== digest)
|
|
118
|
+
throw new Error('task report delivery state changed unexpectedly');
|
|
119
|
+
const sequence = task.sequence + 1;
|
|
120
|
+
this.mutate(() => {
|
|
121
|
+
task.sequence = sequence;
|
|
122
|
+
task.reportCount++;
|
|
123
|
+
task.lastReportAt = now;
|
|
124
|
+
task.digests.push(digest);
|
|
125
|
+
task.pending = undefined;
|
|
126
|
+
if (terminal)
|
|
127
|
+
this.remove(task, 'closed', now);
|
|
128
|
+
else
|
|
129
|
+
task.status = 'open';
|
|
130
|
+
});
|
|
131
|
+
return sequence;
|
|
132
|
+
}
|
|
133
|
+
uncertain(taskId, digest) {
|
|
134
|
+
this.assertHealthy();
|
|
135
|
+
const task = this.tasks.find(item => item.id === taskId);
|
|
136
|
+
if (!task || task.pending?.digest !== digest)
|
|
137
|
+
return;
|
|
138
|
+
this.mutate(() => { task.status = 'uncertain'; });
|
|
139
|
+
}
|
|
140
|
+
revoke(contact, now = Date.now()) {
|
|
141
|
+
this.assertHealthy();
|
|
142
|
+
const revoked = this.tasks.filter(task => task.contact === contact);
|
|
143
|
+
if (!revoked.length)
|
|
144
|
+
return 0;
|
|
145
|
+
this.mutate(() => { for (const task of revoked)
|
|
146
|
+
this.remove(task, 'revoked', now); });
|
|
147
|
+
return revoked.length;
|
|
148
|
+
}
|
|
149
|
+
cleanup(now = Date.now(), effectiveOwners) {
|
|
150
|
+
this.assertHealthy();
|
|
151
|
+
const expired = this.tasks.filter(task => task.expiresAt <= now);
|
|
152
|
+
const revoked = effectiveOwners
|
|
153
|
+
? this.tasks.filter(task => !effectiveOwners.has(task.contact) && !expired.includes(task)) : [];
|
|
154
|
+
const oldTombstones = this.tombstones.filter(item => now - item.at > TOMBSTONE_TTL_MS);
|
|
155
|
+
if (!expired.length && !revoked.length && !oldTombstones.length)
|
|
156
|
+
return 0;
|
|
157
|
+
this.mutate(() => {
|
|
158
|
+
for (const task of expired)
|
|
159
|
+
this.remove(task, 'expired', now);
|
|
160
|
+
for (const task of revoked)
|
|
161
|
+
this.remove(task, 'revoked', now);
|
|
162
|
+
this.tombstones = this.tombstones
|
|
163
|
+
.filter(item => now - item.at <= TOMBSTONE_TTL_MS).slice(-MAX_TOMBSTONES);
|
|
164
|
+
});
|
|
165
|
+
return expired.length + revoked.length;
|
|
166
|
+
}
|
|
167
|
+
findOpen(taskId) {
|
|
168
|
+
if (!HEX_64.test(taskId))
|
|
169
|
+
throw new Error('owner task ID must be exactly 64 lowercase hexadecimal characters');
|
|
170
|
+
const task = this.tasks.find(item => item.id === taskId);
|
|
171
|
+
if (task)
|
|
172
|
+
return task;
|
|
173
|
+
const tombstone = this.tombstones.find(item => item.id === taskId);
|
|
174
|
+
if (tombstone)
|
|
175
|
+
throw new Error(`owner task is ${tombstone.state}`);
|
|
176
|
+
throw new Error('unknown owner task ID');
|
|
177
|
+
}
|
|
178
|
+
remove(task, state, now) {
|
|
179
|
+
this.tasks = this.tasks.filter(item => item !== task);
|
|
180
|
+
this.tombstones.push({ id: task.id, state, at: now });
|
|
181
|
+
this.tombstones = this.tombstones.slice(-MAX_TOMBSTONES);
|
|
182
|
+
}
|
|
183
|
+
mutate(change) {
|
|
184
|
+
const snapshot = JSON.stringify({ tasks: this.tasks, tombstones: this.tombstones });
|
|
185
|
+
change();
|
|
186
|
+
try {
|
|
187
|
+
this.persist();
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const old = JSON.parse(snapshot);
|
|
191
|
+
this.tasks = old.tasks;
|
|
192
|
+
this.tombstones = old.tombstones;
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
persist() {
|
|
197
|
+
replaceFileAtomically(this.path, JSON.stringify({
|
|
198
|
+
version: 1, tasks: this.tasks, tombstones: this.tombstones,
|
|
199
|
+
}) + '\n', 0o600);
|
|
200
|
+
chmodSync(this.path, 0o600);
|
|
201
|
+
}
|
|
202
|
+
assertHealthy() {
|
|
203
|
+
if (this.corruptReason)
|
|
204
|
+
throw new Error(`owner task state is corrupt; refusing operation: ${this.corruptReason}`);
|
|
205
|
+
}
|
|
206
|
+
validTask(value) {
|
|
207
|
+
if (!value || typeof value !== 'object')
|
|
208
|
+
return false;
|
|
209
|
+
const task = value;
|
|
210
|
+
return HEX_64.test(task.id) && HEX_64.test(task.requestId) && CID.test(task.contact)
|
|
211
|
+
&& typeof task.wireId === 'string' && task.wireId.length > 0 && task.wireId.length <= 1_024
|
|
212
|
+
&& Number.isSafeInteger(task.createdAt) && task.createdAt >= 0
|
|
213
|
+
&& Number.isSafeInteger(task.expiresAt) && task.expiresAt - task.createdAt === OWNER_TASK_TTL_MS
|
|
214
|
+
&& ['open', 'sending', 'uncertain'].includes(task.status)
|
|
215
|
+
&& Number.isSafeInteger(task.sequence) && task.sequence >= 0
|
|
216
|
+
&& Number.isSafeInteger(task.reportCount) && task.reportCount >= 0
|
|
217
|
+
&& task.reportCount <= OWNER_TASK_MAX_REPORTS
|
|
218
|
+
&& Array.isArray(task.digests) && task.digests.length <= OWNER_TASK_MAX_REPORTS
|
|
219
|
+
&& task.digests.every(digest => HEX_64.test(digest))
|
|
220
|
+
&& new Set(task.digests).size === task.digests.length
|
|
221
|
+
&& task.sequence === task.reportCount && task.reportCount === task.digests.length
|
|
222
|
+
&& (task.reportCount === 0
|
|
223
|
+
? task.lastReportAt === undefined
|
|
224
|
+
: Number.isSafeInteger(task.lastReportAt) && task.lastReportAt >= task.createdAt)
|
|
225
|
+
&& (task.pending === undefined || this.validPending(task.pending))
|
|
226
|
+
&& (task.pending === undefined || !task.digests.includes(task.pending.digest))
|
|
227
|
+
&& (task.status === 'open' ? task.pending === undefined : task.pending !== undefined);
|
|
228
|
+
}
|
|
229
|
+
validPending(value) {
|
|
230
|
+
if (!value || typeof value !== 'object')
|
|
231
|
+
return false;
|
|
232
|
+
const pending = value;
|
|
233
|
+
return HEX_64.test(pending.digest) && ['progress', 'done', 'blocked'].includes(pending.phase)
|
|
234
|
+
&& Number.isSafeInteger(pending.chars) && pending.chars >= 1 && pending.chars <= 280
|
|
235
|
+
&& Number.isSafeInteger(pending.bytes) && pending.bytes >= 1 && pending.bytes <= 1_024;
|
|
236
|
+
}
|
|
237
|
+
validTombstone(value) {
|
|
238
|
+
if (!value || typeof value !== 'object')
|
|
239
|
+
return false;
|
|
240
|
+
const item = value;
|
|
241
|
+
return HEX_64.test(item.id) && ['closed', 'expired', 'revoked'].includes(item.state)
|
|
242
|
+
&& Number.isSafeInteger(item.at);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
export const ownerTaskDigest = (phase, message) => createHash('sha256').update(`${phase}\0${message}`).digest('hex');
|
|
246
|
+
export const ownerTaskAuditId = (value) => createHash('sha256').update(value).digest('hex').slice(0, 12);
|
package/dist/resolved-plan.js
CHANGED
|
@@ -20,6 +20,12 @@ export function resolvedPlan(cfg) {
|
|
|
20
20
|
startStaggerMs: cfg.startStaggerMs,
|
|
21
21
|
diagnostics: cfg.diagnostics.map(diagnostic => ({ ...diagnostic })),
|
|
22
22
|
roles: cfg.roles.map(resolvedRolePlan),
|
|
23
|
+
loops: cfg.loops.map(loop => sortedObject({
|
|
24
|
+
name: loop.name, selectors: [...loop.selectors], roles: [...loop.roleNames],
|
|
25
|
+
intervalMs: loop.intervalMs, initialDelayMs: loop.initialDelayMs, jitterMs: loop.jitterMs,
|
|
26
|
+
enabled: loop.enabled, sourceFile: loop.sourceFile,
|
|
27
|
+
prompt: { bytes: loop.promptBytes, sha256: loop.promptHash },
|
|
28
|
+
})),
|
|
23
29
|
watchdogs: cfg.watchdogs.map(w => sortedObject({
|
|
24
30
|
name: w.name, sourceFile: w.sourceFile, enabled: w.enabled,
|
|
25
31
|
intervalMs: w.intervalMs, coordinator: w.coordinator, watch: [...w.watch],
|
|
@@ -61,6 +67,11 @@ export function resolvedRolePlan(role) {
|
|
|
61
67
|
},
|
|
62
68
|
monitor: role.monitor,
|
|
63
69
|
ownerChannel: role.owner_channel ?? null,
|
|
70
|
+
loops: (role.loops ?? []).map(loop => ({
|
|
71
|
+
name: loop.name, enabled: loop.enabled, intervalMs: loop.intervalMs,
|
|
72
|
+
initialDelayMs: loop.initialDelayMs, jitterMs: loop.jitterMs,
|
|
73
|
+
definitionHash: loop.definitionHash, prompt: { bytes: loop.promptBytes, sha256: loop.promptHash },
|
|
74
|
+
})),
|
|
64
75
|
isolation: role.isolation ?? null,
|
|
65
76
|
worklog: role.worklog ?? null,
|
|
66
77
|
authProxy: role.auth_proxy ?? null,
|