@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
|
@@ -115,6 +115,13 @@ export interface TaskNotificationEvent {
|
|
|
115
115
|
taskId: string;
|
|
116
116
|
};
|
|
117
117
|
taskId: string;
|
|
118
|
+
/** Born read in the notification service: pushed, never an unread item. Absent on events queued before it. */
|
|
119
|
+
delivery?: 'push-only';
|
|
120
|
+
}
|
|
121
|
+
/** Persisted reads of Progress entries and To review revisions; absent until the first post-upgrade change. */
|
|
122
|
+
export interface TaskContributionReads {
|
|
123
|
+
progress_read: string[];
|
|
124
|
+
review_revision: number;
|
|
118
125
|
}
|
|
119
126
|
/** Who wrote a progress entry or review change, as established by the Fleet surface. */
|
|
120
127
|
export type TaskContributionAuthor = {
|
|
@@ -162,6 +169,9 @@ export interface TaskRecord {
|
|
|
162
169
|
review?: TaskReview;
|
|
163
170
|
/** Pending lifecycle events, committed atomically with their task change. */
|
|
164
171
|
notification_events?: TaskNotificationEvent[];
|
|
172
|
+
contribution_reads?: TaskContributionReads;
|
|
173
|
+
/** Rooms whose notifications belong to this task; only grows. */
|
|
174
|
+
notification_manifest?: import('./notification-manifest.js').TaskNotificationManifest;
|
|
165
175
|
workspace?: import('./workspace.js').OwnedWorkspace;
|
|
166
176
|
task_id: string;
|
|
167
177
|
/** Stable organizational list identifier. Missing legacy values mean `default`. */
|
package/dist/session/acp.d.ts
CHANGED
|
@@ -208,6 +208,9 @@ export declare class AcpSession implements AgentSession {
|
|
|
208
208
|
private activeTurn?;
|
|
209
209
|
private constructor();
|
|
210
210
|
static start(options: AcpSessionOptions): Promise<AcpSession>;
|
|
211
|
+
/** Construction failed after the child and connection exist: close the connection, stop the child (bounded TERM, then
|
|
212
|
+
* KILL), and check liveness at the end. Every cleanup failure is kept; a child not proven exited is named by pid. */
|
|
213
|
+
private static releaseFailedStart;
|
|
211
214
|
/**
|
|
212
215
|
* Honest restart recovery: a prompt that was admitted but never
|
|
213
216
|
* started is safe to dispatch again; a turn that had already started may
|
package/dist/session/acp.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConversationObservation } from './conversation-observation.js';
|
|
2
|
+
import { recordSessionOwnership } from './session-ownership.js';
|
|
2
3
|
import { appendTaskCreated } from './task-notice.js';
|
|
3
4
|
import { spawn } from 'node:child_process';
|
|
4
5
|
import { createHash, randomUUID } from 'node:crypto';
|
|
@@ -336,7 +337,17 @@ export class AcpSession {
|
|
|
336
337
|
this.conversation = new ConversationEventStore(join(options.stateDir, '.conversation'), {
|
|
337
338
|
roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
|
|
338
339
|
});
|
|
340
|
+
// Only earlier generations are below the page floor. The current ownership
|
|
341
|
+
// binding must be visible through both canonical pages and passive replay.
|
|
339
342
|
this.conversationStartCursor = this.conversation.lastCursor();
|
|
343
|
+
// Unavailable ownership refuses the start; release the ledger descriptor this constructor opened.
|
|
344
|
+
try {
|
|
345
|
+
recordSessionOwnership(this.conversation, this.sessionGeneration, options.stateDir);
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
this.conversation.close();
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
340
351
|
// A durable append can precede its inventory mutation. Capture only after
|
|
341
352
|
// the synchronous owner checkpoint, never the state at append notification.
|
|
342
353
|
this.conversation.subscribe(() => this.observation.scheduleCapture());
|
|
@@ -417,7 +428,14 @@ export class AcpSession {
|
|
|
417
428
|
});
|
|
418
429
|
const stream = acp.ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
|
|
419
430
|
const connection = app.connect(stream);
|
|
420
|
-
|
|
431
|
+
try {
|
|
432
|
+
instance = new AcpSession(options, child, connection);
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
// A cleanup exception never replaces the ownership failure: both are kept.
|
|
436
|
+
throw await AcpSession.releaseFailedStart(child, connection, error)
|
|
437
|
+
.catch(cleanupError => new AggregateError([error, cleanupError], 'ACP start failed and its cleanup failed', { cause: error }));
|
|
438
|
+
}
|
|
421
439
|
try {
|
|
422
440
|
await instance.initialize();
|
|
423
441
|
instance.startStallWatchdog();
|
|
@@ -430,6 +448,45 @@ export class AcpSession {
|
|
|
430
448
|
throw error;
|
|
431
449
|
}
|
|
432
450
|
}
|
|
451
|
+
/** Construction failed after the child and connection exist: close the connection, stop the child (bounded TERM, then
|
|
452
|
+
* KILL), and check liveness at the end. Every cleanup failure is kept; a child not proven exited is named by pid. */
|
|
453
|
+
static async releaseFailedStart(child, connection, error) {
|
|
454
|
+
const failures = [];
|
|
455
|
+
try {
|
|
456
|
+
connection.close();
|
|
457
|
+
}
|
|
458
|
+
catch (closeError) {
|
|
459
|
+
failures.push(closeError);
|
|
460
|
+
}
|
|
461
|
+
const exited = () => child.exitCode !== null || child.signalCode !== null;
|
|
462
|
+
const wait = (ms) => new Promise(resolve => {
|
|
463
|
+
if (exited())
|
|
464
|
+
return resolve();
|
|
465
|
+
const settle = () => { clearTimeout(timer); child.off('exit', settle); resolve(); };
|
|
466
|
+
const timer = setTimeout(settle, ms);
|
|
467
|
+
timer.unref();
|
|
468
|
+
child.on('exit', settle);
|
|
469
|
+
});
|
|
470
|
+
let killFailed = false;
|
|
471
|
+
const signal = (name) => { try {
|
|
472
|
+
child.kill(name);
|
|
473
|
+
}
|
|
474
|
+
catch (killError) {
|
|
475
|
+
killFailed = true;
|
|
476
|
+
failures.push(killError);
|
|
477
|
+
} };
|
|
478
|
+
if (!exited()) {
|
|
479
|
+
signal('SIGTERM');
|
|
480
|
+
await wait(2_500);
|
|
481
|
+
}
|
|
482
|
+
if (!exited()) {
|
|
483
|
+
signal('SIGKILL');
|
|
484
|
+
await wait(2_500);
|
|
485
|
+
}
|
|
486
|
+
if (!exited())
|
|
487
|
+
failures.push(new Error(`ACP child (pid ${child.pid}) ${killFailed ? 'exit unproven: a stop signal failed' : 'is still running'}`));
|
|
488
|
+
return failures.length ? new AggregateError([error, ...failures], 'ACP start failed and its release is unproven', { cause: error }) : error;
|
|
489
|
+
}
|
|
433
490
|
/**
|
|
434
491
|
* Honest restart recovery: a prompt that was admitted but never
|
|
435
492
|
* started is safe to dispatch again; a turn that had already started may
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConversationObservation } from './conversation-observation.js';
|
|
2
|
+
import { recordSessionOwnership } from './session-ownership.js';
|
|
2
3
|
import { appendTaskCreated } from './task-notice.js';
|
|
3
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
5
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -173,7 +174,17 @@ export class CodexAppServerSession {
|
|
|
173
174
|
this.conversation = new ConversationEventStore(join(options.stateDir, '.conversation'), {
|
|
174
175
|
roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
|
|
175
176
|
});
|
|
177
|
+
// Only earlier generations are below the page floor. The current ownership
|
|
178
|
+
// binding must be visible through both canonical pages and passive replay.
|
|
176
179
|
this.conversationStartCursor = this.conversation.lastCursor();
|
|
180
|
+
// Unavailable ownership refuses the start; release the ledger descriptor this constructor opened.
|
|
181
|
+
try {
|
|
182
|
+
recordSessionOwnership(this.conversation, this.sessionGeneration, options.stateDir);
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
this.conversation.close();
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
177
188
|
// A durable append can precede its inventory mutation. Capture only after
|
|
178
189
|
// the synchronous owner checkpoint, never the state at append notification.
|
|
179
190
|
this.conversation.subscribe(() => this.observation.scheduleCapture());
|
|
@@ -191,7 +202,17 @@ export class CodexAppServerSession {
|
|
|
191
202
|
onRequest: (method, id, params) => instance?.serverRequest(method, id, params),
|
|
192
203
|
onExit: exit => instance?.transportExited(exit),
|
|
193
204
|
});
|
|
194
|
-
|
|
205
|
+
try {
|
|
206
|
+
instance = new CodexAppServerSession(options, transport);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
// Existing bounded TERM→KILL; its waits are not proof, so liveness is checked after it returns.
|
|
210
|
+
const failures = [];
|
|
211
|
+
await transport.close().catch((closeError) => { failures.push(closeError); });
|
|
212
|
+
if (transport.isAlive())
|
|
213
|
+
failures.push(new Error(`Codex app-server transport (pid ${transport.pid}) is still running`));
|
|
214
|
+
throw failures.length ? new AggregateError([error, ...failures], 'Codex app-server start failed and its release is unproven', { cause: error }) : error;
|
|
215
|
+
}
|
|
195
216
|
try {
|
|
196
217
|
await instance.initialize();
|
|
197
218
|
instance.recoverOpenPrompts();
|
|
@@ -8,7 +8,7 @@ import type { PromptOrigin, SessionSnapshot, TurnCancellationSource, TurnOutcome
|
|
|
8
8
|
* file touches the wire: transport updates are reduced into these shapes by a
|
|
9
9
|
* session adapter, and the store assigns `seq`/`eventId`/timestamps.
|
|
10
10
|
*/
|
|
11
|
-
export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'file.attached' | 'fleet.task_created' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
|
|
11
|
+
export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'file.attached' | 'fleet.task_created' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'session.ownership' | 'capabilities.updated' | 'error'
|
|
12
12
|
/** A well-formed transport update this version cannot represent. Bounded, never a crash. */
|
|
13
13
|
| 'unsupported';
|
|
14
14
|
/** Where a conversation record came from. Typed provenance, never prompt text. */
|
|
@@ -142,6 +142,12 @@ export interface UsageUpdatedPayload {
|
|
|
142
142
|
export interface SessionStatePayload {
|
|
143
143
|
currentModeId?: string;
|
|
144
144
|
}
|
|
145
|
+
/** First event of a generation: the launch it belongs to and that launch's task, or null for a non-task role. */
|
|
146
|
+
export interface SessionOwnershipPayload {
|
|
147
|
+
/** Null for a role without temp-supervisor metadata (explicitly standalone). */
|
|
148
|
+
launchId: string | null;
|
|
149
|
+
taskId: string | null;
|
|
150
|
+
}
|
|
145
151
|
export interface SessionInfoPayload {
|
|
146
152
|
title?: string | null;
|
|
147
153
|
updatedAt?: string | null;
|
|
@@ -236,7 +242,7 @@ export interface BoundedJson {
|
|
|
236
242
|
}
|
|
237
243
|
export type ConversationPayload = {
|
|
238
244
|
attachment: import('../file-delivery/types.js').DeliveredFile;
|
|
239
|
-
} | MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload | TaskCreatedNotice;
|
|
245
|
+
} | MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | SessionOwnershipPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload | TaskCreatedNotice;
|
|
240
246
|
export interface ConversationEventV1 {
|
|
241
247
|
schemaVersion: 1;
|
|
242
248
|
roleId: string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ConversationEventStore } from './conversation-store.js';
|
|
2
|
+
/** Durable generation → launch/task evidence, independent of the transcript tail. Bounded to recent generations. */
|
|
3
|
+
export declare const SESSION_OWNERSHIP_FILE = ".session-ownership.json";
|
|
4
|
+
export interface SessionOwnership {
|
|
5
|
+
launchId: string | null;
|
|
6
|
+
taskId: string | null;
|
|
7
|
+
}
|
|
8
|
+
/** Canonical Fleet task identity; a task owner always has a launch. */
|
|
9
|
+
export declare const validOwnerTask: (taskId: unknown) => taskId is string;
|
|
10
|
+
/** Absent evidence is `{}` (no file: ENOENT only). Unreadable or invalid evidence throws and is never treated as absent. */
|
|
11
|
+
export declare function readSessionOwnership(stateDir: string): Record<string, SessionOwnership>;
|
|
12
|
+
/**
|
|
13
|
+
* Bind this session generation to its launch before any prompt can be admitted. A task member's generation names
|
|
14
|
+
* its task; a role without temp-supervisor metadata is explicitly standalone. Existing but unreadable metadata is
|
|
15
|
+
* unavailable evidence: the session refuses to start rather than guess. For a task-owned generation both the
|
|
16
|
+
* sidecar and the ledger event must persist, or the session start fails (no prompt is admitted without its binding).
|
|
17
|
+
*/
|
|
18
|
+
export declare function recordSessionOwnership(conversation: ConversationEventStore, sessionGeneration: string, stateDir: string): void;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { replaceFileAtomically } from '../atomic-file.js';
|
|
4
|
+
import { readTempSupervisor, TEMP_SUPERVISOR_FILE } from '../temp-lifecycle.js';
|
|
5
|
+
/** Durable generation → launch/task evidence, independent of the transcript tail. Bounded to recent generations. */
|
|
6
|
+
export const SESSION_OWNERSHIP_FILE = '.session-ownership.json';
|
|
7
|
+
const MAX_GENERATIONS = 16;
|
|
8
|
+
/** Canonical Fleet task identity; a task owner always has a launch. */
|
|
9
|
+
export const validOwnerTask = (taskId) => typeof taskId === 'string' && /^[0-9a-z]{9}[0-9a-f]{8}$/.test(taskId);
|
|
10
|
+
/** Absent evidence is `{}` (no file: ENOENT only). Unreadable or invalid evidence throws and is never treated as absent. */
|
|
11
|
+
export function readSessionOwnership(stateDir) {
|
|
12
|
+
let text;
|
|
13
|
+
try {
|
|
14
|
+
text = readFileSync(join(stateDir, SESSION_OWNERSHIP_FILE), 'utf8');
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
if (error.code === 'ENOENT')
|
|
18
|
+
return {};
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
if (text.length > 64 * 1024)
|
|
22
|
+
throw new Error('session ownership evidence too large');
|
|
23
|
+
const value = JSON.parse(text);
|
|
24
|
+
if (value?.schema !== 1 || !value.generations || typeof value.generations !== 'object' || Array.isArray(value.generations))
|
|
25
|
+
throw new Error('invalid session ownership evidence');
|
|
26
|
+
const result = {};
|
|
27
|
+
for (const [generation, owner] of Object.entries(value.generations)) {
|
|
28
|
+
if (!owner || !(owner.launchId === null || typeof owner.launchId === 'string')
|
|
29
|
+
|| !(owner.taskId === null || validOwnerTask(owner.taskId)) || (owner.taskId !== null && owner.launchId === null))
|
|
30
|
+
throw new Error('invalid session ownership evidence');
|
|
31
|
+
result[generation] = { launchId: owner.launchId, taskId: owner.taskId };
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Bind this session generation to its launch before any prompt can be admitted. A task member's generation names
|
|
37
|
+
* its task; a role without temp-supervisor metadata is explicitly standalone. Existing but unreadable metadata is
|
|
38
|
+
* unavailable evidence: the session refuses to start rather than guess. For a task-owned generation both the
|
|
39
|
+
* sidecar and the ledger event must persist, or the session start fails (no prompt is admitted without its binding).
|
|
40
|
+
*/
|
|
41
|
+
export function recordSessionOwnership(conversation, sessionGeneration, stateDir) {
|
|
42
|
+
let owner;
|
|
43
|
+
if (!existsSync(join(stateDir, TEMP_SUPERVISOR_FILE)))
|
|
44
|
+
owner = { launchId: null, taskId: null };
|
|
45
|
+
else {
|
|
46
|
+
const metadata = readTempSupervisor(stateDir);
|
|
47
|
+
if (!metadata)
|
|
48
|
+
throw new Error('session ownership unavailable: temp supervisor metadata is unreadable');
|
|
49
|
+
const taskId = metadata.taskOwner?.taskId ?? null;
|
|
50
|
+
if (taskId !== null && !validOwnerTask(taskId))
|
|
51
|
+
throw new Error('session ownership unavailable: invalid task owner');
|
|
52
|
+
owner = { launchId: metadata.launchId, taskId };
|
|
53
|
+
}
|
|
54
|
+
const draft = { kind: 'session.ownership', sessionGeneration, payload: owner };
|
|
55
|
+
if (!owner.taskId) {
|
|
56
|
+
// A standalone role keeps the existing degrade-not-die behaviour. A sidecar that cannot be updated is left exactly as
|
|
57
|
+
// it was (never reset): the ledger event still binds this generation, and the observer refuses to guess from it.
|
|
58
|
+
try {
|
|
59
|
+
writeSidecar(stateDir, sessionGeneration, owner);
|
|
60
|
+
}
|
|
61
|
+
catch { /* evidence preserved; the ledger binding remains */ }
|
|
62
|
+
conversation.appendSafe(draft);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
writeSidecar(stateDir, sessionGeneration, owner);
|
|
66
|
+
conversation.append(draft);
|
|
67
|
+
}
|
|
68
|
+
/** Initializes only on proven absence. Corrupt or unreadable prior evidence throws and its bytes stay untouched. */
|
|
69
|
+
function writeSidecar(stateDir, sessionGeneration, owner) {
|
|
70
|
+
const generations = readSessionOwnership(stateDir);
|
|
71
|
+
delete generations[sessionGeneration];
|
|
72
|
+
const kept = Object.entries(generations).slice(-(MAX_GENERATIONS - 1));
|
|
73
|
+
replaceFileAtomically(join(stateDir, SESSION_OWNERSHIP_FILE), JSON.stringify({ schema: 1, generations: Object.fromEntries([...kept, [sessionGeneration, owner]]) }) + '\n', 0o600);
|
|
74
|
+
}
|
|
@@ -160,8 +160,15 @@ export async function runFleetManager(entrypoint, deps = {}) {
|
|
|
160
160
|
try {
|
|
161
161
|
try {
|
|
162
162
|
const config = producerConfig(process.env, undefined, log);
|
|
163
|
-
if (config)
|
|
164
|
-
|
|
163
|
+
if (config) {
|
|
164
|
+
const [{ listTasks }, { TaskLayouts }] = await Promise.all([import('../rooms-tasks/task-state.js'), import('../application/task-layouts.js')]);
|
|
165
|
+
const layouts = new TaskLayouts();
|
|
166
|
+
notifications = new TaskNotificationProducer(config, log, fetch, {
|
|
167
|
+
// Unreadable task or layout evidence is not an empty inventory: it throws and the bootstrap run stays incomplete.
|
|
168
|
+
tasks: () => listTasks({ strict: true }),
|
|
169
|
+
layoutRooms: task => (layouts.view(task)?.rooms ?? []).flatMap(room => room.identity_cid ? [room.identity_cid] : []),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
165
172
|
}
|
|
166
173
|
catch {
|
|
167
174
|
log('[fleet] task notifications unavailable; committed events retained');
|
|
@@ -91,6 +91,8 @@ export interface ResourceDescriptor {
|
|
|
91
91
|
list?: string;
|
|
92
92
|
groupByList?: boolean;
|
|
93
93
|
includeDeleting?: boolean;
|
|
94
|
+
/** Previews: each agent's newest assistant message time only, without its text. */
|
|
95
|
+
activityOnly?: boolean;
|
|
94
96
|
}
|
|
95
97
|
export declare function canonicalResourceDescriptor(input: unknown): ResourceDescriptor;
|
|
96
98
|
interface BoundProjection {
|
|
@@ -156,9 +156,11 @@ export function canonicalResourceDescriptor(input) {
|
|
|
156
156
|
throw Error('invalid resource descriptor');
|
|
157
157
|
const value = input;
|
|
158
158
|
if (!/^[A-Za-z0-9_-]{1,128}$/.test(value.viewId) || !['directory', 'roles', 'tasks', 'task-lists', 'configuration', 'previews', 'task-layout', 'creation-action', 'action', 'member-addition', 'login', 'onboarding-health', 'subscriptions'].includes(value.resource)
|
|
159
|
-
|| Object.keys(value).some(key => !['viewId', 'resource', 'includeTemporary', 'includeDefinitions', 'ids', 'state', 'list', 'groupByList', 'includeDeleting', 'entityId', 'receiptId', 'provider'].includes(key)))
|
|
159
|
+
|| Object.keys(value).some(key => !['viewId', 'resource', 'includeTemporary', 'includeDefinitions', 'ids', 'state', 'list', 'groupByList', 'includeDeleting', 'entityId', 'receiptId', 'provider', 'activityOnly'].includes(key)))
|
|
160
160
|
throw Error('invalid resource descriptor');
|
|
161
|
-
|
|
161
|
+
if (value.activityOnly !== undefined && value.resource !== 'previews')
|
|
162
|
+
throw Error('invalid resource descriptor');
|
|
163
|
+
for (const key of ['includeTemporary', 'includeDefinitions', 'groupByList', 'includeDeleting', 'activityOnly'])
|
|
162
164
|
if (value[key] !== undefined && typeof value[key] !== 'boolean')
|
|
163
165
|
throw Error('invalid resource descriptor');
|
|
164
166
|
if (value.ids !== undefined && (!Array.isArray(value.ids) || value.ids.length > 500 || value.ids.some(id => typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,120}$/.test(id))))
|
package/dist/web/server.js
CHANGED
|
@@ -2,7 +2,7 @@ import { FleetResourceViews, ResourceBindingError, canonicalResourceDescriptor }
|
|
|
2
2
|
import { publishFleetResourceCommit } from '../application/resource-observer.js';
|
|
3
3
|
import { stateRoot, defaultConfigPath } from '../paths.js';
|
|
4
4
|
import { EMPTY_TASK_LAYOUT } from '../rooms-tasks/empty-layout.js';
|
|
5
|
-
import { conversationPreview } from '../application/conversation-history.js';
|
|
5
|
+
import { conversationActivity, conversationPreview } from '../application/conversation-history.js';
|
|
6
6
|
import { registerConversationStream } from './conversation-stream.js';
|
|
7
7
|
import { conversationHasAttachment } from '../application/conversation-history.js';
|
|
8
8
|
import { readDeliveredFile } from '../file-delivery/store.js';
|
|
@@ -26,7 +26,7 @@ import { WebAuth } from './auth.js';
|
|
|
26
26
|
import { FleetEventBus } from './events.js';
|
|
27
27
|
import { ROLE_NAME_RE } from '../config.js';
|
|
28
28
|
import { TaskListError } from '../rooms-tasks/task-lists.js';
|
|
29
|
-
import { TaskStateError } from '../rooms-tasks/task-state.js';
|
|
29
|
+
import { acknowledgeTaskProgress, acknowledgeTaskReview, contributionUnread, TaskStateError } from '../rooms-tasks/task-state.js';
|
|
30
30
|
import { appendTaskProgress, getTaskReview, listTaskProgress, publicProgressEntry, publicReview, readTaskReviewArtifact, setTaskReviewMarkdown, taskContributionSummary, TaskReviewError, } from '../rooms-tasks/task-review.js';
|
|
31
31
|
import { isProvider } from '../subscriptions/store.js';
|
|
32
32
|
const ONBOARDING_WORK = ['coordination', 'development', 'review'];
|
|
@@ -371,9 +371,12 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
371
371
|
const filter = { strict, ...(state ? { state } : {}), ...(query.list ? { list: query.list } : {}),
|
|
372
372
|
...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) };
|
|
373
373
|
const api = requireTaskRooms();
|
|
374
|
+
// Navigation totals cover every task in scope, not only the filtered or visible cards.
|
|
375
|
+
const unreadTotal = () => api.listTasks({ strict, ...(query.includeDeleting === 'true' ? { includeDeleting: true } : {}) })
|
|
376
|
+
.reduce((sum, task) => { const unread = contributionUnread(task); return sum + unread.progress + (unread.review ? 1 : 0); }, 0);
|
|
374
377
|
return taskApi(() => query.groupByList === 'true'
|
|
375
|
-
? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => wireTask(api.withLayoutRooms(task))) })) }
|
|
376
|
-
: { tasks: api.listTasks(filter).map(task => wireTask(api.withLayoutRooms(task))) });
|
|
378
|
+
? { groups: api.groupedTasks(filter).map(group => ({ ...group, tasks: group.tasks.map(task => wireTask(api.withLayoutRooms(task))) })), contribution_unread_total: unreadTotal() }
|
|
379
|
+
: { tasks: api.listTasks(filter).map(task => wireTask(api.withLayoutRooms(task))), contribution_unread_total: unreadTotal() });
|
|
377
380
|
};
|
|
378
381
|
const project = async (descriptor, signal, owner = '') => {
|
|
379
382
|
signal.throwIfAborted();
|
|
@@ -426,7 +429,8 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
426
429
|
return { ...services.configuration.read(descriptor.includeDefinitions ?? false), provenance: services.presetProvenance?.read() ?? null };
|
|
427
430
|
case 'directory': {
|
|
428
431
|
const [roles, tasks] = await Promise.all([services.query.list(true, true), projectTasks({})]);
|
|
429
|
-
|
|
432
|
+
const { tasks: rows, contribution_unread_total } = tasks;
|
|
433
|
+
return { roles, tasks: rows, contribution_unread_total, lists: requireTaskRooms().listTaskLists() };
|
|
430
434
|
}
|
|
431
435
|
case 'previews': {
|
|
432
436
|
const roles = await services.repository.list(true), selected = new Map(roles.map(role => [role.id, role]));
|
|
@@ -437,14 +441,15 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
437
441
|
if (!role)
|
|
438
442
|
continue;
|
|
439
443
|
try {
|
|
440
|
-
previews[id] = dir ? await
|
|
444
|
+
previews[id] = descriptor.activityOnly ? (dir ? await conversationActivity(dir, signal) : { at: '' })
|
|
445
|
+
: dir ? await conversationPreview(dir, signal) : { text: 'No recent agent output', at: '' };
|
|
441
446
|
}
|
|
442
447
|
catch {
|
|
443
448
|
signal.throwIfAborted();
|
|
444
449
|
// A damaged/unreadable ledger is unavailable, not empty or removed.
|
|
445
450
|
// Keep this selected row observable so a repair can refresh it,
|
|
446
451
|
// without retiring previews for the other selected roles.
|
|
447
|
-
previews[id] = { text: 'Preview unavailable', at: '', unavailable: true };
|
|
452
|
+
previews[id] = descriptor.activityOnly ? { at: '', unavailable: true } : { text: 'Preview unavailable', at: '', unavailable: true };
|
|
448
453
|
}
|
|
449
454
|
}
|
|
450
455
|
return { previews };
|
|
@@ -505,7 +510,7 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
505
510
|
for (const resource of ['task-layout', 'creation-action', 'action', 'member-addition', 'login', 'onboarding-health'])
|
|
506
511
|
app.get('/api/v1/' + resource, async () => { throw new ResourceBindingError(409, 'resource binding required'); });
|
|
507
512
|
app.get('/api/v1/directory', async (request) => { auth.authenticate(request); return project({ viewId: 'read', resource: 'directory' }, new AbortController().signal); });
|
|
508
|
-
app.get('/api/v1/previews', async (request) => { auth.authenticate(request); const ids = String(
|
|
513
|
+
app.get('/api/v1/previews', async (request) => { auth.authenticate(request); const query = request.query, ids = String(query.ids ?? '').split(',').filter(Boolean); return project(canonicalResourceDescriptor({ viewId: 'read', resource: 'previews', ids, ...(query.activityOnly === 'true' ? { activityOnly: true } : {}) }), new AbortController().signal); });
|
|
509
514
|
app.get('/api/v1/task-lists', async (request) => {
|
|
510
515
|
auth.authenticate(request);
|
|
511
516
|
return project({ viewId: 'read', resource: 'task-lists' }, new AbortController().signal);
|
|
@@ -619,6 +624,22 @@ export async function buildWebServer(services, boundary, options = {}) {
|
|
|
619
624
|
reply.code(201);
|
|
620
625
|
return { entry: publicProgressEntry(result.entry), replayed: result.replayed };
|
|
621
626
|
});
|
|
627
|
+
/** Acknowledge Progress entries the browser actually showed; returns the task's remaining unread. */
|
|
628
|
+
app.post('/api/v1/tasks/:id/progress/read', async (request) => {
|
|
629
|
+
auth.authenticate(request, true);
|
|
630
|
+
const ids = request.body?.ids;
|
|
631
|
+
if (!Array.isArray(ids) || ids.length > 200 || ids.some(id => typeof id !== 'string' || id.length > 64))
|
|
632
|
+
throw new FleetError('invalid_request', 'ids must be at most 200 progress entry IDs');
|
|
633
|
+
return { unread: await reviewApi(() => acknowledgeTaskProgress(request.params.id, ids)) };
|
|
634
|
+
});
|
|
635
|
+
/** Acknowledge the To review revision the browser fetched and rendered. */
|
|
636
|
+
app.post('/api/v1/tasks/:id/review/read', async (request) => {
|
|
637
|
+
auth.authenticate(request, true);
|
|
638
|
+
const revision = request.body?.revision;
|
|
639
|
+
if (!Number.isSafeInteger(revision) || revision < 0)
|
|
640
|
+
throw new FleetError('invalid_request', 'revision must be a non-negative integer');
|
|
641
|
+
return { unread: await reviewApi(() => acknowledgeTaskReview(request.params.id, revision)) };
|
|
642
|
+
});
|
|
622
643
|
app.get('/api/v1/tasks/:id/review', async (request) => {
|
|
623
644
|
auth.authenticate(request);
|
|
624
645
|
const review = await reviewApi(() => getTaskReview(request.params.id));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@agentclientprotocol/sdk": "^1.3.0",
|
|
51
51
|
"@fastify/websocket": "^11.2.0",
|
|
52
|
-
"@ours.network/cli": "4.0.
|
|
53
|
-
"@ours.network/mcp": "2.0.
|
|
54
|
-
"@ours.network/sdk": "5.0.
|
|
52
|
+
"@ours.network/cli": "4.0.1",
|
|
53
|
+
"@ours.network/mcp": "2.0.1",
|
|
54
|
+
"@ours.network/sdk": "5.0.1",
|
|
55
55
|
"commander": "^12.1.0",
|
|
56
56
|
"fastify": "^5.4.0",
|
|
57
57
|
"qrcode": "1.5.4",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"@agentclientprotocol/codex-acp": "1.10.0"
|
|
66
66
|
},
|
|
67
67
|
"devDependencies": {
|
|
68
|
-
"@ours.network/daemon": "5.0.
|
|
68
|
+
"@ours.network/daemon": "5.0.1",
|
|
69
69
|
"@types/node": "^22.20.4",
|
|
70
70
|
"@types/qrcode": "^1.5.6",
|
|
71
71
|
"@types/ws": "^8.18.1",
|