@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.
- package/dist/build-info.json +5 -5
- package/dist/channels/identity-reads.d.ts +69 -0
- package/dist/channels/identity-reads.js +47 -0
- package/dist/channels/indexer.d.ts +55 -0
- package/dist/channels/indexer.js +214 -0
- package/dist/channels/runtime.d.ts +30 -0
- package/dist/channels/runtime.js +125 -0
- package/dist/channels/service.d.ts +61 -0
- package/dist/channels/service.js +88 -0
- package/dist/channels/sources.d.ts +46 -0
- package/dist/channels/sources.js +68 -0
- package/dist/channels/store.d.ts +103 -0
- package/dist/channels/store.js +204 -0
- package/dist/rooms-tasks/task-state.js +7 -2
- package/dist/web/fleet-config-service.d.ts +8 -0
- package/dist/web/fleet-config-service.js +23 -8
- package/dist/web/resource-views.d.ts +3 -1
- package/dist/web/resource-views.js +33 -16
- package/dist/web/runtime.js +13 -0
- package/dist/web/server.d.ts +3 -0
- package/dist/web/server.js +26 -0
- package/package.json +5 -5
|
@@ -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:
|
|
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:
|
|
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.
|
|
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;
|