@ours.network/fleet 2.2.1 → 2.2.3
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/dist/application/conversation-history.js +165 -14
- package/dist/build-info.json +4 -4
- package/dist/rooms-tasks/task-review.d.ts +4 -0
- package/dist/rooms-tasks/task-review.js +10 -0
- package/dist/web/resource-views.js +8 -6
- package/dist/web/server.js +2 -2
- package/docs/task-progress-review.md +5 -2
- package/package.json +1 -1
|
@@ -7,39 +7,155 @@ const HISTORICAL_KINDS = ['file.attached', 'fleet.task_created', 'prompt.admitte
|
|
|
7
7
|
export function conversationEventVisible(event, sessionGeneration, resumedSessionId) {
|
|
8
8
|
return event.source !== 'agent_replay' && (event.sessionGeneration === sessionGeneration || (!!resumedSessionId && HISTORICAL_KINDS.includes(event.kind) && event.acpSessionId === resumedSessionId));
|
|
9
9
|
}
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
// A seek attempt is transactional: its caller discards all collected rows and
|
|
11
|
+
// performs one ordinary scan if a probe or the file identity is no longer safe.
|
|
12
|
+
class SeekFallback extends Error {
|
|
13
|
+
}
|
|
14
|
+
const stamp = (info) => `${info.dev}:${info.ino}:${info.size}:${info.mtimeMs}:${info.ctimeMs}`;
|
|
15
|
+
/** Strict lower bound in an admitted monotonic writer segment, on this FD only. */
|
|
16
|
+
async function seekBefore(file, size, before, signal) {
|
|
17
|
+
const read = async (position, length) => {
|
|
18
|
+
signal?.throwIfAborted();
|
|
19
|
+
const bytes = Buffer.alloc(length);
|
|
20
|
+
const { bytesRead } = await file.read(bytes, 0, length, position);
|
|
21
|
+
signal?.throwIfAborted();
|
|
22
|
+
if (bytesRead !== length)
|
|
23
|
+
throw new SeekFallback('segment changed during seek');
|
|
24
|
+
return bytes;
|
|
25
|
+
};
|
|
26
|
+
if (!size)
|
|
27
|
+
return 0;
|
|
28
|
+
// An active partial append is not a safe boundary, even if the first event
|
|
29
|
+
// would let us skip this whole segment.
|
|
30
|
+
if ((await read(size - 1, 1))[0] !== 10)
|
|
31
|
+
throw new SeekFallback('partial segment');
|
|
32
|
+
const record = async (position) => {
|
|
33
|
+
let start = position, end = position;
|
|
34
|
+
const left = [], right = [];
|
|
35
|
+
while (start > 0) {
|
|
36
|
+
const length = Math.min(4096, start), chunk = await read(start - length, length);
|
|
37
|
+
const newline = chunk.lastIndexOf(10);
|
|
38
|
+
left.push(chunk.subarray(newline + 1));
|
|
39
|
+
start -= length;
|
|
40
|
+
if (newline >= 0) {
|
|
41
|
+
start += newline + 1;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
while (end < size) {
|
|
46
|
+
const chunk = await read(end, Math.min(4096, size - end)), newline = chunk.indexOf(10);
|
|
47
|
+
right.push(newline < 0 ? chunk : chunk.subarray(0, newline));
|
|
48
|
+
end += newline < 0 ? chunk.length : newline + 1;
|
|
49
|
+
if (newline >= 0)
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
let event;
|
|
53
|
+
try {
|
|
54
|
+
event = JSON.parse(Buffer.concat([...left.reverse(), ...right]).toString('utf8'));
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new SeekFallback('unreadable seek record');
|
|
58
|
+
}
|
|
59
|
+
if (!event || event.schemaVersion !== 1 || !Number.isSafeInteger(event.seq) || event.seq <= 0)
|
|
60
|
+
throw new SeekFallback('legacy seek record');
|
|
61
|
+
return { start, end, seq: event.seq };
|
|
62
|
+
};
|
|
63
|
+
const first = await record(0);
|
|
64
|
+
if (first.seq >= before)
|
|
65
|
+
return 0;
|
|
66
|
+
let low = first.end, high = size, lower = first.seq, upper = Infinity;
|
|
67
|
+
while (low < high) {
|
|
68
|
+
const found = await record(Math.floor((low + high) / 2));
|
|
69
|
+
if (found.start < low || found.end > high || found.seq <= lower || found.seq >= upper)
|
|
70
|
+
throw new SeekFallback('non-monotonic seek record');
|
|
71
|
+
if (found.seq < before) {
|
|
72
|
+
low = found.end;
|
|
73
|
+
lower = found.seq;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
high = found.start;
|
|
77
|
+
upper = found.seq;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return low;
|
|
81
|
+
}
|
|
82
|
+
/** Newest-first lines; optional finite-before seek is only for a healthy writer. */
|
|
83
|
+
async function readLinesBackward(path, visit, signal, seek) {
|
|
12
84
|
signal?.throwIfAborted();
|
|
13
|
-
|
|
85
|
+
let file;
|
|
86
|
+
try {
|
|
87
|
+
file = await open(path, 'r');
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
signal?.throwIfAborted();
|
|
91
|
+
if (seek)
|
|
92
|
+
throw new SeekFallback('segment disappeared');
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
14
95
|
try {
|
|
15
|
-
|
|
16
|
-
let
|
|
17
|
-
|
|
96
|
+
const initial = await file.stat(), key = stamp(initial);
|
|
97
|
+
let position = seek ? await seekBefore(file, initial.size, seek.before, signal) : initial.size;
|
|
98
|
+
let carry = Buffer.alloc(0), more = true;
|
|
99
|
+
scan: while (position > 0) {
|
|
18
100
|
signal?.throwIfAborted();
|
|
19
101
|
const size = Math.min(256 * 1024, position);
|
|
20
102
|
position -= size;
|
|
21
103
|
const chunk = Buffer.alloc(size);
|
|
22
|
-
await file.read(chunk, 0, size, position);
|
|
104
|
+
const { bytesRead } = await file.read(chunk, 0, size, position);
|
|
23
105
|
signal?.throwIfAborted();
|
|
24
|
-
|
|
106
|
+
if (seek && bytesRead !== size)
|
|
107
|
+
throw new SeekFallback('short segment read');
|
|
108
|
+
const buffer = Buffer.concat([chunk.subarray(0, bytesRead), carry]);
|
|
25
109
|
let end = buffer.length;
|
|
26
110
|
for (let i = buffer.length - 1; i >= 0; i--) {
|
|
27
111
|
if (buffer[i] !== 10)
|
|
28
112
|
continue;
|
|
29
|
-
if (end > i + 1 && !visit(buffer.toString('utf8', i + 1, end)))
|
|
30
|
-
|
|
113
|
+
if (end > i + 1 && !visit(buffer.toString('utf8', i + 1, end))) {
|
|
114
|
+
more = false;
|
|
115
|
+
break scan;
|
|
116
|
+
}
|
|
31
117
|
end = i;
|
|
32
118
|
}
|
|
33
119
|
carry = buffer.subarray(0, end);
|
|
34
120
|
}
|
|
35
|
-
if (carry.length && !visit(carry.toString('utf8')))
|
|
36
|
-
|
|
37
|
-
|
|
121
|
+
if (more && carry.length && !visit(carry.toString('utf8')))
|
|
122
|
+
more = false;
|
|
123
|
+
if (seek) {
|
|
124
|
+
signal?.throwIfAborted();
|
|
125
|
+
const current = await stat(path).catch(() => undefined);
|
|
126
|
+
if (stamp(await file.stat()) !== key || !current || stamp(current) !== key)
|
|
127
|
+
throw new SeekFallback('segment changed during read');
|
|
128
|
+
seek.checked.push({ path, key });
|
|
129
|
+
}
|
|
130
|
+
return more;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
signal?.throwIfAborted();
|
|
134
|
+
if (seek && !(error instanceof SeekFallback))
|
|
135
|
+
throw new SeekFallback('seek read unavailable');
|
|
136
|
+
throw error;
|
|
38
137
|
}
|
|
39
138
|
finally {
|
|
40
139
|
await file.close();
|
|
41
140
|
}
|
|
42
141
|
}
|
|
142
|
+
/** Existing writer provenance, never a substitute for the captured barrier. */
|
|
143
|
+
async function seekManifest(dir, files, signal) {
|
|
144
|
+
try {
|
|
145
|
+
signal?.throwIfAborted();
|
|
146
|
+
const raw = await readFile(join(dir, 'manifest.json'), { encoding: 'utf8', signal }), value = JSON.parse(raw);
|
|
147
|
+
if (value.schemaVersion !== 1 || !Number.isSafeInteger(value.nextSeq) || value.nextSeq < 1
|
|
148
|
+
|| !Array.isArray(value.segments) || value.segments.length !== files.length
|
|
149
|
+
|| value.segments.some((name, i) => name !== files[files.length - 1 - i])
|
|
150
|
+
|| (value.failedGenerations !== undefined && (!Array.isArray(value.failedGenerations) || value.failedGenerations.length > 0)))
|
|
151
|
+
return;
|
|
152
|
+
return raw;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
signal?.throwIfAborted();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
43
159
|
// Rotated segments never change, so their last cursor is reused until the file itself changes.
|
|
44
160
|
const lastSeqs = new Map();
|
|
45
161
|
/** The last event cursor in a segment, read from its tail; undefined when none is readable. */
|
|
@@ -72,6 +188,17 @@ async function lastSeq(path) {
|
|
|
72
188
|
* `nextCursor` is the newest ledger position seen, for forward `after` paging.
|
|
73
189
|
*/
|
|
74
190
|
export async function conversationTailPage(stateDir, request, snapshot, resumedSessionId, signal) {
|
|
191
|
+
try {
|
|
192
|
+
return await tailPage(stateDir, request, snapshot, resumedSessionId, signal, true);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
signal?.throwIfAborted();
|
|
196
|
+
if (!(error instanceof SeekFallback))
|
|
197
|
+
throw error;
|
|
198
|
+
return tailPage(stateDir, request, snapshot, resumedSessionId, signal, false);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function tailPage(stateDir, request, snapshot, resumedSessionId, signal, allowSeek) {
|
|
75
202
|
signal?.throwIfAborted();
|
|
76
203
|
const before = request.before === undefined ? Infinity : Number(request.before);
|
|
77
204
|
if (before !== Infinity && (!Number.isSafeInteger(before) || before < 0))
|
|
@@ -93,6 +220,11 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
|
|
|
93
220
|
// generation on every open. Legacy/incomplete snapshots still need context.
|
|
94
221
|
const complete = !snapshot.observationFailure && Number.isSafeInteger(barrier) && barrier >= 0
|
|
95
222
|
&& Number.isSafeInteger(observation.stateRevision) && observation.stateRevision >= 0;
|
|
223
|
+
const dir = join(stateDir, '.conversation');
|
|
224
|
+
const manifest = allowSeek && before !== Infinity && complete && !snapshot.historyDegraded
|
|
225
|
+
? await seekManifest(dir, files, signal) : undefined;
|
|
226
|
+
const seek = manifest === undefined ? undefined : { before, checked: [] };
|
|
227
|
+
let previousSeq = Infinity;
|
|
96
228
|
const needsCapabilities = !complete || !Array.isArray(observation.commands);
|
|
97
229
|
const pending = new Set(complete && Array.isArray(observation.pendingPermissions)
|
|
98
230
|
&& snapshot.pendingPermissionIds.every(id => observation.pendingPermissions.some(permission => permission.permissionId === id))
|
|
@@ -117,9 +249,16 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
|
|
|
117
249
|
event = JSON.parse(line);
|
|
118
250
|
}
|
|
119
251
|
catch {
|
|
252
|
+
if (seek)
|
|
253
|
+
throw new SeekFallback('unreadable page record');
|
|
120
254
|
return true;
|
|
121
255
|
}
|
|
122
256
|
const seq = Number(event.seq);
|
|
257
|
+
if (seek) {
|
|
258
|
+
if (event.schemaVersion !== 1 || !Number.isSafeInteger(seq) || seq <= 0 || seq >= previousSeq)
|
|
259
|
+
throw new SeekFallback('non-monotonic page record');
|
|
260
|
+
previousSeq = seq;
|
|
261
|
+
}
|
|
123
262
|
if (!Number.isSafeInteger(seq))
|
|
124
263
|
return true;
|
|
125
264
|
if (barrier !== undefined && seq > barrier)
|
|
@@ -142,10 +281,22 @@ export async function conversationTailPage(stateDir, request, snapshot, resumedS
|
|
|
142
281
|
hasOlder = true;
|
|
143
282
|
}
|
|
144
283
|
return !hasOlder || wantsContext();
|
|
145
|
-
}, signal);
|
|
284
|
+
}, signal, seek);
|
|
146
285
|
if (!more)
|
|
147
286
|
break;
|
|
148
287
|
}
|
|
288
|
+
if (seek) {
|
|
289
|
+
signal?.throwIfAborted();
|
|
290
|
+
const currentFiles = (await readdir(dir).catch(() => { throw new SeekFallback('ledger disappeared'); })).filter(n => /^events-\d{6}\.jsonl$/.test(n)).sort().reverse();
|
|
291
|
+
if (await seekManifest(dir, currentFiles, signal) !== manifest || currentFiles.join() !== files.join())
|
|
292
|
+
throw new SeekFallback('segments rotated during page');
|
|
293
|
+
for (const checked of seek.checked) {
|
|
294
|
+
signal?.throwIfAborted();
|
|
295
|
+
const current = await stat(checked.path).catch(() => undefined);
|
|
296
|
+
if (!current || stamp(current) !== checked.key)
|
|
297
|
+
throw new SeekFallback('segment replaced during page');
|
|
298
|
+
}
|
|
299
|
+
}
|
|
149
300
|
events.reverse();
|
|
150
301
|
context.reverse();
|
|
151
302
|
return { events, snapshot, hasOlder, context, hasMore: false,
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2.2.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "2.2.3",
|
|
3
|
+
"buildId": "3ac91b025945",
|
|
4
|
+
"commit": "576470bf1cd528fb2d517d081358b3e5c667eea3",
|
|
5
5
|
"dirty": false,
|
|
6
|
-
"builtAt": "2026-10-
|
|
6
|
+
"builtAt": "2026-10-29T08:13:26.500Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"service.refresh-v1",
|
|
9
9
|
"managed-cli.codex-reasoning-effort-v1",
|
|
@@ -11,6 +11,10 @@ export declare class TaskReviewError extends Error {
|
|
|
11
11
|
export declare function reviewArtifactMime(name: string): string;
|
|
12
12
|
export declare function checkArtifactName(name: string): string;
|
|
13
13
|
export declare function listTaskProgress(taskId: string): TaskProgressEntry[];
|
|
14
|
+
/** Browser snapshot: entry bodies and their saved read state come from one task record. */
|
|
15
|
+
export declare function taskProgressSnapshot(taskId: string): Array<Omit<TaskProgressEntry, 'request_key'> & {
|
|
16
|
+
unread: boolean;
|
|
17
|
+
}>;
|
|
14
18
|
/**
|
|
15
19
|
* Append one milestone. Text is kept exactly as written; whitespace-only text is
|
|
16
20
|
* rejected. A retry with the same author-scoped request_key returns the original
|
|
@@ -73,6 +73,16 @@ export function listTaskProgress(taskId) {
|
|
|
73
73
|
assertTaskId(taskId);
|
|
74
74
|
return getTask(taskId).progress ?? [];
|
|
75
75
|
}
|
|
76
|
+
/** Browser snapshot: entry bodies and their saved read state come from one task record. */
|
|
77
|
+
export function taskProgressSnapshot(taskId) {
|
|
78
|
+
assertTaskId(taskId);
|
|
79
|
+
const task = getTask(taskId);
|
|
80
|
+
const reads = task.contribution_reads;
|
|
81
|
+
const seen = new Set(reads?.progress_read);
|
|
82
|
+
return (task.progress ?? []).map(entry => ({
|
|
83
|
+
...publicProgressEntry(entry), unread: !!reads && !seen.has(entry.id),
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
76
86
|
/**
|
|
77
87
|
* Append one milestone. Text is kept exactly as written; whitespace-only text is
|
|
78
88
|
* rejected. A retry with the same author-scoped request_key returns the original
|
|
@@ -384,13 +384,15 @@ export class FleetResourceViews {
|
|
|
384
384
|
// otherwise reserved for an independent task receipt.
|
|
385
385
|
if (this.scope.configPath && resolve(path) === resolve(this.scope.configPath))
|
|
386
386
|
return true;
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
//
|
|
387
|
+
// These projections read role config/private status and conversation ledgers,
|
|
388
|
+
// not adopted Codex telemetry or this gateway's auth/audit bookkeeping. Those
|
|
389
|
+
// writes otherwise invalidate every in-flight directory capture on a busy host.
|
|
390
|
+
// Keep explicit config, declared role roots and unknown/ancestor wakes fenced.
|
|
390
391
|
const roots = this.scope.roots ?? [agentsRoot(), tmpRoot()];
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
392
|
+
const roleProjection = ['directory', 'roles', 'previews'].includes(descriptor.resource);
|
|
393
|
+
const inRoleRoot = roots.some(root => { const local = relative(resolve(root), path); return local === '' || (local !== '..' && !local.startsWith('..' + sep)); });
|
|
394
|
+
if (roleProjection && !inRoleRoot && (/^(?:history\.jsonl|models_cache\.json|(?:thread_history|state|logs|queue|goals)_\d+\.sqlite(?:-wal|-shm|-journal)?)$/.test(relative(resolve(homedir(), '.codex'), path))
|
|
395
|
+
|| /^(?:workspace-devices\.db(?:-wal|-shm|-journal)?|audit[/\\]audit\.jsonl)$/.test(relative(resolve(stateRoot(), 'web'), path))))
|
|
394
396
|
return false;
|
|
395
397
|
// Preview selection reads role config/state and conversation ledgers, never
|
|
396
398
|
// task layout run/operation/brief receipts. A worker may commit several of
|
package/dist/web/server.js
CHANGED
|
@@ -27,7 +27,7 @@ import { FleetEventBus } from './events.js';
|
|
|
27
27
|
import { ROLE_NAME_RE } from '../config.js';
|
|
28
28
|
import { TaskListError } from '../rooms-tasks/task-lists.js';
|
|
29
29
|
import { acknowledgeTaskProgress, acknowledgeTaskReview, contributionUnread, TaskStateError } from '../rooms-tasks/task-state.js';
|
|
30
|
-
import { appendTaskProgress, getTaskReview,
|
|
30
|
+
import { appendTaskProgress, getTaskReview, taskProgressSnapshot, publicProgressEntry, publicReview, readTaskReviewArtifact, setTaskReviewMarkdown, taskContributionSummary, TaskReviewError, } from '../rooms-tasks/task-review.js';
|
|
31
31
|
import { isProvider } from '../subscriptions/store.js';
|
|
32
32
|
const ONBOARDING_WORK = ['coordination', 'development', 'review'];
|
|
33
33
|
const statusFor = (code) => ({
|
|
@@ -608,7 +608,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
608
608
|
});
|
|
609
609
|
app.get('/api/v1/tasks/:id/progress', async (request) => {
|
|
610
610
|
auth.authenticate(request);
|
|
611
|
-
return { progress:
|
|
611
|
+
return { progress: await reviewApi(() => taskProgressSnapshot(request.params.id)) };
|
|
612
612
|
});
|
|
613
613
|
app.post('/api/v1/tasks/:id/progress', async (request, reply) => {
|
|
614
614
|
auth.authenticate(request, true);
|
|
@@ -4,7 +4,10 @@ Every task can carry two optional sections next to its description. Tasks create
|
|
|
4
4
|
before this feature, or that never use it, have neither and behave as before.
|
|
5
5
|
|
|
6
6
|
- **Progress** is an append-only, chronological milestone log: free-form text
|
|
7
|
-
entries, each with a stable ID, the time it was recorded and its author.
|
|
7
|
+
entries, each with a stable ID, the time it was recorded and its author. The
|
|
8
|
+
browser response also includes `unread` per entry from the same task snapshot,
|
|
9
|
+
based on persisted entry-ID acknowledgments. Legacy records without read state
|
|
10
|
+
report all existing entries as read; retry keys remain private. It is
|
|
8
11
|
for significant verified milestones (root cause found, PR opened with its link,
|
|
9
12
|
a measured result with its honest scope, final deliverable ready), not for room
|
|
10
13
|
chat, routine status, handoffs or ordinary review rounds. Entries cannot be
|
|
@@ -99,7 +102,7 @@ workspace-device bearer. `GET /api/v1/meta` reports API minor version 1.
|
|
|
99
102
|
| Route | Result |
|
|
100
103
|
| --- | --- |
|
|
101
104
|
| `GET /tasks`, `GET /tasks/:id` and other task payloads | `progress_count` and `review: {has_markdown, artifact_count, revision, updated_at?}`; `revision` grows with every package change (0 without a package). Bodies are not inlined. |
|
|
102
|
-
| `GET /tasks/:id/progress` | `{progress: [{id, at, author, text}]}`, oldest first. |
|
|
105
|
+
| `GET /tasks/:id/progress` | `{progress: [{id, at, author, text, unread}]}`, oldest first. |
|
|
103
106
|
| `POST /tasks/:id/progress` | `{text, requestKey?}` (or an `Idempotency-Key` header): 201 new, 200 replay, 409 conflicting replay. |
|
|
104
107
|
| `GET /tasks/:id/review` | `{review: {markdown, updated_at, updated_by, artifacts: [{id, name, mime, size, sha256, added_at, added_by}]}}` or `{review: null}`. |
|
|
105
108
|
| `PUT /tasks/:id/review` | `{markdown, expectedMarkdown}`; 409 when the Markdown changed meanwhile. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.3",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|