@ours.network/fleet 2.3.3 → 2.4.0-nightly.2

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.
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "2.3.3",
3
- "buildId": "f813635f30d3",
4
- "commit": "b0dbf450ed45e131fb34f64d71a16e8eb960b77f",
5
- "dirty": false,
6
- "builtAt": "2026-11-03T12:22:11.870Z",
2
+ "version": "2.4.0-nightly.2",
3
+ "buildId": "00857536f945",
4
+ "commit": "75b806fcaf0cb66fc066eb5a1b2a8578be9ab9b6",
5
+ "dirty": true,
6
+ "builtAt": "2026-11-03T18:50:41.228Z",
7
7
  "capabilities": [
8
8
  "service.refresh-v1",
9
9
  "managed-cli.codex-reasoning-effort-v1",
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Minimal HTTP adapter for the daemon's lease-free identity reads
3
+ * (`GET /identities/<name>/contacts-activity|history`, ours-sdk
4
+ * docs/identity-reads.md). Kept local so Fleet does not need a newer SDK client;
5
+ * the shapes are the daemon contract verbatim. The ordinary workspace client
6
+ * credential is reread on every request. No identity is ever bound.
7
+ */
8
+ export interface IdentityRef {
9
+ identity: string;
10
+ expectedCid: string;
11
+ expectedProducerGeneration?: string;
12
+ }
13
+ export interface SourcePosition {
14
+ protocolVersion: 1;
15
+ producerGeneration: string;
16
+ position: number;
17
+ }
18
+ export interface ContactRootClaim {
19
+ root_cid: string;
20
+ root_name: string;
21
+ role_id: string;
22
+ }
23
+ export interface ContactsActivity {
24
+ identity: {
25
+ name: string;
26
+ cid: string;
27
+ };
28
+ source: SourcePosition;
29
+ contacts: Array<{
30
+ name: string;
31
+ container_id: string;
32
+ }>;
33
+ roots: Record<string, ContactRootClaim>;
34
+ activity: Record<string, {
35
+ at: string;
36
+ } | {
37
+ unavailable: true;
38
+ }>;
39
+ }
40
+ export interface IdentityHistoryPage {
41
+ identity: {
42
+ name: string;
43
+ cid: string;
44
+ };
45
+ source: SourcePosition;
46
+ items: Array<Record<string, unknown> & {
47
+ seq: number;
48
+ wire_id: string;
49
+ }>;
50
+ next_cursor: number | null;
51
+ }
52
+ export declare class IdentityReadError extends Error {
53
+ readonly status: number;
54
+ readonly code: string | undefined;
55
+ constructor(status: number, code: string | undefined, message: string);
56
+ }
57
+ export interface IdentityReader {
58
+ contactsActivity(ref: IdentityRef, signal?: AbortSignal): Promise<ContactsActivity>;
59
+ history(ref: IdentityRef & {
60
+ peer_cid: string;
61
+ before_seq?: number;
62
+ limit?: number;
63
+ }, signal?: AbortSignal): Promise<IdentityHistoryPage>;
64
+ }
65
+ export declare function createIdentityReader(options: {
66
+ endpoint: string;
67
+ credentialPath: string;
68
+ fetchImpl?: typeof fetch;
69
+ }): IdentityReader;
@@ -0,0 +1,47 @@
1
+ import { readPrivateFile } from '@ours.network/sdk/connector';
2
+ export class IdentityReadError extends Error {
3
+ status;
4
+ code;
5
+ constructor(status, code, message) {
6
+ super(message);
7
+ this.status = status;
8
+ this.code = code;
9
+ }
10
+ }
11
+ export function createIdentityReader(options) {
12
+ const fetchImpl = options.fetchImpl ?? fetch;
13
+ const base = options.endpoint.replace(/\/+$/, '');
14
+ async function get(name, kind, query, signal) {
15
+ const params = new URLSearchParams();
16
+ for (const [key, value] of Object.entries(query))
17
+ if (value !== undefined)
18
+ params.set(key, String(value));
19
+ const response = await fetchImpl(`${base}/identities/${encodeURIComponent(name)}/${kind}?${params}`, {
20
+ redirect: 'error',
21
+ signal: signal ?? AbortSignal.timeout(15_000),
22
+ headers: { 'x-ours-api-token': readPrivateFile(options.credentialPath, 4096).toString('utf8').trim() },
23
+ });
24
+ let json;
25
+ try {
26
+ json = await response.json();
27
+ }
28
+ catch {
29
+ throw new IdentityReadError(response.status, undefined, `${kind}: non-JSON response (HTTP ${response.status})`);
30
+ }
31
+ if (response.ok)
32
+ return json;
33
+ const error = json?.error;
34
+ if (error && typeof error === 'object') {
35
+ const { code, message } = error;
36
+ throw new IdentityReadError(response.status, typeof code === 'string' ? code : undefined, `${kind}: ${String(message ?? code)}`);
37
+ }
38
+ throw new IdentityReadError(response.status, undefined, `${kind}: ${String(error ?? `HTTP ${response.status}`)}`);
39
+ }
40
+ return {
41
+ contactsActivity: (ref, signal) => get(ref.identity, 'contacts-activity', { expectedCid: ref.expectedCid, expectedProducerGeneration: ref.expectedProducerGeneration }, signal),
42
+ history: (ref, signal) => get(ref.identity, 'history', {
43
+ peer_cid: ref.peer_cid, before_seq: ref.before_seq, limit: ref.limit,
44
+ expectedCid: ref.expectedCid, expectedProducerGeneration: ref.expectedProducerGeneration,
45
+ }, signal),
46
+ };
47
+ }
@@ -0,0 +1,55 @@
1
+ import { type ContactsActivity, type IdentityReader } from './identity-reads.js';
2
+ import { type ChannelSources } from './sources.js';
3
+ import type { ChannelEdge, ChannelsStore } from './store.js';
4
+ export interface ResourceFrame {
5
+ kind: string;
6
+ producerGeneration: string;
7
+ position: number;
8
+ }
9
+ export interface ResourceEventSource {
10
+ resourceEvents(identity: string, options: {
11
+ signal: AbortSignal;
12
+ expectedGeneration?: string;
13
+ after?: number;
14
+ }): AsyncIterable<ResourceFrame>;
15
+ }
16
+ export interface ChannelsIndexerDeps {
17
+ store: ChannelsStore;
18
+ reader: IdentityReader;
19
+ events: ResourceEventSource;
20
+ discover(): Promise<ChannelSources>;
21
+ onChange(): void;
22
+ log(message: string): void;
23
+ debounceMs?: number;
24
+ }
25
+ /**
26
+ * Event-driven Channels projection. One passive resource-event stream per live
27
+ * perspective source; idle coverage frames are ignored and a committed frame
28
+ * only schedules work when the source position advanced. Work is debounced,
29
+ * at most one observation runs per source, and each observation reads only
30
+ * that source's contacts and activity (one lease-free daemon read), never other sources.
31
+ */
32
+ export declare class ChannelsIndexer {
33
+ private readonly deps;
34
+ private readonly watches;
35
+ private internal;
36
+ private syncing?;
37
+ private resync;
38
+ private stopped;
39
+ private readonly debounceMs;
40
+ constructor(deps: ChannelsIndexerDeps);
41
+ /** Re-derive sources and the exclusion set; start/stop streams accordingly. */
42
+ sync(): Promise<void>;
43
+ stop(): void;
44
+ private syncOnce;
45
+ /** The daemon source of `sourceCid` changed under a caller: drop its pin and observe again. */
46
+ invalidate(sourceCid: string): void;
47
+ private retire;
48
+ private follow;
49
+ private retired;
50
+ private schedule;
51
+ private run;
52
+ private observe;
53
+ }
54
+ /** Contacts of one observation, minus internal identities, as metadata-only edges. */
55
+ export declare function edgesFrom(observation: ContactsActivity, internal: Set<string>): ChannelEdge[];
@@ -0,0 +1,214 @@
1
+ import { IdentityReadError } from './identity-reads.js';
2
+ import { norm } from './sources.js';
3
+ /**
4
+ * Event-driven Channels projection. One passive resource-event stream per live
5
+ * perspective source; idle coverage frames are ignored and a committed frame
6
+ * only schedules work when the source position advanced. Work is debounced,
7
+ * at most one observation runs per source, and each observation reads only
8
+ * that source's contacts and activity (one lease-free daemon read), never other sources.
9
+ */
10
+ export class ChannelsIndexer {
11
+ deps;
12
+ watches = new Map();
13
+ internal = new Set();
14
+ syncing;
15
+ resync = false;
16
+ stopped = false;
17
+ debounceMs;
18
+ constructor(deps) {
19
+ this.deps = deps;
20
+ this.debounceMs = deps.debounceMs ?? 250;
21
+ }
22
+ /** Re-derive sources and the exclusion set; start/stop streams accordingly. */
23
+ sync() {
24
+ if (this.syncing) {
25
+ this.resync = true;
26
+ return this.syncing;
27
+ }
28
+ this.syncing = (async () => {
29
+ try {
30
+ do {
31
+ this.resync = false;
32
+ await this.syncOnce();
33
+ } while (this.resync && !this.stopped);
34
+ }
35
+ finally {
36
+ this.syncing = undefined;
37
+ }
38
+ })();
39
+ return this.syncing;
40
+ }
41
+ stop() {
42
+ this.stopped = true;
43
+ for (const watch of this.watches.values())
44
+ this.retire(watch);
45
+ this.watches.clear();
46
+ }
47
+ async syncOnce() {
48
+ if (this.stopped)
49
+ return;
50
+ const found = await this.deps.discover();
51
+ if (this.stopped)
52
+ return;
53
+ const internalChanged = found.internal.size !== this.internal.size || [...found.internal].some(cid => !this.internal.has(cid));
54
+ this.internal = found.internal;
55
+ this.deps.store.setSources(found.sources);
56
+ // Cached edges of every source, ended ones included, lose newly internal identities.
57
+ if (internalChanged)
58
+ this.deps.store.excludeContacts(found.internal);
59
+ const live = new Map(found.sources.filter(s => !s.ended).map(s => [s.cid, s]));
60
+ for (const [cid, watch] of this.watches) {
61
+ const next = live.get(cid);
62
+ if (!next || next.name !== watch.source.name) {
63
+ this.retire(watch);
64
+ this.watches.delete(cid);
65
+ }
66
+ else
67
+ watch.source = next;
68
+ }
69
+ for (const source of live.values()) {
70
+ let watch = this.watches.get(source.cid);
71
+ if (!watch) {
72
+ watch = { source, abort: new AbortController(), inflight: false, dirty: false };
73
+ this.watches.set(source.cid, watch);
74
+ void this.follow(watch);
75
+ this.schedule(watch); // bounded bootstrap/backfill for a new source
76
+ }
77
+ else if (internalChanged)
78
+ this.schedule(watch);
79
+ }
80
+ this.deps.onChange();
81
+ }
82
+ /** The daemon source of `sourceCid` changed under a caller: drop its pin and observe again. */
83
+ invalidate(sourceCid) {
84
+ const watch = this.watches.get(sourceCid.toUpperCase());
85
+ if (!watch || watch.abort.signal.aborted)
86
+ return;
87
+ watch.generation = undefined;
88
+ watch.position = undefined;
89
+ this.schedule(watch);
90
+ }
91
+ retire(watch) {
92
+ watch.abort.abort();
93
+ if (watch.timer)
94
+ clearTimeout(watch.timer);
95
+ watch.timer = undefined;
96
+ }
97
+ async follow(watch) {
98
+ let backoff = 1_000;
99
+ while (!watch.abort.signal.aborted) {
100
+ try {
101
+ for await (const frame of this.deps.events.resourceEvents(watch.source.name, {
102
+ signal: watch.abort.signal, ...(watch.generation ? { expectedGeneration: watch.generation } : {}),
103
+ })) {
104
+ backoff = 1_000;
105
+ if (frame.kind === 'coverage')
106
+ continue;
107
+ // ready/committed at the already-observed position change nothing; resync always reads.
108
+ if (frame.kind !== 'resync' && frame.producerGeneration === watch.generation && frame.position === watch.position)
109
+ continue;
110
+ if (frame.producerGeneration !== watch.generation)
111
+ watch.position = undefined;
112
+ watch.generation = frame.producerGeneration;
113
+ this.schedule(watch);
114
+ }
115
+ }
116
+ catch (error) {
117
+ if (watch.abort.signal.aborted)
118
+ return;
119
+ this.deps.log(`channels: ${watch.source.name} event stream interrupted: ${error.message}`);
120
+ }
121
+ if (this.retired(watch))
122
+ return;
123
+ // The stream ended or failed: data may be behind until it is re-established.
124
+ watch.generation = undefined;
125
+ this.deps.store.markStale(watch.source.cid);
126
+ this.deps.onChange();
127
+ await new Promise(resolve => setTimeout(resolve, backoff));
128
+ backoff = Math.min(backoff * 2, 30_000);
129
+ }
130
+ }
131
+ retired(watch) {
132
+ return this.stopped || watch.abort.signal.aborted || this.watches.get(watch.source.cid) !== watch;
133
+ }
134
+ schedule(watch) {
135
+ watch.dirty = true;
136
+ if (watch.timer || watch.inflight || watch.abort.signal.aborted)
137
+ return;
138
+ watch.timer = setTimeout(() => { watch.timer = undefined; void this.run(watch); }, this.debounceMs);
139
+ }
140
+ async run(watch) {
141
+ if (watch.inflight || watch.abort.signal.aborted)
142
+ return;
143
+ watch.inflight = true;
144
+ watch.dirty = false;
145
+ try {
146
+ const observation = await this.observe(watch);
147
+ // Retired (stopped, removed or replaced) work never touches the store or a successor.
148
+ if (!observation || this.retired(watch))
149
+ return;
150
+ this.deps.store.replaceEdges(watch.source.cid, edgesFrom(observation, this.internal), observation.source);
151
+ watch.generation = observation.source.producerGeneration;
152
+ watch.position = observation.source.position;
153
+ this.deps.onChange();
154
+ }
155
+ catch (error) {
156
+ if (this.retired(watch))
157
+ return;
158
+ this.deps.store.markStale(watch.source.cid);
159
+ this.deps.onChange();
160
+ if (error instanceof IdentityReadError && (error.code === 'identity_not_found' || error.code === 'identity_mismatch'))
161
+ void this.sync();
162
+ else
163
+ this.deps.log(`channels: ${watch.source.name} observation failed: ${error.message}`);
164
+ }
165
+ finally {
166
+ watch.inflight = false;
167
+ if (watch.dirty && !this.retired(watch))
168
+ this.schedule(watch);
169
+ }
170
+ }
171
+ async observe(watch) {
172
+ for (let attempt = 0;; attempt++) {
173
+ try {
174
+ return await this.deps.reader.contactsActivity({
175
+ identity: watch.source.name, expectedCid: watch.source.cid,
176
+ ...(watch.generation ? { expectedProducerGeneration: watch.generation } : {}),
177
+ }, watch.abort.signal);
178
+ }
179
+ catch (error) {
180
+ if (!(error instanceof IdentityReadError) || attempt >= 2)
181
+ throw error;
182
+ // A new daemon source generation (daemon restart): drop the stale pin and read again.
183
+ if (error.code === 'source_generation_changed') {
184
+ watch.generation = undefined;
185
+ watch.position = undefined;
186
+ continue;
187
+ }
188
+ // The identity committed during the read: its own wake will follow; retry briefly.
189
+ if (error.code !== 'source_changed')
190
+ throw error;
191
+ await new Promise(resolve => setTimeout(resolve, 100 * (attempt + 1)));
192
+ if (watch.abort.signal.aborted)
193
+ return undefined;
194
+ }
195
+ }
196
+ }
197
+ }
198
+ /** Contacts of one observation, minus internal identities, as metadata-only edges. */
199
+ export function edgesFrom(observation, internal) {
200
+ const edges = [];
201
+ for (const contact of observation.contacts) {
202
+ const cid = norm(contact.container_id);
203
+ if (internal.has(cid))
204
+ continue;
205
+ const activity = observation.activity[contact.container_id];
206
+ const root = observation.roots[contact.container_id];
207
+ edges.push({
208
+ contactCid: cid, contactName: contact.name,
209
+ lastActivity: activity && 'at' in activity ? activity.at : '',
210
+ ...(root?.root_cid ? { rootCid: norm(root.root_cid), rootName: root.root_name, roleId: root.role_id } : {}),
211
+ });
212
+ }
213
+ return edges;
214
+ }
@@ -0,0 +1,30 @@
1
+ import type { RoleRepository } from '../application/role-repository.js';
2
+ import { ChannelsService } from './service.js';
3
+ export declare const channelsPrivateDir: () => string;
4
+ /**
5
+ * Build the Fleet-side Channels service over the workspace's ordinary daemon
6
+ * client profile: identity discovery, passive resource events and lease-free
7
+ * identity reads. No identity is ever bound. Returns undefined when this host
8
+ * has no daemon profile.
9
+ */
10
+ export declare function createChannels(options: {
11
+ configPath: string;
12
+ repository: RoleRepository;
13
+ log?: (message: string) => void;
14
+ }): {
15
+ service: ChannelsService;
16
+ close(): void;
17
+ } | undefined;
18
+ /**
19
+ * The files Channels source discovery reads: the manifest (Owner CID, roles), split role
20
+ * definitions, permanent and temporary role definitions, agent journals and room records.
21
+ * Conversation ledgers and other state under the role roots are not inputs.
22
+ */
23
+ export declare function channelSourceInputs(configPath: string): {
24
+ journals: string;
25
+ rooms: string;
26
+ splitRoot: string;
27
+ permanentRoles: string;
28
+ temporaryRoles: string;
29
+ affects: (changed: string) => boolean;
30
+ };
@@ -0,0 +1,125 @@
1
+ import { join, relative, resolve, sep } from 'node:path';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { attachOursClient } from '@ours.network/sdk/client';
4
+ import { readClientProfile } from '../client-profile.js';
5
+ import { loadConfig, splitRootFor } from '../config.js';
6
+ import { agentsRoot, stateRoot, tmpRoot } from '../paths.js';
7
+ import { observeFleetResources, publishFleetResourceCommit } from '../application/resource-observer.js';
8
+ import { listRoomRecords } from '../rooms-tasks/room-state.js';
9
+ import { privateRuntimeRoot } from '../agent-ours/service.js';
10
+ import { createIdentityReader } from './identity-reads.js';
11
+ import { ChannelsIndexer } from './indexer.js';
12
+ import { ChannelsService } from './service.js';
13
+ import { readAgentRecords, resolveSources } from './sources.js';
14
+ import { ChannelsStore } from './store.js';
15
+ export const channelsPrivateDir = () => join(stateRoot(), 'private-channels');
16
+ /**
17
+ * Build the Fleet-side Channels service over the workspace's ordinary daemon
18
+ * client profile: identity discovery, passive resource events and lease-free
19
+ * identity reads. No identity is ever bound. Returns undefined when this host
20
+ * has no daemon profile.
21
+ */
22
+ export function createChannels(options) {
23
+ let profile;
24
+ try {
25
+ profile = readClientProfile();
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ const log = options.log ?? (message => console.warn(message));
31
+ const store = new ChannelsStore(channelsPrivateDir());
32
+ const reader = createIdentityReader({ endpoint: profile.endpoint, credentialPath: profile.credentialPath });
33
+ const attach = () => attachOursClient({
34
+ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId, credentialPath: profile.credentialPath,
35
+ sessionMode: 'external', leaseToken: `ours-fleet-channels-${process.pid}-${randomUUID()}`, env: {},
36
+ });
37
+ let eventsClient;
38
+ const events = {
39
+ async *resourceEvents(identity, opts) {
40
+ eventsClient ??= await attach();
41
+ yield* eventsClient.resourceEvents(identity, opts);
42
+ },
43
+ };
44
+ let last;
45
+ const discover = async () => {
46
+ const ownerCid = loadConfig(options.configPath).rooms?.owner.expected_cid;
47
+ const roleByIdentity = new Map();
48
+ for (const role of await options.repository.list(true))
49
+ if (role.config?.identity)
50
+ roleByIdentity.set(role.config.identity, role.id);
51
+ const client = await attach();
52
+ let identities;
53
+ try {
54
+ identities = (await client.listIdentities());
55
+ }
56
+ finally {
57
+ await client.close().catch(() => { });
58
+ }
59
+ const found = resolveSources({
60
+ ownerCid: ownerCid ?? '',
61
+ identities,
62
+ agents: readAgentRecords(privateRuntimeRoot()),
63
+ roleByIdentity,
64
+ roomCids: listRoomRecords().map(room => room.room_identity_cid).filter((cid) => typeof cid === 'string'),
65
+ });
66
+ if (!ownerCid)
67
+ found.internal.delete('');
68
+ last = found;
69
+ return found;
70
+ };
71
+ const indexer = new ChannelsIndexer({ store, reader, events, discover, log, onChange: () => publishFleetResourceCommit(store.path) });
72
+ const sync = () => { void indexer.sync().catch(error => log(`channels: sync failed: ${error.message}`)); };
73
+ const service = new ChannelsService({ store, reader, indexer, ownerRoot: () => last?.rootCid, ready: () => last !== undefined });
74
+ sync();
75
+ // Re-derive sources when one of their inputs commits, so new or stopped agents change
76
+ // perspectives without polling. Bursts coalesce. The configuration scope also tracks the
77
+ // manifest file itself; every wake is filtered to the actual inputs (never this index).
78
+ const lifetime = new AbortController();
79
+ const inputs = channelSourceInputs(options.configPath);
80
+ let pending, relevant = false;
81
+ observeFleetResources({
82
+ configPath: options.configPath, retryWatchFailures: true,
83
+ paths: [inputs.journals, inputs.rooms, inputs.splitRoot, inputs.permanentRoles, inputs.temporaryRoles],
84
+ onInvalidated: path => { if (path === undefined || inputs.affects(path))
85
+ relevant = true; },
86
+ }, () => {
87
+ if (!relevant || pending || lifetime.signal.aborted)
88
+ return;
89
+ relevant = false;
90
+ pending = setTimeout(() => { pending = undefined; sync(); }, 1_000);
91
+ pending.unref?.();
92
+ }, lifetime.signal);
93
+ return {
94
+ service,
95
+ close() { lifetime.abort(); if (pending)
96
+ clearTimeout(pending); indexer.stop(); void eventsClient?.close().catch(() => { }); store.close(); },
97
+ };
98
+ }
99
+ /**
100
+ * The files Channels source discovery reads: the manifest (Owner CID, roles), split role
101
+ * definitions, permanent and temporary role definitions, agent journals and room records.
102
+ * Conversation ledgers and other state under the role roots are not inputs.
103
+ */
104
+ export function channelSourceInputs(configPath) {
105
+ const config = resolve(configPath);
106
+ const journals = resolve(privateRuntimeRoot()), rooms = resolve(stateRoot(), 'rooms'), splitRoot = resolve(splitRootFor(config));
107
+ const permanentRoles = resolve(agentsRoot()), temporaryRoles = resolve(tmpRoot());
108
+ const under = (path, root) => { const local = relative(root, path); return local !== '..' && !local.startsWith('..' + sep) && !local.startsWith(sep) ? local : undefined; };
109
+ const affects = (changed) => {
110
+ const path = resolve(changed);
111
+ if (path === config || under(path, journals) !== undefined || under(path, rooms) !== undefined || under(path, splitRoot) !== undefined)
112
+ return true;
113
+ for (const root of [permanentRoles, temporaryRoles]) {
114
+ const local = under(path, root);
115
+ if (local === undefined)
116
+ continue;
117
+ const parts = local.split(sep).filter(Boolean);
118
+ // The roles directory, a role directory appearing/disappearing, or its definition.
119
+ return parts.length <= 1 || (parts.length === 2 && parts[1] === 'role.yaml');
120
+ }
121
+ // Ancestor replacement of any input root.
122
+ return [config, journals, rooms, splitRoot, permanentRoles, temporaryRoles].some(root => under(root, path) !== undefined);
123
+ };
124
+ return { journals, rooms, splitRoot, permanentRoles, temporaryRoles, affects };
125
+ }
@@ -0,0 +1,61 @@
1
+ import { type IdentityHistoryPage, type IdentityReader } from './identity-reads.js';
2
+ import type { ChannelsIndexer } from './indexer.js';
3
+ import type { ChannelPage, ChannelPerspective, ChannelRow, ChannelsStore } from './store.js';
4
+ export interface ChannelsServiceDeps {
5
+ store: ChannelsStore;
6
+ reader: IdentityReader;
7
+ indexer: ChannelsIndexer;
8
+ /** Actual root CID of this workspace's Owner, when known. */
9
+ ownerRoot(): string | undefined;
10
+ /** False until the first discovery completed. */
11
+ ready(): boolean;
12
+ }
13
+ export type ChannelRowView = ChannelRow & {
14
+ claimsOwnerRoot: boolean;
15
+ };
16
+ /**
17
+ * Owner-facing Channels queries over the durable projection. Served only
18
+ * behind the Fleet web Owner session; agents get no route to it.
19
+ */
20
+ export declare class ChannelsService {
21
+ private readonly deps;
22
+ constructor(deps: ChannelsServiceDeps);
23
+ status(): {
24
+ ready: boolean;
25
+ sources: ReturnType<ChannelsStore['sourcesSummary']>;
26
+ };
27
+ private view;
28
+ list(options: {
29
+ cursor?: string;
30
+ limit?: number;
31
+ q?: string;
32
+ root?: string;
33
+ }): ChannelPage & {
34
+ channels: ChannelRowView[];
35
+ };
36
+ /** The SSE snapshot: the first Recent page. */
37
+ snapshot(): {
38
+ ready: boolean;
39
+ channels: ChannelRowView[];
40
+ nextCursor: string | null;
41
+ };
42
+ get(cid: string): {
43
+ channel: ChannelRowView;
44
+ perspectives: ChannelPerspective[];
45
+ };
46
+ /**
47
+ * One perspective's own history with this contact; never a merged view, never consuming.
48
+ * Pinned to the source's CID and last observed daemon source generation; a response is
49
+ * released only while that same perspective is still current.
50
+ */
51
+ history(contactCid: string, sourceCid: string, page: {
52
+ before_seq?: number;
53
+ limit?: number;
54
+ }): Promise<IdentityHistoryPage & {
55
+ perspective: {
56
+ sourceCid: string;
57
+ kind: string;
58
+ roleId?: string;
59
+ };
60
+ }>;
61
+ }
@@ -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
+ }
@@ -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';
@@ -155,7 +155,7 @@ export function canonicalResourceDescriptor(input) {
155
155
  if (!input || typeof input !== 'object' || Array.isArray(input))
156
156
  throw Error('invalid resource descriptor');
157
157
  const value = input;
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)
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', 'channels'].includes(value.resource)
159
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')
@@ -381,6 +381,12 @@ export class FleetResourceViews {
381
381
  || this.configurationInputs().some(input => within(resolve(input), changed));
382
382
  }
383
383
  affects(descriptor, path, structural = false) {
384
+ // The Channels projection commits only its own private database; no other view reads it.
385
+ const channelsRoot = resolve(stateRoot(), 'private-channels'), channelsPath = path === channelsRoot || path.startsWith(channelsRoot + sep);
386
+ if (descriptor.resource === 'channels')
387
+ return channelsPath || channelsRoot.startsWith(resolve(path) + sep);
388
+ if (channelsPath)
389
+ return false;
384
390
  if (descriptor.resource === 'configuration')
385
391
  return this.configurationDependency(path);
386
392
  // An explicitly selected manifest may use any pathname, including one
@@ -1,3 +1,4 @@
1
+ import { createChannels } from '../channels/runtime.js';
1
2
  import { ConversationRoleObserver } from '../application/conversation-role-observer.js';
2
3
  import { replaceFileAtomically } from '../atomic-file.js';
3
4
  import { validateAccountOrigin } from '../account-origin.js';
@@ -196,6 +197,15 @@ export async function startWebConsole(options) {
196
197
  const taskRooms = new TaskRoomApplicationService(options.configPath, { binPath: () => options.binPath });
197
198
  // One-time, lock-guarded mission key migration before serving; failure leaves it to the next mutation.
198
199
  await taskRooms.prepareMissionKeys().catch(error => console.warn(`mission key migration deferred: ${error.message}`));
200
+ // Ours Channels runs only where a daemon client profile exists.
201
+ let channels;
202
+ try {
203
+ channels = createChannels({ configPath: configuration.path, repository });
204
+ }
205
+ catch (error) {
206
+ console.warn(`Ours Channels unavailable: ${error.message}`);
207
+ channels = undefined;
208
+ }
199
209
  let server;
200
210
  try {
201
211
  server = await buildWebServer({
@@ -219,6 +229,7 @@ export async function startWebConsole(options) {
219
229
  });
220
230
  },
221
231
  codexModels: createCodexModelDiscovery(),
232
+ ...(channels ? { channels: channels.service } : {}),
222
233
  subscriptions: new SubscriptionService({
223
234
  agents: async () => (await query.list(true)).map(({ role, status }) => ({
224
235
  roleId: role.id, stateDir: repository.stateDir(role), running: status.supervisor.liveness === 'running',
@@ -241,6 +252,7 @@ export async function startWebConsole(options) {
241
252
  }, { origin: `http://127.0.0.1:${requestedPort}`, host: `127.0.0.1:${requestedPort}` }, { auth });
242
253
  }
243
254
  catch (error) {
255
+ channels?.close();
244
256
  lock.release();
245
257
  throw error;
246
258
  }
@@ -336,6 +348,7 @@ export async function startWebConsole(options) {
336
348
  await control?.close();
337
349
  await gateway?.close();
338
350
  await server.close();
351
+ channels?.close();
339
352
  }
340
353
  finally {
341
354
  lock.release();
@@ -24,6 +24,7 @@ import type { PresetProvenance } from '../application/preset-provenance.js';
24
24
  import type { OfferedModels } from '../subscriptions/cli.js';
25
25
  import type { SubscriptionService } from '../subscriptions/service.js';
26
26
  import type { LayoutBindingGrants } from '../rooms-tasks/layout-binding-grants.js';
27
+ import type { ChannelsService } from '../channels/service.js';
27
28
  export interface WebServices {
28
29
  resourceScope?: FleetResourceScope;
29
30
  invalidateResourceCaches?(): void;
@@ -48,6 +49,8 @@ export interface WebServices {
48
49
  presetProvenance?: PresetProvenance;
49
50
  oursTools?: Pick<SupervisorOursTools, 'list' | 'call'>;
50
51
  subscriptions?: SubscriptionService;
52
+ /** Ours Channels: Owner-only cross-identity external contacts. Absent: not configured. */
53
+ channels?: ChannelsService;
51
54
  /** First-time setup from the packaged presets; the account App supplies only the model choices. */
52
55
  onboardingSetup?(answers: InitAnswers): Promise<void>;
53
56
  /** The Codex models the runtime that sessions will use offers the active account. Absent: this server cannot tell, and filters nothing. */
@@ -460,6 +460,10 @@ export async function buildWebServer(services, boundary, options = {}) {
460
460
  throw error;
461
461
  }
462
462
  case 'subscriptions': return { providers: await subscriptions().list(true) };
463
+ case 'channels':
464
+ if (!services.channels)
465
+ throw new FleetError('capability_unavailable', 'Ours Channels is not configured on this server');
466
+ return services.channels.snapshot();
463
467
  case 'onboarding-health': {
464
468
  const revision = services.configuration?.read().revision;
465
469
  if (onboardingObserved && onboardingObserved.revision !== revision)
@@ -556,6 +560,25 @@ export async function buildWebServer(services, boundary, options = {}) {
556
560
  });
557
561
  for (const resource of ['task-layout', 'creation-action', 'action', 'member-addition', 'login', 'onboarding-health'])
558
562
  app.get('/api/v1/' + resource, async () => { throw new ResourceBindingError(409, 'resource binding required'); });
563
+ const channels = () => { if (!services.channels)
564
+ throw new FleetError('capability_unavailable', 'Ours Channels is not configured on this server'); return services.channels; };
565
+ const channelCid = (value) => { const cid = String(value ?? ''); if (!/^[0-9A-Fa-f]{64}$/.test(cid))
566
+ throw new FleetError('invalid_request', 'a 64-hex container id is required'); return cid.toUpperCase(); };
567
+ const optionalInt = (value, name, max = Number.MAX_SAFE_INTEGER) => { if (value === undefined || value === '')
568
+ return undefined; const n = Number(value); if (!Number.isSafeInteger(n) || n < 1 || n > max)
569
+ throw new FleetError('invalid_request', `${name} must be an integer from 1 to ${max}`); return n; };
570
+ app.get('/api/v1/channels', async (request) => {
571
+ auth.authenticate(request);
572
+ const query = request.query;
573
+ return channels().list({ ...(query.cursor ? { cursor: String(query.cursor) } : {}), ...(query.q ? { q: String(query.q).slice(0, 200) } : {}), ...(query.root ? { root: channelCid(query.root) } : {}), ...(query.limit ? { limit: optionalInt(query.limit, 'limit', 200) } : {}) });
574
+ });
575
+ app.get('/api/v1/channels-status', async (request) => { auth.authenticate(request); return channels().status(); });
576
+ app.get('/api/v1/channels/:cid', async (request) => { auth.authenticate(request); return channels().get(channelCid(request.params.cid)); });
577
+ app.get('/api/v1/channels/:cid/perspectives/:source/messages', async (request) => {
578
+ auth.authenticate(request);
579
+ const before = optionalInt(request.query.before_seq, 'before_seq'), limit = optionalInt(request.query.limit, 'limit', 200);
580
+ return channels().history(channelCid(request.params.cid), channelCid(request.params.source), { ...(before !== undefined ? { before_seq: before } : {}), ...(limit !== undefined ? { limit } : {}) });
581
+ });
559
582
  app.get('/api/v1/directory', async (request) => { auth.authenticate(request); return project({ viewId: 'read', resource: 'directory' }, new AbortController().signal); });
560
583
  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); });
561
584
  app.get('/api/v1/task-lists', async (request) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "2.3.3",
3
+ "version": "2.4.0-nightly.2",
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.2.0",
53
- "@ours.network/mcp": "2.0.3",
54
- "@ours.network/sdk": "5.2.0",
52
+ "@ours.network/cli": "4.2.1-nightly.1",
53
+ "@ours.network/mcp": "2.1.0-nightly.1",
54
+ "@ours.network/sdk": "5.2.1-nightly.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.2.0",
68
+ "@ours.network/daemon": "5.2.1-nightly.1",
69
69
  "@types/node": "^22.20.4",
70
70
  "@types/qrcode": "^1.5.6",
71
71
  "@types/ws": "^8.18.1",