@ours.network/fleet 2.1.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application/conversation-history.d.ts +8 -0
- package/dist/application/conversation-history.js +37 -2
- package/dist/application/task-room-service.d.ts +7 -0
- package/dist/application/task-room-service.js +19 -0
- package/dist/build-info.json +4 -4
- package/dist/notifications/fleet-producer.d.ts +0 -1
- package/dist/notifications/fleet-producer.js +65 -47
- package/dist/notifications/mission-ownership.d.ts +31 -0
- package/dist/notifications/mission-ownership.js +148 -0
- package/dist/notifications/outbox.d.ts +10 -0
- package/dist/notifications/outbox.js +28 -2
- package/dist/notifications/task-producer.d.ts +11 -1
- package/dist/notifications/task-producer.js +34 -5
- package/dist/rooms-tasks/layout-service.d.ts +2 -0
- package/dist/rooms-tasks/layout-service.js +7 -2
- package/dist/rooms-tasks/layout.d.ts +5 -1
- package/dist/rooms-tasks/layout.js +7 -1
- package/dist/rooms-tasks/notification-manifest.d.ts +29 -0
- package/dist/rooms-tasks/notification-manifest.js +65 -0
- package/dist/rooms-tasks/task-review.d.ts +2 -0
- package/dist/rooms-tasks/task-review.js +7 -5
- package/dist/rooms-tasks/task-state.d.ts +35 -0
- package/dist/rooms-tasks/task-state.js +151 -17
- package/dist/rooms-tasks/types.d.ts +10 -0
- package/dist/session/acp.d.ts +3 -0
- package/dist/session/acp.js +58 -1
- package/dist/session/codex-app-server.js +22 -1
- package/dist/session/conversation-types.d.ts +8 -2
- package/dist/session/session-ownership.d.ts +18 -0
- package/dist/session/session-ownership.js +74 -0
- package/dist/supervisor/manager.js +9 -2
- package/dist/web/resource-views.d.ts +2 -0
- package/dist/web/resource-views.js +4 -2
- package/dist/web/server.js +29 -8
- package/package.json +5 -5
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TaskRecord } from '../rooms-tasks/types.js';
|
|
1
2
|
import type { ProducerConfig } from './outbox.js';
|
|
2
3
|
/** The Fleet manager owns this sender even when the web console is stopped.
|
|
3
4
|
* Events are already durable in their owning task commit, so no second outbox
|
|
@@ -6,13 +7,22 @@ export declare class TaskNotificationProducer {
|
|
|
6
7
|
private readonly config;
|
|
7
8
|
private readonly warn;
|
|
8
9
|
private readonly request;
|
|
10
|
+
/** Existing tasks and their layout room CIDs, registered once per start before own-room OFF can be chosen. */
|
|
11
|
+
private readonly missions?;
|
|
9
12
|
private timer?;
|
|
10
13
|
private dirty;
|
|
11
14
|
private running?;
|
|
12
15
|
private offset;
|
|
13
16
|
private stopped;
|
|
14
17
|
private readonly shutdown;
|
|
15
|
-
|
|
18
|
+
private bootstrapped;
|
|
19
|
+
private bootstrapAt;
|
|
20
|
+
constructor(config: ProducerConfig, warn?: (line: string) => void, request?: typeof fetch,
|
|
21
|
+
/** Existing tasks and their layout room CIDs, registered once per start before own-room OFF can be chosen. */
|
|
22
|
+
missions?: {
|
|
23
|
+
tasks: () => TaskRecord[];
|
|
24
|
+
layoutRooms: (task: TaskRecord) => string[];
|
|
25
|
+
} | undefined);
|
|
16
26
|
drain(): Promise<void>;
|
|
17
27
|
private retry;
|
|
18
28
|
private drainOnce;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { observeFleetResources } from '../application/resource-observer.js';
|
|
2
2
|
import { tasksDir } from '../rooms-tasks/task-state.js';
|
|
3
|
-
import { acknowledgeTaskNotification, pendingTaskNotifications } from '../rooms-tasks/task-state.js';
|
|
3
|
+
import { acknowledgeTaskNotification, convertRetiredTaskNotifications, pendingTaskNotifications } from '../rooms-tasks/task-state.js';
|
|
4
|
+
import { bootstrapMissionRooms, drainMissionCleanup } from './mission-ownership.js';
|
|
4
5
|
/** The Fleet manager owns this sender even when the web console is stopped.
|
|
5
6
|
* Events are already durable in their owning task commit, so no second outbox
|
|
6
7
|
* admission/crash window is needed. A failed task does not block other tasks. */
|
|
@@ -8,16 +9,22 @@ export class TaskNotificationProducer {
|
|
|
8
9
|
config;
|
|
9
10
|
warn;
|
|
10
11
|
request;
|
|
12
|
+
missions;
|
|
11
13
|
timer;
|
|
12
14
|
dirty = false;
|
|
13
15
|
running;
|
|
14
16
|
offset = 0;
|
|
15
17
|
stopped = false;
|
|
16
18
|
shutdown = new AbortController();
|
|
17
|
-
|
|
19
|
+
bootstrapped = false;
|
|
20
|
+
bootstrapAt = 0;
|
|
21
|
+
constructor(config, warn = () => { }, request = fetch,
|
|
22
|
+
/** Existing tasks and their layout room CIDs, registered once per start before own-room OFF can be chosen. */
|
|
23
|
+
missions) {
|
|
18
24
|
this.config = config;
|
|
19
25
|
this.warn = warn;
|
|
20
26
|
this.request = request;
|
|
27
|
+
this.missions = missions;
|
|
21
28
|
// Subscribe before startup replay: a commit while fetch is pending needs a second pass.
|
|
22
29
|
observeFleetResources({ retryWatchFailures: true, paths: [tasksDir()], roots: [] }, state => {
|
|
23
30
|
if (state.health !== 'healthy')
|
|
@@ -49,15 +56,35 @@ export class TaskNotificationProducer {
|
|
|
49
56
|
});
|
|
50
57
|
return this.running;
|
|
51
58
|
}
|
|
52
|
-
retry() {
|
|
59
|
+
retry(delay = 3000) {
|
|
53
60
|
if (this.stopped || this.timer)
|
|
54
61
|
return;
|
|
55
|
-
this.timer = setTimeout(() => { this.timer = undefined; void this.drain(); },
|
|
62
|
+
this.timer = setTimeout(() => { this.timer = undefined; void this.drain(); }, delay);
|
|
56
63
|
this.timer.unref();
|
|
57
64
|
}
|
|
58
65
|
async drainOnce() {
|
|
59
66
|
if (this.stopped)
|
|
60
67
|
return;
|
|
68
|
+
// Deletion first: a retired task's notifications go before any other delivery work in this pass.
|
|
69
|
+
if (convertRetiredTaskNotifications() || await drainMissionCleanup(this.config, this.warn, this.request))
|
|
70
|
+
this.retry();
|
|
71
|
+
if (this.missions && !this.bootstrapped && !this.stopped) {
|
|
72
|
+
// A failed run waits 30s: unavailable inventory or a refused registration needs repair, not a tight loop.
|
|
73
|
+
const wait = this.bootstrapAt - Date.now();
|
|
74
|
+
if (wait > 0)
|
|
75
|
+
this.retry(wait);
|
|
76
|
+
else {
|
|
77
|
+
try {
|
|
78
|
+
await bootstrapMissionRooms(this.config, this.missions.tasks, this.missions.layoutRooms, this.request);
|
|
79
|
+
this.bootstrapped = true;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
this.warn('Mission room registration pending; own-room message preference stays unavailable');
|
|
83
|
+
this.bootstrapAt = Date.now() + 30_000;
|
|
84
|
+
this.retry(30_000);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
61
88
|
const pending = pendingTaskNotifications(this.warn);
|
|
62
89
|
if (!pending.length)
|
|
63
90
|
return;
|
|
@@ -83,7 +110,9 @@ export class TaskNotificationProducer {
|
|
|
83
110
|
});
|
|
84
111
|
await response.body?.cancel();
|
|
85
112
|
if (!response.ok) {
|
|
86
|
-
this.warn(
|
|
113
|
+
this.warn(response.status === 400 && event.delivery
|
|
114
|
+
? 'Notification service does not support push-only task events; committed events retained'
|
|
115
|
+
: `Task notification delivery pending (HTTP ${response.status}); committed event retained`);
|
|
87
116
|
break;
|
|
88
117
|
}
|
|
89
118
|
acknowledgeTaskNotification(task.taskId, event.eventId);
|
|
@@ -30,6 +30,8 @@ export declare class RoomLayoutService {
|
|
|
30
30
|
supervisor(id?: string, templates?: Record<string, AgentTemplateDefinition>): NativeLayoutSupervisor;
|
|
31
31
|
private owner;
|
|
32
32
|
private engine;
|
|
33
|
+
/** A task's layout rooms are registered with the notification service before the Owner can join them. */
|
|
34
|
+
private beforeOwner;
|
|
33
35
|
open(id: string, cleanup?: boolean): RoomLayout;
|
|
34
36
|
/** `scope` lets an owner (e.g. a Task) snapshot an adjusted definition and pin agent working directories. */
|
|
35
37
|
create(name: string, bindings?: Record<string, LayoutInstance>, id?: string, scope?: {
|
|
@@ -178,11 +178,16 @@ export class RoomLayoutService {
|
|
|
178
178
|
const path = layoutRunPath(id);
|
|
179
179
|
if (existsSync(path))
|
|
180
180
|
assertLayoutFile(path);
|
|
181
|
-
return new RoomLayout(path, () => createCoworkAdapter(), this.supervisor(id), cleanup ? undefined : this.owner());
|
|
181
|
+
return new RoomLayout(path, () => createCoworkAdapter(), this.supervisor(id), cleanup ? undefined : this.owner(), this.beforeOwner(id));
|
|
182
|
+
}
|
|
183
|
+
/** A task's layout rooms are registered with the notification service before the Owner can join them. */
|
|
184
|
+
beforeOwner(id) {
|
|
185
|
+
const taskId = /^task-([0-9a-z]{9}[0-9a-f]{8})$/.exec(id)?.[1];
|
|
186
|
+
return taskId ? async (roomCid) => (await import('../notifications/mission-ownership.js')).associateRoomNotifications(taskId, roomCid) : undefined;
|
|
182
187
|
}
|
|
183
188
|
open(id, cleanup = false) {
|
|
184
189
|
const initial = this.engine(id, cleanup), snapshot = initial.snapshot();
|
|
185
|
-
return new RoomLayout(layoutRunPath(id), () => createCoworkAdapter(), this.supervisor(id, snapshot.agent_templates ?? {}), cleanup ? undefined : this.owner());
|
|
190
|
+
return new RoomLayout(layoutRunPath(id), () => createCoworkAdapter(), this.supervisor(id, snapshot.agent_templates ?? {}), cleanup ? undefined : this.owner(), this.beforeOwner(id));
|
|
186
191
|
}
|
|
187
192
|
/** `scope` lets an owner (e.g. a Task) snapshot an adjusted definition and pin agent working directories. */
|
|
188
193
|
async create(name, bindings = {}, id = `run-${randomUUID()}`, scope = {}) {
|
|
@@ -104,9 +104,13 @@ export declare class RoomLayout {
|
|
|
104
104
|
private file;
|
|
105
105
|
private supervisor;
|
|
106
106
|
private owner?;
|
|
107
|
+
/** Runs before the Owner may join each room; a failure stops here and the next pass retries it. */
|
|
108
|
+
private beforeOwner?;
|
|
107
109
|
private coworkAdapter?;
|
|
108
110
|
private readonly coworkFactory;
|
|
109
|
-
constructor(file: string, cowork: CoworkAdapter | (() => CoworkAdapter), supervisor: LayoutSupervisor, owner?: LayoutOwner | undefined
|
|
111
|
+
constructor(file: string, cowork: CoworkAdapter | (() => CoworkAdapter), supervisor: LayoutSupervisor, owner?: LayoutOwner | undefined,
|
|
112
|
+
/** Runs before the Owner may join each room; a failure stops here and the next pass retries it. */
|
|
113
|
+
beforeOwner?: ((roomCid: string) => Promise<void>) | undefined);
|
|
110
114
|
private get cowork();
|
|
111
115
|
snapshot(): RoomLayoutState;
|
|
112
116
|
private save;
|
|
@@ -39,12 +39,16 @@ export class RoomLayout {
|
|
|
39
39
|
file;
|
|
40
40
|
supervisor;
|
|
41
41
|
owner;
|
|
42
|
+
beforeOwner;
|
|
42
43
|
coworkAdapter;
|
|
43
44
|
coworkFactory;
|
|
44
|
-
constructor(file, cowork, supervisor, owner
|
|
45
|
+
constructor(file, cowork, supervisor, owner,
|
|
46
|
+
/** Runs before the Owner may join each room; a failure stops here and the next pass retries it. */
|
|
47
|
+
beforeOwner) {
|
|
45
48
|
this.file = file;
|
|
46
49
|
this.supervisor = supervisor;
|
|
47
50
|
this.owner = owner;
|
|
51
|
+
this.beforeOwner = beforeOwner;
|
|
48
52
|
this.coworkFactory = typeof cowork === 'function' ? cowork : () => cowork;
|
|
49
53
|
}
|
|
50
54
|
get cowork() { return this.coworkAdapter ??= this.coworkFactory(); }
|
|
@@ -161,6 +165,8 @@ export class RoomLayout {
|
|
|
161
165
|
activation_requirements: !spec.members.length && s.owner ? [{ role: s.owner.role, count: 1 }]
|
|
162
166
|
: [...new Set(spec.members.map(roleFor))].map(role => ({ role, count: spec.members.filter(m => roleFor(m) === role).length })),
|
|
163
167
|
}), native => { room.native = native; s.order.push(key); });
|
|
168
|
+
if (s.owner && !room.owner_ready && this.beforeOwner)
|
|
169
|
+
await this.beforeOwner(room.native.identity_cid);
|
|
164
170
|
if (s.owner && !room.owner_ready)
|
|
165
171
|
await this.mutation(s, `owner:${key}`, async () => {
|
|
166
172
|
await this.cowork.acceptInvite(room.native.room_id, this.owner.invite, { role: s.owner.role, expected_cid: s.owner.cid });
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification ownership of a task's rooms. Room identity CIDs are recorded when Fleet records the room, and are
|
|
3
|
+
* never removed (a room erased early stays attributable), so deleting the task can retire its rooms' notifications
|
|
4
|
+
* even after room records are gone.
|
|
5
|
+
*/
|
|
6
|
+
export interface TaskNotificationManifest {
|
|
7
|
+
revision: number;
|
|
8
|
+
room_cids: string[];
|
|
9
|
+
}
|
|
10
|
+
export declare const canonicalCid: (cid: string) => string;
|
|
11
|
+
export declare function validRoomCid(cid: unknown): cid is string;
|
|
12
|
+
/** Union of rooms; the revision advances only when a room is added. */
|
|
13
|
+
export declare function withManifestRooms(manifest: TaskNotificationManifest | undefined, cids: string[]): TaskNotificationManifest;
|
|
14
|
+
/** Durable intent to retire a deleted task's notifications; written at deletion acceptance, merged, removed on service acknowledgement. */
|
|
15
|
+
export interface MissionCleanupIntent {
|
|
16
|
+
schema: 1;
|
|
17
|
+
taskId: string;
|
|
18
|
+
contactIds: string[];
|
|
19
|
+
}
|
|
20
|
+
export declare const missionCleanupDir: () => string;
|
|
21
|
+
/** Merge rooms into the task's intent. An existing intent that cannot be read is never replaced: this throws and the
|
|
22
|
+
* file keeps its bytes, so a later writer with only some rooms cannot erase coverage recorded earlier. */
|
|
23
|
+
export declare function queueMissionCleanup(taskId: string, contactIds: string[], dir?: string): void;
|
|
24
|
+
/** Readable intents, in directory order. Unreadable ones are reported by `unavailableMissionCleanups`, never dropped. */
|
|
25
|
+
export declare function pendingMissionCleanups(dir?: string): MissionCleanupIntent[];
|
|
26
|
+
/** Task IDs whose intent file exists but cannot be read. Their bytes are kept; they block nothing else but stay pending. */
|
|
27
|
+
export declare function unavailableMissionCleanups(dir?: string): string[];
|
|
28
|
+
/** Remove only the exact intent the service acknowledged; a merge since then, or an unreadable file, keeps it for another pass. */
|
|
29
|
+
export declare function settleMissionCleanup(intent: MissionCleanupIntent, dir?: string): boolean;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
4
|
+
import { stateRoot } from '../paths.js';
|
|
5
|
+
const CID = /^[A-Za-z0-9_-]{1,512}$/;
|
|
6
|
+
export const canonicalCid = (cid) => /^[a-f0-9]{64}$/i.test(cid) ? cid.toUpperCase() : cid;
|
|
7
|
+
export function validRoomCid(cid) { return typeof cid === 'string' && CID.test(cid); }
|
|
8
|
+
/** Union of rooms; the revision advances only when a room is added. */
|
|
9
|
+
export function withManifestRooms(manifest, cids) {
|
|
10
|
+
const current = manifest ?? { revision: 0, room_cids: [] };
|
|
11
|
+
const added = cids.filter(validRoomCid).map(canonicalCid).filter(cid => !current.room_cids.includes(cid));
|
|
12
|
+
return added.length ? { revision: current.revision + 1, room_cids: [...current.room_cids, ...new Set(added)] } : current;
|
|
13
|
+
}
|
|
14
|
+
export const missionCleanupDir = () => join(stateRoot(), 'notifications', 'mission-cleanup');
|
|
15
|
+
const TASK_ID = /^[0-9a-z]{9}[0-9a-f]{8}$/;
|
|
16
|
+
/** Merge rooms into the task's intent. An existing intent that cannot be read is never replaced: this throws and the
|
|
17
|
+
* file keeps its bytes, so a later writer with only some rooms cannot erase coverage recorded earlier. */
|
|
18
|
+
export function queueMissionCleanup(taskId, contactIds, dir = missionCleanupDir()) {
|
|
19
|
+
if (!TASK_ID.test(taskId))
|
|
20
|
+
throw new Error('invalid task ID');
|
|
21
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
22
|
+
const path = join(dir, `${taskId}.json`), previous = readMissionCleanup(path, taskId);
|
|
23
|
+
if (previous === 'unavailable')
|
|
24
|
+
throw new Error(`mission cleanup intent for ${taskId} is unreadable; file retained`);
|
|
25
|
+
const known = previous === 'absent' ? [] : previous.contactIds;
|
|
26
|
+
const merged = [...new Set([...known, ...contactIds.filter(validRoomCid).map(canonicalCid)])];
|
|
27
|
+
if (previous !== 'absent' && merged.length === known.length)
|
|
28
|
+
return;
|
|
29
|
+
replaceFileAtomically(path, JSON.stringify({ schema: 1, taskId, contactIds: merged }) + '\n', 0o600);
|
|
30
|
+
}
|
|
31
|
+
/** Only a missing file is absent. Unreadable, malformed or mismatched content is unavailable, never "no intent". */
|
|
32
|
+
function readMissionCleanup(path, taskId) {
|
|
33
|
+
let text;
|
|
34
|
+
try {
|
|
35
|
+
text = readFileSync(path, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return error.code === 'ENOENT' ? 'absent' : 'unavailable';
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const value = JSON.parse(text);
|
|
42
|
+
if (value?.schema === 1 && value.taskId === taskId && Array.isArray(value.contactIds) && value.contactIds.every(validRoomCid))
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
catch { /* malformed */ }
|
|
46
|
+
return 'unavailable';
|
|
47
|
+
}
|
|
48
|
+
const intentFiles = (dir) => existsSync(dir) ? readdirSync(dir).filter(name => /^[0-9a-z]{9}[0-9a-f]{8}\.json$/.test(name)) : [];
|
|
49
|
+
/** Readable intents, in directory order. Unreadable ones are reported by `unavailableMissionCleanups`, never dropped. */
|
|
50
|
+
export function pendingMissionCleanups(dir = missionCleanupDir()) {
|
|
51
|
+
return intentFiles(dir).map(name => readMissionCleanup(join(dir, name), name.slice(0, -5)))
|
|
52
|
+
.filter((intent) => typeof intent === 'object');
|
|
53
|
+
}
|
|
54
|
+
/** Task IDs whose intent file exists but cannot be read. Their bytes are kept; they block nothing else but stay pending. */
|
|
55
|
+
export function unavailableMissionCleanups(dir = missionCleanupDir()) {
|
|
56
|
+
return intentFiles(dir).filter(name => readMissionCleanup(join(dir, name), name.slice(0, -5)) === 'unavailable').map(name => name.slice(0, -5));
|
|
57
|
+
}
|
|
58
|
+
/** Remove only the exact intent the service acknowledged; a merge since then, or an unreadable file, keeps it for another pass. */
|
|
59
|
+
export function settleMissionCleanup(intent, dir = missionCleanupDir()) {
|
|
60
|
+
const path = join(dir, `${intent.taskId}.json`), current = readMissionCleanup(path, intent.taskId);
|
|
61
|
+
if (typeof current !== 'object' || current.contactIds.length !== intent.contactIds.length)
|
|
62
|
+
return false;
|
|
63
|
+
rmSync(path, { force: true });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
@@ -103,6 +103,8 @@ export declare function publicReview(review: TaskReview): TaskReview;
|
|
|
103
103
|
/** Compact summary for list and detail payloads; bodies are fetched separately. */
|
|
104
104
|
export declare function taskContributionSummary(task: TaskRecord): {
|
|
105
105
|
progress_count: number;
|
|
106
|
+
progress_unread: number;
|
|
107
|
+
review_unread: boolean;
|
|
106
108
|
review: {
|
|
107
109
|
has_markdown: boolean;
|
|
108
110
|
artifact_count: number;
|
|
@@ -12,7 +12,7 @@ import { open } from 'node:fs/promises';
|
|
|
12
12
|
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
13
13
|
import { stateRoot } from '../paths.js';
|
|
14
14
|
import { MAX_FILE_BYTES, openDirectory } from '../file-delivery/reader.js';
|
|
15
|
-
import { getTask, TaskStateError, updateTaskContribution } from './task-state.js';
|
|
15
|
+
import { contributionUnread, getTask, reviewRevision, TaskStateError, updateTaskContribution } from './task-state.js';
|
|
16
16
|
import { assertWorkspacePresent } from './workspace.js';
|
|
17
17
|
export const PROGRESS_TEXT_MAX = 4_000;
|
|
18
18
|
export const PROGRESS_MAX_ENTRIES = 200;
|
|
@@ -109,7 +109,7 @@ export function appendTaskProgress(taskId, input) {
|
|
|
109
109
|
// ── Review Markdown ────────────────────────────────────────────────────
|
|
110
110
|
/** Every change to the package gets the next revision, so readers can tell any two states apart. */
|
|
111
111
|
function nextRevision(review) {
|
|
112
|
-
return (review
|
|
112
|
+
return reviewRevision(review) + 1;
|
|
113
113
|
}
|
|
114
114
|
export function getTaskReview(taskId) {
|
|
115
115
|
assertTaskId(taskId);
|
|
@@ -544,17 +544,19 @@ export function publicProgressEntry(entry) {
|
|
|
544
544
|
return rest;
|
|
545
545
|
}
|
|
546
546
|
export function publicReview(review) {
|
|
547
|
-
return { ...review, revision: review
|
|
547
|
+
return { ...review, revision: reviewRevision(review), artifacts: review.artifacts.map(({ request_key: _key, ...rest }) => rest) };
|
|
548
548
|
}
|
|
549
549
|
/** Compact summary for list and detail payloads; bodies are fetched separately. */
|
|
550
550
|
export function taskContributionSummary(task) {
|
|
551
|
+
const unread = contributionUnread(task);
|
|
551
552
|
return {
|
|
552
553
|
progress_count: task.progress?.length ?? 0,
|
|
554
|
+
progress_unread: unread.progress,
|
|
555
|
+
review_unread: unread.review,
|
|
553
556
|
review: {
|
|
554
557
|
has_markdown: !!task.review?.markdown.trim(),
|
|
555
558
|
artifact_count: task.review?.artifacts.length ?? 0,
|
|
556
|
-
|
|
557
|
-
revision: task.review ? task.review.revision ?? 1 : 0,
|
|
559
|
+
revision: reviewRevision(task.review),
|
|
558
560
|
...(task.review ? { updated_at: task.review.updated_at } : {}),
|
|
559
561
|
},
|
|
560
562
|
};
|
|
@@ -1,13 +1,41 @@
|
|
|
1
1
|
import type { ArchivedMemberAbsence } from './types.js';
|
|
2
2
|
import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent, TaskDeletionActor, TaskDeletionMemberPhase, TaskNotificationEvent } from './types.js';
|
|
3
|
+
import { type TaskNotificationManifest } from './notification-manifest.js';
|
|
3
4
|
export declare const tasksDir: () => string;
|
|
4
5
|
export declare class TaskStateError extends Error {
|
|
5
6
|
}
|
|
7
|
+
/** Packages written before revisions existed count as revision 1; their next change becomes 2. No package is 0. */
|
|
8
|
+
export declare const reviewRevision: (review: {
|
|
9
|
+
revision?: number;
|
|
10
|
+
} | undefined) => number;
|
|
11
|
+
/** Item-based Progress unread and revision-based To review unread. An absent read state means nothing is unread. */
|
|
12
|
+
export declare function contributionUnread(task: Pick<TaskRecord, 'progress' | 'review' | 'contribution_reads'>): {
|
|
13
|
+
progress: number;
|
|
14
|
+
review: boolean;
|
|
15
|
+
};
|
|
16
|
+
/** Acknowledge Progress entries the user actually viewed; unknown IDs are ignored and the set only grows. */
|
|
17
|
+
export declare function acknowledgeTaskProgress(id: string, ids: string[]): {
|
|
18
|
+
progress: number;
|
|
19
|
+
review: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** Acknowledge the To review revision the user saw. Only moves forward; a revision newer than current is refused. */
|
|
22
|
+
export declare function acknowledgeTaskReview(id: string, revision: number): {
|
|
23
|
+
progress: number;
|
|
24
|
+
review: boolean;
|
|
25
|
+
};
|
|
6
26
|
/** Atomic snapshots contain the events, including intermediate states between polls. */
|
|
27
|
+
/** `upgraded`: IDs of events queued by an earlier release without a delivery policy. They are sent push-only under the
|
|
28
|
+
* same event ID (state transitions never count as unread); the queued record itself is not rewritten. A service that
|
|
29
|
+
* already admitted one as an inbox row repairs that row to push-only itself, so the retry matches; any refusal keeps the
|
|
30
|
+
* event queued. */
|
|
7
31
|
export declare function pendingTaskNotifications(warn?: (line: string) => void): Array<{
|
|
8
32
|
taskId: string;
|
|
9
33
|
events: TaskNotificationEvent[];
|
|
34
|
+
upgraded: string[];
|
|
10
35
|
}>;
|
|
36
|
+
/** Earlier releases kept a deleted task's queued events in `.notification-retired`. They are obsolete: each
|
|
37
|
+
* becomes a cleanup intent for its task and is never delivered. Returns the number converted. */
|
|
38
|
+
export declare function convertRetiredTaskNotifications(): number;
|
|
11
39
|
/** Service event-id dedupe covers acceptance followed by a crash before this acknowledgement. */
|
|
12
40
|
export declare function acknowledgeTaskNotification(id: string, eventId: string): void;
|
|
13
41
|
/**
|
|
@@ -62,6 +90,13 @@ export declare function setTaskTerminalIntentError(id: string, error: string, re
|
|
|
62
90
|
export declare function finishTaskTerminalIntent(id: string): TaskRecord;
|
|
63
91
|
export declare function failTask(id: string, error: string): TaskRecord;
|
|
64
92
|
export declare function updateTaskRoom(id: string, roomId: string, roomIdentityCid: string): TaskRecord;
|
|
93
|
+
/** Record room CIDs as this task's notification-owned rooms (union only). Returns the manifest to register. */
|
|
94
|
+
export declare function recordNotificationRooms(id: string, cids: string[]): TaskNotificationManifest;
|
|
95
|
+
/** After a refused registration (409), move the manifest revision past the refused one; rooms are unchanged. A retry
|
|
96
|
+
* then registers the full union under a newer revision. Nothing happens if the manifest already moved on. */
|
|
97
|
+
export declare function advanceNotificationRevision(id: string, refused: number): void;
|
|
98
|
+
/** Every room this task ever recorded, including the current one from records written before manifests existed. */
|
|
99
|
+
export declare function notificationRoomCids(task: Pick<TaskRecord, 'notification_manifest' | 'room_identity_cid'>): string[];
|
|
65
100
|
export declare function updateTaskTemplate(id: string, template: TaskTemplateRef): TaskRecord;
|
|
66
101
|
export declare function updateTaskMembers(id: string, members: TaskMemberRole[]): TaskRecord;
|
|
67
102
|
/**
|
|
@@ -8,6 +8,7 @@ import { stateRoot } from '../paths.js';
|
|
|
8
8
|
import { planWorkspace, ensureWorkspace } from './workspace.js';
|
|
9
9
|
import { storedRoomLaunchPolicy, TASK_TERMINAL_STATES, TASK_CANCELLABLE_STATES } from './types.js';
|
|
10
10
|
import { DEFAULT_TASK_LIST_ID, readTaskLists } from './task-lists.js';
|
|
11
|
+
import { queueMissionCleanup, withManifestRooms } from './notification-manifest.js';
|
|
11
12
|
export const tasksDir = () => join(stateRoot(), 'tasks');
|
|
12
13
|
function taskPath(id) { return join(tasksDir(), `${id}.json`); }
|
|
13
14
|
function taskLockPath(id) { return join(tasksDir(), '.locks', id); }
|
|
@@ -165,14 +166,24 @@ function writeTask(record) {
|
|
|
165
166
|
const status = (task) => task.deletion?.status === 'pending' ? 'deleting' : task.blocked ? `${task.state} (blocked)` : task.state;
|
|
166
167
|
// Read pending events under the same task lock as every mutation. Metadata edits
|
|
167
168
|
// and retries preserve the queue; acknowledgement cannot be resurrected by stale input.
|
|
168
|
-
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
pending
|
|
172
|
-
title: `Task ${!previous ? 'created' : 'updated'}: ${record.title}`.slice(0, 160),
|
|
173
|
-
body: `Task ${record.task_id}: ${before ? `${before} → ` : 'created in '}${next}.`,
|
|
174
|
-
target: { kind: 'task', taskId: record.task_id } });
|
|
169
|
+
let pending = [...(previous?.notification_events ?? [])];
|
|
170
|
+
if (record.deletion?.status === 'pending') {
|
|
171
|
+
// Deletion acceptance is the notification fence: queued transitions are obsolete and nothing new is produced.
|
|
172
|
+
pending = [];
|
|
175
173
|
}
|
|
174
|
+
else {
|
|
175
|
+
if (!previous || status(previous) !== status(record)) {
|
|
176
|
+
const next = status(record), before = previous ? status(previous) : undefined;
|
|
177
|
+
pending.push({ eventId: `task:${record.task_id}:${randomUUID()}`, taskId: record.task_id,
|
|
178
|
+
title: `Task ${!previous ? 'created' : 'updated'}: ${record.title}`.slice(0, 160),
|
|
179
|
+
body: `Task ${record.task_id}: ${before ? `${before} → ` : 'created in '}${next}.`,
|
|
180
|
+
target: { kind: 'task', taskId: record.task_id }, delivery: 'push-only' });
|
|
181
|
+
}
|
|
182
|
+
pending.push(...contributionEvents(previous, record));
|
|
183
|
+
}
|
|
184
|
+
if (previous && !previous.contribution_reads && contributionsChanged(previous, record))
|
|
185
|
+
// First post-upgrade change: everything that existed before it counts as read, under this same lock.
|
|
186
|
+
persisted.contribution_reads = { progress_read: (previous.progress ?? []).map(entry => entry.id), review_revision: reviewRevision(previous.review) };
|
|
176
187
|
if (pending.length)
|
|
177
188
|
persisted.notification_events = pending;
|
|
178
189
|
else
|
|
@@ -180,9 +191,76 @@ function writeTask(record) {
|
|
|
180
191
|
replaceFileAtomically(taskPath(record.task_id), JSON.stringify(persisted, null, 2) + '\n');
|
|
181
192
|
publishFleetResourceCommit(taskPath(record.task_id));
|
|
182
193
|
}
|
|
194
|
+
/** Packages written before revisions existed count as revision 1; their next change becomes 2. No package is 0. */
|
|
195
|
+
export const reviewRevision = (review) => review ? review.revision ?? 1 : 0;
|
|
196
|
+
const contributionsChanged = (previous, record) => (record.progress ?? []).some(entry => !(previous.progress ?? []).some(old => old.id === entry.id))
|
|
197
|
+
|| reviewRevision(record.review) !== reviewRevision(previous.review);
|
|
198
|
+
/** New Progress entries and new To review revisions push once each; reads and replays produce nothing. */
|
|
199
|
+
function contributionEvents(previous, record) {
|
|
200
|
+
if (!previous)
|
|
201
|
+
return [];
|
|
202
|
+
const id = record.task_id, events = [];
|
|
203
|
+
for (const entry of record.progress ?? []) {
|
|
204
|
+
if ((previous.progress ?? []).some(old => old.id === entry.id))
|
|
205
|
+
continue;
|
|
206
|
+
events.push({ eventId: `task:${id}:progress:${entry.id}`, taskId: id, title: `Progress: ${record.title}`.slice(0, 160),
|
|
207
|
+
body: (entry.text.trim() || 'New progress entry').slice(0, 512), target: { kind: 'task', taskId: id }, delivery: 'push-only' });
|
|
208
|
+
}
|
|
209
|
+
const revision = reviewRevision(record.review);
|
|
210
|
+
if (revision > reviewRevision(previous.review))
|
|
211
|
+
events.push({ eventId: `task:${id}:review:${revision}`, taskId: id, title: `To review updated: ${record.title}`.slice(0, 160),
|
|
212
|
+
body: `Task ${id}: the review package changed.`, target: { kind: 'task', taskId: id }, delivery: 'push-only' });
|
|
213
|
+
return events;
|
|
214
|
+
}
|
|
215
|
+
/** Item-based Progress unread and revision-based To review unread. An absent read state means nothing is unread. */
|
|
216
|
+
export function contributionUnread(task) {
|
|
217
|
+
const reads = task.contribution_reads;
|
|
218
|
+
if (!reads)
|
|
219
|
+
return { progress: 0, review: false };
|
|
220
|
+
const seen = new Set(reads.progress_read);
|
|
221
|
+
return { progress: (task.progress ?? []).filter(entry => !seen.has(entry.id)).length, review: reviewRevision(task.review) > reads.review_revision };
|
|
222
|
+
}
|
|
223
|
+
/** Acknowledge Progress entries the user actually viewed; unknown IDs are ignored and the set only grows. */
|
|
224
|
+
export function acknowledgeTaskProgress(id, ids) {
|
|
225
|
+
assertCanonicalTaskId(id);
|
|
226
|
+
return withTaskLock(id, () => {
|
|
227
|
+
const task = readTask(id);
|
|
228
|
+
assertNoPendingDeletion(task);
|
|
229
|
+
// Without a read state everything present already counts as read: there is nothing to record.
|
|
230
|
+
const reads = task.contribution_reads;
|
|
231
|
+
if (!reads)
|
|
232
|
+
return contributionUnread(task);
|
|
233
|
+
const present = new Set((task.progress ?? []).map(entry => entry.id));
|
|
234
|
+
const added = [...new Set(ids.filter(entryId => present.has(entryId) && !reads.progress_read.includes(entryId)))];
|
|
235
|
+
if (added.length) {
|
|
236
|
+
task.contribution_reads = { ...reads, progress_read: [...reads.progress_read, ...added] };
|
|
237
|
+
writeTask(task);
|
|
238
|
+
}
|
|
239
|
+
return contributionUnread(task);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/** Acknowledge the To review revision the user saw. Only moves forward; a revision newer than current is refused. */
|
|
243
|
+
export function acknowledgeTaskReview(id, revision) {
|
|
244
|
+
assertCanonicalTaskId(id);
|
|
245
|
+
return withTaskLock(id, () => {
|
|
246
|
+
const task = readTask(id);
|
|
247
|
+
assertNoPendingDeletion(task);
|
|
248
|
+
const current = reviewRevision(task.review);
|
|
249
|
+
if (!Number.isSafeInteger(revision) || revision < 0 || revision > current)
|
|
250
|
+
throw new TaskStateError('That review revision does not exist.');
|
|
251
|
+
const reads = task.contribution_reads;
|
|
252
|
+
if (reads && revision > reads.review_revision) {
|
|
253
|
+
task.contribution_reads = { ...reads, review_revision: Math.max(reads.review_revision, revision) };
|
|
254
|
+
writeTask(task);
|
|
255
|
+
}
|
|
256
|
+
return contributionUnread(task);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
183
259
|
const retiredNotificationsDir = () => join(tasksDir(), '.notification-retired');
|
|
184
260
|
function typedTaskNotification(value, id) {
|
|
185
|
-
if (!value || typeof value !== 'object' || Object.keys(value).some(key => !['eventId', 'title', 'body', 'target', 'taskId'].includes(key)))
|
|
261
|
+
if (!value || typeof value !== 'object' || Object.keys(value).some(key => !['eventId', 'title', 'body', 'target', 'taskId', 'delivery'].includes(key)))
|
|
262
|
+
return false;
|
|
263
|
+
if (value.delivery !== undefined && value.delivery !== 'push-only')
|
|
186
264
|
return false;
|
|
187
265
|
const target = value.target;
|
|
188
266
|
return typeof value.eventId === 'string' && value.eventId.startsWith(`task:${id}:`) && value.eventId.length <= 512
|
|
@@ -193,9 +271,13 @@ function typedTaskNotification(value, id) {
|
|
|
193
271
|
&& typeof value.body === 'string' && !!value.body.trim() && value.body.length <= 512;
|
|
194
272
|
}
|
|
195
273
|
/** Atomic snapshots contain the events, including intermediate states between polls. */
|
|
274
|
+
/** `upgraded`: IDs of events queued by an earlier release without a delivery policy. They are sent push-only under the
|
|
275
|
+
* same event ID (state transitions never count as unread); the queued record itself is not rewritten. A service that
|
|
276
|
+
* already admitted one as an inbox row repairs that row to push-only itself, so the retry matches; any refusal keeps the
|
|
277
|
+
* event queued. */
|
|
196
278
|
export function pendingTaskNotifications(warn = () => { }) {
|
|
197
279
|
const result = [];
|
|
198
|
-
for (const dir of [tasksDir()
|
|
280
|
+
for (const dir of [tasksDir()]) {
|
|
199
281
|
if (!existsSync(dir))
|
|
200
282
|
continue;
|
|
201
283
|
for (const file of readdirSync(dir).filter(file => TASK_ID_PATTERN.test(file.slice(0, -5)) && file.endsWith('.json'))) {
|
|
@@ -214,9 +296,11 @@ export function pendingTaskNotifications(warn = () => { }) {
|
|
|
214
296
|
throw new Error('invalid notification events');
|
|
215
297
|
// Only typed task events are delivered. Unsupported entries are never sent or converted,
|
|
216
298
|
// and they do not block later lifecycle events.
|
|
217
|
-
const
|
|
299
|
+
const typed = value.notification_events.filter(event => typedTaskNotification(event, id));
|
|
300
|
+
const upgraded = typed.filter(event => event.delivery === undefined).map(event => event.eventId);
|
|
301
|
+
const events = typed.map(event => event.delivery ? event : { ...event, delivery: 'push-only' });
|
|
218
302
|
if (events.length)
|
|
219
|
-
result.push({ taskId: id, events });
|
|
303
|
+
result.push({ taskId: id, events, upgraded });
|
|
220
304
|
}
|
|
221
305
|
catch (error) {
|
|
222
306
|
if (!isNotFoundError(error))
|
|
@@ -226,6 +310,25 @@ export function pendingTaskNotifications(warn = () => { }) {
|
|
|
226
310
|
}
|
|
227
311
|
return result;
|
|
228
312
|
}
|
|
313
|
+
/** Earlier releases kept a deleted task's queued events in `.notification-retired`. They are obsolete: each
|
|
314
|
+
* becomes a cleanup intent for its task and is never delivered. Returns the number converted. */
|
|
315
|
+
export function convertRetiredTaskNotifications() {
|
|
316
|
+
const dir = retiredNotificationsDir();
|
|
317
|
+
if (!existsSync(dir))
|
|
318
|
+
return 0;
|
|
319
|
+
let converted = 0;
|
|
320
|
+
for (const file of readdirSync(dir).filter(file => file.endsWith('.json') && TASK_ID_PATTERN.test(file.slice(0, -5)))) {
|
|
321
|
+
const id = file.slice(0, -5);
|
|
322
|
+
withTaskLock(id, () => {
|
|
323
|
+
if (existsSync(taskPath(id)))
|
|
324
|
+
return; // a prepared copy has no authority while the task exists
|
|
325
|
+
queueMissionCleanup(id, []);
|
|
326
|
+
rmSync(join(dir, file), { force: true });
|
|
327
|
+
converted++;
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return converted;
|
|
331
|
+
}
|
|
229
332
|
/** Service event-id dedupe covers acceptance followed by a crash before this acknowledgement. */
|
|
230
333
|
export function acknowledgeTaskNotification(id, eventId) {
|
|
231
334
|
assertCanonicalTaskId(id);
|
|
@@ -583,10 +686,41 @@ export function updateTaskRoom(id, roomId, roomIdentityCid) {
|
|
|
583
686
|
assertNoPendingTerminalIntent(t);
|
|
584
687
|
t.room_id = roomId;
|
|
585
688
|
t.room_identity_cid = roomIdentityCid;
|
|
689
|
+
t.notification_manifest = withManifestRooms(t.notification_manifest, [roomIdentityCid]);
|
|
586
690
|
writeTask(t);
|
|
587
691
|
return t;
|
|
588
692
|
});
|
|
589
693
|
}
|
|
694
|
+
/** Record room CIDs as this task's notification-owned rooms (union only). Returns the manifest to register. */
|
|
695
|
+
export function recordNotificationRooms(id, cids) {
|
|
696
|
+
assertCanonicalTaskId(id);
|
|
697
|
+
return withTaskLock(id, () => {
|
|
698
|
+
const t = readTask(id);
|
|
699
|
+
assertNoPendingDeletion(t);
|
|
700
|
+
const next = withManifestRooms(t.notification_manifest, cids);
|
|
701
|
+
if (next !== t.notification_manifest) {
|
|
702
|
+
t.notification_manifest = next;
|
|
703
|
+
writeTask(t);
|
|
704
|
+
}
|
|
705
|
+
return next;
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
/** After a refused registration (409), move the manifest revision past the refused one; rooms are unchanged. A retry
|
|
709
|
+
* then registers the full union under a newer revision. Nothing happens if the manifest already moved on. */
|
|
710
|
+
export function advanceNotificationRevision(id, refused) {
|
|
711
|
+
assertCanonicalTaskId(id);
|
|
712
|
+
withTaskLock(id, () => {
|
|
713
|
+
const t = readTask(id);
|
|
714
|
+
if (t.deletion?.status === 'pending' || t.notification_manifest?.revision !== refused)
|
|
715
|
+
return;
|
|
716
|
+
t.notification_manifest = { ...t.notification_manifest, revision: refused + 1 };
|
|
717
|
+
writeTask(t);
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
/** Every room this task ever recorded, including the current one from records written before manifests existed. */
|
|
721
|
+
export function notificationRoomCids(task) {
|
|
722
|
+
return withManifestRooms(task.notification_manifest, task.room_identity_cid ? [task.room_identity_cid] : []).room_cids;
|
|
723
|
+
}
|
|
590
724
|
export function updateTaskTemplate(id, template) {
|
|
591
725
|
return withTaskLock(id, () => {
|
|
592
726
|
const t = readTask(id);
|
|
@@ -729,6 +863,7 @@ export function beginTaskDeletionIntent(id, actor) {
|
|
|
729
863
|
// Heal a crash between the intent write and the receipt write.
|
|
730
864
|
if (!readTaskDeletionReceipt(id))
|
|
731
865
|
writeDeletionReceiptForIntent(stored);
|
|
866
|
+
queueMissionCleanup(id, notificationRoomCids(stored));
|
|
732
867
|
return { status: 'pending', task: presentTaskLenient(stored) };
|
|
733
868
|
}
|
|
734
869
|
const now = new Date().toISOString();
|
|
@@ -741,7 +876,9 @@ export function beginTaskDeletionIntent(id, actor) {
|
|
|
741
876
|
name: member.name, identity_cid: member.identity_cid, phase: 'pending', updated_at: now,
|
|
742
877
|
})),
|
|
743
878
|
};
|
|
879
|
+
// The task write drops queued transitions; the cleanup intent then retires everything already delivered.
|
|
744
880
|
writeTask(stored);
|
|
881
|
+
queueMissionCleanup(id, notificationRoomCids(stored));
|
|
745
882
|
writeDeletionReceiptForIntent(stored);
|
|
746
883
|
return { status: 'accepted', task: presentTaskLenient(stored) };
|
|
747
884
|
});
|
|
@@ -967,12 +1104,9 @@ export function unlinkDeletedTask(id) {
|
|
|
967
1104
|
}
|
|
968
1105
|
if (stored.deletion?.status !== 'pending')
|
|
969
1106
|
throw new TaskStateError(`task ${id} has no pending deletion`);
|
|
970
|
-
//
|
|
971
|
-
//
|
|
972
|
-
|
|
973
|
-
title: `Task deleted: ${stored.title}`.slice(0, 160), body: `Task ${id}: deleting → deleted.`,
|
|
974
|
-
target: { kind: 'task-deleted', taskId: id } };
|
|
975
|
-
replaceFileAtomically(join(retiredNotificationsDir(), `${id}.json`), JSON.stringify({ notification_events: [...(stored.notification_events ?? []), removed] }) + '\n');
|
|
1107
|
+
// Merge rooms proven since acceptance into the durable cleanup intent before the record disappears.
|
|
1108
|
+
// No deletion notification is produced: deleting retires the task's notifications instead.
|
|
1109
|
+
queueMissionCleanup(id, notificationRoomCids(stored));
|
|
976
1110
|
try {
|
|
977
1111
|
unlinkSync(taskPath(id));
|
|
978
1112
|
}
|