@ours.network/fleet 2.3.1 → 2.3.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/build-info.json +4 -4
- package/dist/rooms-tasks/task-contributions.d.ts +50 -0
- package/dist/rooms-tasks/task-contributions.js +169 -0
- package/dist/rooms-tasks/task-state.d.ts +12 -0
- package/dist/rooms-tasks/task-state.js +43 -2
- package/dist/web/fleet-config-service.d.ts +8 -0
- package/dist/web/fleet-config-service.js +23 -8
- package/dist/web/resource-views.d.ts +2 -0
- package/dist/web/resource-views.js +26 -15
- package/dist/web/server.js +37 -0
- package/docs/task-progress-review.md +2 -0
- package/package.json +1 -1
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "2.3.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "2.3.3",
|
|
3
|
+
"buildId": "f813635f30d3",
|
|
4
|
+
"commit": "b0dbf450ed45e131fb34f64d71a16e8eb960b77f",
|
|
5
5
|
"dirty": false,
|
|
6
|
-
"builtAt": "2026-
|
|
6
|
+
"builtAt": "2026-11-03T12:22:11.870Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"service.refresh-v1",
|
|
9
9
|
"managed-cli.codex-reasoning-effort-v1",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { TaskRecord } from './types.js';
|
|
2
|
+
export declare const CONTRIBUTION_PAGE_MAX = 100;
|
|
3
|
+
export declare const CONTRIBUTION_CUTOFF_MAX = 200;
|
|
4
|
+
export interface ContributionItem {
|
|
5
|
+
/** `p:<task>:<entry>` or `r:<task>:<revision>`: a new review revision is a new item. */
|
|
6
|
+
id: string;
|
|
7
|
+
kind: 'progress' | 'review';
|
|
8
|
+
task_id: string;
|
|
9
|
+
mission_key?: string;
|
|
10
|
+
task_title: string;
|
|
11
|
+
task_state: TaskRecord['state'];
|
|
12
|
+
at: string;
|
|
13
|
+
excerpt: string;
|
|
14
|
+
unread: boolean;
|
|
15
|
+
entry_id?: string;
|
|
16
|
+
revision?: number;
|
|
17
|
+
}
|
|
18
|
+
/** What one task had when it was listed; the bulk read acknowledges nothing beyond it. */
|
|
19
|
+
export interface ContributionCutoff {
|
|
20
|
+
task_id: string;
|
|
21
|
+
progress: number;
|
|
22
|
+
progress_last: string | null;
|
|
23
|
+
review: number;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* One page, newest first. The cursor is a position, not an item: it stays valid when the item it
|
|
27
|
+
* names is read, filtered out or deleted. `cutoff` lists every task with unread items as listed;
|
|
28
|
+
* `unavailable` counts task records that could not be read.
|
|
29
|
+
*/
|
|
30
|
+
export declare function listTaskContributions(options?: {
|
|
31
|
+
before?: string;
|
|
32
|
+
limit?: number;
|
|
33
|
+
unread?: boolean;
|
|
34
|
+
}): {
|
|
35
|
+
items: ContributionItem[];
|
|
36
|
+
next_cursor: string | null;
|
|
37
|
+
unread_total: number;
|
|
38
|
+
cutoff: ContributionCutoff[];
|
|
39
|
+
unavailable: number;
|
|
40
|
+
};
|
|
41
|
+
export type ContributionReadResult = {
|
|
42
|
+
task_id: string;
|
|
43
|
+
} & ({
|
|
44
|
+
status: 'read';
|
|
45
|
+
read: number;
|
|
46
|
+
} | {
|
|
47
|
+
status: 'changed' | 'gone' | 'deleting' | 'failed';
|
|
48
|
+
});
|
|
49
|
+
/** Read every task of a captured cutoff up to that cutoff, each under its own lock; one task's outcome never blocks another. */
|
|
50
|
+
export declare function readTaskContributions(cutoff: ContributionCutoff[]): ContributionReadResult[];
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress entries and To review revisions of every task as one newest-first notification list,
|
|
3
|
+
* with the canonical read state of each task record. Nothing is copied: the task records stay the
|
|
4
|
+
* only read truth, and the per-task routes and the bulk read below change the same markers.
|
|
5
|
+
*
|
|
6
|
+
* The projection is retained in this process. Each request lists the task directory and stats each
|
|
7
|
+
* record; only a record whose inode, size or time changed is read again (every write replaces the
|
|
8
|
+
* file atomically). Room history and other services are never consulted.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { acknowledgeTaskContributionsThrough, contributionUnread, getTask, reviewRevision, tasksDir, TaskStateError } from './task-state.js';
|
|
13
|
+
import { TaskReviewError } from './task-review.js';
|
|
14
|
+
const TASK_FILE = /^([0-9a-z]{9}[0-9a-f]{8})\.json$/;
|
|
15
|
+
const EXCERPT_MAX = 200;
|
|
16
|
+
export const CONTRIBUTION_PAGE_MAX = 100;
|
|
17
|
+
export const CONTRIBUTION_CUTOFF_MAX = 200;
|
|
18
|
+
let cache;
|
|
19
|
+
function excerpt(text) {
|
|
20
|
+
const points = [...text.replace(/\s+/g, ' ').trim()];
|
|
21
|
+
return points.length > EXCERPT_MAX ? points.slice(0, EXCERPT_MAX - 1).join('') + '…' : points.join('');
|
|
22
|
+
}
|
|
23
|
+
function reviewExcerpt(task) {
|
|
24
|
+
const line = task.review?.markdown.split('\n').map(text => text.replace(/^\s*(?:#{1,6}\s|>\s?|[-*+]\s|\d+\.\s)*/, '').trim()).find(Boolean);
|
|
25
|
+
if (line)
|
|
26
|
+
return excerpt(line);
|
|
27
|
+
const files = task.review?.artifacts.length ?? 0;
|
|
28
|
+
return files ? `${files} attached ${files === 1 ? 'file' : 'files'}` : 'The review request was cleared.';
|
|
29
|
+
}
|
|
30
|
+
const time = (at) => { const value = Date.parse(at); return Number.isFinite(value) ? value : 0; };
|
|
31
|
+
function project(task, stamp) {
|
|
32
|
+
const base = {
|
|
33
|
+
task_id: task.task_id, ...(task.key ? { mission_key: task.key } : {}), task_title: task.title, task_state: task.state,
|
|
34
|
+
};
|
|
35
|
+
const reads = task.contribution_reads, seen = new Set(reads?.progress_read);
|
|
36
|
+
const items = (task.progress ?? []).map(entry => ({
|
|
37
|
+
...base, id: `p:${task.task_id}:${entry.id}`, kind: 'progress', at: entry.at, order: time(entry.at),
|
|
38
|
+
excerpt: excerpt(entry.text), unread: !!reads && !seen.has(entry.id), entry_id: entry.id,
|
|
39
|
+
}));
|
|
40
|
+
const unread = contributionUnread(task);
|
|
41
|
+
if (task.review) {
|
|
42
|
+
const revision = reviewRevision(task.review);
|
|
43
|
+
items.push({ ...base, id: `r:${task.task_id}:${revision}`, kind: 'review', at: task.review.updated_at,
|
|
44
|
+
order: time(task.review.updated_at), excerpt: reviewExcerpt(task), unread: unread.review, revision });
|
|
45
|
+
}
|
|
46
|
+
const progress = task.progress ?? [];
|
|
47
|
+
return { stamp, items, unread: unread.progress + (unread.review ? 1 : 0),
|
|
48
|
+
...(unread.progress || unread.review ? { cutoff: { task_id: task.task_id, progress: progress.length,
|
|
49
|
+
progress_last: progress.at(-1)?.id ?? null, review: reviewRevision(task.review) } } : {}) };
|
|
50
|
+
}
|
|
51
|
+
/** Bring the retained projection up to date with the task directory; returns the tasks not readable now. */
|
|
52
|
+
function refresh() {
|
|
53
|
+
const dir = tasksDir();
|
|
54
|
+
if (cache?.dir !== dir)
|
|
55
|
+
cache = { dir, tasks: new Map() };
|
|
56
|
+
const current = cache;
|
|
57
|
+
let names;
|
|
58
|
+
// No task directory yet means no tasks; any other failure is an error, never an empty list.
|
|
59
|
+
try {
|
|
60
|
+
names = readdirSync(dir);
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (error.code !== 'ENOENT')
|
|
64
|
+
throw error;
|
|
65
|
+
names = [];
|
|
66
|
+
}
|
|
67
|
+
const present = new Set();
|
|
68
|
+
let unavailable = 0;
|
|
69
|
+
for (const name of names) {
|
|
70
|
+
const id = TASK_FILE.exec(name)?.[1];
|
|
71
|
+
if (!id)
|
|
72
|
+
continue;
|
|
73
|
+
try {
|
|
74
|
+
// Writes replace the record by rename: a new inode, and a change time in nanoseconds.
|
|
75
|
+
const stat = statSync(join(dir, name), { bigint: true });
|
|
76
|
+
const stamp = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
77
|
+
present.add(id);
|
|
78
|
+
if (current.tasks.get(id)?.stamp === stamp)
|
|
79
|
+
continue;
|
|
80
|
+
const task = getTask(id);
|
|
81
|
+
current.index = undefined;
|
|
82
|
+
// A task pending deletion lists nothing and counts nothing.
|
|
83
|
+
if (task.deletion?.status === 'pending')
|
|
84
|
+
current.tasks.set(id, { stamp, items: [], unread: 0 });
|
|
85
|
+
else
|
|
86
|
+
current.tasks.set(id, project(task, stamp));
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error.code === 'ENOENT' || (error instanceof TaskStateError && /not found/.test(error.message)))
|
|
90
|
+
continue;
|
|
91
|
+
present.add(id);
|
|
92
|
+
unavailable++;
|
|
93
|
+
if (current.tasks.delete(id))
|
|
94
|
+
current.index = undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const id of current.tasks.keys())
|
|
98
|
+
if (!present.has(id)) {
|
|
99
|
+
current.tasks.delete(id);
|
|
100
|
+
current.index = undefined;
|
|
101
|
+
}
|
|
102
|
+
return unavailable;
|
|
103
|
+
}
|
|
104
|
+
function index() {
|
|
105
|
+
return cache.index ??= [...cache.tasks.values()].flatMap(task => task.items)
|
|
106
|
+
.sort((a, b) => b.order - a.order || (a.id < b.id ? 1 : a.id > b.id ? -1 : 0));
|
|
107
|
+
}
|
|
108
|
+
function encodeCursor(item) {
|
|
109
|
+
return Buffer.from(JSON.stringify([item.order, item.id])).toString('base64url');
|
|
110
|
+
}
|
|
111
|
+
function decodeCursor(cursor) {
|
|
112
|
+
try {
|
|
113
|
+
const [order, id] = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
|
|
114
|
+
if (typeof order === 'number' && Number.isFinite(order) && typeof id === 'string' && id.length <= 200)
|
|
115
|
+
return { order, id };
|
|
116
|
+
}
|
|
117
|
+
catch { /* invalid below */ }
|
|
118
|
+
throw new TaskReviewError('invalid_request', 'invalid cursor');
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* One page, newest first. The cursor is a position, not an item: it stays valid when the item it
|
|
122
|
+
* names is read, filtered out or deleted. `cutoff` lists every task with unread items as listed;
|
|
123
|
+
* `unavailable` counts task records that could not be read.
|
|
124
|
+
*/
|
|
125
|
+
export function listTaskContributions(options = {}) {
|
|
126
|
+
const limit = options.limit ?? 50;
|
|
127
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > CONTRIBUTION_PAGE_MAX)
|
|
128
|
+
throw new TaskReviewError('invalid_request', `limit must be 1-${CONTRIBUTION_PAGE_MAX}`);
|
|
129
|
+
const after = options.before === undefined ? undefined : decodeCursor(options.before);
|
|
130
|
+
const unavailable = refresh();
|
|
131
|
+
const page = [];
|
|
132
|
+
let more = false;
|
|
133
|
+
for (const item of index()) {
|
|
134
|
+
if (after && (item.order > after.order || (item.order === after.order && item.id >= after.id)))
|
|
135
|
+
continue;
|
|
136
|
+
if (options.unread && !item.unread)
|
|
137
|
+
continue;
|
|
138
|
+
if (page.length === limit) {
|
|
139
|
+
more = true;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
page.push(item);
|
|
143
|
+
}
|
|
144
|
+
const tasks = [...cache.tasks.values()];
|
|
145
|
+
return {
|
|
146
|
+
items: page.map(({ order: _order, ...item }) => item),
|
|
147
|
+
next_cursor: more ? encodeCursor(page.at(-1)) : null,
|
|
148
|
+
unread_total: tasks.reduce((sum, task) => sum + task.unread, 0),
|
|
149
|
+
cutoff: tasks.flatMap(task => task.cutoff ? [task.cutoff] : []),
|
|
150
|
+
unavailable,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** Read every task of a captured cutoff up to that cutoff, each under its own lock; one task's outcome never blocks another. */
|
|
154
|
+
export function readTaskContributions(cutoff) {
|
|
155
|
+
return cutoff.map(({ task_id, progress, progress_last, review }) => {
|
|
156
|
+
try {
|
|
157
|
+
const read = acknowledgeTaskContributionsThrough(task_id, { progress, progressLast: progress_last, review });
|
|
158
|
+
return read === 'changed' ? { task_id, status: 'changed' } : { task_id, status: 'read', read };
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error instanceof TaskStateError && /pending deletion/.test(error.message))
|
|
162
|
+
return { task_id, status: 'deleting' };
|
|
163
|
+
if (error instanceof TaskStateError && /not found/.test(error.message))
|
|
164
|
+
return { task_id, status: 'gone' };
|
|
165
|
+
// An unreadable or unwritable record fails alone; tasks already read stay read and are reported.
|
|
166
|
+
return { task_id, status: 'failed' };
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -24,6 +24,18 @@ export declare function acknowledgeTaskReview(id: string, revision: number): {
|
|
|
24
24
|
progress: number;
|
|
25
25
|
review: boolean;
|
|
26
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Bulk read up to a captured cutoff: the first `progress` entries (Progress is append-only, so an
|
|
29
|
+
* entry appended after the capture has a higher index) and To review up to exactly `review`, never
|
|
30
|
+
* a newer revision. Both are written in one task change. Returns how many items that were unread
|
|
31
|
+
* are read now, or `changed` (nothing written) when the task no longer matches the cutoff, including a
|
|
32
|
+
* review revision the task never had; refuses a task pending deletion.
|
|
33
|
+
*/
|
|
34
|
+
export declare function acknowledgeTaskContributionsThrough(id: string, cutoff: {
|
|
35
|
+
progress: number;
|
|
36
|
+
progressLast: string | null;
|
|
37
|
+
review: number;
|
|
38
|
+
}): number | 'changed';
|
|
27
39
|
/** Atomic snapshots contain the events, including intermediate states between polls. */
|
|
28
40
|
/** `upgraded`: IDs of events queued by an earlier release without a delivery policy. They are sent push-only under the
|
|
29
41
|
* same event ID (state transitions never count as unread); the queued record itself is not rewritten. A service that
|
|
@@ -179,7 +179,7 @@ function writeTask(record, options = {}) {
|
|
|
179
179
|
const next = status(record), before = previous ? status(previous) : undefined;
|
|
180
180
|
pending.push({ eventId: `task:${record.task_id}:${randomUUID()}`, taskId: record.task_id,
|
|
181
181
|
title: `Task ${!previous ? 'created' : 'updated'}: ${taskLabel(record)}`.slice(0, 160),
|
|
182
|
-
body:
|
|
182
|
+
body: taskSentence(record, `${before ? `${before} → ` : 'created in '}${next}.`),
|
|
183
183
|
target: { kind: 'task', taskId: record.task_id }, delivery: 'push-only' });
|
|
184
184
|
}
|
|
185
185
|
pending.push(...contributionEvents(previous, record));
|
|
@@ -196,6 +196,11 @@ function writeTask(record, options = {}) {
|
|
|
196
196
|
}
|
|
197
197
|
/** Human label for notification titles: the mission key, when issued, precedes the title. */
|
|
198
198
|
const taskLabel = (record) => record.key ? `${record.key} · ${record.title}` : record.title;
|
|
199
|
+
/**
|
|
200
|
+
* Notification body naming the task by its mission key. The opaque task ID stays in routing and
|
|
201
|
+
* event fields only; a task without a key relies on its title, which the notification title shows.
|
|
202
|
+
*/
|
|
203
|
+
const taskSentence = (record, text) => record.key ? `Task ${record.key}: ${text}` : text[0].toUpperCase() + text.slice(1);
|
|
199
204
|
/** Packages written before revisions existed count as revision 1; their next change becomes 2. No package is 0. */
|
|
200
205
|
export const reviewRevision = (review) => review ? review.revision ?? 1 : 0;
|
|
201
206
|
const contributionsChanged = (previous, record) => (record.progress ?? []).some(entry => !(previous.progress ?? []).some(old => old.id === entry.id))
|
|
@@ -214,7 +219,7 @@ function contributionEvents(previous, record) {
|
|
|
214
219
|
const revision = reviewRevision(record.review);
|
|
215
220
|
if (revision > reviewRevision(previous.review))
|
|
216
221
|
events.push({ eventId: `task:${id}:review:${revision}`, taskId: id, title: `To review updated: ${taskLabel(record)}`.slice(0, 160),
|
|
217
|
-
body:
|
|
222
|
+
body: taskSentence(record, 'the review package changed.'), target: { kind: 'task', taskId: id }, delivery: 'push-only' });
|
|
218
223
|
return events;
|
|
219
224
|
}
|
|
220
225
|
/** Item-based Progress unread and revision-based To review unread. An absent read state means nothing is unread. */
|
|
@@ -261,6 +266,42 @@ export function acknowledgeTaskReview(id, revision) {
|
|
|
261
266
|
return contributionUnread(task);
|
|
262
267
|
});
|
|
263
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Bulk read up to a captured cutoff: the first `progress` entries (Progress is append-only, so an
|
|
271
|
+
* entry appended after the capture has a higher index) and To review up to exactly `review`, never
|
|
272
|
+
* a newer revision. Both are written in one task change. Returns how many items that were unread
|
|
273
|
+
* are read now, or `changed` (nothing written) when the task no longer matches the cutoff, including a
|
|
274
|
+
* review revision the task never had; refuses a task pending deletion.
|
|
275
|
+
*/
|
|
276
|
+
export function acknowledgeTaskContributionsThrough(id, cutoff) {
|
|
277
|
+
assertCanonicalTaskId(id);
|
|
278
|
+
return withTaskLock(id, () => {
|
|
279
|
+
const task = readTask(id);
|
|
280
|
+
assertNoPendingDeletion(task);
|
|
281
|
+
const { progress, progressLast, review } = cutoff;
|
|
282
|
+
// The cutoff names its last entry: a different entry at that position reads nothing.
|
|
283
|
+
const entries = task.progress ?? [];
|
|
284
|
+
if (!Number.isSafeInteger(progress) || progress < 0 || progress > entries.length
|
|
285
|
+
|| (progress > 0 ? entries[progress - 1].id : null) !== progressLast
|
|
286
|
+
|| !Number.isSafeInteger(review) || review < 0 || review > reviewRevision(task.review))
|
|
287
|
+
return 'changed';
|
|
288
|
+
const reads = task.contribution_reads;
|
|
289
|
+
if (!reads)
|
|
290
|
+
return 0;
|
|
291
|
+
const seen = new Set(reads.progress_read);
|
|
292
|
+
const added = entries.slice(0, progress).map(entry => entry.id).filter(entryId => !seen.has(entryId));
|
|
293
|
+
const reviewRead = review > reads.review_revision;
|
|
294
|
+
if (!added.length && !reviewRead)
|
|
295
|
+
return 0;
|
|
296
|
+
task.contribution_reads = {
|
|
297
|
+
progress_read: [...reads.progress_read, ...added],
|
|
298
|
+
review_revision: reviewRead ? review : reads.review_revision,
|
|
299
|
+
};
|
|
300
|
+
writeTask(task);
|
|
301
|
+
// An older revision moves the marker but leaves the current package unread: it is not counted.
|
|
302
|
+
return added.length + (reviewRead && review === reviewRevision(task.review) ? 1 : 0);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
264
305
|
const retiredNotificationsDir = () => join(tasksDir(), '.notification-retired');
|
|
265
306
|
function typedTaskNotification(value, id) {
|
|
266
307
|
if (!value || typeof value !== 'object' || Object.keys(value).some(key => !['eventId', 'title', 'body', 'target', 'taskId', 'delivery'].includes(key)))
|
|
@@ -50,6 +50,14 @@ export declare class FleetConfigService {
|
|
|
50
50
|
private validationInputs;
|
|
51
51
|
/** Cached during canonical reads; observer filtering must never parse YAML. */
|
|
52
52
|
resourceValidationInputs(): readonly string[];
|
|
53
|
+
/**
|
|
54
|
+
* Record the manifest's external file references before a projection that loads
|
|
55
|
+
* the configuration, so a reference anywhere (including a task workspace) fences
|
|
56
|
+
* that capture even before the configuration itself has been read. A manifest that
|
|
57
|
+
* cannot be read keeps the last recorded inputs; the projection reports its error.
|
|
58
|
+
*/
|
|
59
|
+
observeValidationInputs(): void;
|
|
60
|
+
private recordValidationInputs;
|
|
53
61
|
constructor(options?: FleetConfigServiceOptions);
|
|
54
62
|
read(includeDefinitions?: boolean): ConfigReadResult;
|
|
55
63
|
preview(baseRevision: string, input: unknown): Promise<ConfigPreviewResult>;
|
|
@@ -21,6 +21,28 @@ export class FleetConfigService {
|
|
|
21
21
|
validationInputs = [];
|
|
22
22
|
/** Cached during canonical reads; observer filtering must never parse YAML. */
|
|
23
23
|
resourceValidationInputs() { return this.validationInputs; }
|
|
24
|
+
/**
|
|
25
|
+
* Record the manifest's external file references before a projection that loads
|
|
26
|
+
* the configuration, so a reference anywhere (including a task workspace) fences
|
|
27
|
+
* that capture even before the configuration itself has been read. A manifest that
|
|
28
|
+
* cannot be read keeps the last recorded inputs; the projection reports its error.
|
|
29
|
+
*/
|
|
30
|
+
observeValidationInputs() {
|
|
31
|
+
try {
|
|
32
|
+
this.recordValidationInputs(parse(trustedFile(this.path), { prettyErrors: false }));
|
|
33
|
+
}
|
|
34
|
+
catch { /* unchanged */ }
|
|
35
|
+
}
|
|
36
|
+
recordValidationInputs(parsed) {
|
|
37
|
+
const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
38
|
+
const manifest = record(parsed);
|
|
39
|
+
const vars = record(manifest.vars);
|
|
40
|
+
const expand = (value) => value.replace(/\$\{(\w+)\}/g, (match, key) => key in vars ? String(vars[key]) : match);
|
|
41
|
+
const invite = record(record(manifest.rooms).owner).public_invite_file;
|
|
42
|
+
const prompts = Object.values(record(manifest.watchdogs)).map(value => record(value).prompt_file);
|
|
43
|
+
this.validationInputs = [...(typeof invite === 'string' ? [expand(expand(invite))] : []),
|
|
44
|
+
...prompts.filter((value) => typeof value === 'string').map(expand)].map(value => resolve(value));
|
|
45
|
+
}
|
|
24
46
|
constructor(options = {}) {
|
|
25
47
|
this.path = options.configPath ?? defaultConfigPath();
|
|
26
48
|
this.preflight = options.preflight ?? (async () => emptyReport());
|
|
@@ -30,16 +52,9 @@ export class FleetConfigService {
|
|
|
30
52
|
const sources = readSources(this.path);
|
|
31
53
|
let raw;
|
|
32
54
|
try {
|
|
33
|
-
const manifest = parse(sources.documents.get(basename(sources.manifest)), { prettyErrors: false });
|
|
34
|
-
const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
35
|
-
const vars = record(manifest.vars);
|
|
36
|
-
const expand = (value) => value.replace(/\$\{(\w+)\}/g, (match, key) => key in vars ? String(vars[key]) : match);
|
|
37
|
-
const invite = record(record(manifest.rooms).owner).public_invite_file;
|
|
38
|
-
const prompts = Object.values(record(manifest.watchdogs)).map(value => record(value).prompt_file);
|
|
39
55
|
// Record even invalid/missing references before validation, so repairing an
|
|
40
56
|
// already observed dependency can never retain a certified stale capture.
|
|
41
|
-
this.
|
|
42
|
-
...prompts.filter((value) => typeof value === 'string').map(expand)].map(value => resolve(value));
|
|
57
|
+
this.recordValidationInputs(parse(sources.documents.get(basename(sources.manifest)), { prettyErrors: false }));
|
|
43
58
|
loadConfig(this.path, { yamlMode: 'strict' });
|
|
44
59
|
raw = modelFrom(sources, includeDefinitions);
|
|
45
60
|
}
|
|
@@ -135,6 +135,8 @@ export declare class FleetResourceViews {
|
|
|
135
135
|
private coverage;
|
|
136
136
|
/** Certify each view's last projected source; never a position its capture did not read. */
|
|
137
137
|
private cover;
|
|
138
|
+
/** Whether `path` is, contains or lies within the manifest, its split definitions or another configuration input. */
|
|
139
|
+
private configurationDependency;
|
|
138
140
|
private affects;
|
|
139
141
|
private schedule;
|
|
140
142
|
private refresh;
|
|
@@ -364,22 +364,25 @@ export class FleetResourceViews {
|
|
|
364
364
|
view.qualifiedAt = now;
|
|
365
365
|
} });
|
|
366
366
|
}
|
|
367
|
+
/** Whether `path` is, contains or lies within the manifest, its split definitions or another configuration input. */
|
|
368
|
+
configurationDependency(path) {
|
|
369
|
+
const manifest = resolve(this.scope.configPath ?? defaultConfigPath());
|
|
370
|
+
const root = resolve(splitRootFor(manifest)), changed = resolve(path);
|
|
371
|
+
const within = (candidate, parent) => {
|
|
372
|
+
const local = relative(parent, candidate);
|
|
373
|
+
return local === '' || (local !== '..' && !local.startsWith('..' + sep));
|
|
374
|
+
};
|
|
375
|
+
// Configuration and provenance read the manifest and split definitions;
|
|
376
|
+
// loadConfig also rejects legacy YAML files in the global fleet.d root.
|
|
377
|
+
// Ancestor replacement/unknown directory wakes remain conservative.
|
|
378
|
+
const legacy = resolve(fleetDDir());
|
|
379
|
+
return within(changed, root) || within(root, changed) || within(manifest, changed)
|
|
380
|
+
|| within(changed, legacy) || within(legacy, changed)
|
|
381
|
+
|| this.configurationInputs().some(input => within(resolve(input), changed));
|
|
382
|
+
}
|
|
367
383
|
affects(descriptor, path, structural = false) {
|
|
368
|
-
if (descriptor.resource === 'configuration')
|
|
369
|
-
|
|
370
|
-
const root = resolve(splitRootFor(manifest)), changed = resolve(path);
|
|
371
|
-
const within = (candidate, parent) => {
|
|
372
|
-
const local = relative(parent, candidate);
|
|
373
|
-
return local === '' || (local !== '..' && !local.startsWith('..' + sep));
|
|
374
|
-
};
|
|
375
|
-
// Configuration and provenance read the manifest and split definitions;
|
|
376
|
-
// loadConfig also rejects legacy YAML files in the global fleet.d root.
|
|
377
|
-
// Ancestor replacement/unknown directory wakes remain conservative.
|
|
378
|
-
const legacy = resolve(fleetDDir());
|
|
379
|
-
return within(changed, root) || within(root, changed) || within(manifest, changed)
|
|
380
|
-
|| within(changed, legacy) || within(legacy, changed)
|
|
381
|
-
|| this.configurationInputs().some(input => within(resolve(input), changed));
|
|
382
|
-
}
|
|
384
|
+
if (descriptor.resource === 'configuration')
|
|
385
|
+
return this.configurationDependency(path);
|
|
383
386
|
// An explicitly selected manifest may use any pathname, including one
|
|
384
387
|
// otherwise reserved for an independent task receipt.
|
|
385
388
|
if (this.scope.configPath && resolve(path) === resolve(this.scope.configPath))
|
|
@@ -394,6 +397,14 @@ export class FleetResourceViews {
|
|
|
394
397
|
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
398
|
|| /^(?:workspace-devices\.db(?:-wal|-shm|-journal)?|audit[/\\]audit\.jsonl)$/.test(relative(resolve(stateRoot(), 'web'), path))))
|
|
396
399
|
return false;
|
|
400
|
+
// Task and room workspaces hold their agents' own execution artifacts (checkouts,
|
|
401
|
+
// builds, test logs); role projections read nothing there except declared role
|
|
402
|
+
// roots and configuration (manifest, split definitions), which stay fenced. Working
|
|
403
|
+
// agents write there continuously, which otherwise fences every directory capture
|
|
404
|
+
// until it resyncs. The workspace directories themselves and their ancestors remain full wakes.
|
|
405
|
+
if (roleProjection && !inRoleRoot && /^(?:tasks|rooms)[/\\][^/\\]+[/\\]/.test(relative(resolve(stateRoot(), 'workspaces'), path))
|
|
406
|
+
&& !this.configurationDependency(path))
|
|
407
|
+
return false;
|
|
397
408
|
// Preview selection reads role config/state and conversation ledgers, never
|
|
398
409
|
// task layout run/operation/brief receipts. A worker may commit several of
|
|
399
410
|
// these while that independent canonical preview read is in flight.
|
package/dist/web/server.js
CHANGED
|
@@ -25,6 +25,7 @@ import { FleetEventBus } from './events.js';
|
|
|
25
25
|
import { ROLE_NAME_RE } from '../config.js';
|
|
26
26
|
import { TaskListError } from '../rooms-tasks/task-lists.js';
|
|
27
27
|
import { acknowledgeTaskProgress, acknowledgeTaskReview, contributionUnread, resolveTaskRef, TaskStateError } from '../rooms-tasks/task-state.js';
|
|
28
|
+
import { CONTRIBUTION_CUTOFF_MAX, CONTRIBUTION_PAGE_MAX, listTaskContributions, readTaskContributions } from '../rooms-tasks/task-contributions.js';
|
|
28
29
|
import { MissionKeyError } from '../rooms-tasks/mission-keys.js';
|
|
29
30
|
import { appendTaskProgress, getTaskReview, taskProgressSnapshot, publicProgressEntry, publicReview, readTaskReviewArtifact, setTaskReviewMarkdown, taskContributionSummary, TaskReviewError, } from '../rooms-tasks/task-review.js';
|
|
30
31
|
import { isProvider } from '../subscriptions/store.js';
|
|
@@ -424,6 +425,9 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
424
425
|
const project = async (descriptor, signal, owner = '') => {
|
|
425
426
|
signal.throwIfAborted();
|
|
426
427
|
services.invalidateResourceCaches?.();
|
|
428
|
+
// Role projections load the configuration: fence this capture on its external inputs too.
|
|
429
|
+
if (['directory', 'roles', 'previews'].includes(descriptor.resource))
|
|
430
|
+
services.configuration?.observeValidationInputs();
|
|
427
431
|
switch (descriptor.resource) {
|
|
428
432
|
case 'task-layout':
|
|
429
433
|
try {
|
|
@@ -683,6 +687,39 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
683
687
|
reply.code(201);
|
|
684
688
|
return { entry: publicProgressEntry(result.entry), replayed: result.replayed };
|
|
685
689
|
});
|
|
690
|
+
/**
|
|
691
|
+
* Progress and To review of every task, newest first, for the notification list. The first page
|
|
692
|
+
* (no `before`) also carries the cutoff a bulk read may acknowledge up to: each task with unread
|
|
693
|
+
* items as it was read, one task at a time (per task, not one atomic instant across tasks).
|
|
694
|
+
*/
|
|
695
|
+
app.get('/api/v1/contributions', async (request) => {
|
|
696
|
+
auth.authenticate(request);
|
|
697
|
+
const query = request.query;
|
|
698
|
+
if (query.unread !== undefined && !['true', 'false'].includes(query.unread))
|
|
699
|
+
throw new FleetError('invalid_request', 'unread must be true or false');
|
|
700
|
+
if (query.limit !== undefined && !/^\d{1,3}$/.test(query.limit))
|
|
701
|
+
throw new FleetError('invalid_request', `limit must be 1-${CONTRIBUTION_PAGE_MAX}`);
|
|
702
|
+
const page = await reviewApi(() => listTaskContributions({
|
|
703
|
+
...(query.before !== undefined ? { before: query.before } : {}),
|
|
704
|
+
...(query.limit !== undefined ? { limit: Number(query.limit) } : {}),
|
|
705
|
+
unread: query.unread === 'true',
|
|
706
|
+
}));
|
|
707
|
+
const { cutoff, ...rest } = page;
|
|
708
|
+
return query.before === undefined ? page : rest;
|
|
709
|
+
});
|
|
710
|
+
/** Bulk read up to a captured cutoff, at most `CONTRIBUTION_CUTOFF_MAX` tasks per request, with a result per task. */
|
|
711
|
+
app.post('/api/v1/contributions/read', async (request) => {
|
|
712
|
+
auth.authenticate(request, true);
|
|
713
|
+
const cutoff = request.body?.cutoff;
|
|
714
|
+
const valid = (item) => !!item && typeof item === 'object' && Object.keys(item).length === 4
|
|
715
|
+
&& typeof item.task_id === 'string' && /^[0-9a-z]{9}[0-9a-f]{8}$/.test(item.task_id) && Number.isSafeInteger(item.progress) && item.progress >= 0
|
|
716
|
+
&& (item.progress_last === null || (typeof item.progress_last === 'string' && item.progress_last.length <= 64))
|
|
717
|
+
&& Number.isSafeInteger(item.review) && item.review >= 0;
|
|
718
|
+
if (!Array.isArray(cutoff) || cutoff.length > CONTRIBUTION_CUTOFF_MAX || !cutoff.every(valid)
|
|
719
|
+
|| new Set(cutoff.map(item => item.task_id)).size !== cutoff.length)
|
|
720
|
+
throw new FleetError('invalid_request', `cutoff must list at most ${CONTRIBUTION_CUTOFF_MAX} distinct tasks as captured`);
|
|
721
|
+
return { results: await reviewApi(() => readTaskContributions(cutoff)) };
|
|
722
|
+
});
|
|
686
723
|
/** Acknowledge Progress entries the browser actually showed; returns the task's remaining unread. */
|
|
687
724
|
app.post('/api/v1/tasks/:id/progress/read', async (request) => {
|
|
688
725
|
auth.authenticate(request, true);
|
|
@@ -155,6 +155,8 @@ workspace-device bearer. `GET /api/v1/meta` reports API minor version 1.
|
|
|
155
155
|
| `GET /tasks/:id/review` | `{review: {revision, markdown, updated_at, updated_by, artifacts: [{id, name, mime, size, sha256, added_at, added_by}]}}` or `{review: null}`. |
|
|
156
156
|
| `PUT /tasks/:id/review` | `{markdown, expectedMarkdown}`; 409 when the Markdown changed meanwhile. |
|
|
157
157
|
| `GET /tasks/:id/review/artifacts/:artifactId` | The retained bytes as `application/octet-stream` attachment with `Content-Security-Policy: default-src 'none'; sandbox`, `nosniff` and `no-store`. Another task's artifact ID is 404. |
|
|
158
|
+
| `GET /contributions?limit=1-100&before=&unread=true` | Every task's Progress entries and current To review revision for the notification list, newest first: `{items: [{id, kind, task_id, mission_key?, task_title, task_state, at, excerpt, unread, entry_id? \| revision?}], next_cursor, unread_total, unavailable}`. The cursor is a position, valid after its item is read or deleted. Tasks pending deletion are left out; `unavailable` counts unreadable task records. The first page also has `cutoff: [{task_id, progress, progress_last, review}]` for every task with unread items, each captured from that task's own read (per task, not one instant across tasks). |
|
|
159
|
+
| `POST /contributions/read` | `{cutoff}` with at most 200 distinct tasks from a first page: reads each task's first `progress` entries and review revision `review`, never later ones, one task change each. `{results: [{task_id, status}]}` in request order, `status` `read` (with `read`: items that were unread and are read now), `changed` (the task no longer matches its cutoff; nothing written), `gone`, `deleting` or `failed`. |
|
|
158
160
|
|
|
159
161
|
The HTTP API has no upload route: the browser never submits a file path.
|
|
160
162
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.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",
|