@ours.network/fleet 0.18.0-nightly.6 → 0.18.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 +111 -43
- package/dist/application/fleet-query-service.js +12 -0
- package/dist/application/role-creation-service.js +3 -1
- package/dist/application/types.d.ts +11 -0
- package/dist/briefing.js +9 -2
- package/dist/build-info.json +7 -6
- package/dist/capabilities.d.ts +3 -1
- package/dist/capabilities.js +3 -0
- package/dist/cli.js +83 -7
- package/dist/config.d.ts +11 -3
- package/dist/config.js +40 -15
- package/dist/creation.d.ts +14 -15
- package/dist/creation.js +19 -13
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +117 -35
- package/dist/doctor.d.ts +1 -5
- package/dist/doctor.js +11 -18
- package/dist/fleet-proxy.d.ts +5 -0
- package/dist/harness/acp-agent.js +11 -6
- package/dist/harness/claude-code.js +204 -11
- package/dist/harness/codex.d.ts +4 -1
- package/dist/harness/codex.js +74 -12
- package/dist/harness/types.d.ts +54 -4
- package/dist/harness-plugins.d.ts +48 -0
- package/dist/harness-plugins.js +309 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/loops/manager.d.ts +30 -1
- package/dist/loops/manager.js +69 -6
- package/dist/loops/state.d.ts +18 -0
- package/dist/loops/state.js +4 -0
- package/dist/model-env.d.ts +71 -0
- package/dist/model-env.js +106 -0
- package/dist/monitor.js +1 -1
- package/dist/ops.js +1 -1
- package/dist/owner-channel/attachments.d.ts +2 -25
- package/dist/owner-channel/attachments.js +5 -61
- package/dist/owner-channel/channel.d.ts +30 -29
- package/dist/owner-channel/channel.js +291 -291
- package/dist/owner-channel/mcp.d.ts +24 -0
- package/dist/owner-channel/mcp.js +145 -0
- package/dist/owner-channel/notices.d.ts +7 -0
- package/dist/owner-channel/notices.js +9 -0
- package/dist/resolved-plan.js +1 -0
- package/dist/runner.d.ts +48 -0
- package/dist/runner.js +237 -85
- package/dist/session/acp.d.ts +104 -0
- package/dist/session/acp.js +213 -10
- package/dist/session/activity.d.ts +31 -0
- package/dist/session/activity.js +48 -0
- package/dist/session/conversation-normalizer.d.ts +6 -0
- package/dist/session/conversation-normalizer.js +153 -10
- package/dist/session/conversation-types.d.ts +23 -4
- package/dist/session/types.d.ts +35 -0
- package/dist/spawn.js +29 -17
- package/dist/supervisor/systemd.js +2 -29
- package/dist/watchdog/briefing.js +7 -0
- package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
- package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
- package/dist/web-app/index.html +1 -1
- package/dist/worklog.d.ts +7 -1
- package/dist/worklog.js +191 -39
- package/package.json +1 -3
- package/dist/owner-channel/message-recovery.d.ts +0 -25
- package/dist/owner-channel/message-recovery.js +0 -114
- package/dist/owner-channel/ours-client.d.ts +0 -148
- package/dist/owner-channel/ours-client.js +0 -231
package/dist/web-app/index.html
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<link rel="manifest" href="/manifest.webmanifest" />
|
|
9
9
|
<link rel="icon" href="/icons/ours-fleet.svg" type="image/svg+xml" />
|
|
10
10
|
<title>ours fleet console</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-BCBK78hw.js"></script>
|
|
12
12
|
<link rel="stylesheet" crossorigin href="/assets/index-DuC-xnX4.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
package/dist/worklog.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { WorklogPolicy } from './config.js';
|
|
2
|
+
export declare const WORKLOG_ARCHIVE_DIR = "WORKLOG.archives";
|
|
2
3
|
export interface WorklogInspection {
|
|
3
4
|
enabled: boolean;
|
|
4
5
|
bytes: number;
|
|
@@ -12,6 +13,11 @@ export interface WorklogRotation {
|
|
|
12
13
|
archivePath?: string;
|
|
13
14
|
}
|
|
14
15
|
export declare function inspectWorklog(path: string, policy?: WorklogPolicy): WorklogInspection;
|
|
16
|
+
/**
|
|
17
|
+
* Keep a bounded set of recent archives beside WORKLOG.md without deleting
|
|
18
|
+
* history. Older archives move atomically-by-link into WORKLOG.archives/.
|
|
19
|
+
* The legacy name remains exported for source compatibility.
|
|
20
|
+
*/
|
|
15
21
|
export declare function pruneWorklogArchives(path: string, maxArchives: number): void;
|
|
16
22
|
/**
|
|
17
23
|
* Conservatively rotate a stable snapshot. A changed size/mtime/inode aborts
|
|
@@ -20,6 +26,6 @@ export declare function pruneWorklogArchives(path: string, maxArchives: number):
|
|
|
20
26
|
export declare function rotateWorklog(path: string, policy?: WorklogPolicy, deps?: {
|
|
21
27
|
now?: () => Date;
|
|
22
28
|
beforeCommit?: () => void;
|
|
23
|
-
/** Deterministic test hook
|
|
29
|
+
/** Deterministic test hook after the full archive is published. */
|
|
24
30
|
afterArchiveRename?: () => void;
|
|
25
31
|
}): WorklogRotation;
|
package/dist/worklog.js
CHANGED
|
@@ -1,10 +1,59 @@
|
|
|
1
|
-
import { existsSync, linkSync,
|
|
1
|
+
import { closeSync, constants, existsSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, unlinkSync, } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import { basename, dirname, join } from 'node:path';
|
|
3
|
-
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { replaceFileAtomically } from './atomic-file.js';
|
|
5
|
-
const ARCHIVE_RE = /^WORKLOG
|
|
5
|
+
const ARCHIVE_RE = /^WORKLOG\.(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.\d{3}Z)(?:\.(\d+))?\.md$/;
|
|
6
|
+
export const WORKLOG_ARCHIVE_DIR = 'WORKLOG.archives';
|
|
7
|
+
const entryStat = (path) => {
|
|
8
|
+
try {
|
|
9
|
+
return lstatSync(path);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error.code === 'ENOENT')
|
|
13
|
+
return undefined;
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
const requireRegularFile = (path, label) => {
|
|
18
|
+
const stat = entryStat(path);
|
|
19
|
+
if (!stat)
|
|
20
|
+
throw new Error(`${label} disappeared before it could be preserved`);
|
|
21
|
+
if (!stat.isFile()) {
|
|
22
|
+
const kind = stat.isSymbolicLink() ? 'symbolic link' : 'non-regular file';
|
|
23
|
+
throw new Error(`refusing worklog rotation: ${label} is a ${kind}`);
|
|
24
|
+
}
|
|
25
|
+
return stat;
|
|
26
|
+
};
|
|
27
|
+
const sameInode = (left, right) => left.dev === right.dev && left.ino === right.ino;
|
|
28
|
+
const sameSnapshot = (left, right) => sameInode(left, right) && left.size === right.size
|
|
29
|
+
&& left.mtimeMs === right.mtimeMs && left.ctimeMs === right.ctimeMs;
|
|
30
|
+
const sha256 = (content) => createHash('sha256').update(content).digest('hex');
|
|
31
|
+
/** Remove a newly published duplicate only while the source inode is still available. */
|
|
32
|
+
const cleanupDuplicateLink = (source, target, expected) => {
|
|
33
|
+
try {
|
|
34
|
+
const sourceStat = entryStat(source);
|
|
35
|
+
const targetStat = entryStat(target);
|
|
36
|
+
if (sourceStat && targetStat && sameInode(expected, sourceStat) && sameInode(expected, targetStat))
|
|
37
|
+
unlinkSync(target);
|
|
38
|
+
}
|
|
39
|
+
catch { /* cleanup is best-effort; preserve the primary failure */ }
|
|
40
|
+
};
|
|
41
|
+
const validateArchiveBoundary = (path) => {
|
|
42
|
+
const archiveDir = join(dirname(path), WORKLOG_ARCHIVE_DIR);
|
|
43
|
+
const stat = entryStat(archiveDir);
|
|
44
|
+
if (stat && !stat.isDirectory()) {
|
|
45
|
+
const kind = stat.isSymbolicLink() ? 'symbolic link' : 'non-directory';
|
|
46
|
+
throw new Error(`refusing worklog retention: ${WORKLOG_ARCHIVE_DIR} is a ${kind}`);
|
|
47
|
+
}
|
|
48
|
+
return stat;
|
|
49
|
+
};
|
|
6
50
|
export function inspectWorklog(path, policy) {
|
|
7
|
-
const
|
|
51
|
+
const stat = entryStat(path);
|
|
52
|
+
if (policy && stat && !stat.isFile()) {
|
|
53
|
+
const kind = stat.isSymbolicLink() ? 'symbolic link' : 'non-regular file';
|
|
54
|
+
throw new Error(`refusing worklog rotation: WORKLOG.md is a ${kind}`);
|
|
55
|
+
}
|
|
56
|
+
const bytes = stat?.isFile() ? stat.size : 0;
|
|
8
57
|
return {
|
|
9
58
|
enabled: policy !== undefined,
|
|
10
59
|
bytes,
|
|
@@ -22,11 +71,70 @@ const archiveName = (path, now, collision) => {
|
|
|
22
71
|
const stamp = now.toISOString().replace(/:/g, '-');
|
|
23
72
|
return join(dirname(path), `WORKLOG.${stamp}${collision ? `.${collision}` : ''}.md`);
|
|
24
73
|
};
|
|
74
|
+
/**
|
|
75
|
+
* Keep a bounded set of recent archives beside WORKLOG.md without deleting
|
|
76
|
+
* history. Older archives move atomically-by-link into WORKLOG.archives/.
|
|
77
|
+
* The legacy name remains exported for source compatibility.
|
|
78
|
+
*/
|
|
25
79
|
export function pruneWorklogArchives(path, maxArchives) {
|
|
26
80
|
const dir = dirname(path);
|
|
27
|
-
const archives = readdirSync(dir).filter(name => ARCHIVE_RE.test(name)).sort(
|
|
28
|
-
|
|
29
|
-
|
|
81
|
+
const archives = readdirSync(dir).filter(name => ARCHIVE_RE.test(name)).sort((a, b) => {
|
|
82
|
+
const left = ARCHIVE_RE.exec(a);
|
|
83
|
+
const right = ARCHIVE_RE.exec(b);
|
|
84
|
+
return left[1].localeCompare(right[1])
|
|
85
|
+
|| Number(left[2] ?? 0) - Number(right[2] ?? 0);
|
|
86
|
+
}).reverse();
|
|
87
|
+
const older = archives.slice(maxArchives);
|
|
88
|
+
if (!older.length)
|
|
89
|
+
return;
|
|
90
|
+
const archiveDir = join(dir, WORKLOG_ARCHIVE_DIR);
|
|
91
|
+
let boundary = validateArchiveBoundary(path);
|
|
92
|
+
if (!boundary) {
|
|
93
|
+
try {
|
|
94
|
+
mkdirSync(archiveDir, { mode: 0o700 });
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error.code !== 'EEXIST')
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
boundary = validateArchiveBoundary(path);
|
|
101
|
+
}
|
|
102
|
+
if (!boundary)
|
|
103
|
+
throw new Error(`refusing worklog retention: ${WORKLOG_ARCHIVE_DIR} is unavailable`);
|
|
104
|
+
for (const old of older) {
|
|
105
|
+
const source = join(dir, old);
|
|
106
|
+
const sourceBefore = requireRegularFile(source, `archive ${old}`);
|
|
107
|
+
let collision = 0;
|
|
108
|
+
for (;;) {
|
|
109
|
+
const name = collision === 0 ? old : old.replace(/\.md$/, `.${collision}.md`);
|
|
110
|
+
const target = join(archiveDir, name);
|
|
111
|
+
try {
|
|
112
|
+
const currentBoundary = validateArchiveBoundary(path);
|
|
113
|
+
if (!currentBoundary || !sameInode(boundary, currentBoundary))
|
|
114
|
+
throw new Error(`refusing worklog retention: ${WORKLOG_ARCHIVE_DIR} changed`);
|
|
115
|
+
// Link then unlink: a crash can leave a duplicate, never lose the only
|
|
116
|
+
// archive. EEXIST chooses a new name rather than overwriting history.
|
|
117
|
+
linkSync(source, target);
|
|
118
|
+
const published = requireRegularFile(target, `cold archive ${name}`);
|
|
119
|
+
const sourceCurrent = requireRegularFile(source, `archive ${old}`);
|
|
120
|
+
const finalBoundary = validateArchiveBoundary(path);
|
|
121
|
+
if (!sameInode(sourceBefore, published) || !sameInode(sourceBefore, sourceCurrent)
|
|
122
|
+
|| !finalBoundary || !sameInode(boundary, finalBoundary)) {
|
|
123
|
+
throw new Error(`refusing worklog retention: archive boundary changed during move`);
|
|
124
|
+
}
|
|
125
|
+
unlinkSync(source);
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error.code === 'EEXIST') {
|
|
130
|
+
collision++;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
cleanupDuplicateLink(source, target, sourceBefore);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
30
138
|
}
|
|
31
139
|
/**
|
|
32
140
|
* Conservatively rotate a stable snapshot. A changed size/mtime/inode aborts
|
|
@@ -36,57 +144,101 @@ export function rotateWorklog(path, policy, deps = {}) {
|
|
|
36
144
|
const inspection = inspectWorklog(path, policy);
|
|
37
145
|
if (!policy || !inspection.overLimit)
|
|
38
146
|
return { rotated: false, beforeBytes: inspection.bytes, afterBytes: inspection.bytes };
|
|
39
|
-
|
|
40
|
-
|
|
147
|
+
// Refuse a pre-existing symlink/non-directory even before publishing an
|
|
148
|
+
// archive or replacing the live path. Cold retention must remain inside the
|
|
149
|
+
// role's state boundary.
|
|
150
|
+
validateArchiveBoundary(path);
|
|
151
|
+
const pathBeforeOpen = requireRegularFile(path, 'WORKLOG.md');
|
|
152
|
+
let fd;
|
|
153
|
+
let before;
|
|
154
|
+
let content;
|
|
155
|
+
try {
|
|
156
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
157
|
+
before = fstatSync(fd);
|
|
158
|
+
if (!before.isFile() || !sameInode(pathBeforeOpen, before))
|
|
159
|
+
throw new Error('refusing worklog rotation: WORKLOG.md changed while opening');
|
|
160
|
+
content = readFileSync(fd);
|
|
161
|
+
const afterRead = fstatSync(fd);
|
|
162
|
+
if (!sameSnapshot(before, afterRead))
|
|
163
|
+
return {
|
|
164
|
+
rotated: false, deferred: true,
|
|
165
|
+
beforeBytes: before.size, afterBytes: afterRead.size,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
if (fd !== undefined)
|
|
170
|
+
closeSync(fd);
|
|
171
|
+
}
|
|
41
172
|
const start = safeTailStart(content, policy.keep_tail_kb * 1024);
|
|
42
173
|
const tail = content.subarray(start);
|
|
174
|
+
const tailStartsMidLine = start > 0 && content[start - 1] !== 0x0a;
|
|
43
175
|
deps.beforeCommit?.();
|
|
44
|
-
const current =
|
|
45
|
-
if (current
|
|
46
|
-
|| current.mtimeMs !== before.mtimeMs) {
|
|
176
|
+
const current = requireRegularFile(path, 'WORKLOG.md');
|
|
177
|
+
if (!sameSnapshot(current, before)) {
|
|
47
178
|
return {
|
|
48
179
|
rotated: false, deferred: true,
|
|
49
180
|
beforeBytes: before.size, afterBytes: current.size,
|
|
50
181
|
};
|
|
51
182
|
}
|
|
52
|
-
|
|
53
|
-
let archivePath =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const preparedTail = join(dirname(path), `.${basename(path)}.${randomUUID()}.rotate`);
|
|
60
|
-
replaceFileAtomically(preparedTail, tail.toString('utf8'), before.mode & 0o777);
|
|
61
|
-
try {
|
|
62
|
-
// Linearization point: the complete original inode becomes the archive.
|
|
63
|
-
// Writers that opened before this rename keep appending to that inode, so
|
|
64
|
-
// their acknowledged bytes remain in the archive even after the move.
|
|
65
|
-
renameSync(path, archivePath);
|
|
66
|
-
deps.afterArchiveRename?.();
|
|
183
|
+
const rotatedAt = deps.now?.() ?? new Date();
|
|
184
|
+
let archivePath = '';
|
|
185
|
+
// Publish the complete original inode under a collision-safe archive name.
|
|
186
|
+
// link(2) is create-without-overwrite; concurrent appenders holding the old
|
|
187
|
+
// inode continue into the archive after the live path is replaced.
|
|
188
|
+
for (let collision = 0;; collision++) {
|
|
189
|
+
archivePath = archiveName(path, rotatedAt, collision);
|
|
67
190
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
191
|
+
linkSync(path, archivePath);
|
|
192
|
+
const published = requireRegularFile(archivePath, `archive ${basename(archivePath)}`);
|
|
193
|
+
if (!sameInode(before, published)) {
|
|
194
|
+
try {
|
|
195
|
+
unlinkSync(archivePath);
|
|
196
|
+
}
|
|
197
|
+
catch { /* keep the primary safety failure */ }
|
|
198
|
+
throw new Error('refusing worklog rotation: published archive is not the inspected file');
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
73
201
|
}
|
|
74
|
-
catch (
|
|
75
|
-
if (
|
|
76
|
-
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error.code === 'EEXIST')
|
|
204
|
+
continue;
|
|
205
|
+
throw error;
|
|
77
206
|
}
|
|
78
207
|
}
|
|
79
|
-
|
|
80
|
-
|
|
208
|
+
try {
|
|
209
|
+
deps.afterArchiveRename?.();
|
|
210
|
+
const liveBeforeReplace = requireRegularFile(path, 'WORKLOG.md');
|
|
211
|
+
if (!sameInode(before, liveBeforeReplace))
|
|
212
|
+
throw new Error('refusing worklog rotation: WORKLOG.md changed after archive publication');
|
|
213
|
+
// The helper writes and fsyncs a same-directory temp, atomically renames it
|
|
214
|
+
// over the live path, then fsyncs the directory. WORKLOG.md is never absent.
|
|
215
|
+
replaceFileAtomically(path, tail.toString('utf8'), before.mode & 0o777);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
// If the inspected inode is still the live file, the archive is only a
|
|
219
|
+
// duplicate artifact. Remove it best-effort and preserve the real error.
|
|
220
|
+
// If the live path changed, retain the archive because it may be the only
|
|
221
|
+
// remaining link to the inspected history.
|
|
222
|
+
cleanupDuplicateLink(path, archivePath, before);
|
|
223
|
+
throw error;
|
|
81
224
|
}
|
|
82
|
-
const
|
|
225
|
+
const archiveContent = readFileSync(archivePath);
|
|
226
|
+
const liveContent = existsSync(path) ? readFileSync(path) : Buffer.alloc(0);
|
|
227
|
+
const liveBytes = liveContent.length;
|
|
83
228
|
const status = {
|
|
84
229
|
schemaVersion: 1,
|
|
85
|
-
rotatedAt:
|
|
230
|
+
rotatedAt: rotatedAt.toISOString(),
|
|
86
231
|
beforeBytes: before.size,
|
|
87
232
|
afterBytes: liveBytes,
|
|
88
233
|
archive: basename(archivePath),
|
|
234
|
+
archiveBytes: archiveContent.length,
|
|
235
|
+
archiveSha256: sha256(archiveContent),
|
|
236
|
+
liveSha256: sha256(liveContent),
|
|
89
237
|
archiveContainsFullSnapshot: true,
|
|
238
|
+
tailOmittedPrefixBytes: start,
|
|
239
|
+
...(tailStartsMidLine ? { tailStartsMidLine: true } : {}),
|
|
240
|
+
olderArchives: WORKLOG_ARCHIVE_DIR,
|
|
241
|
+
recentArchiveLimit: policy.max_archives,
|
|
90
242
|
};
|
|
91
243
|
replaceFileAtomically(join(dirname(path), '.worklog-rotation.json'), `${JSON.stringify(status, null, 2)}\n`);
|
|
92
244
|
pruneWorklogArchives(path, policy.max_archives);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.18.0
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -38,8 +38,6 @@
|
|
|
38
38
|
"@agentclientprotocol/sdk": "^1.3.0",
|
|
39
39
|
"@fastify/static": "^10.1.2",
|
|
40
40
|
"@fastify/websocket": "^11.2.0",
|
|
41
|
-
"@ours.network/cli": "1.0.1",
|
|
42
|
-
"@ours.network/sdk": "3.0.1",
|
|
43
41
|
"@xterm/addon-fit": "0.10.0",
|
|
44
42
|
"@xterm/addon-serialize": "0.13.0",
|
|
45
43
|
"@xterm/headless": "5.5.0",
|
|
@@ -1,25 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,114 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,148 +0,0 @@
|
|
|
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 {};
|