@ours.network/fleet 2.3.1 → 2.4.0-nightly.1

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.
@@ -0,0 +1,88 @@
1
+ import { FleetError } from '../application/errors.js';
2
+ import { IdentityReadError } from './identity-reads.js';
3
+ /**
4
+ * Owner-facing Channels queries over the durable projection. Served only
5
+ * behind the Fleet web Owner session; agents get no route to it.
6
+ */
7
+ export class ChannelsService {
8
+ deps;
9
+ constructor(deps) {
10
+ this.deps = deps;
11
+ }
12
+ status() {
13
+ return { ready: this.deps.ready(), sources: this.deps.store.sourcesSummary() };
14
+ }
15
+ view(row) {
16
+ const root = this.deps.ownerRoot();
17
+ return { ...row, claimsOwnerRoot: !!root && row.claimedRoot?.rootCid === root };
18
+ }
19
+ list(options) {
20
+ let page;
21
+ try {
22
+ page = this.deps.store.list(options);
23
+ }
24
+ catch (error) {
25
+ throw new FleetError('invalid_request', error.message);
26
+ }
27
+ return { ...page, channels: page.channels.map(row => this.view(row)) };
28
+ }
29
+ /** The SSE snapshot: the first Recent page. */
30
+ snapshot() {
31
+ const page = this.deps.store.list({ limit: 50 });
32
+ return { ready: this.deps.ready(), channels: page.channels.map(row => this.view(row)), nextCursor: page.nextCursor };
33
+ }
34
+ get(cid) {
35
+ const found = this.deps.store.get(cid);
36
+ if (!found)
37
+ throw new FleetError('resource_not_found', 'no such channel');
38
+ return { channel: this.view(found.channel), perspectives: found.perspectives };
39
+ }
40
+ /**
41
+ * One perspective's own history with this contact; never a merged view, never consuming.
42
+ * Pinned to the source's CID and last observed daemon source generation; a response is
43
+ * released only while that same perspective is still current.
44
+ */
45
+ async history(contactCid, sourceCid, page) {
46
+ const contact = contactCid.toUpperCase(), source = sourceCid.toUpperCase();
47
+ const perspective = this.deps.store.hasPerspective(contact, source);
48
+ if (!perspective)
49
+ throw new FleetError('resource_not_found', 'that identity is not a perspective of this channel');
50
+ if (perspective.ended)
51
+ throw new FleetError('capability_unavailable', 'this perspective has ended; its history is no longer available');
52
+ let result;
53
+ try {
54
+ result = await this.deps.reader.history({
55
+ identity: perspective.name, expectedCid: source, peer_cid: contact,
56
+ ...(perspective.generation ? { expectedProducerGeneration: perspective.generation } : {}),
57
+ ...(page.before_seq !== undefined ? { before_seq: page.before_seq } : {}),
58
+ ...(page.limit !== undefined ? { limit: page.limit } : {}),
59
+ });
60
+ }
61
+ catch (error) {
62
+ if (error instanceof IdentityReadError) {
63
+ if (error.code === 'identity_not_found' || error.code === 'identity_mismatch') {
64
+ void this.deps.indexer.sync();
65
+ throw new FleetError('resource_not_found', 'this perspective is no longer available');
66
+ }
67
+ if (error.code === 'source_generation_changed') {
68
+ // The daemon restarted under this perspective: re-observe it before paging again.
69
+ this.deps.indexer.invalidate(source);
70
+ throw new FleetError('conflict', 'the identity source changed; reload this history', { retryable: true });
71
+ }
72
+ if (error.code === 'source_changed')
73
+ throw new FleetError('conflict', 'the identity changed during the read; retry', { retryable: true });
74
+ throw new FleetError('capability_unavailable', error.message);
75
+ }
76
+ throw error;
77
+ }
78
+ // The perspective may have ended, left or been replaced while the read was in flight.
79
+ const current = this.deps.store.hasPerspective(contact, source);
80
+ if (!current)
81
+ throw new FleetError('resource_not_found', 'this perspective is no longer available');
82
+ if (current.ended)
83
+ throw new FleetError('capability_unavailable', 'this perspective has ended; its history is no longer available');
84
+ if (current.name !== perspective.name || current.generation !== perspective.generation)
85
+ throw new FleetError('conflict', 'this perspective changed during the read; retry', { retryable: true });
86
+ return { ...result, perspective: { sourceCid: source, kind: current.kind, ...(current.roleId ? { roleId: current.roleId } : {}) } };
87
+ }
88
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Who Channels reads (perspective sources) and which CIDs are never channels
3
+ * (the internal-exclusion set). Both come from local records only: a peer's
4
+ * claimed root never makes it internal, because root claims are not proven.
5
+ */
6
+ export interface PerspectiveSource {
7
+ kind: 'owner' | 'agent';
8
+ /** Daemon identity name. */
9
+ name: string;
10
+ cid: string;
11
+ /** Fleet role id for agent sources (the "Open chat with agent" target). */
12
+ roleId?: string;
13
+ /** Released/retired/removed: kept as metadata only, never read again. */
14
+ ended: boolean;
15
+ }
16
+ export interface ChannelSources {
17
+ ownerCid: string;
18
+ rootCid?: string;
19
+ sources: PerspectiveSource[];
20
+ internal: Set<string>;
21
+ }
22
+ export interface DaemonIdentityRow {
23
+ name: string;
24
+ cid?: string;
25
+ kind?: string;
26
+ }
27
+ export interface AgentRecord {
28
+ name: string;
29
+ cid: string;
30
+ phase: string;
31
+ }
32
+ export declare const norm: (cid: string) => string;
33
+ /**
34
+ * Retained supervisor journals (`private-ours/<binder>/state.json` and archived
35
+ * `retired-<instance>/state.json`). They exist for stopped agents too, so a
36
+ * stopped agent stays a perspective source; retired generations mark ended.
37
+ */
38
+ export declare function readAgentRecords(privateRoot: string): AgentRecord[];
39
+ export declare function resolveSources(input: {
40
+ ownerCid: string;
41
+ identities: DaemonIdentityRow[];
42
+ agents: AgentRecord[];
43
+ /** Fleet role id by daemon identity name, for roles this workspace defines. */
44
+ roleByIdentity: Map<string, string>;
45
+ roomCids: string[];
46
+ }): ChannelSources;
@@ -0,0 +1,68 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ const CID = /^[0-9a-f]{64}$/i;
4
+ const ENDED_PHASES = new Set(['TERMINAL_INTENT', 'CLEANUP_PENDING', 'RELEASED', 'FAILED']);
5
+ export const norm = (cid) => cid.toUpperCase();
6
+ /**
7
+ * Retained supervisor journals (`private-ours/<binder>/state.json` and archived
8
+ * `retired-<instance>/state.json`). They exist for stopped agents too, so a
9
+ * stopped agent stays a perspective source; retired generations mark ended.
10
+ */
11
+ export function readAgentRecords(privateRoot) {
12
+ if (!existsSync(privateRoot))
13
+ return [];
14
+ const records = [];
15
+ const read = (path, retired) => {
16
+ try {
17
+ const value = JSON.parse(readFileSync(path, 'utf8'));
18
+ if (typeof value.name !== 'string' || typeof value.cid !== 'string' || !CID.test(value.cid))
19
+ return;
20
+ records.push({ name: value.name, cid: norm(value.cid), phase: retired ? 'RELEASED' : String(value.phase ?? '') });
21
+ }
22
+ catch { /* missing or corrupt journal: not a source */ }
23
+ };
24
+ for (const entry of readdirSync(privateRoot, { withFileTypes: true })) {
25
+ if (!entry.isDirectory())
26
+ continue;
27
+ const dir = join(privateRoot, entry.name);
28
+ read(join(dir, 'state.json'), false);
29
+ for (const child of readdirSync(dir, { withFileTypes: true })) {
30
+ if (child.isDirectory() && child.name.startsWith('retired-'))
31
+ read(join(dir, child.name, 'state.json'), true);
32
+ }
33
+ }
34
+ return records;
35
+ }
36
+ export function resolveSources(input) {
37
+ const ownerCid = norm(input.ownerCid);
38
+ const live = new Map(input.identities.filter(row => row.cid && CID.test(row.cid)).map(row => [norm(row.cid), row]));
39
+ const rootRow = input.identities.find(row => row.kind === 'root' && row.cid && CID.test(row.cid));
40
+ const rootCid = rootRow ? norm(rootRow.cid) : undefined;
41
+ const internal = new Set([ownerCid]);
42
+ if (rootCid)
43
+ internal.add(rootCid);
44
+ for (const cid of input.roomCids)
45
+ if (CID.test(cid))
46
+ internal.add(norm(cid));
47
+ const sources = [];
48
+ const ownerRow = live.get(ownerCid);
49
+ if (ownerRow)
50
+ sources.push({ kind: 'owner', name: ownerRow.name, cid: ownerCid, ended: false });
51
+ // One source per agent CID: a live journal wins over retired generations.
52
+ const byCid = new Map();
53
+ for (const record of input.agents) {
54
+ internal.add(record.cid);
55
+ const prior = byCid.get(record.cid);
56
+ if (!prior || (ENDED_PHASES.has(prior.phase) && !ENDED_PHASES.has(record.phase)))
57
+ byCid.set(record.cid, record);
58
+ }
59
+ for (const record of byCid.values()) {
60
+ const roleId = input.roleByIdentity.get(record.name);
61
+ if (roleId === undefined || record.cid === ownerCid)
62
+ continue; // not a workspace role: excluded, not a perspective
63
+ const row = live.get(record.cid);
64
+ sources.push({ kind: 'agent', name: record.name, cid: record.cid, roleId,
65
+ ended: ENDED_PHASES.has(record.phase) || !row || row.name !== record.name });
66
+ }
67
+ return { ownerCid, ...(rootCid ? { rootCid } : {}), sources, internal };
68
+ }
@@ -0,0 +1,103 @@
1
+ import type { PerspectiveSource } from './sources.js';
2
+ /** One local identity's view of one external contact. Metadata only, never bodies. */
3
+ export interface ChannelEdge {
4
+ contactCid: string;
5
+ contactName: string;
6
+ lastActivity: string;
7
+ rootCid?: string;
8
+ rootName?: string;
9
+ roleId?: string;
10
+ }
11
+ export interface ChannelPerspective {
12
+ sourceCid: string;
13
+ sourceName: string;
14
+ kind: 'owner' | 'agent';
15
+ roleId?: string;
16
+ ended: boolean;
17
+ stale: boolean;
18
+ contactName: string;
19
+ lastActivity: string;
20
+ claimedRoot?: {
21
+ rootCid: string;
22
+ rootName: string;
23
+ roleId: string;
24
+ };
25
+ }
26
+ export interface ChannelRow {
27
+ cid: string;
28
+ name: string;
29
+ lastActivity: string;
30
+ /** Present only when every perspective with a claim agrees. Signed claim, not verified. */
31
+ claimedRoot?: {
32
+ rootCid: string;
33
+ rootName: string;
34
+ };
35
+ conflictingRootClaims: boolean;
36
+ perspectives: number;
37
+ ownerConnected: boolean;
38
+ }
39
+ export interface ChannelPage {
40
+ channels: ChannelRow[];
41
+ nextCursor: string | null;
42
+ }
43
+ /** Rows whose perspectives have all ended drop out of Recent after this long. */
44
+ export declare const ENDED_RETENTION_MS: number;
45
+ export declare const CHANNELS_DB = "channels.db";
46
+ /**
47
+ * Durable Channels projection. Edges are keyed (source, contact) and replaced
48
+ * per source in one transaction; the per-contact row is recomputed from its
49
+ * edges, so the same external contact known to several identities is one row
50
+ * with several perspectives, and two contacts sharing a root stay two rows.
51
+ */
52
+ export declare class ChannelsStore {
53
+ private readonly now;
54
+ private readonly db;
55
+ readonly path: string;
56
+ constructor(dir: string, now?: () => number);
57
+ close(): void;
58
+ private transaction;
59
+ /** Reconcile the source set: unknown sources and their edges are removed, ended ones keep metadata. */
60
+ setSources(sources: PerspectiveSource[]): void;
61
+ /** Replace one source's edges from a fresh observation at the given daemon source position. */
62
+ replaceEdges(sourceCid: string, edges: ChannelEdge[], observed?: {
63
+ producerGeneration: string;
64
+ position: number;
65
+ }): void;
66
+ /**
67
+ * Drop every edge to a now-internal identity, from every source including ended ones,
68
+ * whose cached edges are never re-observed. Local SQL only.
69
+ */
70
+ excludeContacts(internal: Set<string>): void;
71
+ markStale(sourceCid: string): void;
72
+ private recompute;
73
+ /**
74
+ * Keyset-paginated channel rows, newest activity first. `root` narrows to rows
75
+ * claiming that root; `q` matches contact name, root name or CID prefix.
76
+ */
77
+ list(options?: {
78
+ cursor?: string;
79
+ limit?: number;
80
+ q?: string;
81
+ root?: string;
82
+ }): ChannelPage;
83
+ /** One channel with every perspective; null when unknown. */
84
+ get(cid: string): {
85
+ channel: ChannelRow;
86
+ perspectives: ChannelPerspective[];
87
+ } | null;
88
+ /** Whether `sourceCid` is a recorded perspective of `contactCid`. */
89
+ hasPerspective(contactCid: string, sourceCid: string): {
90
+ name: string;
91
+ kind: string;
92
+ roleId?: string;
93
+ ended: boolean;
94
+ generation?: string;
95
+ } | null;
96
+ sourcesSummary(): Array<{
97
+ cid: string;
98
+ name: string;
99
+ kind: string;
100
+ ended: boolean;
101
+ stale: boolean;
102
+ }>;
103
+ }
@@ -0,0 +1,204 @@
1
+ import { createRequire } from 'node:module';
2
+ import { chmodSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ /** Rows whose perspectives have all ended drop out of Recent after this long. */
5
+ export const ENDED_RETENTION_MS = 30 * 86_400_000;
6
+ export const CHANNELS_DB = 'channels.db';
7
+ /**
8
+ * Durable Channels projection. Edges are keyed (source, contact) and replaced
9
+ * per source in one transaction; the per-contact row is recomputed from its
10
+ * edges, so the same external contact known to several identities is one row
11
+ * with several perspectives, and two contacts sharing a root stay two rows.
12
+ */
13
+ export class ChannelsStore {
14
+ now;
15
+ db;
16
+ path;
17
+ constructor(dir, now = Date.now) {
18
+ this.now = now;
19
+ const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite');
20
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
21
+ chmodSync(dir, 0o700);
22
+ this.path = join(dir, CHANNELS_DB);
23
+ this.db = new DatabaseSync(this.path);
24
+ chmodSync(this.path, 0o600);
25
+ this.db.exec(`PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;
26
+ CREATE TABLE IF NOT EXISTS sources(cid TEXT PRIMARY KEY,name TEXT NOT NULL,kind TEXT NOT NULL,role_id TEXT,ended INTEGER NOT NULL DEFAULT 0,stale INTEGER NOT NULL DEFAULT 1,observed_at INTEGER);
27
+ CREATE TABLE IF NOT EXISTS edges(source_cid TEXT NOT NULL,contact_cid TEXT NOT NULL,contact_name TEXT NOT NULL,last_activity TEXT NOT NULL,root_cid TEXT,root_name TEXT,role_id TEXT,PRIMARY KEY(source_cid,contact_cid));
28
+ CREATE INDEX IF NOT EXISTS edges_contact ON edges(contact_cid);
29
+ CREATE TABLE IF NOT EXISTS channels(cid TEXT PRIMARY KEY,name TEXT NOT NULL,last_activity TEXT NOT NULL,root_cid TEXT,root_name TEXT,conflicting INTEGER NOT NULL,perspectives INTEGER NOT NULL,owner_connected INTEGER NOT NULL,live_perspectives INTEGER NOT NULL);
30
+ CREATE INDEX IF NOT EXISTS channels_recent ON channels(last_activity DESC,cid);
31
+ CREATE INDEX IF NOT EXISTS channels_root ON channels(root_cid,last_activity DESC,cid);`);
32
+ // The daemon source lifetime each source was last observed at (pins later history reads).
33
+ const columns = new Set(this.db.prepare('PRAGMA table_info(sources)').all().map(c => c.name));
34
+ if (!columns.has('generation'))
35
+ this.db.exec('ALTER TABLE sources ADD COLUMN generation TEXT');
36
+ if (!columns.has('position'))
37
+ this.db.exec('ALTER TABLE sources ADD COLUMN position INTEGER');
38
+ }
39
+ close() { this.db.close(); }
40
+ transaction(run) {
41
+ this.db.exec('BEGIN IMMEDIATE');
42
+ try {
43
+ const result = run();
44
+ this.db.exec('COMMIT');
45
+ return result;
46
+ }
47
+ catch (error) {
48
+ this.db.exec('ROLLBACK');
49
+ throw error;
50
+ }
51
+ }
52
+ /** Reconcile the source set: unknown sources and their edges are removed, ended ones keep metadata. */
53
+ setSources(sources) {
54
+ this.transaction(() => {
55
+ const keep = new Set(sources.map(s => s.cid));
56
+ const touched = new Set();
57
+ for (const row of this.db.prepare('SELECT cid FROM sources').all()) {
58
+ if (keep.has(row.cid))
59
+ continue;
60
+ for (const edge of this.db.prepare('SELECT contact_cid FROM edges WHERE source_cid=?').all(row.cid))
61
+ touched.add(edge.contact_cid);
62
+ this.db.prepare('DELETE FROM edges WHERE source_cid=?').run(row.cid);
63
+ this.db.prepare('DELETE FROM sources WHERE cid=?').run(row.cid);
64
+ }
65
+ for (const s of sources) {
66
+ this.db.prepare(`INSERT INTO sources(cid,name,kind,role_id,ended) VALUES(?,?,?,?,?)
67
+ ON CONFLICT(cid) DO UPDATE SET name=excluded.name,kind=excluded.kind,role_id=excluded.role_id,ended=excluded.ended`)
68
+ .run(s.cid, s.name, s.kind, s.roleId ?? null, s.ended ? 1 : 0);
69
+ for (const edge of this.db.prepare('SELECT contact_cid FROM edges WHERE source_cid=?').all(s.cid))
70
+ touched.add(edge.contact_cid);
71
+ }
72
+ for (const cid of touched)
73
+ this.recompute(cid);
74
+ });
75
+ }
76
+ /** Replace one source's edges from a fresh observation at the given daemon source position. */
77
+ replaceEdges(sourceCid, edges, observed) {
78
+ this.transaction(() => {
79
+ const touched = new Set(edges.map(e => e.contactCid));
80
+ for (const row of this.db.prepare('SELECT contact_cid FROM edges WHERE source_cid=?').all(sourceCid))
81
+ touched.add(row.contact_cid);
82
+ this.db.prepare('DELETE FROM edges WHERE source_cid=?').run(sourceCid);
83
+ const insert = this.db.prepare('INSERT INTO edges(source_cid,contact_cid,contact_name,last_activity,root_cid,root_name,role_id) VALUES(?,?,?,?,?,?,?)');
84
+ for (const e of edges)
85
+ insert.run(sourceCid, e.contactCid, e.contactName, e.lastActivity, e.rootCid ?? null, e.rootName ?? null, e.roleId ?? null);
86
+ this.db.prepare('UPDATE sources SET stale=0,observed_at=?,generation=?,position=? WHERE cid=?')
87
+ .run(this.now(), observed?.producerGeneration ?? null, observed?.position ?? null, sourceCid);
88
+ for (const cid of touched)
89
+ this.recompute(cid);
90
+ });
91
+ }
92
+ /**
93
+ * Drop every edge to a now-internal identity, from every source including ended ones,
94
+ * whose cached edges are never re-observed. Local SQL only.
95
+ */
96
+ excludeContacts(internal) {
97
+ if (!internal.size)
98
+ return;
99
+ this.transaction(() => {
100
+ const cids = [...internal];
101
+ const marks = cids.map(() => '?').join(',');
102
+ const touched = this.db.prepare(`SELECT DISTINCT contact_cid FROM edges WHERE contact_cid IN (${marks})`).all(...cids).map(r => r.contact_cid);
103
+ if (!touched.length)
104
+ return;
105
+ this.db.prepare(`DELETE FROM edges WHERE contact_cid IN (${marks})`).run(...cids);
106
+ for (const cid of touched)
107
+ this.recompute(cid);
108
+ });
109
+ }
110
+ markStale(sourceCid) {
111
+ this.db.prepare('UPDATE sources SET stale=1 WHERE cid=?').run(sourceCid);
112
+ }
113
+ recompute(contactCid) {
114
+ const edges = this.db.prepare(`SELECT e.*,s.kind,s.ended FROM edges e JOIN sources s ON s.cid=e.source_cid WHERE e.contact_cid=?`)
115
+ .all(contactCid);
116
+ if (!edges.length) {
117
+ this.db.prepare('DELETE FROM channels WHERE cid=?').run(contactCid);
118
+ return;
119
+ }
120
+ const newest = edges.reduce((a, b) => (b.last_activity > a.last_activity ? b : a));
121
+ const claims = new Map();
122
+ for (const e of edges)
123
+ if (e.root_cid)
124
+ claims.set(e.root_cid, e.root_name ?? '');
125
+ const single = claims.size === 1 ? [...claims.entries()][0] : undefined;
126
+ this.db.prepare(`INSERT INTO channels(cid,name,last_activity,root_cid,root_name,conflicting,perspectives,owner_connected,live_perspectives) VALUES(?,?,?,?,?,?,?,?,?)
127
+ ON CONFLICT(cid) DO UPDATE SET name=excluded.name,last_activity=excluded.last_activity,root_cid=excluded.root_cid,root_name=excluded.root_name,
128
+ conflicting=excluded.conflicting,perspectives=excluded.perspectives,owner_connected=excluded.owner_connected,live_perspectives=excluded.live_perspectives`)
129
+ .run(contactCid, newest.contact_name, newest.last_activity, single?.[0] ?? null, single?.[1] ?? null, claims.size > 1 ? 1 : 0, edges.length, edges.some(e => e.kind === 'owner' && !e.ended) ? 1 : 0, edges.filter(e => !e.ended).length);
130
+ }
131
+ /**
132
+ * Keyset-paginated channel rows, newest activity first. `root` narrows to rows
133
+ * claiming that root; `q` matches contact name, root name or CID prefix.
134
+ */
135
+ list(options = {}) {
136
+ const limit = Math.min(Math.max(Math.trunc(options.limit ?? 50), 1), 200);
137
+ const where = ['(live_perspectives>0 OR last_activity>=?)'];
138
+ const args = [new Date(this.now() - ENDED_RETENTION_MS).toISOString()];
139
+ if (options.root) {
140
+ where.push('root_cid=?');
141
+ args.push(options.root.toUpperCase());
142
+ }
143
+ if (options.q) {
144
+ const q = options.q.trim().toLowerCase();
145
+ where.push(`(instr(lower(name),?)>0 OR instr(lower(coalesce(root_name,'')),?)>0 OR cid LIKE ?)`);
146
+ args.push(q, q, `${q.toUpperCase().replace(/[%_]/g, '')}%`);
147
+ }
148
+ if (options.cursor) {
149
+ const [at, cid] = decodeCursor(options.cursor);
150
+ where.push('(last_activity<? OR (last_activity=? AND cid>?))');
151
+ args.push(at, at, cid);
152
+ }
153
+ const rows = this.db.prepare(`SELECT * FROM channels WHERE ${where.join(' AND ')} ORDER BY last_activity DESC,cid ASC LIMIT ?`)
154
+ .all(...args, limit + 1);
155
+ const more = rows.length > limit;
156
+ if (more)
157
+ rows.pop();
158
+ const last = rows.at(-1);
159
+ return { channels: rows.map(toRow), nextCursor: more && last ? encodeCursor(last.last_activity, last.cid) : null };
160
+ }
161
+ /** One channel with every perspective; null when unknown. */
162
+ get(cid) {
163
+ const row = this.db.prepare('SELECT * FROM channels WHERE cid=?').get(cid.toUpperCase());
164
+ if (!row)
165
+ return null;
166
+ const edges = this.db.prepare(`SELECT e.*,s.name AS source_name,s.kind,s.role_id AS source_role,s.ended,s.stale FROM edges e JOIN sources s ON s.cid=e.source_cid
167
+ WHERE e.contact_cid=? ORDER BY e.last_activity DESC,s.kind DESC`).all(row.cid);
168
+ return {
169
+ channel: toRow(row),
170
+ perspectives: edges.map(e => ({
171
+ sourceCid: e.source_cid, sourceName: e.source_name, kind: e.kind, ...(e.source_role ? { roleId: e.source_role } : {}),
172
+ ended: !!e.ended, stale: !!e.stale, contactName: e.contact_name, lastActivity: e.last_activity,
173
+ ...(e.root_cid ? { claimedRoot: { rootCid: e.root_cid, rootName: e.root_name ?? '', roleId: e.role_id ?? '' } } : {}),
174
+ })),
175
+ };
176
+ }
177
+ /** Whether `sourceCid` is a recorded perspective of `contactCid`. */
178
+ hasPerspective(contactCid, sourceCid) {
179
+ const row = this.db.prepare(`SELECT s.name,s.kind,s.role_id,s.ended,s.generation FROM edges e JOIN sources s ON s.cid=e.source_cid WHERE e.contact_cid=? AND e.source_cid=?`)
180
+ .get(contactCid.toUpperCase(), sourceCid.toUpperCase());
181
+ return row ? { name: row.name, kind: row.kind, ...(row.role_id ? { roleId: row.role_id } : {}), ended: !!row.ended, ...(row.generation ? { generation: row.generation } : {}) } : null;
182
+ }
183
+ sourcesSummary() {
184
+ return this.db.prepare('SELECT cid,name,kind,ended,stale FROM sources ORDER BY kind DESC,name').all()
185
+ .map(s => ({ cid: s.cid, name: s.name, kind: s.kind, ended: !!s.ended, stale: !!s.stale }));
186
+ }
187
+ }
188
+ function toRow(row) {
189
+ return {
190
+ cid: row.cid, name: row.name, lastActivity: row.last_activity,
191
+ ...(row.root_cid ? { claimedRoot: { rootCid: row.root_cid, rootName: row.root_name ?? '' } } : {}),
192
+ conflictingRootClaims: !!row.conflicting, perspectives: Number(row.perspectives), ownerConnected: !!row.owner_connected,
193
+ };
194
+ }
195
+ const encodeCursor = (at, cid) => Buffer.from(JSON.stringify([at, cid])).toString('base64url');
196
+ function decodeCursor(cursor) {
197
+ try {
198
+ const value = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
199
+ if (Array.isArray(value) && value.length === 2 && value.every(v => typeof v === 'string'))
200
+ return value;
201
+ }
202
+ catch { /* fall through */ }
203
+ throw new Error('invalid channels cursor');
204
+ }
@@ -179,7 +179,7 @@ function writeTask(record, options = {}) {
179
179
  const next = status(record), before = previous ? status(previous) : undefined;
180
180
  pending.push({ eventId: `task:${record.task_id}:${randomUUID()}`, taskId: record.task_id,
181
181
  title: `Task ${!previous ? 'created' : 'updated'}: ${taskLabel(record)}`.slice(0, 160),
182
- body: `Task ${record.task_id}: ${before ? `${before} → ` : 'created in '}${next}.`,
182
+ body: taskSentence(record, `${before ? `${before} → ` : 'created in '}${next}.`),
183
183
  target: { kind: 'task', taskId: record.task_id }, delivery: 'push-only' });
184
184
  }
185
185
  pending.push(...contributionEvents(previous, record));
@@ -196,6 +196,11 @@ function writeTask(record, options = {}) {
196
196
  }
197
197
  /** Human label for notification titles: the mission key, when issued, precedes the title. */
198
198
  const taskLabel = (record) => record.key ? `${record.key} · ${record.title}` : record.title;
199
+ /**
200
+ * Notification body naming the task by its mission key. The opaque task ID stays in routing and
201
+ * event fields only; a task without a key relies on its title, which the notification title shows.
202
+ */
203
+ const taskSentence = (record, text) => record.key ? `Task ${record.key}: ${text}` : text[0].toUpperCase() + text.slice(1);
199
204
  /** Packages written before revisions existed count as revision 1; their next change becomes 2. No package is 0. */
200
205
  export const reviewRevision = (review) => review ? review.revision ?? 1 : 0;
201
206
  const contributionsChanged = (previous, record) => (record.progress ?? []).some(entry => !(previous.progress ?? []).some(old => old.id === entry.id))
@@ -214,7 +219,7 @@ function contributionEvents(previous, record) {
214
219
  const revision = reviewRevision(record.review);
215
220
  if (revision > reviewRevision(previous.review))
216
221
  events.push({ eventId: `task:${id}:review:${revision}`, taskId: id, title: `To review updated: ${taskLabel(record)}`.slice(0, 160),
217
- body: `Task ${id}: the review package changed.`, target: { kind: 'task', taskId: id }, delivery: 'push-only' });
222
+ body: taskSentence(record, 'the review package changed.'), target: { kind: 'task', taskId: id }, delivery: 'push-only' });
218
223
  return events;
219
224
  }
220
225
  /** Item-based Progress unread and revision-based To review unread. An absent read state means nothing is unread. */
@@ -50,6 +50,14 @@ export declare class FleetConfigService {
50
50
  private validationInputs;
51
51
  /** Cached during canonical reads; observer filtering must never parse YAML. */
52
52
  resourceValidationInputs(): readonly string[];
53
+ /**
54
+ * Record the manifest's external file references before a projection that loads
55
+ * the configuration, so a reference anywhere (including a task workspace) fences
56
+ * that capture even before the configuration itself has been read. A manifest that
57
+ * cannot be read keeps the last recorded inputs; the projection reports its error.
58
+ */
59
+ observeValidationInputs(): void;
60
+ private recordValidationInputs;
53
61
  constructor(options?: FleetConfigServiceOptions);
54
62
  read(includeDefinitions?: boolean): ConfigReadResult;
55
63
  preview(baseRevision: string, input: unknown): Promise<ConfigPreviewResult>;
@@ -21,6 +21,28 @@ export class FleetConfigService {
21
21
  validationInputs = [];
22
22
  /** Cached during canonical reads; observer filtering must never parse YAML. */
23
23
  resourceValidationInputs() { return this.validationInputs; }
24
+ /**
25
+ * Record the manifest's external file references before a projection that loads
26
+ * the configuration, so a reference anywhere (including a task workspace) fences
27
+ * that capture even before the configuration itself has been read. A manifest that
28
+ * cannot be read keeps the last recorded inputs; the projection reports its error.
29
+ */
30
+ observeValidationInputs() {
31
+ try {
32
+ this.recordValidationInputs(parse(trustedFile(this.path), { prettyErrors: false }));
33
+ }
34
+ catch { /* unchanged */ }
35
+ }
36
+ recordValidationInputs(parsed) {
37
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
38
+ const manifest = record(parsed);
39
+ const vars = record(manifest.vars);
40
+ const expand = (value) => value.replace(/\$\{(\w+)\}/g, (match, key) => key in vars ? String(vars[key]) : match);
41
+ const invite = record(record(manifest.rooms).owner).public_invite_file;
42
+ const prompts = Object.values(record(manifest.watchdogs)).map(value => record(value).prompt_file);
43
+ this.validationInputs = [...(typeof invite === 'string' ? [expand(expand(invite))] : []),
44
+ ...prompts.filter((value) => typeof value === 'string').map(expand)].map(value => resolve(value));
45
+ }
24
46
  constructor(options = {}) {
25
47
  this.path = options.configPath ?? defaultConfigPath();
26
48
  this.preflight = options.preflight ?? (async () => emptyReport());
@@ -30,16 +52,9 @@ export class FleetConfigService {
30
52
  const sources = readSources(this.path);
31
53
  let raw;
32
54
  try {
33
- const manifest = parse(sources.documents.get(basename(sources.manifest)), { prettyErrors: false });
34
- const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
35
- const vars = record(manifest.vars);
36
- const expand = (value) => value.replace(/\$\{(\w+)\}/g, (match, key) => key in vars ? String(vars[key]) : match);
37
- const invite = record(record(manifest.rooms).owner).public_invite_file;
38
- const prompts = Object.values(record(manifest.watchdogs)).map(value => record(value).prompt_file);
39
55
  // Record even invalid/missing references before validation, so repairing an
40
56
  // already observed dependency can never retain a certified stale capture.
41
- this.validationInputs = [...(typeof invite === 'string' ? [expand(expand(invite))] : []),
42
- ...prompts.filter((value) => typeof value === 'string').map(expand)].map(value => resolve(value));
57
+ this.recordValidationInputs(parse(sources.documents.get(basename(sources.manifest)), { prettyErrors: false }));
43
58
  loadConfig(this.path, { yamlMode: 'strict' });
44
59
  raw = modelFrom(sources, includeDefinitions);
45
60
  }
@@ -80,7 +80,7 @@ export declare class ResourceOutput {
80
80
  }
81
81
  export interface ResourceDescriptor {
82
82
  viewId: string;
83
- resource: 'directory' | 'roles' | 'tasks' | 'task-lists' | 'configuration' | 'previews' | 'task-layout' | 'creation-action' | 'action' | 'member-addition' | 'login' | 'onboarding-health' | 'subscriptions';
83
+ resource: 'directory' | 'roles' | 'tasks' | 'task-lists' | 'configuration' | 'previews' | 'task-layout' | 'creation-action' | 'action' | 'member-addition' | 'login' | 'onboarding-health' | 'subscriptions' | 'channels';
84
84
  entityId?: string;
85
85
  receiptId?: string;
86
86
  provider?: 'claude' | 'codex';
@@ -135,6 +135,8 @@ export declare class FleetResourceViews {
135
135
  private coverage;
136
136
  /** Certify each view's last projected source; never a position its capture did not read. */
137
137
  private cover;
138
+ /** Whether `path` is, contains or lies within the manifest, its split definitions or another configuration input. */
139
+ private configurationDependency;
138
140
  private affects;
139
141
  private schedule;
140
142
  private refresh;