@ours.network/fleet 1.2.0-nightly.80 → 1.2.0-nightly.81
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 +1 -0
- package/dist/agent-ours/mcp-endpoint.d.ts +3 -0
- package/dist/agent-ours/mcp-endpoint.js +3 -0
- package/dist/agent-ours/service.d.ts +3 -0
- package/dist/agent-ours/service.js +10 -0
- package/dist/briefing.js +23 -0
- package/dist/build-info.json +4 -4
- package/dist/cli.js +2 -1
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +20 -0
- package/dist/fleet-command-audit.d.ts +6 -0
- package/dist/fleet-command-audit.js +7 -1
- package/dist/preset-migration.js +12 -0
- package/dist/rooms-tasks/cli.js +117 -0
- package/dist/rooms-tasks/deletion.js +3 -0
- package/dist/rooms-tasks/task-review.d.ts +113 -0
- package/dist/rooms-tasks/task-review.js +561 -0
- package/dist/rooms-tasks/task-state.d.ts +8 -0
- package/dist/rooms-tasks/task-state.js +115 -29
- package/dist/rooms-tasks/task-tools.d.ts +35 -0
- package/dist/rooms-tasks/task-tools.js +134 -0
- package/dist/rooms-tasks/types.d.ts +43 -0
- package/dist/web/resource-views.js +12 -4
- package/dist/web/server.js +80 -11
- package/docs/task-progress-review.md +143 -0
- package/docs/task-workspaces.md +1 -1
- package/package.json +3 -2
- package/presets/fleet/agent_templates/LocalCoordinator.yaml +2 -1
- package/presets/fleet/roles/Coordinator.yaml +9 -2
- package/presets/fleet/roles/Critic.yaml +11 -0
- package/presets/fleet/roles/Developer.yaml +19 -0
- package/presets/fleet/roles/Engineer.yaml +19 -0
- package/presets/fleet/roles/LocalCoordinator.yaml +17 -0
- package/presets/manifest.json +1 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { publishFleetResourceCommit } from '../application/resource-observer.js';
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, rmSync, renameSync } from 'node:fs';
|
|
2
|
+
import { existsSync, linkSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, rmSync, renameSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { replaceFileAtomically } from '../atomic-file.js';
|
|
5
|
+
import { replaceFileAtomically, withSynchronousFileLock } from '../atomic-file.js';
|
|
6
|
+
import { incarnationEnded, processIncarnation } from '../process-incarnation.js';
|
|
6
7
|
import { stateRoot } from '../paths.js';
|
|
7
8
|
import { planWorkspace, ensureWorkspace } from './workspace.js';
|
|
8
9
|
import { storedRoomLaunchPolicy, TASK_TERMINAL_STATES, TASK_CANCELLABLE_STATES } from './types.js';
|
|
@@ -11,56 +12,125 @@ export const tasksDir = () => join(stateRoot(), 'tasks');
|
|
|
11
12
|
function taskPath(id) { return join(tasksDir(), `${id}.json`); }
|
|
12
13
|
function taskLockPath(id) { return join(tasksDir(), '.locks', id); }
|
|
13
14
|
const localTaskLocks = new Set();
|
|
15
|
+
/**
|
|
16
|
+
* Remove a lock atomically: rename it to a unique name first, so no other
|
|
17
|
+
* process ever observes a half-removed lock.
|
|
18
|
+
*/
|
|
19
|
+
function retireLock(path) {
|
|
20
|
+
const retired = `${path}.retired.${process.pid}.${randomUUID()}`;
|
|
21
|
+
try {
|
|
22
|
+
renameSync(path, retired);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code === 'ENOENT')
|
|
26
|
+
return;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
rmSync(retired, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
/** A legacy lock directory without an owner file younger than this may still be releasing. */
|
|
32
|
+
const LEGACY_OWNERLESS_LOCK_GRACE_MS = 5_000;
|
|
33
|
+
/**
|
|
34
|
+
* Hold a task's cross-process lock while `fn` runs.
|
|
35
|
+
*
|
|
36
|
+
* The lock is a file holding its owner record, published complete by hard-linking
|
|
37
|
+
* a written temporary file: link never replaces an existing lock, and there is no
|
|
38
|
+
* moment at which a live claimer has an ownerless lock that could look abandoned.
|
|
39
|
+
* A lock is reclaimed only when its owner process has provably ended; reclaimers
|
|
40
|
+
* take turns and remove only the lock they observed. Directory locks are left by
|
|
41
|
+
* earlier versions; they are honored, and an ownerless one is reclaimed once it is
|
|
42
|
+
* old enough, since this version never creates one.
|
|
43
|
+
*/
|
|
14
44
|
function withTaskLock(id, fn) {
|
|
15
45
|
if (localTaskLocks.has(id))
|
|
16
46
|
return fn();
|
|
17
47
|
const path = taskLockPath(id);
|
|
18
|
-
const ownerPath = join(path, 'owner.json');
|
|
19
48
|
const token = randomUUID();
|
|
20
49
|
mkdirSync(join(tasksDir(), '.locks'), { recursive: true });
|
|
21
|
-
|
|
22
|
-
const claimPath = `${path}.claim.${process.pid}.${randomUUID()}`;
|
|
50
|
+
const readOwner = () => {
|
|
23
51
|
try {
|
|
24
|
-
|
|
25
|
-
writeFileSync(join(claimPath, 'owner.json'), JSON.stringify({ token, pid: process.pid }));
|
|
26
|
-
renameSync(claimPath, path);
|
|
27
|
-
break;
|
|
52
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
28
53
|
}
|
|
29
54
|
catch (error) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
55
|
+
if (error.code !== 'EISDIR')
|
|
56
|
+
return undefined;
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(readFileSync(join(path, 'owner.json'), 'utf8'));
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
/** Who holds the lock now, and whether it can be proved abandoned. */
|
|
66
|
+
const holder = () => {
|
|
67
|
+
let stat;
|
|
68
|
+
try {
|
|
69
|
+
stat = lstatSync(path);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const owner = readOwner();
|
|
75
|
+
if (owner && typeof owner.pid === 'number') {
|
|
33
76
|
let alive = false;
|
|
34
77
|
try {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
catch { /* dead */ }
|
|
42
|
-
}
|
|
78
|
+
process.kill(owner.pid, 0);
|
|
79
|
+
alive = true;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
alive = error.code !== 'ESRCH';
|
|
43
83
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
84
|
+
return { token: owner.token, stale: !alive || incarnationEnded(owner.pid, owner) };
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
token: undefined,
|
|
88
|
+
stale: stat.isDirectory() && Date.now() - stat.mtimeMs > LEGACY_OWNERLESS_LOCK_GRACE_MS,
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
const claimPath = `${path}.claim.${process.pid}.${randomUUID()}`;
|
|
92
|
+
writeFileSync(claimPath, JSON.stringify({ token, pid: process.pid, ...processIncarnation(process.pid) }));
|
|
93
|
+
try {
|
|
94
|
+
for (;;) {
|
|
95
|
+
try {
|
|
96
|
+
linkSync(claimPath, path);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error.code !== 'EEXIST')
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
const observed = holder();
|
|
104
|
+
if (observed?.stale) {
|
|
105
|
+
try {
|
|
106
|
+
withSynchronousFileLock(`${path}.reclaim`, () => {
|
|
107
|
+
const current = holder();
|
|
108
|
+
if (current?.stale && current.token === observed.token)
|
|
109
|
+
retireLock(path);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
// A contender stalled inside the reclaim section: keep waiting like any other poll.
|
|
114
|
+
if (!(error instanceof Error) || !/^Timed out acquiring synchronous/.test(error.message))
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
47
117
|
continue;
|
|
48
118
|
}
|
|
49
119
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
50
120
|
}
|
|
51
121
|
}
|
|
122
|
+
finally {
|
|
123
|
+
rmSync(claimPath, { force: true });
|
|
124
|
+
}
|
|
52
125
|
localTaskLocks.add(id);
|
|
53
126
|
try {
|
|
54
127
|
return fn();
|
|
55
128
|
}
|
|
56
129
|
finally {
|
|
57
130
|
localTaskLocks.delete(id);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
rmSync(path, { recursive: true, force: true });
|
|
62
|
-
}
|
|
63
|
-
catch { /* ownership cannot be proved */ }
|
|
131
|
+
// Only this live owner removes its lock; reclaimers never take a live owner's lock.
|
|
132
|
+
if (readOwner()?.token === token)
|
|
133
|
+
retireLock(path);
|
|
64
134
|
}
|
|
65
135
|
}
|
|
66
136
|
function generateTaskId() {
|
|
@@ -985,6 +1055,22 @@ export function updateTaskLayout(id, expected, next) {
|
|
|
985
1055
|
return { task: readTask(id), ...(releasedSnapshot ? { releasedSnapshot } : {}) };
|
|
986
1056
|
});
|
|
987
1057
|
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Progress and To review edits: one read-modify-write under the task lock, so
|
|
1060
|
+
* concurrent writers never lose each other's changes. Lifecycle state is untouched.
|
|
1061
|
+
*/
|
|
1062
|
+
export function updateTaskContribution(id, change) {
|
|
1063
|
+
assertCanonicalTaskId(id);
|
|
1064
|
+
return withTaskLock(id, () => {
|
|
1065
|
+
const task = readTask(id);
|
|
1066
|
+
assertNoPendingDeletion(task);
|
|
1067
|
+
assertNoPendingTerminalIntent(task);
|
|
1068
|
+
const { value, changed } = change(task);
|
|
1069
|
+
if (changed)
|
|
1070
|
+
writeTask(task);
|
|
1071
|
+
return value;
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
988
1074
|
export function updateTaskBrief(id, brief, expectedBrief) {
|
|
989
1075
|
assertCanonicalTaskId(id);
|
|
990
1076
|
return withTaskLock(id, () => {
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { publicProgressEntry } from './task-review.js';
|
|
3
|
+
import type { TaskContributionAuthor, TaskProgressEntry } from './types.js';
|
|
4
|
+
/** The task this agent belongs to, from supervisor launch records; never from tool arguments. */
|
|
5
|
+
export interface TaskToolBinding {
|
|
6
|
+
taskId: string;
|
|
7
|
+
role: string;
|
|
8
|
+
identityCid?: string;
|
|
9
|
+
/** Launched as a task-owned temporary member (layout participant). */
|
|
10
|
+
taskOwned: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare const TASK_TOOL_NAMES: readonly ["fleet_task_progress_list", "fleet_task_progress_append", "fleet_task_review_show", "fleet_task_review_set", "fleet_task_review_detach"];
|
|
13
|
+
export declare const PROGRESS_PAGE_MAX = 50;
|
|
14
|
+
/** One chronological page of the log; `next_after` continues after the last returned entry. */
|
|
15
|
+
export declare function progressPage(entries: TaskProgressEntry[], input: {
|
|
16
|
+
after?: string;
|
|
17
|
+
limit?: number;
|
|
18
|
+
}): {
|
|
19
|
+
progress: ReturnType<typeof publicProgressEntry>[];
|
|
20
|
+
total: number;
|
|
21
|
+
next_after?: string;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Confirm the binding against the task record: a listed member must match by
|
|
25
|
+
* name and identity; an unlisted caller must be a task-owned launch.
|
|
26
|
+
*/
|
|
27
|
+
export declare function verifiedTaskAuthor(binding: TaskToolBinding): TaskContributionAuthor;
|
|
28
|
+
/**
|
|
29
|
+
* Task Progress and To review tools on the agent's own managed MCP endpoint.
|
|
30
|
+
* The task and author come from the caller's supervisor, so an agent can only
|
|
31
|
+
* read and write the task it was launched for. Review files are attached with
|
|
32
|
+
* `ours-fleet task review-package <id> --attach <path>`, which reads the file
|
|
33
|
+
* under the agent's own permissions.
|
|
34
|
+
*/
|
|
35
|
+
export declare function registerTaskTools(server: McpServer, binding: () => TaskToolBinding | undefined, admit: () => Promise<() => void>): void;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { appendTaskProgress, detachTaskReviewArtifact, getTaskReview, listTaskProgress, publicProgressEntry, publicReview, setTaskReviewMarkdown, TaskReviewError, PROGRESS_TEXT_MAX, REVIEW_MARKDOWN_MAX, } from './task-review.js';
|
|
3
|
+
import { getTask, TaskStateError } from './task-state.js';
|
|
4
|
+
export const TASK_TOOL_NAMES = Object.freeze([
|
|
5
|
+
'fleet_task_progress_list', 'fleet_task_progress_append',
|
|
6
|
+
'fleet_task_review_show', 'fleet_task_review_set', 'fleet_task_review_detach',
|
|
7
|
+
]);
|
|
8
|
+
/** Compact JSON: a result is carried twice (text and structured) inside one bounded bridge frame. */
|
|
9
|
+
function result(value) {
|
|
10
|
+
return {
|
|
11
|
+
content: [{ type: 'text', text: JSON.stringify(value) }],
|
|
12
|
+
structuredContent: value,
|
|
13
|
+
isError: false,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export const PROGRESS_PAGE_MAX = 50;
|
|
17
|
+
/** Serialized entry bytes per page, well inside the bridge's frame limit even at maximal escaping. */
|
|
18
|
+
const PROGRESS_PAGE_BYTES = 200_000;
|
|
19
|
+
/** One chronological page of the log; `next_after` continues after the last returned entry. */
|
|
20
|
+
export function progressPage(entries, input) {
|
|
21
|
+
const limit = input.limit ?? PROGRESS_PAGE_MAX;
|
|
22
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > PROGRESS_PAGE_MAX)
|
|
23
|
+
throw new TaskReviewError('invalid_request', `limit must be 1..${PROGRESS_PAGE_MAX}`);
|
|
24
|
+
let start = 0;
|
|
25
|
+
if (input.after !== undefined) {
|
|
26
|
+
start = entries.findIndex(entry => entry.id === input.after) + 1;
|
|
27
|
+
if (!start)
|
|
28
|
+
throw new TaskReviewError('invalid_request', 'after must be the id of an entry in this log');
|
|
29
|
+
}
|
|
30
|
+
const page = [];
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
for (const entry of entries.slice(start, start + limit)) {
|
|
33
|
+
const visible = publicProgressEntry(entry), size = Buffer.byteLength(JSON.stringify(visible));
|
|
34
|
+
if (page.length && bytes + size > PROGRESS_PAGE_BYTES)
|
|
35
|
+
break;
|
|
36
|
+
page.push(visible);
|
|
37
|
+
bytes += size;
|
|
38
|
+
}
|
|
39
|
+
const end = start + page.length;
|
|
40
|
+
return { progress: page, total: entries.length, ...(end < entries.length ? { next_after: entries[end - 1].id } : {}) };
|
|
41
|
+
}
|
|
42
|
+
function failure(code, message) {
|
|
43
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: code, message }) }], isError: true };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Confirm the binding against the task record: a listed member must match by
|
|
47
|
+
* name and identity; an unlisted caller must be a task-owned launch.
|
|
48
|
+
*/
|
|
49
|
+
export function verifiedTaskAuthor(binding) {
|
|
50
|
+
const task = getTask(binding.taskId);
|
|
51
|
+
const member = task.member_roles.find(item => item.name === binding.role);
|
|
52
|
+
if (member ? (binding.identityCid && member.identity_cid !== binding.identityCid) : !binding.taskOwned)
|
|
53
|
+
throw new TaskReviewError('conflict', `agent ${binding.role} is not a member of task ${binding.taskId}`);
|
|
54
|
+
return { kind: 'agent', role: binding.role, ...(binding.identityCid ? { identity_cid: binding.identityCid } : {}), surface: 'mcp' };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Task Progress and To review tools on the agent's own managed MCP endpoint.
|
|
58
|
+
* The task and author come from the caller's supervisor, so an agent can only
|
|
59
|
+
* read and write the task it was launched for. Review files are attached with
|
|
60
|
+
* `ours-fleet task review-package <id> --attach <path>`, which reads the file
|
|
61
|
+
* under the agent's own permissions.
|
|
62
|
+
*/
|
|
63
|
+
export function registerTaskTools(server, binding, admit) {
|
|
64
|
+
const run = async (operation) => {
|
|
65
|
+
let release;
|
|
66
|
+
try {
|
|
67
|
+
release = await admit();
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return failure('runtime_not_ready', 'the managed runtime is not admitting tool calls right now; nothing was read or changed');
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const bound = binding();
|
|
74
|
+
if (!bound)
|
|
75
|
+
return failure('task_unavailable', 'this agent was not launched for a Fleet task');
|
|
76
|
+
try {
|
|
77
|
+
return result(operation(bound, verifiedTaskAuthor(bound)));
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
if (error instanceof TaskReviewError)
|
|
81
|
+
return failure(error.code, error.message);
|
|
82
|
+
if (error instanceof TaskStateError)
|
|
83
|
+
return failure('task_unavailable', error.message);
|
|
84
|
+
return failure('task_store_failed', 'the task could not be read or updated; nothing was changed');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
release();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
server.registerTool('fleet_task_progress_list', {
|
|
92
|
+
description: 'Read the Progress milestone log of the task you were launched for, oldest first, one page at a time. '
|
|
93
|
+
+ 'When next_after is present, call again with after set to it for the following entries.',
|
|
94
|
+
inputSchema: {
|
|
95
|
+
after: z.string().optional().describe('Entry id from the previous page\'s next_after; omit for the first page.'),
|
|
96
|
+
limit: z.number().int().optional().describe(`Maximum entries per page (1..${PROGRESS_PAGE_MAX}, default ${PROGRESS_PAGE_MAX}). Pages are also bounded in size.`),
|
|
97
|
+
},
|
|
98
|
+
}, async (args) => run(bound => ({ task_id: bound.taskId, ...progressPage(listTaskProgress(bound.taskId), args) })));
|
|
99
|
+
server.registerTool('fleet_task_progress_append', {
|
|
100
|
+
description: 'Append ONE significant, verified milestone to your task\'s Progress log (e.g. root cause found, PR opened with link, '
|
|
101
|
+
+ 'measured result with its scope, final deliverable ready). Not for routine status, handoffs or review chatter. '
|
|
102
|
+
+ 'Entries are permanent and shown to the Owner as written. Repeating a call with the same request_key and text returns the original entry.',
|
|
103
|
+
inputSchema: {
|
|
104
|
+
text: z.string().describe(`Free-form milestone text, kept exactly as written (max ${PROGRESS_TEXT_MAX} characters).`),
|
|
105
|
+
request_key: z.string().optional().describe('Stable key for this milestone so a retried call cannot add it twice (e.g. "pr-opened-123").'),
|
|
106
|
+
},
|
|
107
|
+
}, async (args) => run((bound, author) => {
|
|
108
|
+
const appended = appendTaskProgress(bound.taskId, { text: args.text, requestKey: args.request_key, author });
|
|
109
|
+
return { task_id: bound.taskId, entry: publicProgressEntry(appended.entry), replayed: appended.replayed };
|
|
110
|
+
}));
|
|
111
|
+
server.registerTool('fleet_task_review_show', {
|
|
112
|
+
description: 'Read your task\'s To review package: its Markdown and the retained files attached to it.',
|
|
113
|
+
inputSchema: {},
|
|
114
|
+
}, async () => run(bound => {
|
|
115
|
+
const review = getTaskReview(bound.taskId);
|
|
116
|
+
return { task_id: bound.taskId, review: review ? publicReview(review) : null };
|
|
117
|
+
}));
|
|
118
|
+
server.registerTool('fleet_task_review_set', {
|
|
119
|
+
description: 'Replace the Markdown of your task\'s To review package: the self-contained material the Owner reviews without reading the room or task description '
|
|
120
|
+
+ '(outcome, clickable PR/spec links, validation and CI, independent verdict, limits, decisions needed). '
|
|
121
|
+
+ 'Pass the Markdown you last read as expected_markdown ("" when empty); a concurrent change is refused instead of overwritten.',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
markdown: z.string().describe(`Review Markdown (max ${REVIEW_MARKDOWN_MAX} characters). Links use the same safe rules as task descriptions.`),
|
|
124
|
+
expected_markdown: z.string().describe('The current Markdown as you last read it, or "" if there is none.'),
|
|
125
|
+
},
|
|
126
|
+
}, async (args) => run((bound, author) => ({
|
|
127
|
+
task_id: bound.taskId,
|
|
128
|
+
review: publicReview(setTaskReviewMarkdown(bound.taskId, { markdown: args.markdown, expectedMarkdown: args.expected_markdown, author })),
|
|
129
|
+
})));
|
|
130
|
+
server.registerTool('fleet_task_review_detach', {
|
|
131
|
+
description: 'Remove one file from your task\'s To review package by artifact_id (from fleet_task_review_show).',
|
|
132
|
+
inputSchema: { artifact_id: z.string() },
|
|
133
|
+
}, async (args) => run((bound, author) => ({ task_id: bound.taskId, detached: detachTaskReviewArtifact(bound.taskId, args.artifact_id, { author }) })));
|
|
134
|
+
}
|
|
@@ -116,7 +116,50 @@ export interface TaskNotificationEvent {
|
|
|
116
116
|
};
|
|
117
117
|
taskId: string;
|
|
118
118
|
}
|
|
119
|
+
/** Who wrote a progress entry or review change, as established by the Fleet surface. */
|
|
120
|
+
export type TaskContributionAuthor = {
|
|
121
|
+
kind: 'agent';
|
|
122
|
+
role: string;
|
|
123
|
+
identity_cid?: string;
|
|
124
|
+
surface: 'mcp' | 'cli';
|
|
125
|
+
} | {
|
|
126
|
+
kind: 'local_control';
|
|
127
|
+
surface: 'cli' | 'web';
|
|
128
|
+
};
|
|
129
|
+
/** One append-only milestone; text is stored exactly as authored. */
|
|
130
|
+
export interface TaskProgressEntry {
|
|
131
|
+
id: string;
|
|
132
|
+
at: string;
|
|
133
|
+
author: TaskContributionAuthor;
|
|
134
|
+
text: string;
|
|
135
|
+
/** Caller retry key, scoped to the author. */
|
|
136
|
+
request_key?: string;
|
|
137
|
+
}
|
|
138
|
+
/** Metadata of a retained review file; bytes live in Fleet's private task review store. */
|
|
139
|
+
export interface TaskReviewArtifact {
|
|
140
|
+
id: string;
|
|
141
|
+
name: string;
|
|
142
|
+
mime: string;
|
|
143
|
+
size: number;
|
|
144
|
+
sha256: string;
|
|
145
|
+
added_at: string;
|
|
146
|
+
added_by: TaskContributionAuthor;
|
|
147
|
+
request_key?: string;
|
|
148
|
+
}
|
|
149
|
+
/** Self-contained material the Owner reviews: Markdown plus retained files. */
|
|
150
|
+
export interface TaskReview {
|
|
151
|
+
/** Increases with every change to the Markdown or the files; absent on packages written before it. */
|
|
152
|
+
revision?: number;
|
|
153
|
+
markdown: string;
|
|
154
|
+
updated_at: string;
|
|
155
|
+
updated_by: TaskContributionAuthor;
|
|
156
|
+
artifacts: TaskReviewArtifact[];
|
|
157
|
+
}
|
|
119
158
|
export interface TaskRecord {
|
|
159
|
+
/** Optional milestone log; absent on tasks that never recorded one. */
|
|
160
|
+
progress?: TaskProgressEntry[];
|
|
161
|
+
/** Optional To review package; absent until an agent prepares it. */
|
|
162
|
+
review?: TaskReview;
|
|
120
163
|
/** Pending lifecycle events, committed atomically with their task change. */
|
|
121
164
|
notification_events?: TaskNotificationEvent[];
|
|
122
165
|
workspace?: import('./workspace.js').OwnedWorkspace;
|
|
@@ -291,9 +291,10 @@ export class FleetResourceViews {
|
|
|
291
291
|
void initial.catch(() => { });
|
|
292
292
|
const controller = new AbortController();
|
|
293
293
|
const source = connection.observer.cursor(path => this.affects(descriptor, path), controller.signal);
|
|
294
|
-
// Independent agents contribute ordered, last-observed statuses
|
|
295
|
-
//
|
|
296
|
-
|
|
294
|
+
// Independent agents contribute ordered, last-observed statuses and completed
|
|
295
|
+
// preview records. Activity wakes follow-up reads without revoking structural
|
|
296
|
+
// capture; the read still certifies only its starting source watermark.
|
|
297
|
+
const captureSource = descriptor.resource === 'directory' || descriptor.resource === 'roles' || descriptor.resource === 'previews'
|
|
297
298
|
? connection.observer.cursor(path => this.affects(descriptor, path, true), controller.signal)
|
|
298
299
|
: source;
|
|
299
300
|
const view = { source, captureSource, scheduledPosition: -1, descriptor, epoch, baselineId: randomUUID(), controller, revision: 0, sequence: 0, dirty: false, running: false, failed: false, initial, resolveInitial, rejectInitial };
|
|
@@ -406,8 +407,15 @@ export class FleetResourceViews {
|
|
|
406
407
|
continue;
|
|
407
408
|
if (structural && (descriptor.resource === 'directory' || descriptor.resource === 'roles'))
|
|
408
409
|
return false;
|
|
409
|
-
if (descriptor.resource === 'previews')
|
|
410
|
+
if (descriptor.resource === 'previews') {
|
|
411
|
+
// conversationPreview reads bounded completed records, independently per
|
|
412
|
+
// role. These known writer paths still wake ordered follow-up captures.
|
|
413
|
+
// Unknown children, parent replacement and explicit config paths retain
|
|
414
|
+
// their capture fence; do not waive the whole conversation subtree.
|
|
415
|
+
if (structural && parts.length === 3 && /^(?:events-\d{6}\.jsonl|manifest\.json)$/.test(parts[2]))
|
|
416
|
+
return false;
|
|
410
417
|
return (descriptor.ids ?? []).includes(parts[0]);
|
|
418
|
+
}
|
|
411
419
|
// Directory and roles intentionally remain conservative: live status
|
|
412
420
|
// includes activity. Configuration was classified by its inputs above.
|
|
413
421
|
}
|
package/dist/web/server.js
CHANGED
|
@@ -26,6 +26,7 @@ import { FleetEventBus } from './events.js';
|
|
|
26
26
|
import { ROLE_NAME_RE } from '../config.js';
|
|
27
27
|
import { TaskListError } from '../rooms-tasks/task-lists.js';
|
|
28
28
|
import { TaskStateError } from '../rooms-tasks/task-state.js';
|
|
29
|
+
import { appendTaskProgress, getTaskReview, listTaskProgress, publicProgressEntry, publicReview, readTaskReviewArtifact, setTaskReviewMarkdown, taskContributionSummary, TaskReviewError, } from '../rooms-tasks/task-review.js';
|
|
29
30
|
import { isProvider } from '../subscriptions/store.js';
|
|
30
31
|
const ONBOARDING_WORK = ['coordination', 'development', 'review'];
|
|
31
32
|
const statusFor = (code) => ({
|
|
@@ -189,7 +190,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
189
190
|
app.get('/api/v1/meta', async (request) => {
|
|
190
191
|
auth.authenticate(request);
|
|
191
192
|
return {
|
|
192
|
-
version: VERSION, api: { major: 1, minor:
|
|
193
|
+
version: VERSION, api: { major: 1, minor: 1 },
|
|
193
194
|
websocketProtocols: [
|
|
194
195
|
'ours-fleet-events.v1', 'ours-fleet-conversation.v1',
|
|
195
196
|
],
|
|
@@ -327,6 +328,31 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
327
328
|
throw error;
|
|
328
329
|
}
|
|
329
330
|
};
|
|
331
|
+
const reviewApi = async (fn) => taskApi(async () => {
|
|
332
|
+
try {
|
|
333
|
+
return await fn();
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
if (error instanceof TaskReviewError) {
|
|
337
|
+
const code = error.code === 'not_found' ? 'resource_not_found' : error.code === 'limit_exceeded' ? 'conflict' : error.code;
|
|
338
|
+
throw new FleetError(code, error.message);
|
|
339
|
+
}
|
|
340
|
+
if (error instanceof TaskStateError && error.message.startsWith('invalid task ID'))
|
|
341
|
+
throw new FleetError('resource_not_found', 'task not found');
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
/** Browser task payloads carry Progress/To review counts; bodies have their own routes. */
|
|
346
|
+
const wireTask = (task) => {
|
|
347
|
+
const { progress: _progress, review: _review, ...rest } = task;
|
|
348
|
+
return { ...rest, ...taskContributionSummary(task) };
|
|
349
|
+
};
|
|
350
|
+
const wireTaskResult = (result) => {
|
|
351
|
+
const value = result;
|
|
352
|
+
if (value && typeof value === 'object' && value.task && typeof value.task === 'object')
|
|
353
|
+
return { ...value, task: wireTask(value.task) };
|
|
354
|
+
return result;
|
|
355
|
+
};
|
|
330
356
|
const requireTaskRooms = () => {
|
|
331
357
|
if (!services.taskRooms)
|
|
332
358
|
throw new FleetError('capability_unavailable', 'task operations are unavailable');
|
|
@@ -345,8 +371,8 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
345
371
|
...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
|
|
346
372
|
const api = requireTaskRooms();
|
|
347
373
|
return taskApi(() => query.groupByList === 'true'
|
|
348
|
-
? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => api.withLayoutRooms(task)) })) }
|
|
349
|
-
: { tasks: api.listTasks(filter).map(task => api.withLayoutRooms(task)) });
|
|
374
|
+
? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => wireTask(api.withLayoutRooms(task))) })) }
|
|
375
|
+
: { tasks: api.listTasks(filter).map(task => wireTask(api.withLayoutRooms(task))) });
|
|
350
376
|
};
|
|
351
377
|
const project = async (descriptor, signal, owner = '') => {
|
|
352
378
|
signal.throwIfAborted();
|
|
@@ -551,27 +577,70 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
551
577
|
origin: { type: 'web' },
|
|
552
578
|
}));
|
|
553
579
|
reply.code(201);
|
|
554
|
-
return { task };
|
|
580
|
+
return { task: wireTask(task) };
|
|
555
581
|
});
|
|
556
582
|
app.patch('/api/v1/tasks/:id/list', async (request) => {
|
|
557
583
|
auth.authenticate(request, true);
|
|
558
584
|
const taskId = request.params.id;
|
|
559
585
|
const list = String(request.body?.list ?? '');
|
|
560
|
-
return { task: await taskApi(() => requireTaskRooms().moveTask({ actor: { kind: 'local_control', surface: 'web' }, taskId, list })) };
|
|
586
|
+
return { task: wireTask(await taskApi(() => requireTaskRooms().moveTask({ actor: { kind: 'local_control', surface: 'web' }, taskId, list }))) };
|
|
561
587
|
});
|
|
562
588
|
app.patch('/api/v1/tasks/:id/description', async (request) => {
|
|
563
589
|
auth.authenticate(request, true);
|
|
564
590
|
const body = request.body;
|
|
565
591
|
if (typeof body?.brief !== 'string' || typeof body.expectedBrief !== 'string' || body.brief.length > 100_000)
|
|
566
592
|
throw new FleetError('invalid_request', 'brief and expectedBrief must be strings; brief maximum is 100000 characters');
|
|
567
|
-
return { task: await taskApi(() => requireTaskRooms().editTaskDescription({
|
|
593
|
+
return { task: wireTask(await taskApi(() => requireTaskRooms().editTaskDescription({
|
|
568
594
|
actor: { kind: 'local_control', surface: 'web' }, taskId: request.params.id,
|
|
569
595
|
brief: body.brief, expectedBrief: body.expectedBrief,
|
|
570
|
-
})) };
|
|
596
|
+
}))) };
|
|
571
597
|
});
|
|
572
598
|
app.get('/api/v1/tasks/:id', async (request) => {
|
|
573
599
|
auth.authenticate(request);
|
|
574
|
-
return taskApi(() => requireTaskRooms().getTask(request.params.id));
|
|
600
|
+
return wireTaskResult(await taskApi(() => requireTaskRooms().getTask(request.params.id)));
|
|
601
|
+
});
|
|
602
|
+
app.get('/api/v1/tasks/:id/progress', async (request) => {
|
|
603
|
+
auth.authenticate(request);
|
|
604
|
+
return { progress: (await reviewApi(() => listTaskProgress(request.params.id))).map(publicProgressEntry) };
|
|
605
|
+
});
|
|
606
|
+
app.post('/api/v1/tasks/:id/progress', async (request, reply) => {
|
|
607
|
+
auth.authenticate(request, true);
|
|
608
|
+
const body = request.body;
|
|
609
|
+
const header = request.headers['idempotency-key'];
|
|
610
|
+
const requestKey = typeof body?.requestKey === 'string' ? body.requestKey : typeof header === 'string' ? header : undefined;
|
|
611
|
+
if (typeof body?.text !== 'string' || (body.requestKey !== undefined && typeof body.requestKey !== 'string'))
|
|
612
|
+
throw new FleetError('invalid_request', 'text must be a string; requestKey, when given, a string');
|
|
613
|
+
const result = await reviewApi(() => appendTaskProgress(request.params.id, {
|
|
614
|
+
text: body.text, requestKey, author: { kind: 'local_control', surface: 'web' },
|
|
615
|
+
}));
|
|
616
|
+
if (!result.replayed)
|
|
617
|
+
reply.code(201);
|
|
618
|
+
return { entry: publicProgressEntry(result.entry), replayed: result.replayed };
|
|
619
|
+
});
|
|
620
|
+
app.get('/api/v1/tasks/:id/review', async (request) => {
|
|
621
|
+
auth.authenticate(request);
|
|
622
|
+
const review = await reviewApi(() => getTaskReview(request.params.id));
|
|
623
|
+
return { review: review ? publicReview(review) : null };
|
|
624
|
+
});
|
|
625
|
+
app.put('/api/v1/tasks/:id/review', async (request) => {
|
|
626
|
+
auth.authenticate(request, true);
|
|
627
|
+
const body = request.body;
|
|
628
|
+
if (typeof body?.markdown !== 'string' || typeof body.expectedMarkdown !== 'string')
|
|
629
|
+
throw new FleetError('invalid_request', 'markdown and expectedMarkdown must be strings');
|
|
630
|
+
const review = await reviewApi(() => setTaskReviewMarkdown(request.params.id, {
|
|
631
|
+
markdown: body.markdown, expectedMarkdown: body.expectedMarkdown,
|
|
632
|
+
author: { kind: 'local_control', surface: 'web' },
|
|
633
|
+
}));
|
|
634
|
+
return { review: publicReview(review) };
|
|
635
|
+
});
|
|
636
|
+
app.get('/api/v1/tasks/:id/review/artifacts/:artifactId', async (request, reply) => {
|
|
637
|
+
auth.authenticate(request);
|
|
638
|
+
const { artifact, bytes } = await reviewApi(() => readTaskReviewArtifact(request.params.id, request.params.artifactId));
|
|
639
|
+
reply.header('Cache-Control', 'private, no-store');
|
|
640
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
641
|
+
reply.header('Content-Security-Policy', "default-src 'none'; sandbox");
|
|
642
|
+
reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(artifact.name).replace(/'/g, '%27')}`);
|
|
643
|
+
return reply.type('application/octet-stream').send(bytes);
|
|
575
644
|
});
|
|
576
645
|
app.post('/api/v1/tasks/:id/members', async (request, reply) => {
|
|
577
646
|
auth.authenticate(request, true);
|
|
@@ -601,7 +670,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
601
670
|
const task = await taskApi(() => requireTaskRooms().setTaskLayout({ actor: { kind: 'local_control', surface: 'web' },
|
|
602
671
|
taskId: request.params.id, layout: body.layout, expectedLayout: body.expectedLayout }));
|
|
603
672
|
await audit.record({ requestId: request.id, browser: session.id, action: 'task.layout.set', result: 'succeeded' });
|
|
604
|
-
return { task: requireTaskRooms().withLayoutRooms(task) };
|
|
673
|
+
return { task: wireTask(requireTaskRooms().withLayoutRooms(task)) };
|
|
605
674
|
});
|
|
606
675
|
app.get('/api/v1/tasks/:id/layout', async (request) => {
|
|
607
676
|
auth.authenticate(request);
|
|
@@ -673,7 +742,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
673
742
|
const body = (request.body ?? {});
|
|
674
743
|
const input = { actor: { kind: 'local_control', surface: 'web' }, taskId };
|
|
675
744
|
const api = requireTaskRooms();
|
|
676
|
-
return taskApi(async () => {
|
|
745
|
+
return wireTaskResult(await taskApi(async () => {
|
|
677
746
|
switch (action) {
|
|
678
747
|
case 'start': {
|
|
679
748
|
// A task's layout can be chosen or changed but never removed, so this check cannot go stale.
|
|
@@ -701,7 +770,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
701
770
|
return { task, pending };
|
|
702
771
|
}
|
|
703
772
|
}
|
|
704
|
-
});
|
|
773
|
+
}));
|
|
705
774
|
});
|
|
706
775
|
}
|
|
707
776
|
app.get('/api/v1/configuration/provenance', async (request) => {
|