@ours.network/fleet 1.2.0-nightly.70 → 1.2.0-nightly.72
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/account-origin.d.ts +4 -1
- package/dist/account-origin.js +27 -2
- package/dist/application/conversation-history.d.ts +7 -0
- package/dist/application/conversation-history.js +65 -0
- package/dist/application/fleet-query-service.d.ts +1 -1
- package/dist/application/fleet-query-service.js +2 -2
- package/dist/application/resource-observer.d.ts +40 -0
- package/dist/application/resource-observer.js +278 -0
- package/dist/application/role-removal-service.js +1 -1
- package/dist/application/role-repository.d.ts +1 -1
- package/dist/application/role-repository.js +11 -5
- package/dist/application/task-layouts.js +2 -0
- package/dist/application/task-room-service.d.ts +2 -0
- package/dist/application/task-room-service.js +2 -2
- package/dist/build-info.json +4 -4
- package/dist/daemon-recovery.d.ts +13 -1
- package/dist/daemon-recovery.js +117 -3
- package/dist/monitor.d.ts +1 -0
- package/dist/monitor.js +1 -1
- package/dist/notifications/fleet-producer.d.ts +4 -1
- package/dist/notifications/fleet-producer.js +39 -10
- package/dist/notifications/outbox.d.ts +14 -1
- package/dist/notifications/outbox.js +51 -10
- package/dist/notifications/target-cleanup.d.ts +10 -4
- package/dist/notifications/target-cleanup.js +35 -23
- package/dist/notifications/task-producer.d.ts +3 -1
- package/dist/notifications/task-producer.js +38 -5
- package/dist/ops.js +1 -1
- package/dist/paths.d.ts +3 -0
- package/dist/paths.js +3 -0
- package/dist/rooms-tasks/add-member.js +2 -1
- package/dist/rooms-tasks/layout.js +2 -1
- package/dist/rooms-tasks/task-state.d.ts +1 -0
- package/dist/rooms-tasks/task-state.js +27 -11
- package/dist/rooms-tasks/types.d.ts +4 -1
- package/dist/runner.js +22 -7
- package/dist/subscriptions/login.d.ts +4 -3
- package/dist/subscriptions/login.js +14 -6
- package/dist/subscriptions/service.d.ts +1 -1
- package/dist/subscriptions/service.js +4 -4
- package/dist/subscriptions/store.js +3 -0
- package/dist/tunnel-setup-input.js +7 -1
- package/dist/watchdog/query.d.ts +3 -1
- package/dist/watchdog/query.js +2 -1
- package/dist/web/auth.d.ts +2 -2
- package/dist/web/auth.js +4 -4
- package/dist/web/events.d.ts +2 -0
- package/dist/web/events.js +9 -0
- package/dist/web/prefix-gateway.js +20 -10
- package/dist/web/resource-views.d.ts +124 -0
- package/dist/web/resource-views.js +424 -0
- package/dist/web/runtime.js +6 -3
- package/dist/web/server.d.ts +3 -0
- package/dist/web/server.js +171 -20
- package/dist/workspace-enrollment.js +53 -28
- package/dist/workspace-replacement.js +28 -10
- package/docs/conversation-stream.md +6 -0
- package/package.json +1 -1
package/dist/account-origin.d.ts
CHANGED
|
@@ -1,2 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
/** Syntax validation for an authority already selected by trusted private state/configuration. */
|
|
2
2
|
export declare function validateAccountOrigin(value: unknown): string;
|
|
3
|
+
/** Incoming grants/payloads must match explicit operator configuration before any network action. */
|
|
4
|
+
export declare function validateConfiguredAccountOrigin(value: unknown): string;
|
|
5
|
+
export declare function validateConfiguredWorkspaceHost(value: unknown): boolean;
|
package/dist/account-origin.js
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
|
-
|
|
1
|
+
/** Syntax validation for an authority already selected by trusted private state/configuration. */
|
|
2
2
|
export function validateAccountOrigin(value) {
|
|
3
|
-
|
|
3
|
+
let url;
|
|
4
|
+
try {
|
|
5
|
+
if (typeof value !== 'string')
|
|
6
|
+
throw Error();
|
|
7
|
+
url = new URL(value);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
throw Error('Unsupported account origin');
|
|
11
|
+
}
|
|
12
|
+
if (url.protocol !== 'https:' || url.origin !== value || url.username || url.password)
|
|
4
13
|
throw Error('Unsupported account origin');
|
|
5
14
|
return value;
|
|
6
15
|
}
|
|
16
|
+
/** Incoming grants/payloads must match explicit operator configuration before any network action. */
|
|
17
|
+
export function validateConfiguredAccountOrigin(value) {
|
|
18
|
+
const configured = validateAccountOrigin(process.env.OURS_ACCOUNT_ORIGIN);
|
|
19
|
+
if (value !== configured)
|
|
20
|
+
throw Error('Unsupported account origin');
|
|
21
|
+
return configured;
|
|
22
|
+
}
|
|
23
|
+
const dnsName = (value) => typeof value === 'string' && value.length <= 253 && value.includes('.') && value.split('.').every(label => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label));
|
|
24
|
+
export function validateConfiguredWorkspaceHost(value) {
|
|
25
|
+
if (!dnsName(value))
|
|
26
|
+
return false;
|
|
27
|
+
if (process.env.OURS_WORKSPACE_HOST !== undefined)
|
|
28
|
+
return dnsName(process.env.OURS_WORKSPACE_HOST) && value === process.env.OURS_WORKSPACE_HOST;
|
|
29
|
+
const domain = process.env.OURS_WORKSPACE_DOMAIN;
|
|
30
|
+
return dnsName(domain) && value.endsWith('.' + domain) && !value.slice(0, -domain.length - 1).includes('.');
|
|
31
|
+
}
|
|
@@ -25,3 +25,10 @@ export declare function resumedConversationPage(stateDir: string, request: {
|
|
|
25
25
|
}, live: ConversationPageView, expectedSessionId: string): Promise<ConversationPageView>;
|
|
26
26
|
/** Download authorization requires an actual published attachment, never an orphan copy. */
|
|
27
27
|
export declare function conversationHasAttachment(stateDir: string, attachment: import('../file-delivery/types.js').DeliveredFile): Promise<boolean>;
|
|
28
|
+
/** Passive, bounded last assistant text for directory previews. Never opens the
|
|
29
|
+
* ledger writer/recovery path or acquires a session/controller. An incomplete
|
|
30
|
+
* newest append is not committed yet; completed malformed records still fail. */
|
|
31
|
+
export declare function conversationPreview(stateDir: string, signal: AbortSignal): Promise<{
|
|
32
|
+
text: string;
|
|
33
|
+
at: string;
|
|
34
|
+
}>;
|
|
@@ -213,3 +213,68 @@ export async function conversationHasAttachment(stateDir, attachment) {
|
|
|
213
213
|
}
|
|
214
214
|
return found;
|
|
215
215
|
}
|
|
216
|
+
/** Passive, bounded last assistant text for directory previews. Never opens the
|
|
217
|
+
* ledger writer/recovery path or acquires a session/controller. An incomplete
|
|
218
|
+
* newest append is not committed yet; completed malformed records still fail. */
|
|
219
|
+
export async function conversationPreview(stateDir, signal) {
|
|
220
|
+
signal.throwIfAborted();
|
|
221
|
+
const dir = join(stateDir, '.conversation');
|
|
222
|
+
let files;
|
|
223
|
+
try {
|
|
224
|
+
files = (await readdir(dir)).filter(name => /^events-\d{6}\.jsonl$/.test(name)).sort().reverse();
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
if (error.code === 'ENOENT')
|
|
228
|
+
return { text: 'No recent agent output', at: '' };
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
const events = [];
|
|
232
|
+
for (const name of files) {
|
|
233
|
+
signal.throwIfAborted();
|
|
234
|
+
const file = await open(join(dir, name), 'r');
|
|
235
|
+
try {
|
|
236
|
+
const size = (await file.stat()).size, length = Math.min(size, 2 * 1024 * 1024), buffer = Buffer.alloc(length);
|
|
237
|
+
await file.read(buffer, 0, length, size - length);
|
|
238
|
+
const completeEnd = buffer.lastIndexOf(10) + 1;
|
|
239
|
+
// Only the newest segment can have an in-flight append. If its tail
|
|
240
|
+
// fills the bounded window, we cannot establish the previous preview.
|
|
241
|
+
if (completeEnd < length && (name !== files[0] || (size > length && completeEnd === 0)))
|
|
242
|
+
throw Error('preview ledger has partial record');
|
|
243
|
+
const lines = buffer.toString('utf8', 0, completeEnd).split('\n');
|
|
244
|
+
lines.pop();
|
|
245
|
+
if (size > length)
|
|
246
|
+
lines.shift();
|
|
247
|
+
for (let i = lines.length - 1; i >= 0 && events.length < 200; i--) {
|
|
248
|
+
const event = JSON.parse(lines[i]);
|
|
249
|
+
if (event.schemaVersion !== 1 || !Number.isSafeInteger(event.seq))
|
|
250
|
+
throw Error('preview ledger invalid');
|
|
251
|
+
events.push(event);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
await file.close();
|
|
256
|
+
}
|
|
257
|
+
if (events.length >= 200)
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
signal.throwIfAborted();
|
|
261
|
+
const assistant = (event) => ['message.chunk', 'message.replace'].includes(event.kind) && event.payload?.role === 'assistant' && typeof event.payload?.content?.type === 'string';
|
|
262
|
+
const end = events.findIndex(assistant);
|
|
263
|
+
if (end < 0)
|
|
264
|
+
return { text: 'No recent agent output', at: '' };
|
|
265
|
+
const last = events[end], parts = [];
|
|
266
|
+
for (let i = end; i < events.length; i++) {
|
|
267
|
+
const event = events[i];
|
|
268
|
+
if (last.messageId !== undefined) {
|
|
269
|
+
if (!assistant(event) || event.messageId !== last.messageId)
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
else if (!assistant(event) || event.messageId !== undefined)
|
|
273
|
+
break;
|
|
274
|
+
const content = event.payload.content;
|
|
275
|
+
parts.unshift(content.type === 'text' ? content.text : `[${content.type}]`);
|
|
276
|
+
if (event.kind === 'message.replace')
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
return { text: parts.join('').replace(/\s+/g, ' ').trim().slice(0, 300) || 'No recent agent output', at: last.at ?? '' };
|
|
280
|
+
}
|
|
@@ -24,7 +24,7 @@ export declare class FleetQueryService {
|
|
|
24
24
|
private readonly options;
|
|
25
25
|
private readonly control;
|
|
26
26
|
constructor(options: FleetQueryOptions);
|
|
27
|
-
list(includeTemporary?: boolean): Promise<Array<{
|
|
27
|
+
list(includeTemporary?: boolean, strict?: boolean): Promise<Array<{
|
|
28
28
|
role: RoleRecord;
|
|
29
29
|
status: RoleStatus;
|
|
30
30
|
capabilities: RoleCapabilities;
|
|
@@ -75,8 +75,8 @@ export class FleetQueryService {
|
|
|
75
75
|
this.options = options;
|
|
76
76
|
this.control = options.control ?? controlRequest;
|
|
77
77
|
}
|
|
78
|
-
async list(includeTemporary = false) {
|
|
79
|
-
const roles = (await this.options.repository.list())
|
|
78
|
+
async list(includeTemporary = false, strict = false) {
|
|
79
|
+
const roles = (await this.options.repository.list(strict))
|
|
80
80
|
.filter(role => (role.configured && role.lifetime === 'permanent') || (includeTemporary && role.lifetime === 'temporary'));
|
|
81
81
|
return Promise.all(roles.map(async (role) => {
|
|
82
82
|
const status = await this.status(role);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type FSWatcher } from 'node:fs';
|
|
2
|
+
export interface FleetResourceState {
|
|
3
|
+
protocolVersion: 1;
|
|
4
|
+
producerGeneration: string;
|
|
5
|
+
position: number;
|
|
6
|
+
health: 'healthy' | 'failed' | 'unsupported';
|
|
7
|
+
}
|
|
8
|
+
export interface FleetResourceScope {
|
|
9
|
+
configPath?: string;
|
|
10
|
+
subscribeMutations?: (listener: () => void) => () => void;
|
|
11
|
+
/** Durable notification owners may reinstall degraded watches without idle content polling. */
|
|
12
|
+
retryWatchFailures?: boolean;
|
|
13
|
+
roots?: string[];
|
|
14
|
+
/** Overrides full Fleet discovery for narrow owners such as task delivery. */
|
|
15
|
+
paths?: string[];
|
|
16
|
+
watch?: (path: string, listener: (event: string, filename: string | null) => void) => Pick<FSWatcher, 'on' | 'close'>;
|
|
17
|
+
}
|
|
18
|
+
/** Only call after durable persistence succeeds. Observers cannot fail a commit. */
|
|
19
|
+
export declare function publishFleetResourceCommit(path: string): void;
|
|
20
|
+
/** Native notifications cover supported local mounts. No idle content scan or
|
|
21
|
+
* silent claim of coverage after watch failure. Parents survive atomic rename.
|
|
22
|
+
* Discovery reads directory metadata only, never opens a controller or transcript. */
|
|
23
|
+
export declare function observeFleetResources(scope: FleetResourceScope, onCommitted: (state: FleetResourceState) => void, signal: AbortSignal): {
|
|
24
|
+
snapshot: () => {
|
|
25
|
+
health: "healthy" | "failed" | "unsupported";
|
|
26
|
+
protocolVersion: 1;
|
|
27
|
+
producerGeneration: string;
|
|
28
|
+
position: number;
|
|
29
|
+
};
|
|
30
|
+
cursor: (accepts: (path: string) => boolean, lifetime: AbortSignal) => {
|
|
31
|
+
snapshot: () => {
|
|
32
|
+
position: number;
|
|
33
|
+
health: "healthy" | "failed" | "unsupported";
|
|
34
|
+
protocolVersion: 1;
|
|
35
|
+
producerGeneration: string;
|
|
36
|
+
};
|
|
37
|
+
dispose: () => void;
|
|
38
|
+
};
|
|
39
|
+
dispose: () => void;
|
|
40
|
+
};
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { lstatSync, statSync, readdirSync, watch } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
6
|
+
import { splitRootFor } from '../config.js';
|
|
7
|
+
import { agentsRoot, tmpRoot, stateRoot, defaultConfigPath } from '../paths.js';
|
|
8
|
+
// Directory discovery is shared by payload and notification owners. Each owner
|
|
9
|
+
// has its own lifetime/coverage; the underlying OS watch is reference counted.
|
|
10
|
+
const sharedWatches = new Map();
|
|
11
|
+
function sharedWatch(path, listener) {
|
|
12
|
+
const key = `${path}:${identity(path, true)}`;
|
|
13
|
+
let entry = sharedWatches.get(key);
|
|
14
|
+
if (!entry) {
|
|
15
|
+
const clients = new Set(), listeners = new Map();
|
|
16
|
+
const handle = watch(path, { persistent: false }, (event, name) => { for (const fn of [...listeners.values()])
|
|
17
|
+
fn(event, name); });
|
|
18
|
+
entry = { handle, clients, listeners };
|
|
19
|
+
sharedWatches.set(key, entry);
|
|
20
|
+
handle.on('error', error => { for (const client of [...clients])
|
|
21
|
+
client.emit('error', error); });
|
|
22
|
+
handle.on('close', () => { for (const client of [...clients])
|
|
23
|
+
client.emit('close'); });
|
|
24
|
+
}
|
|
25
|
+
const current = entry, client = new EventEmitter();
|
|
26
|
+
let closed = false;
|
|
27
|
+
current.clients.add(client);
|
|
28
|
+
current.listeners.set(client, listener);
|
|
29
|
+
return Object.assign(client, { close() {
|
|
30
|
+
if (closed)
|
|
31
|
+
return;
|
|
32
|
+
closed = true;
|
|
33
|
+
current.clients.delete(client);
|
|
34
|
+
current.listeners.delete(client);
|
|
35
|
+
if (!current.clients.size) {
|
|
36
|
+
sharedWatches.delete(key);
|
|
37
|
+
current.handle.close();
|
|
38
|
+
}
|
|
39
|
+
} });
|
|
40
|
+
}
|
|
41
|
+
const commits = new Set();
|
|
42
|
+
/** Only call after durable persistence succeeds. Observers cannot fail a commit. */
|
|
43
|
+
export function publishFleetResourceCommit(path) {
|
|
44
|
+
for (const listener of [...commits]) {
|
|
45
|
+
try {
|
|
46
|
+
listener(resolve(path));
|
|
47
|
+
}
|
|
48
|
+
catch { }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function directoryGone(error) {
|
|
52
|
+
return ['ENOENT', 'ENOTDIR'].includes(error.code ?? '');
|
|
53
|
+
}
|
|
54
|
+
function identity(path, followLink = false) {
|
|
55
|
+
try {
|
|
56
|
+
const value = followLink ? statSync(path) : lstatSync(path);
|
|
57
|
+
if (value.isSymbolicLink())
|
|
58
|
+
throw Error('symbolic resource directory');
|
|
59
|
+
return value.isDirectory() ? `${value.dev}:${value.ino}:${value.birthtimeMs}` : undefined;
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (directoryGone(error))
|
|
63
|
+
return;
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function within(path, root) { return path === root || path.startsWith(root + sep); }
|
|
68
|
+
/** Native notifications cover supported local mounts. No idle content scan or
|
|
69
|
+
* silent claim of coverage after watch failure. Parents survive atomic rename.
|
|
70
|
+
* Discovery reads directory metadata only, never opens a controller or transcript. */
|
|
71
|
+
export function observeFleetResources(scope, onCommitted, signal) {
|
|
72
|
+
const config = resolve(scope.configPath ?? defaultConfigPath());
|
|
73
|
+
const roots = (scope.roots ?? [agentsRoot(), tmpRoot()]).map(p => resolve(p));
|
|
74
|
+
const paths = (scope.paths ?? (scope.roots && !scope.configPath ? roots : [...roots, splitRootFor(config), join(stateRoot(), 'tasks'), join(stateRoot(), 'rooms'), join(stateRoot(), 'watchdogs'), join(stateRoot(), 'layouts'), join(stateRoot(), 'member-additions'), join(stateRoot(), 'subscriptions')])).map(p => resolve(p));
|
|
75
|
+
// Explicit roots without config select discovery only (notification producers).
|
|
76
|
+
const configEnabled = scope.configPath !== undefined || (!scope.paths && !scope.roots);
|
|
77
|
+
const adoptedHomes = configEnabled ? [join(homedir(), '.claude'), join(homedir(), '.codex')] : [];
|
|
78
|
+
if (configEnabled)
|
|
79
|
+
paths.push(dirname(config), resolve(stateRoot()), ...adoptedHomes);
|
|
80
|
+
// Explicit resource locations and their ancestors already belong to the
|
|
81
|
+
// caller's read scope (e.g. /home -> /var/home or a linked state root).
|
|
82
|
+
// Discovered descendants keep lstat validation and never traverse child links.
|
|
83
|
+
const trustedPaths = new Set(paths);
|
|
84
|
+
for (const path of paths) {
|
|
85
|
+
let parent = path;
|
|
86
|
+
for (let depth = 0; depth < 4 && dirname(parent) !== parent; depth++, parent = dirname(parent))
|
|
87
|
+
trustedPaths.add(parent);
|
|
88
|
+
}
|
|
89
|
+
const resourceIdentity = (path) => identity(path, trustedPaths.has(path));
|
|
90
|
+
let state = { protocolVersion: 1, producerGeneration: randomUUID(), position: 0, health: 'healthy' };
|
|
91
|
+
let disposed = signal.aborted, queued = false;
|
|
92
|
+
let recovery;
|
|
93
|
+
let installing = false, installFailure;
|
|
94
|
+
const cursors = new Set();
|
|
95
|
+
const watchers = new Map();
|
|
96
|
+
const release = () => { const old = [...watchers.values()]; watchers.clear(); for (const entry of old)
|
|
97
|
+
entry.handle.close(); };
|
|
98
|
+
const fail = (error) => {
|
|
99
|
+
if (installing) {
|
|
100
|
+
installFailure = error;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (disposed || state.health !== 'healthy')
|
|
104
|
+
return;
|
|
105
|
+
state = { ...state, position: state.position + 1, health: ['ENOSYS', 'ENOTSUP', 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM'].includes(error.code ?? '') ? 'unsupported' : 'failed' };
|
|
106
|
+
release();
|
|
107
|
+
onCommitted({ ...state });
|
|
108
|
+
recover();
|
|
109
|
+
};
|
|
110
|
+
const install = () => {
|
|
111
|
+
installing = true;
|
|
112
|
+
installFailure = undefined;
|
|
113
|
+
try {
|
|
114
|
+
const wanted = new Set();
|
|
115
|
+
const cover = (dir) => {
|
|
116
|
+
const id = resourceIdentity(dir);
|
|
117
|
+
if (!id)
|
|
118
|
+
return;
|
|
119
|
+
wanted.add(dir);
|
|
120
|
+
const existing = watchers.get(dir);
|
|
121
|
+
if (existing && existing.identity === id)
|
|
122
|
+
return;
|
|
123
|
+
if (existing) {
|
|
124
|
+
watchers.delete(dir);
|
|
125
|
+
existing.handle.close();
|
|
126
|
+
}
|
|
127
|
+
let handle;
|
|
128
|
+
try {
|
|
129
|
+
handle = (scope.watch ?? sharedWatch)(dir, (_event, filename) => {
|
|
130
|
+
if (watchers.get(dir)?.handle !== handle)
|
|
131
|
+
return;
|
|
132
|
+
// Ignore sibling noise from ancestor coverage, lock churn and delivery acknowledgements.
|
|
133
|
+
const changed = filename === null ? dir : resolve(dir, String(filename));
|
|
134
|
+
if (changed.split(sep).some(name => name === '.locks' || name.endsWith('.lock') || name.endsWith('.tmp')))
|
|
135
|
+
return;
|
|
136
|
+
if (paths.some(path => within(changed, path) || within(path, changed)) || (configEnabled && changed === config) || filename === null)
|
|
137
|
+
wake(filename === null ? undefined : changed);
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
// Parent coverage owns removal/recreation. Only verified disappearance
|
|
142
|
+
// is benign; permissions, unsupported watches and other errors fail.
|
|
143
|
+
if (directoryGone(error) && !resourceIdentity(dir))
|
|
144
|
+
return;
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
147
|
+
watchers.set(dir, { identity: id, handle });
|
|
148
|
+
handle.on('error', (error) => { if (watchers.get(dir)?.handle === handle)
|
|
149
|
+
fail(error); });
|
|
150
|
+
handle.on('close', () => { if (watchers.get(dir)?.handle === handle)
|
|
151
|
+
fail(Error('resource watch closed')); });
|
|
152
|
+
if (installFailure)
|
|
153
|
+
throw installFailure;
|
|
154
|
+
};
|
|
155
|
+
const visit = (dir, depth) => {
|
|
156
|
+
const id = resourceIdentity(dir);
|
|
157
|
+
if (!id)
|
|
158
|
+
return;
|
|
159
|
+
// Watch before enumerating children: creation before watch is covered by
|
|
160
|
+
// this scan; creation after watch schedules another discovery pass.
|
|
161
|
+
cover(dir);
|
|
162
|
+
if (!depth)
|
|
163
|
+
return;
|
|
164
|
+
let entries;
|
|
165
|
+
try {
|
|
166
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (directoryGone(error) && !resourceIdentity(dir))
|
|
170
|
+
return;
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === '.locks' || entry.name.endsWith('.lock'))
|
|
175
|
+
continue;
|
|
176
|
+
visit(join(dir, entry.name), depth - 1);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
for (const path of paths) {
|
|
180
|
+
// Enough ancestors to retain the subscription when its state root is replaced.
|
|
181
|
+
let parent = path;
|
|
182
|
+
for (let depth = 0; depth < 4 && dirname(parent) !== parent; depth++, parent = dirname(parent)) {
|
|
183
|
+
if (resourceIdentity(parent)) {
|
|
184
|
+
wanted.add(parent);
|
|
185
|
+
cover(parent);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
visit(path, configEnabled && (path === dirname(config) || adoptedHomes.includes(path)) ? 0 : 3);
|
|
189
|
+
}
|
|
190
|
+
for (const [dir, entry] of watchers) {
|
|
191
|
+
if (!wanted.has(dir) || resourceIdentity(dir) !== entry.identity) {
|
|
192
|
+
watchers.delete(dir);
|
|
193
|
+
entry.handle.close();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (installFailure)
|
|
197
|
+
throw installFailure;
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
installing = false;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const recover = () => {
|
|
204
|
+
if (disposed || state.health === 'healthy' || !scope.retryWatchFailures || recovery)
|
|
205
|
+
return;
|
|
206
|
+
recovery = setTimeout(() => {
|
|
207
|
+
recovery = undefined;
|
|
208
|
+
if (disposed)
|
|
209
|
+
return;
|
|
210
|
+
try {
|
|
211
|
+
install();
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
release();
|
|
215
|
+
recover();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
state = { ...state, position: state.position + 1, health: 'healthy' };
|
|
219
|
+
// Reinstallation precedes one checkpoint recovery pass; later work is
|
|
220
|
+
// event driven again. Degraded retries inspect directory metadata only.
|
|
221
|
+
onCommitted({ ...state });
|
|
222
|
+
}, 3000);
|
|
223
|
+
recovery.unref();
|
|
224
|
+
};
|
|
225
|
+
const wake = (path) => {
|
|
226
|
+
if (disposed || state.health !== 'healthy')
|
|
227
|
+
return;
|
|
228
|
+
state = { ...state, position: state.position + 1 };
|
|
229
|
+
for (const cursor of cursors)
|
|
230
|
+
if (path === undefined || cursor.accepts(path))
|
|
231
|
+
cursor.position = state.position;
|
|
232
|
+
if (queued)
|
|
233
|
+
return;
|
|
234
|
+
queued = true;
|
|
235
|
+
queueMicrotask(() => {
|
|
236
|
+
queued = false;
|
|
237
|
+
if (disposed || state.health !== 'healthy')
|
|
238
|
+
return;
|
|
239
|
+
try {
|
|
240
|
+
install();
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
fail(error);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
onCommitted({ ...state });
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
const direct = (path) => { if (paths.some(root => within(path, root)) || (configEnabled && path === config))
|
|
250
|
+
wake(path); };
|
|
251
|
+
let detach;
|
|
252
|
+
const dispose = () => { if (disposed)
|
|
253
|
+
return; disposed = true; clearTimeout(recovery); recovery = undefined; detach?.(); for (const cursor of [...cursors])
|
|
254
|
+
cursor.dispose(); commits.delete(direct); signal.removeEventListener('abort', dispose); release(); };
|
|
255
|
+
if (!disposed) {
|
|
256
|
+
commits.add(direct);
|
|
257
|
+
detach = scope.subscribeMutations?.(wake);
|
|
258
|
+
signal.addEventListener('abort', dispose, { once: true });
|
|
259
|
+
try {
|
|
260
|
+
install();
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
fail(error);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const snapshot = () => ({ ...state, ...(disposed ? { health: 'failed' } : {}) });
|
|
267
|
+
// View-local cursors share coverage and health, but unrelated file commits
|
|
268
|
+
// cannot invalidate a capture. Unknown wakes remain conservative.
|
|
269
|
+
const cursor = (accepts, lifetime) => {
|
|
270
|
+
const entry = { accepts, position: state.position, dispose() { cursors.delete(entry); lifetime.removeEventListener('abort', entry.dispose); } };
|
|
271
|
+
if (!disposed && !lifetime.aborted) {
|
|
272
|
+
cursors.add(entry);
|
|
273
|
+
lifetime.addEventListener('abort', entry.dispose, { once: true });
|
|
274
|
+
}
|
|
275
|
+
return { snapshot: () => ({ ...snapshot(), position: entry.position }), dispose: entry.dispose };
|
|
276
|
+
};
|
|
277
|
+
return { snapshot, cursor, dispose };
|
|
278
|
+
}
|
|
@@ -100,7 +100,7 @@ export class RoleRemovalService {
|
|
|
100
100
|
renameSync(sourceState, join(recoveryPath, 'orphan-state'));
|
|
101
101
|
removeReminderState(join(recoveryPath, 'orphan-state'));
|
|
102
102
|
}
|
|
103
|
-
await retireNotificationTarget(
|
|
103
|
+
await retireNotificationTarget({ kind: 'agent', agentId: preview.role }).catch(() => { });
|
|
104
104
|
this.options.ops.log(`removed orphaned backend/state '${preview.role}'`);
|
|
105
105
|
}
|
|
106
106
|
return { ...preview, removed: true, recoveryPath };
|
|
@@ -14,7 +14,7 @@ export interface RoleRepositoryOptions {
|
|
|
14
14
|
export declare class RoleRepository {
|
|
15
15
|
private readonly options;
|
|
16
16
|
constructor(options?: RoleRepositoryOptions);
|
|
17
|
-
list(): Promise<RoleRecord[]>;
|
|
17
|
+
list(strict?: boolean): Promise<RoleRecord[]>;
|
|
18
18
|
get(id: string): Promise<RoleRecord | undefined>;
|
|
19
19
|
stateDir(record: RoleRecord): string | undefined;
|
|
20
20
|
private detect;
|
|
@@ -20,13 +20,15 @@ function retainedTaskId(dir, name) {
|
|
|
20
20
|
return undefined;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
-
function safeDirs(root) {
|
|
23
|
+
function safeDirs(root, strict = false) {
|
|
24
24
|
try {
|
|
25
25
|
return readdirSync(root, { withFileTypes: true })
|
|
26
26
|
.filter(entry => entry.isDirectory() && !entry.isSymbolicLink() && ROLE_NAME_RE.test(entry.name))
|
|
27
27
|
.map(entry => entry.name);
|
|
28
28
|
}
|
|
29
|
-
catch {
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (strict && error.code !== 'ENOENT')
|
|
31
|
+
throw error;
|
|
30
32
|
return [];
|
|
31
33
|
}
|
|
32
34
|
}
|
|
@@ -101,7 +103,7 @@ export class RoleRepository {
|
|
|
101
103
|
constructor(options = {}) {
|
|
102
104
|
this.options = options;
|
|
103
105
|
}
|
|
104
|
-
async list() {
|
|
106
|
+
async list(strict = false) {
|
|
105
107
|
const permanentRoot = this.options.permanentRoot ?? agentsRoot();
|
|
106
108
|
const temporaryRoot = this.options.temporaryRoot ?? tmpRoot();
|
|
107
109
|
let configured = [];
|
|
@@ -110,10 +112,12 @@ export class RoleRepository {
|
|
|
110
112
|
configured = loadConfig(this.options.configPath).roles;
|
|
111
113
|
}
|
|
112
114
|
catch (error) {
|
|
115
|
+
if (strict)
|
|
116
|
+
throw error;
|
|
113
117
|
configProblem = { code: 'config_invalid', severity: 'error', source: 'config', detail: error.message };
|
|
114
118
|
}
|
|
115
|
-
const permanent = new Set(safeDirs(permanentRoot));
|
|
116
|
-
const temporary = new Set(safeDirs(temporaryRoot));
|
|
119
|
+
const permanent = new Set(safeDirs(permanentRoot, strict));
|
|
120
|
+
const temporary = new Set(safeDirs(temporaryRoot, strict));
|
|
117
121
|
const configuredByName = new Map(configured.map(role => [role.name, role]));
|
|
118
122
|
const names = [...new Set([...configuredByName.keys(), ...permanent, ...temporary])].sort();
|
|
119
123
|
return boundedMap(names, this.options.concurrency ?? 8, async (name) => {
|
|
@@ -126,6 +130,8 @@ export class RoleRepository {
|
|
|
126
130
|
if (inTemporary) {
|
|
127
131
|
const read = snapshot(join(temporaryRoot, name, 'role.yaml'), name);
|
|
128
132
|
tempRole = read.role;
|
|
133
|
+
if (strict && read.problem)
|
|
134
|
+
throw new Error('temporary role snapshot unavailable');
|
|
129
135
|
if (read.problem) {
|
|
130
136
|
problems.push(read.problem);
|
|
131
137
|
stateHealth = 'corrupt';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { publishFleetResourceCommit } from './resource-observer.js';
|
|
1
2
|
/** Task ↔ Room Layout bridge.
|
|
2
3
|
* A layout task owns exactly one Room Layout run, `task-<task_id>`, snapshotted
|
|
3
4
|
* from the named source layout with task context added to every room contract.
|
|
@@ -134,6 +135,7 @@ export class TaskLayouts {
|
|
|
134
135
|
writeOperation(runId, record) {
|
|
135
136
|
layoutRunPath(runId); // validates the ID and creates the private layouts directory
|
|
136
137
|
replaceFileAtomically(this.operationPath(runId), JSON.stringify({ ...record, updated_at: new Date().toISOString() }), 0o600);
|
|
138
|
+
publishFleetResourceCommit(this.operationPath(runId));
|
|
137
139
|
}
|
|
138
140
|
/** Sanitized read model: no Agent Template snapshots, owner fingerprints or session internals. */
|
|
139
141
|
view(task) {
|
|
@@ -184,12 +184,14 @@ export declare class TaskRoomApplicationService {
|
|
|
184
184
|
state?: TaskState | TaskState[];
|
|
185
185
|
list?: string;
|
|
186
186
|
includeDeleting?: boolean;
|
|
187
|
+
strict?: boolean;
|
|
187
188
|
}): TaskRecord[];
|
|
188
189
|
listTaskLists(): TaskListRecord[];
|
|
189
190
|
groupedTasks(filter?: {
|
|
190
191
|
state?: TaskState | TaskState[];
|
|
191
192
|
list?: string;
|
|
192
193
|
includeDeleting?: boolean;
|
|
194
|
+
strict?: boolean;
|
|
193
195
|
}): Array<{
|
|
194
196
|
list: TaskListRecord;
|
|
195
197
|
tasks: TaskRecord[];
|
|
@@ -136,7 +136,7 @@ export class TaskRoomApplicationService {
|
|
|
136
136
|
return { ...task, layout_rooms: (view?.rooms ?? []).map(({ key, state, room_id, identity_cid }) => ({ key, state, ...(room_id ? { room_id } : {}), ...(identity_cid ? { identity_cid } : {}) })) };
|
|
137
137
|
}
|
|
138
138
|
catch {
|
|
139
|
-
return
|
|
139
|
+
return task; /* An unreadable run cannot prove room absence. */
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
/** Backlog only: choose, change or clear the task's Room Layout before it starts. */
|
|
@@ -302,7 +302,7 @@ export class TaskRoomApplicationService {
|
|
|
302
302
|
}
|
|
303
303
|
listTasks(filter) {
|
|
304
304
|
const listId = filter?.list === undefined ? undefined : resolveTaskList(filter.list).list_id;
|
|
305
|
-
return readTasks({ state: filter?.state, listId, includeDeleting: filter?.includeDeleting });
|
|
305
|
+
return readTasks({ state: filter?.state, listId, includeDeleting: filter?.includeDeleting, strict: filter?.strict });
|
|
306
306
|
}
|
|
307
307
|
listTaskLists() { return readTaskLists(); }
|
|
308
308
|
groupedTasks(filter) {
|
package/dist/build-info.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "1.2.0-nightly.
|
|
3
|
-
"buildId": "
|
|
4
|
-
"commit": "
|
|
2
|
+
"version": "1.2.0-nightly.72",
|
|
3
|
+
"buildId": "b6b76cb3faf6",
|
|
4
|
+
"commit": "912fae4f35543806656b5f0488fce830330681d5",
|
|
5
5
|
"dirty": true,
|
|
6
|
-
"builtAt": "2026-10-
|
|
6
|
+
"builtAt": "2026-10-18T12:25:33.807Z",
|
|
7
7
|
"capabilities": [
|
|
8
8
|
"service.refresh-v1",
|
|
9
9
|
"managed-cli.codex-reasoning-effort-v1",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type AttachOursClientOptions, type OursClient } from '@ours.network/sdk/client';
|
|
2
|
-
import { type FetchLike } from './monitor.js';
|
|
2
|
+
import { type FetchLike, type IdentityPresence } from './monitor.js';
|
|
3
3
|
export declare const DAEMON_RECOVERY_MAX_ATTEMPTS = 6;
|
|
4
4
|
export declare const DAEMON_RECOVERY_INITIAL_BACKOFF_MS = 1000;
|
|
5
5
|
export declare const DAEMON_RECOVERY_MAX_BACKOFF_MS = 5000;
|
|
@@ -30,12 +30,24 @@ export type DaemonGenerationObservation = {
|
|
|
30
30
|
reason: string;
|
|
31
31
|
};
|
|
32
32
|
interface GenerationProbeDeps {
|
|
33
|
+
requestSignal?: AbortSignal;
|
|
33
34
|
readText?(path: string): string;
|
|
34
35
|
canonicalize?(path: string): string;
|
|
35
36
|
attachClient?(options: AttachOursClientOptions): Pick<OursClient, 'version' | 'identities' | 'close'> | Promise<Pick<OursClient, 'version' | 'identities' | 'close'>>;
|
|
36
37
|
}
|
|
37
38
|
/** Read generation and readiness through the selected gateway. Server paths are opaque. */
|
|
38
39
|
export declare function probeDaemonGeneration(fetch: FetchLike, env: NodeJS.ProcessEnv, deps?: GenerationProbeDeps): Promise<DaemonGenerationProbe>;
|
|
40
|
+
export interface SelectedDaemonHealthSnapshot {
|
|
41
|
+
generation: DaemonGenerationProbe;
|
|
42
|
+
identities?: unknown[];
|
|
43
|
+
}
|
|
44
|
+
export interface SelectedDaemonHealthLease {
|
|
45
|
+
sample(): Promise<SelectedDaemonHealthSnapshot>;
|
|
46
|
+
presence(name: string, snapshot: SelectedDaemonHealthSnapshot): IdentityPresence;
|
|
47
|
+
release(): void;
|
|
48
|
+
}
|
|
49
|
+
/** Retained runtime health, independent of browser delivery and stream readiness. */
|
|
50
|
+
export declare function acquireSelectedDaemonHealth(env: NodeJS.ProcessEnv, deps?: GenerationProbeDeps): SelectedDaemonHealthLease;
|
|
39
51
|
export declare class DaemonGenerationObserver {
|
|
40
52
|
private current?;
|
|
41
53
|
private unavailable;
|