@ours.network/fleet 0.17.9 → 0.17.11
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 +38 -3
- package/dist/briefing.js +4 -2
- package/dist/build-info.json +4 -4
- package/dist/config.d.ts +6 -3
- package/dist/config.js +28 -14
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +32 -4
- package/dist/model-env.d.ts +71 -0
- package/dist/model-env.js +106 -0
- package/dist/runner.d.ts +9 -0
- package/dist/runner.js +27 -3
- package/dist/session/acp.d.ts +3 -0
- package/dist/session/acp.js +29 -2
- 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/spawn.js +28 -16
- 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 -1
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.17.
|
|
3
|
+
"version": "0.17.11",
|
|
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",
|