@ours.network/fleet 2.1.1 → 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.
Files changed (47) hide show
  1. package/README.md +41 -2
  2. package/dist/application/conversation-history.d.ts +8 -0
  3. package/dist/application/conversation-history.js +37 -2
  4. package/dist/application/task-room-service.d.ts +7 -0
  5. package/dist/application/task-room-service.js +19 -0
  6. package/dist/build-info.json +4 -4
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +1 -0
  9. package/dist/doctor.js +17 -4
  10. package/dist/notifications/fleet-producer.d.ts +0 -1
  11. package/dist/notifications/fleet-producer.js +65 -47
  12. package/dist/notifications/mission-ownership.d.ts +31 -0
  13. package/dist/notifications/mission-ownership.js +148 -0
  14. package/dist/notifications/outbox.d.ts +10 -0
  15. package/dist/notifications/outbox.js +28 -2
  16. package/dist/notifications/task-producer.d.ts +11 -1
  17. package/dist/notifications/task-producer.js +34 -5
  18. package/dist/rooms-tasks/layout-service.d.ts +2 -0
  19. package/dist/rooms-tasks/layout-service.js +7 -2
  20. package/dist/rooms-tasks/layout.d.ts +5 -1
  21. package/dist/rooms-tasks/layout.js +7 -1
  22. package/dist/rooms-tasks/notification-manifest.d.ts +29 -0
  23. package/dist/rooms-tasks/notification-manifest.js +65 -0
  24. package/dist/rooms-tasks/task-review.d.ts +2 -0
  25. package/dist/rooms-tasks/task-review.js +7 -5
  26. package/dist/rooms-tasks/task-state.d.ts +35 -0
  27. package/dist/rooms-tasks/task-state.js +151 -17
  28. package/dist/rooms-tasks/types.d.ts +10 -0
  29. package/dist/runtime-security.d.ts +1 -0
  30. package/dist/runtime-security.js +6 -0
  31. package/dist/security-versions.d.ts +20 -0
  32. package/dist/security-versions.js +70 -0
  33. package/dist/session/acp.d.ts +3 -0
  34. package/dist/session/acp.js +58 -1
  35. package/dist/session/codex-app-server.js +22 -1
  36. package/dist/session/conversation-types.d.ts +8 -2
  37. package/dist/session/session-ownership.d.ts +18 -0
  38. package/dist/session/session-ownership.js +74 -0
  39. package/dist/setup-tunnel-preflight.js +10 -3
  40. package/dist/supervisor/manager.js +9 -2
  41. package/dist/web/resource-views.d.ts +2 -0
  42. package/dist/web/resource-views.js +4 -2
  43. package/dist/web/runtime.js +1 -1
  44. package/dist/web/server.js +29 -8
  45. package/dist/web/workspace-tunnel.d.ts +2 -1
  46. package/dist/web/workspace-tunnel.js +29 -3
  47. package/package.json +6 -6
@@ -0,0 +1,148 @@
1
+ import { mkdirSync, readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { replaceFileAtomically } from '../atomic-file.js';
4
+ import { stateRoot } from '../paths.js';
5
+ import { canonicalCid, pendingMissionCleanups, settleMissionCleanup, unavailableMissionCleanups, validRoomCid } from '../rooms-tasks/notification-manifest.js';
6
+ import { advanceNotificationRevision, notificationRoomCids, recordNotificationRooms } from '../rooms-tasks/task-state.js';
7
+ import { producerConfig } from './outbox.js';
8
+ /** The service predates mission ownership: it has no such route. Work is retained, never reported as done. */
9
+ export class MissionOwnershipUnsupported extends Error {
10
+ }
11
+ export class MissionOwnershipUnavailable extends Error {
12
+ }
13
+ export async function missionRequest(config, path, body, request = fetch) {
14
+ let response;
15
+ try {
16
+ response = await request(config.origin + path, {
17
+ method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10_000),
18
+ headers: { ...(config.gatewayCredential
19
+ ? { 'X-Ours-Api-Token': config.gatewayCredential, 'X-Ours-Notifications-Producer': config.token }
20
+ : { Authorization: `Bearer ${config.token}` }), 'Content-Type': 'application/json' },
21
+ body: JSON.stringify(body),
22
+ });
23
+ }
24
+ catch {
25
+ throw new MissionOwnershipUnavailable('notification service unreachable');
26
+ }
27
+ const data = await response.json().catch(() => undefined);
28
+ if (response.status === 404)
29
+ throw new MissionOwnershipUnsupported('notification service does not support mission ownership');
30
+ return { status: response.status, data };
31
+ }
32
+ /**
33
+ * Register the task's current manifest. Only a 2xx response whose `contactIds` include every requested room proves
34
+ * coverage. A 409 (mission deleted, stale or conflicting revision) proves nothing about these rooms: the local revision
35
+ * is moved past the refused one, so a later retry registers the full union under a newer revision, and this attempt fails.
36
+ */
37
+ async function registerMission(config, taskId, manifest, request) {
38
+ const { status, data } = await missionRequest(config, '/api/v1/missions/associate', { taskId, revision: manifest.revision, contactIds: manifest.room_cids }, request);
39
+ if (status >= 200 && status < 300) {
40
+ const registered = Array.isArray(data?.contactIds) ? new Set(data.contactIds.filter(validRoomCid).map(canonicalCid)) : undefined;
41
+ if (registered && manifest.room_cids.every(cid => registered.has(cid)))
42
+ return;
43
+ throw new MissionOwnershipUnavailable('notification service did not confirm every mission room');
44
+ }
45
+ if (status === 409)
46
+ advanceNotificationRevision(taskId, manifest.revision);
47
+ throw new MissionOwnershipUnavailable(`notification service refused room registration (HTTP ${status})`);
48
+ }
49
+ /**
50
+ * Register a room as this task's before the Owner can join it: with own-room messages off, the service must know
51
+ * the room before the room can message the Owner. Unreachable service or unconfirmed coverage → throws, so room
52
+ * activation waits and retries. No producer configured → nothing to gate. An older service without the route →
53
+ * admitted with a warning (it cannot suppress own-room messages either).
54
+ */
55
+ export async function associateRoomNotifications(taskId, roomCid, options = {}) {
56
+ const manifest = recordNotificationRooms(taskId, [roomCid]);
57
+ const config = 'config' in options ? options.config : producerConfig();
58
+ if (!config)
59
+ return;
60
+ try {
61
+ await registerMission(config, taskId, manifest, options.request);
62
+ }
63
+ catch (error) {
64
+ if (error instanceof MissionOwnershipUnsupported) {
65
+ options.warn?.('Notification service does not support mission rooms; own-room message preferences are unavailable');
66
+ return;
67
+ }
68
+ if (error instanceof MissionOwnershipUnavailable)
69
+ throw new Error('Waiting for notification service to register the mission room');
70
+ throw error;
71
+ }
72
+ }
73
+ /** Retire every queued deleted task's notifications; an intent is removed only after the service acknowledged it. */
74
+ export async function drainMissionCleanup(config, warn, request = fetch) {
75
+ let pending = false;
76
+ for (const intent of pendingMissionCleanups().slice(0, 32)) {
77
+ try {
78
+ const { status } = await missionRequest(config, '/api/v1/missions/delete', { taskId: intent.taskId, contactIds: intent.contactIds }, request);
79
+ if (status >= 200 && status < 300) {
80
+ if (!settleMissionCleanup(intent))
81
+ pending = true;
82
+ continue;
83
+ }
84
+ warn(`Mission notification cleanup pending (HTTP ${status}); intent retained`);
85
+ }
86
+ catch (error) {
87
+ warn(error instanceof MissionOwnershipUnsupported
88
+ ? 'Notification service does not support mission cleanup; cleanup pending'
89
+ : 'Mission notification cleanup pending; intent retained');
90
+ }
91
+ return true;
92
+ }
93
+ // An unreadable intent stays pending (and its file retained): overall cleanup is never reported done around it.
94
+ const unavailable = unavailableMissionCleanups();
95
+ if (unavailable.length)
96
+ warn(`Mission notification cleanup unavailable for ${unavailable.length} unreadable intent(s); files retained`);
97
+ return pending || unavailable.length > 0 || pendingMissionCleanups().length > 32;
98
+ }
99
+ const bootstrapFile = () => join(stateRoot(), 'notifications', 'mission-bootstrap.json');
100
+ /** Only a missing file starts at 0. Unreadable, corrupt or out-of-range state is refused and its bytes are kept. */
101
+ function nextGeneration() {
102
+ let text;
103
+ try {
104
+ text = readFileSync(bootstrapFile(), 'utf8');
105
+ }
106
+ catch (error) {
107
+ if (error.code !== 'ENOENT')
108
+ throw new MissionOwnershipUnavailable('mission bootstrap state unreadable');
109
+ }
110
+ let generation = 0;
111
+ if (text !== undefined) {
112
+ let value;
113
+ try {
114
+ value = JSON.parse(text);
115
+ }
116
+ catch {
117
+ throw new MissionOwnershipUnavailable('mission bootstrap state corrupt; file retained');
118
+ }
119
+ const stored = value && typeof value === 'object' && !Array.isArray(value) ? value.generation : undefined;
120
+ if (typeof stored !== 'number' || !Number.isSafeInteger(stored) || stored < 0)
121
+ throw new MissionOwnershipUnavailable('mission bootstrap state invalid; file retained');
122
+ generation = stored;
123
+ }
124
+ if (!Number.isSafeInteger(generation + 1))
125
+ throw new MissionOwnershipUnavailable('mission bootstrap generation exhausted');
126
+ mkdirSync(dirname(bootstrapFile()), { recursive: true, mode: 0o700 });
127
+ replaceFileAtomically(bootstrapFile(), JSON.stringify({ generation: generation + 1 }) + '\n', 0o600);
128
+ return generation + 1;
129
+ }
130
+ /**
131
+ * Register every existing task's rooms under one numbered run, then mark the run complete. Until a run completes
132
+ * the service refuses own-room OFF, so no room that existed before this release can escape the preference.
133
+ * The inventory is read in full before the run starts: an unreadable task or layout throws, and the run never
134
+ * completes on partial evidence. Every task must be confirmed by the service before completion.
135
+ */
136
+ export async function bootstrapMissionRooms(config, tasks, layoutRooms, request = fetch) {
137
+ const inventory = tasks().filter(task => task.deletion?.status !== 'pending')
138
+ .map(task => ({ task, cids: [...notificationRoomCids(task), ...layoutRooms(task)] })).filter(entry => entry.cids.length);
139
+ const generation = nextGeneration();
140
+ const begin = await missionRequest(config, '/api/v1/missions/bootstrap', { generation, complete: false }, request);
141
+ if (begin.status < 200 || begin.status >= 300)
142
+ throw new MissionOwnershipUnavailable(`bootstrap refused (HTTP ${begin.status})`);
143
+ for (const { task, cids } of inventory)
144
+ await registerMission(config, task.task_id, recordNotificationRooms(task.task_id, cids), request);
145
+ const done = await missionRequest(config, '/api/v1/missions/bootstrap', { generation, complete: true }, request);
146
+ if (done.status < 200 || done.status >= 300)
147
+ throw new MissionOwnershipUnavailable(`bootstrap completion refused (HTTP ${done.status})`);
148
+ }
@@ -44,12 +44,22 @@ export declare class NotificationOutbox {
44
44
  checkpoint?: unknown;
45
45
  private running?;
46
46
  private retired;
47
+ private again;
48
+ private failed;
49
+ private closed;
47
50
  private timer;
48
51
  constructor(file: string, config: ProducerConfig, project: (value: unknown, id: string) => Promise<NotificationInput>, warn?: (message: string) => void);
49
52
  private persist;
50
53
  advance(cursor: number): void;
51
54
  saveCheckpoint(checkpoint: unknown): void;
52
55
  enqueue(id: string, value: unknown, cursor?: number | undefined): boolean;
56
+ /**
57
+ * Deliver soon after a durable enqueue instead of at the next recovery tick. A request
58
+ * during a pass (which iterates its own snapshot) runs one follow-up pass after it;
59
+ * after a failed pass only the recovery timer retries, so arrivals during an outage
60
+ * add no attempts.
61
+ */
62
+ prompt(): void;
53
63
  drain(): Promise<void>;
54
64
  private drainOnce;
55
65
  /** Wait for the current send before clearing queued sends and retiring its target. */
@@ -99,6 +99,9 @@ export class NotificationOutbox {
99
99
  checkpoint;
100
100
  running;
101
101
  retired = false;
102
+ again = false;
103
+ failed = false;
104
+ closed = false;
102
105
  timer;
103
106
  constructor(file, config, project, warn = () => { }) {
104
107
  this.file = file;
@@ -152,12 +155,34 @@ export class NotificationOutbox {
152
155
  this.persist([...this.entries, { id, value, targetVersion: 1 }], cursor);
153
156
  return true;
154
157
  }
158
+ /**
159
+ * Deliver soon after a durable enqueue instead of at the next recovery tick. A request
160
+ * during a pass (which iterates its own snapshot) runs one follow-up pass after it;
161
+ * after a failed pass only the recovery timer retries, so arrivals during an outage
162
+ * add no attempts.
163
+ */
164
+ prompt() {
165
+ if (this.closed || this.retired || this.failed)
166
+ return;
167
+ if (this.running) {
168
+ this.again = true;
169
+ return;
170
+ }
171
+ void this.drain();
172
+ }
155
173
  drain() {
156
174
  if (this.retired)
157
175
  return Promise.resolve();
158
- return this.running ??= this.drainOnce().finally(() => { this.running = undefined; });
176
+ return this.running ??= this.drainOnce().finally(() => {
177
+ this.running = undefined;
178
+ if (this.again) {
179
+ this.again = false;
180
+ this.prompt();
181
+ }
182
+ });
159
183
  }
160
184
  async drainOnce() {
185
+ this.failed = false;
161
186
  let attempts = 0;
162
187
  for (const entry of [...this.entries]) {
163
188
  if (!supported(entry))
@@ -188,6 +213,7 @@ export class NotificationOutbox {
188
213
  this.persist(this.entries.filter(e => !same(entry.id)(e)));
189
214
  }
190
215
  catch {
216
+ this.failed = true;
191
217
  this.warn('Notification producer delivery pending; retrying from durable outbox');
192
218
  break;
193
219
  }
@@ -195,5 +221,5 @@ export class NotificationOutbox {
195
221
  }
196
222
  /** Wait for the current send before clearing queued sends and retiring its target. */
197
223
  async retire() { this.retired = true; clearInterval(this.timer); await this.running; this.persist(this.entries.filter(entry => !supported(entry))); }
198
- async close() { clearInterval(this.timer); await this.running; }
224
+ async close() { this.closed = true; clearInterval(this.timer); await this.running; }
199
225
  }
@@ -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
- constructor(config: ProducerConfig, warn?: (line: string) => void, request?: typeof fetch);
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
- constructor(config, warn = () => { }, request = fetch) {
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(); }, 3000);
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(`Task notification delivery pending (HTTP ${response.status}); committed event retained`);
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?.revision ?? 0) + 1;
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.revision ?? 1, artifacts: review.artifacts.map(({ request_key: _key, ...rest }) => rest) };
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
- // Packages written before revisions existed count as revision 1 until their next change.
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
  /**