@north-light/api 0.3.561 → 0.3.562
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/api/client.d.ts +27 -4
- package/dist/api/client.js +53 -0
- package/dist/api/contract.d.ts +1 -1
- package/dist/api/contract.js +1 -1
- package/dist/api/dto/bash-jobs.d.ts +5 -1
- package/dist/api/dto/broker-ops.d.ts +25 -1
- package/dist/api/dto/charges.d.ts +9 -2
- package/dist/api/dto/delivery.d.ts +26 -2
- package/dist/api/dto/execution-start.d.ts +57 -0
- package/dist/api/dto/execution-start.js +2 -0
- package/dist/api/dto/modelauth.d.ts +5 -0
- package/dist/api/dto/node-records.d.ts +16 -0
- package/dist/api/dto/objects.d.ts +1 -1
- package/dist/api/dto/reports.d.ts +0 -3
- package/dist/api/dto/run-events.d.ts +7 -1
- package/dist/api/dto/settings.d.ts +55 -0
- package/dist/api/dto/settings.js +4 -0
- package/dist/api/index.d.ts +2 -0
- package/dist/api/index.js +2 -0
- package/dist/api/routes.d.ts +11 -0
- package/dist/api/routes.js +12 -0
- package/dist/core/canvas/paths.d.ts +0 -1
- package/dist/core/canvas/paths.js +0 -3
- package/dist/core/canvas/person-state.d.ts +26 -0
- package/dist/core/canvas/person-state.js +41 -0
- package/dist/core/command-plugins/bundle.d.ts +1 -1
- package/dist/core/command-plugins/bundle.js +5 -5
- package/dist/core/config.d.ts +16 -15
- package/dist/core/config.js +99 -76
- package/dist/core/conversation-store/client.d.ts +2 -0
- package/dist/core/conversation-store/client.js +111 -0
- package/dist/core/conversation-store/index.d.ts +96 -0
- package/dist/core/conversation-store/index.js +62 -0
- package/dist/core/conversation-store/patch.d.ts +3 -0
- package/dist/core/conversation-store/patch.js +192 -0
- package/dist/core/conversation-store/repair.d.ts +45 -0
- package/dist/core/conversation-store/repair.js +84 -0
- package/dist/core/env-name.d.ts +1 -0
- package/dist/core/env-name.js +16 -0
- package/dist/core/events/emit.js +7 -5
- package/dist/core/git.d.ts +28 -0
- package/dist/core/git.js +103 -0
- package/dist/core/installed-plugins.js +12 -7
- package/dist/core/managed-providers.d.ts +72 -0
- package/dist/core/managed-providers.js +61 -0
- package/dist/core/plugin-env.js +1 -2
- package/dist/core/profiles/manifest.d.ts +14 -18
- package/dist/core/profiles/manifest.js +79 -98
- package/dist/core/runtime/broker/attachments-fetch.d.ts +5 -0
- package/dist/core/runtime/broker/attachments-fetch.js +20 -0
- package/dist/core/runtime/broker/daemon-ops.d.ts +10 -1
- package/dist/core/runtime/broker/daemon-ops.js +41 -3
- package/dist/core/runtime/broker/node-token.d.ts +3 -0
- package/dist/core/runtime/broker/node-token.js +16 -0
- package/dist/core/runtime/broker/signal-stream.js +2 -2
- package/dist/core/scoped-state/materialized-plugins.d.ts +18 -0
- package/dist/core/scoped-state/materialized-plugins.js +103 -0
- package/dist/core/scoped-state/person-store.d.ts +72 -0
- package/dist/core/scoped-state/person-store.js +112 -0
- package/dist/core/user-settings.js +1 -1
- package/dist/daemon/broker-signals/bus.d.ts +48 -11
- package/dist/daemon/broker-signals/bus.js +116 -46
- package/dist/shared/inbox-entry-body.js +2 -2
- package/dist/types.d.ts +12 -0
- package/package.json +1 -1
- package/dist/core/canvas/legacy-person.d.ts +0 -4
- package/dist/core/canvas/legacy-person.js +0 -4
- package/dist/core/locked-file.d.ts +0 -27
- package/dist/core/locked-file.js +0 -121
- package/dist/core/profiles/env-store.d.ts +0 -22
- package/dist/core/profiles/env-store.js +0 -166
- package/dist/core/scoped-state/db.d.ts +0 -17
- package/dist/core/scoped-state/db.js +0 -261
- package/dist/core/scoped-state/migrate.d.ts +0 -8
- package/dist/core/scoped-state/migrate.js +0 -187
- package/dist/core/scoped-state/profiles.d.ts +0 -27
- package/dist/core/scoped-state/profiles.js +0 -94
- package/dist/core/scoped-state/providers.d.ts +0 -24
- package/dist/core/scoped-state/providers.js +0 -19
- package/dist/core/scoped-state/schema.d.ts +0 -10
- package/dist/core/scoped-state/schema.js +0 -47
- package/dist/core/scoped-state/settings.d.ts +0 -28
- package/dist/core/scoped-state/settings.js +0 -104
- package/dist/core/sqlite-statements.d.ts +0 -4
- package/dist/core/sqlite-statements.js +0 -17
- package/dist/core/subscription-state.d.ts +0 -129
- package/dist/core/subscription-state.js +0 -299
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { MessageChannel, Worker, receiveMessageOnPort } from 'node:worker_threads';
|
|
2
|
+
import { ConversationNotFoundError } from './index.js';
|
|
3
|
+
export function createStoreClient(address, token) {
|
|
4
|
+
const worker = new Worker(new URL(import.meta.url.endsWith('.ts') ? './client-worker.ts' : './client-worker.js', import.meta.url), { workerData: { address, token } });
|
|
5
|
+
worker.unref();
|
|
6
|
+
let workerFailure;
|
|
7
|
+
worker.on('error', (error) => { workerFailure = error; });
|
|
8
|
+
worker.on('exit', (code) => { workerFailure = new Error(`conversation store worker exited with ${code}`); });
|
|
9
|
+
function settle(method, path, reply) {
|
|
10
|
+
if (!reply)
|
|
11
|
+
throw new Error('conversation store worker returned no reply');
|
|
12
|
+
if (reply.error)
|
|
13
|
+
throw new Error(reply.error);
|
|
14
|
+
if (reply.status === 404)
|
|
15
|
+
throw new ConversationNotFoundError(path.split('/')[2] ?? path);
|
|
16
|
+
if (!reply.status || reply.status >= 400)
|
|
17
|
+
throw new Error(`conversation store ${method} ${path} failed (${reply.status}): ${reply.body}`);
|
|
18
|
+
return { body: reply.body ?? '', ...(reply.path ? { path: reply.path } : {}), ...(reply.tail ? { tail: Number(reply.tail) } : {}) };
|
|
19
|
+
}
|
|
20
|
+
function call(method, path, data) {
|
|
21
|
+
if (workerFailure)
|
|
22
|
+
throw workerFailure;
|
|
23
|
+
const signal = new SharedArrayBuffer(4);
|
|
24
|
+
const { port1, port2 } = new MessageChannel();
|
|
25
|
+
try {
|
|
26
|
+
worker.postMessage({ method, path, data, signal, reply: port2 }, [port2]);
|
|
27
|
+
const result = Atomics.wait(new Int32Array(signal), 0, 0, 30_000);
|
|
28
|
+
if (result === 'timed-out') {
|
|
29
|
+
port1.postMessage('cancel');
|
|
30
|
+
throw new Error(`conversation store request timed out: ${method} ${path}`);
|
|
31
|
+
}
|
|
32
|
+
return settle(method, path, receiveMessageOnPort(port1)?.message);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
port1.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** The same request without parking the calling thread: an interactive surface keeps
|
|
39
|
+
* handling input while the worker waits on the store. */
|
|
40
|
+
function callAsync(method, path) {
|
|
41
|
+
if (workerFailure)
|
|
42
|
+
return Promise.reject(workerFailure);
|
|
43
|
+
const { port1, port2 } = new MessageChannel();
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const timer = setTimeout(() => { port1.postMessage('cancel'); port1.close(); reject(new Error(`conversation store request timed out: ${method} ${path}`)); }, 30_000);
|
|
46
|
+
timer.unref();
|
|
47
|
+
port1.once('message', (reply) => {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
port1.close();
|
|
50
|
+
try {
|
|
51
|
+
resolve(settle(method, path, reply));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
reject(error);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
worker.postMessage({ method, path, reply: port2 }, [port2]);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const url = (id) => `/conversations/${encodeURIComponent(id)}`;
|
|
61
|
+
function enqueueAppend(id, entries) {
|
|
62
|
+
if (workerFailure)
|
|
63
|
+
throw workerFailure;
|
|
64
|
+
worker.postMessage({ kind: 'append', method: 'POST', path: `${url(id)}/entries`, data: { entries } });
|
|
65
|
+
}
|
|
66
|
+
function flush() {
|
|
67
|
+
if (workerFailure)
|
|
68
|
+
return Promise.reject(workerFailure);
|
|
69
|
+
const { port1, port2 } = new MessageChannel();
|
|
70
|
+
return new Promise((resolve, reject) => {
|
|
71
|
+
port1.once('message', (reply) => {
|
|
72
|
+
port1.close();
|
|
73
|
+
try {
|
|
74
|
+
settle('POST', '/commit', reply);
|
|
75
|
+
resolve();
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
reject(error);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
worker.postMessage({ kind: 'flush', method: 'POST', path: '/commit', reply: port2 }, [port2]);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const parseRead = (reply) => {
|
|
85
|
+
const [header, ...rest] = reply.body.trimEnd().split('\n').map((line) => JSON.parse(line));
|
|
86
|
+
const entries = rest;
|
|
87
|
+
if (reply.tail === undefined)
|
|
88
|
+
return { header: header, entries, path: reply.path ?? '' };
|
|
89
|
+
return { header: header, entries: entries.slice(0, reply.tail - 1), tail: entries.slice(reply.tail - 1), path: reply.path ?? '' };
|
|
90
|
+
};
|
|
91
|
+
const readPath = (id, window) => window ? `${url(id)}?head=${window.head}&tail=${window.tail}` : url(id);
|
|
92
|
+
return {
|
|
93
|
+
create(nodeId, header, entries, path) {
|
|
94
|
+
return JSON.parse(call('POST', '/conversations', { node_id: nodeId, header, entries, path }).body).conversation_id;
|
|
95
|
+
},
|
|
96
|
+
append(id, entries) { enqueueAppend(id, entries); },
|
|
97
|
+
flush,
|
|
98
|
+
replace(id, header, entries) { call('PUT', url(id), { header, entries }); },
|
|
99
|
+
read(id, window) { return parseRead(call('GET', readPath(id, window))); },
|
|
100
|
+
async readAsync(id, window) { return parseRead(await callAsync('GET', readPath(id, window))); },
|
|
101
|
+
header(id) { return JSON.parse(call('GET', `${url(id)}/header`).body); },
|
|
102
|
+
fork(id, nodeId, leafId, cwd) {
|
|
103
|
+
return JSON.parse(call('POST', `${url(id)}/fork`, { node_id: nodeId, leaf_id: leafId, cwd }).body).conversation_id;
|
|
104
|
+
},
|
|
105
|
+
list(nodeId) {
|
|
106
|
+
const query = nodeId === undefined ? '' : `?node_id=${encodeURIComponent(nodeId)}`;
|
|
107
|
+
return JSON.parse(call('GET', `/conversations${query}`).body).conversations;
|
|
108
|
+
},
|
|
109
|
+
delete(id) { call('DELETE', url(id)); },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { SessionEntry, SessionHeader } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
export type { SessionEntry, SessionHeader };
|
|
3
|
+
export declare class ConversationNotFoundError extends Error {
|
|
4
|
+
readonly code = "not_found";
|
|
5
|
+
constructor(id: string);
|
|
6
|
+
}
|
|
7
|
+
export declare function isConversationNotFound(error: unknown): boolean;
|
|
8
|
+
export interface ConversationInfo {
|
|
9
|
+
conversation_id: string;
|
|
10
|
+
node_id?: string;
|
|
11
|
+
header: SessionHeader;
|
|
12
|
+
mtime: number;
|
|
13
|
+
size: number;
|
|
14
|
+
path: string;
|
|
15
|
+
}
|
|
16
|
+
/** Byte budgets for a bounded read of a conversation's two ends. */
|
|
17
|
+
export interface ConversationWindow {
|
|
18
|
+
head: number;
|
|
19
|
+
tail: number;
|
|
20
|
+
}
|
|
21
|
+
/** The synchronous shape pi's `SessionManager` statics need (`patch.ts`). The broker's client
|
|
22
|
+
* (`client.ts`: an `Atomics.wait` worker, no db) implements it. It is declared apart from
|
|
23
|
+
* `ConversationStore` so a change to the daemon's store never changes pi's contract. */
|
|
24
|
+
export interface ConversationStoreClient {
|
|
25
|
+
create(nodeId: string, header: SessionHeader, entries: SessionEntry[], path?: string): string;
|
|
26
|
+
/** Enqueue entries in order without blocking the broker's event loop. */
|
|
27
|
+
append(id: string, entries: SessionEntry[]): void;
|
|
28
|
+
/** Wait for pending appends and seal each dirty conversation. */
|
|
29
|
+
flush(): Promise<void>;
|
|
30
|
+
replace(id: string, header: SessionHeader, entries: SessionEntry[]): void;
|
|
31
|
+
/** Given a window and a file larger than head + tail bytes, `entries` holds the entries wholly
|
|
32
|
+
* inside the first `head` bytes and `tail` those wholly inside the last `tail` bytes.
|
|
33
|
+
* Otherwise `entries` is the whole conversation and `tail` is absent. */
|
|
34
|
+
read(id: string, window?: ConversationWindow): {
|
|
35
|
+
header: SessionHeader;
|
|
36
|
+
entries: SessionEntry[];
|
|
37
|
+
tail?: SessionEntry[];
|
|
38
|
+
path: string;
|
|
39
|
+
};
|
|
40
|
+
/** `read` without blocking the calling thread. Stores that read in-process may omit it;
|
|
41
|
+
* use `readConversationAsync`, which falls back to `read`. */
|
|
42
|
+
readAsync?(id: string, window?: ConversationWindow): Promise<ReturnType<ConversationStoreClient['read']>>;
|
|
43
|
+
header(id: string): {
|
|
44
|
+
header: SessionHeader;
|
|
45
|
+
mtime: number;
|
|
46
|
+
size: number;
|
|
47
|
+
path: string;
|
|
48
|
+
};
|
|
49
|
+
fork(id: string, nodeId?: string, leafId?: string, cwd?: string): string;
|
|
50
|
+
list(nodeId?: string): ConversationInfo[];
|
|
51
|
+
delete(id: string): void;
|
|
52
|
+
}
|
|
53
|
+
/** The daemon's file store (`createFileStore`, installed by `sessions.ts`). */
|
|
54
|
+
export interface ConversationStore {
|
|
55
|
+
create(nodeId: string, header: SessionHeader, entries: SessionEntry[], path?: string): Promise<string>;
|
|
56
|
+
append(id: string, entries: SessionEntry[]): Promise<void>;
|
|
57
|
+
replace(id: string, header: SessionHeader, entries: SessionEntry[]): Promise<void>;
|
|
58
|
+
read(id: string, window?: ConversationWindow): Promise<{
|
|
59
|
+
header: SessionHeader;
|
|
60
|
+
entries: SessionEntry[];
|
|
61
|
+
tail?: SessionEntry[];
|
|
62
|
+
path: string;
|
|
63
|
+
}>;
|
|
64
|
+
/** Stream a complete conversation without blocking the daemon on a full-file read. */
|
|
65
|
+
readLines?(id: string): Promise<{
|
|
66
|
+
path: string;
|
|
67
|
+
lines: AsyncIterable<string>;
|
|
68
|
+
}>;
|
|
69
|
+
header(id: string): Promise<{
|
|
70
|
+
header: SessionHeader;
|
|
71
|
+
mtime: number;
|
|
72
|
+
size: number;
|
|
73
|
+
path: string;
|
|
74
|
+
}>;
|
|
75
|
+
fork(id: string, nodeId?: string, leafId?: string, cwd?: string): Promise<string>;
|
|
76
|
+
list(nodeId?: string): Promise<ConversationInfo[]>;
|
|
77
|
+
/** `path` names the transcript when its sessions row is already gone (the in-process store only;
|
|
78
|
+
* the socket client resolves from the row and ignores it). A file already missing there is not an error. */
|
|
79
|
+
delete(id: string, path?: string): Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
export declare function conversationIdFromPath(path: string): string | null;
|
|
82
|
+
export declare function defaultConversationRoot(): string;
|
|
83
|
+
/** Where the client reaches the daemon's /v1 API: `CRTR_API_URL` (an http(s) URL) or the API socket path. */
|
|
84
|
+
export declare function conversationApiAddress(): string;
|
|
85
|
+
export declare function installConversationStore(store: ConversationStore | undefined): void;
|
|
86
|
+
/** The installed store, else the client over the store socket with each call awaited. */
|
|
87
|
+
export declare function conversationStore(): ConversationStore;
|
|
88
|
+
/** The synchronous store pi's `SessionManager` statics call: always the client over the store socket (the installed
|
|
89
|
+
* store is async, so it cannot serve a synchronous caller). */
|
|
90
|
+
export declare function conversationClient(): ConversationStoreClient;
|
|
91
|
+
/** The broker settles mail only after its queued entries and part object are durable. */
|
|
92
|
+
export declare function flushConversation(): Promise<void>;
|
|
93
|
+
/** Read a conversation without parking the event loop on the store worker. */
|
|
94
|
+
export declare function readConversationAsync(id: string, window?: ConversationWindow): Promise<Awaited<ReturnType<ConversationStore['read']>>>;
|
|
95
|
+
export { installSessionManagerStorePatch } from './patch.js';
|
|
96
|
+
export { findOrphanedParents, relinkOrphanedParents, repairConversationFile, type FileRepair, type OrphanRelink } from './repair.js';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { appsRoot } from '../layout.js';
|
|
2
|
+
import { apiSocketPath } from '../canvas/paths.js';
|
|
3
|
+
import { createStoreClient } from './client.js';
|
|
4
|
+
import { currentNodeToken } from '../runtime/broker/node-token.js';
|
|
5
|
+
export class ConversationNotFoundError extends Error {
|
|
6
|
+
code = 'not_found';
|
|
7
|
+
constructor(id) { super(`conversation not found: ${id}`); }
|
|
8
|
+
}
|
|
9
|
+
export function isConversationNotFound(error) {
|
|
10
|
+
return error instanceof ConversationNotFoundError;
|
|
11
|
+
}
|
|
12
|
+
export function conversationIdFromPath(path) {
|
|
13
|
+
const match = /(?:^|\/)[^/]+_([0-9a-f-]{36})\.jsonl$/i.exec(path);
|
|
14
|
+
return match?.[1] ?? null;
|
|
15
|
+
}
|
|
16
|
+
export function defaultConversationRoot() { return appsRoot(); }
|
|
17
|
+
/** Where the client reaches the daemon's /v1 API: `CRTR_API_URL` (an http(s) URL) or the API socket path. */
|
|
18
|
+
export function conversationApiAddress() {
|
|
19
|
+
return process.env['CRTR_API_URL'] || apiSocketPath();
|
|
20
|
+
}
|
|
21
|
+
let local;
|
|
22
|
+
let remote;
|
|
23
|
+
export function installConversationStore(store) { local = store; }
|
|
24
|
+
/** The installed store, else the client over the store socket with each call awaited. */
|
|
25
|
+
export function conversationStore() {
|
|
26
|
+
if (local)
|
|
27
|
+
return local;
|
|
28
|
+
const client = conversationClient();
|
|
29
|
+
return {
|
|
30
|
+
create: async (...args) => client.create(...args),
|
|
31
|
+
append: async (id, entries) => client.append(id, entries),
|
|
32
|
+
replace: async (...args) => client.replace(...args),
|
|
33
|
+
read: async (...args) => client.read(...args),
|
|
34
|
+
header: async (...args) => client.header(...args),
|
|
35
|
+
fork: async (...args) => client.fork(...args),
|
|
36
|
+
list: async (...args) => client.list(...args),
|
|
37
|
+
delete: async (id) => client.delete(id),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** The synchronous store pi's `SessionManager` statics call: always the client over the store socket (the installed
|
|
41
|
+
* store is async, so it cannot serve a synchronous caller). */
|
|
42
|
+
export function conversationClient() {
|
|
43
|
+
const address = conversationApiAddress();
|
|
44
|
+
if (remote?.address !== address)
|
|
45
|
+
remote = { address, store: createStoreClient(address, currentNodeToken()) };
|
|
46
|
+
return remote.store;
|
|
47
|
+
}
|
|
48
|
+
/** The broker settles mail only after its queued entries and part object are durable. */
|
|
49
|
+
export async function flushConversation() {
|
|
50
|
+
if (local)
|
|
51
|
+
return;
|
|
52
|
+
await conversationClient().flush();
|
|
53
|
+
}
|
|
54
|
+
/** Read a conversation without parking the event loop on the store worker. */
|
|
55
|
+
export async function readConversationAsync(id, window) {
|
|
56
|
+
if (local)
|
|
57
|
+
return local.read(id, window);
|
|
58
|
+
const store = conversationClient();
|
|
59
|
+
return store.readAsync ? store.readAsync(id, window) : store.read(id, window);
|
|
60
|
+
}
|
|
61
|
+
export { installSessionManagerStorePatch } from './patch.js';
|
|
62
|
+
export { findOrphanedParents, relinkOrphanedParents, repairConversationFile } from './repair.js';
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { type ConversationStoreClient } from './index.js';
|
|
2
|
+
/** `store` is the synchronous client pi's `SessionManager` needs: the broker's client over the store socket unless a caller supplies one. */
|
|
3
|
+
export declare function installSessionManagerStorePatch(nodeId: string, store?: ConversationStoreClient): void;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { dirname, resolve } from 'node:path';
|
|
2
|
+
import { SessionManager } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { conversationClient, conversationIdFromPath } from './index.js';
|
|
4
|
+
import { findOrphanedParents } from './repair.js';
|
|
5
|
+
import { emitEvent } from '../events/emit.js';
|
|
6
|
+
let installed = false;
|
|
7
|
+
/** `store` is the synchronous client pi's `SessionManager` needs: the broker's client over the store socket unless a caller supplies one. */
|
|
8
|
+
export function installSessionManagerStorePatch(nodeId, store = conversationClient()) {
|
|
9
|
+
if (installed)
|
|
10
|
+
return;
|
|
11
|
+
const prototype = SessionManager.prototype;
|
|
12
|
+
const statics = SessionManager;
|
|
13
|
+
for (const name of ['_setSessionFile', '_rewriteFile', '_persist', 'createBranchedSession', 'newSession']) {
|
|
14
|
+
if (typeof prototype[name] !== 'function')
|
|
15
|
+
throw new Error(`pi SessionManager.${name} changed; store patch cannot start`);
|
|
16
|
+
}
|
|
17
|
+
for (const name of ['create', 'open', 'continueRecent', 'forkFrom', 'findById', 'list', 'listAll']) {
|
|
18
|
+
if (typeof statics[name] !== 'function')
|
|
19
|
+
throw new Error(`pi SessionManager.${name} changed; store patch cannot start`);
|
|
20
|
+
}
|
|
21
|
+
const originalBranch = prototype.createBranchedSession;
|
|
22
|
+
const originalNew = prototype.newSession;
|
|
23
|
+
/** A reload that finds a parent missing from the file would otherwise truncate the branch silently. */
|
|
24
|
+
function reportOrphans(id, entries) {
|
|
25
|
+
const orphans = findOrphanedParents(entries);
|
|
26
|
+
if (orphans.length === 0)
|
|
27
|
+
return;
|
|
28
|
+
emitEvent({
|
|
29
|
+
level: 'error',
|
|
30
|
+
event: 'conversation.orphaned_parent',
|
|
31
|
+
fields: { conversation_id: id, orphan_count: orphans.length, orphans: orphans.slice(0, 20).map((o) => ({ entry_id: o.entryId, missing_parent_id: o.missingParentId })) },
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function fromStore(id, cwdOverride) {
|
|
35
|
+
const { header, entries, path } = store.read(id);
|
|
36
|
+
reportOrphans(header.id, entries);
|
|
37
|
+
const manager = SessionManager.inMemory(cwdOverride ?? header.cwd);
|
|
38
|
+
manager.sessionFile = path;
|
|
39
|
+
manager.sessionDir = dirname(path);
|
|
40
|
+
manager._loadEntries([header, ...entries]);
|
|
41
|
+
manager.persist = true;
|
|
42
|
+
manager.flushed = true;
|
|
43
|
+
const loaded = manager.fileEntries[0];
|
|
44
|
+
if (loaded?.type === 'session' && loaded.version !== header.version)
|
|
45
|
+
manager._rewriteFile();
|
|
46
|
+
return manager;
|
|
47
|
+
}
|
|
48
|
+
function persistNew(manager) {
|
|
49
|
+
const [header, ...entries] = manager.fileEntries;
|
|
50
|
+
if (!header || header.type !== 'session')
|
|
51
|
+
throw new Error('conversation has no session header');
|
|
52
|
+
store.create(nodeId, header, entries);
|
|
53
|
+
manager.sessionFile = store.header(header.id).path;
|
|
54
|
+
manager.sessionDir = dirname(manager.sessionFile);
|
|
55
|
+
manager.flushed = true;
|
|
56
|
+
}
|
|
57
|
+
function appendInOrder(manager, entry) {
|
|
58
|
+
store.append(manager.sessionId, [entry]);
|
|
59
|
+
}
|
|
60
|
+
function idOf(path) {
|
|
61
|
+
const id = conversationIdFromPath(path);
|
|
62
|
+
if (!id)
|
|
63
|
+
throw new Error(`invalid conversation path: ${path}`);
|
|
64
|
+
return id;
|
|
65
|
+
}
|
|
66
|
+
prototype._setSessionFile = function (path) {
|
|
67
|
+
const { header, entries } = store.read(idOf(path));
|
|
68
|
+
this.sessionFile = store.header(header.id).path;
|
|
69
|
+
this.sessionDir = dirname(this.sessionFile);
|
|
70
|
+
reportOrphans(header.id, entries);
|
|
71
|
+
this._loadEntries([header, ...entries]);
|
|
72
|
+
this.flushed = true;
|
|
73
|
+
};
|
|
74
|
+
prototype._rewriteFile = function () {
|
|
75
|
+
if (!this.persist || !this.sessionFile)
|
|
76
|
+
return;
|
|
77
|
+
const [header, ...entries] = this.fileEntries;
|
|
78
|
+
if (!header || header.type !== 'session')
|
|
79
|
+
throw new Error('conversation has no session header');
|
|
80
|
+
store.replace(header.id, header, entries);
|
|
81
|
+
};
|
|
82
|
+
prototype._persist = function (entry) {
|
|
83
|
+
if (!this.persist || !this.sessionFile)
|
|
84
|
+
return;
|
|
85
|
+
if (!this.flushed)
|
|
86
|
+
persistNew(this);
|
|
87
|
+
else
|
|
88
|
+
appendInOrder(this, entry);
|
|
89
|
+
};
|
|
90
|
+
// Every persisted session exists in the store from creation. pi's runtime creates a
|
|
91
|
+
// session and immediately starts another (`newSession({ parentSession })` for /new and
|
|
92
|
+
// root fork), so a still-empty conversation takes the new header under its own id
|
|
93
|
+
// rather than being left behind; a session with entries stays and a new one is created.
|
|
94
|
+
prototype.newSession = function (options) {
|
|
95
|
+
if (!this.persist)
|
|
96
|
+
return originalNew.call(this, options);
|
|
97
|
+
const empty = this.flushed && this.fileEntries.length === 1 && this.sessionFile !== undefined ? this.sessionId : undefined;
|
|
98
|
+
if (empty !== undefined && (options?.id === undefined || options.id === empty)) {
|
|
99
|
+
const path = this.sessionFile;
|
|
100
|
+
originalNew.call(this, { ...options, id: empty });
|
|
101
|
+
const header = this.fileEntries[0];
|
|
102
|
+
if (!header || header.type !== 'session')
|
|
103
|
+
throw new Error('conversation has no session header');
|
|
104
|
+
store.replace(empty, header, []);
|
|
105
|
+
this.sessionFile = path;
|
|
106
|
+
this.flushed = true;
|
|
107
|
+
return path;
|
|
108
|
+
}
|
|
109
|
+
originalNew.call(this, options);
|
|
110
|
+
persistNew(this);
|
|
111
|
+
return this.sessionFile;
|
|
112
|
+
};
|
|
113
|
+
prototype.createBranchedSession = function (leafId) {
|
|
114
|
+
if (!this.persist)
|
|
115
|
+
return originalBranch.call(this, leafId);
|
|
116
|
+
const prior = this.sessionFile;
|
|
117
|
+
this.persist = false;
|
|
118
|
+
try {
|
|
119
|
+
originalBranch.call(this, leafId);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
this.persist = true;
|
|
123
|
+
}
|
|
124
|
+
const header = this.fileEntries[0];
|
|
125
|
+
if (!header || header.type !== 'session')
|
|
126
|
+
throw new Error('branch has no header');
|
|
127
|
+
this.fileEntries[0] = { ...header, parentSession: prior };
|
|
128
|
+
persistNew(this);
|
|
129
|
+
return this.sessionFile;
|
|
130
|
+
};
|
|
131
|
+
statics['create'] = (cwd, _sessionDir, options) => {
|
|
132
|
+
const manager = SessionManager.inMemory(cwd, options);
|
|
133
|
+
manager.persist = true;
|
|
134
|
+
persistNew(manager);
|
|
135
|
+
return manager;
|
|
136
|
+
};
|
|
137
|
+
statics['open'] = (path, _sessionDir, cwdOverride) => fromStore(idOf(path), cwdOverride);
|
|
138
|
+
statics['continueRecent'] = (cwd) => {
|
|
139
|
+
const latest = store.list().find((info) => info.header.cwd === resolve(cwd));
|
|
140
|
+
return latest ? fromStore(latest.conversation_id, cwd) : statics['create'](cwd);
|
|
141
|
+
};
|
|
142
|
+
statics['forkFrom'] = (path, targetCwd) => fromStore(store.fork(idOf(path), nodeId, undefined, targetCwd));
|
|
143
|
+
statics['findById'] = (cwd, id) => store.list().find((info) => info.conversation_id === id && info.header.cwd === resolve(cwd))?.path;
|
|
144
|
+
function sessionInfos(cwd) {
|
|
145
|
+
return store.list().filter((info) => cwd === undefined || info.header.cwd === resolve(cwd)).map((info) => {
|
|
146
|
+
let name;
|
|
147
|
+
let messageCount = 0;
|
|
148
|
+
let firstMessage = '';
|
|
149
|
+
let lastActivity;
|
|
150
|
+
const texts = [];
|
|
151
|
+
for (const entry of store.read(info.conversation_id).entries) {
|
|
152
|
+
if (entry.type === 'session_info')
|
|
153
|
+
name = entry.name?.trim() || undefined;
|
|
154
|
+
if (entry.type !== 'message')
|
|
155
|
+
continue;
|
|
156
|
+
messageCount++;
|
|
157
|
+
const message = entry.message;
|
|
158
|
+
if (message.role !== 'user' && message.role !== 'assistant')
|
|
159
|
+
continue;
|
|
160
|
+
const at = typeof message.timestamp === 'number' ? message.timestamp : Date.parse(entry.timestamp);
|
|
161
|
+
if (!Number.isNaN(at))
|
|
162
|
+
lastActivity = Math.max(lastActivity ?? 0, at);
|
|
163
|
+
const text = typeof message.content === 'string' ? message.content
|
|
164
|
+
: Array.isArray(message.content) ? message.content.filter((block) => block.type === 'text').map((block) => block.text ?? '').join(' ') : '';
|
|
165
|
+
if (!text)
|
|
166
|
+
continue;
|
|
167
|
+
texts.push(text);
|
|
168
|
+
if (!firstMessage && message.role === 'user')
|
|
169
|
+
firstMessage = text;
|
|
170
|
+
}
|
|
171
|
+
const created = new Date(info.header.timestamp);
|
|
172
|
+
return {
|
|
173
|
+
path: info.path, id: info.conversation_id, cwd: info.header.cwd, ...(name ? { name } : {}),
|
|
174
|
+
...(info.header.parentSession ? { parentSessionPath: info.header.parentSession } : {}),
|
|
175
|
+
created, modified: lastActivity && lastActivity > 0 ? new Date(lastActivity) : Number.isNaN(created.getTime()) ? new Date(info.mtime) : created,
|
|
176
|
+
messageCount, firstMessage: firstMessage || '(no messages)', allMessagesText: texts.join(' '),
|
|
177
|
+
};
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
statics['list'] = async (cwd, _sessionDir, onProgress, signal) => {
|
|
181
|
+
signal?.throwIfAborted();
|
|
182
|
+
const sessions = sessionInfos(cwd);
|
|
183
|
+
onProgress?.(sessions.length, sessions.length, sessions);
|
|
184
|
+
return sessions;
|
|
185
|
+
};
|
|
186
|
+
statics['listAll'] = async (...args) => {
|
|
187
|
+
const signal = args.find((arg) => arg instanceof AbortSignal);
|
|
188
|
+
signal?.throwIfAborted();
|
|
189
|
+
return sessionInfos();
|
|
190
|
+
};
|
|
191
|
+
installed = true;
|
|
192
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/** Pure helpers for conversations whose entries name a `parentId` that is not in the file.
|
|
2
|
+
* That shape comes from an append that failed after pi had already advanced its in-memory leaf:
|
|
3
|
+
* later entries were written chained onto the entry that never reached the file, and every
|
|
4
|
+
* reload then stops walking the parent chain at the gap. */
|
|
5
|
+
import type { SessionEntry, SessionHeader } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
export interface OrphanedParent {
|
|
7
|
+
/** Index into the entries array (header excluded) of the entry whose parent is missing. */
|
|
8
|
+
index: number;
|
|
9
|
+
entryId: string;
|
|
10
|
+
missingParentId: string;
|
|
11
|
+
}
|
|
12
|
+
/** Entries whose non-null `parentId` is absent from `entries`. */
|
|
13
|
+
export declare function findOrphanedParents(entries: readonly SessionEntry[]): OrphanedParent[];
|
|
14
|
+
export interface OrphanRelink extends OrphanedParent {
|
|
15
|
+
/** The entry it was relinked to: the one before it in file order, or null at the head. */
|
|
16
|
+
relinkedTo: string | null;
|
|
17
|
+
/** What the lost entry's absence leaves behind for the provider transcript. */
|
|
18
|
+
note?: string;
|
|
19
|
+
}
|
|
20
|
+
/** Relink each orphan onto the entry before it in file order — the leaf pi held when the lost
|
|
21
|
+
* entry was appended. A lost toolResult leaves a tool call without a result, which pi-ai's
|
|
22
|
+
* message transform answers with a synthetic "No result provided" result; a lost toolCall
|
|
23
|
+
* would leave a toolResult with no call, which the transform does not fix, so that case is
|
|
24
|
+
* reported in `note` for the caller to resolve. Returns new entry objects; the input is untouched. */
|
|
25
|
+
export declare function relinkOrphanedParents(entries: readonly SessionEntry[]): {
|
|
26
|
+
entries: SessionEntry[];
|
|
27
|
+
relinks: OrphanRelink[];
|
|
28
|
+
};
|
|
29
|
+
/** Serialize a conversation file: header first, one JSON line per entry. */
|
|
30
|
+
export declare function conversationLines(header: SessionHeader, entries: readonly SessionEntry[]): string;
|
|
31
|
+
export interface FileRepair {
|
|
32
|
+
path: string;
|
|
33
|
+
entries: number;
|
|
34
|
+
/** Branch length walked from the last entry, before and after the relink. */
|
|
35
|
+
branchBefore: number;
|
|
36
|
+
branchAfter: number;
|
|
37
|
+
relinks: OrphanRelink[];
|
|
38
|
+
backup?: string;
|
|
39
|
+
}
|
|
40
|
+
/** Relink one conversation file's orphaned entries in place, after copying it to `<path>.bak-orphans`.
|
|
41
|
+
* The caller must have stopped the owning node's broker: a running broker holds the conversation in
|
|
42
|
+
* memory and appends to the file. `write: false` reports what would change and touches nothing. */
|
|
43
|
+
export declare function repairConversationFile(path: string, options: {
|
|
44
|
+
write: boolean;
|
|
45
|
+
}): FileRepair;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
/** Entries whose non-null `parentId` is absent from `entries`. */
|
|
3
|
+
export function findOrphanedParents(entries) {
|
|
4
|
+
const ids = new Set(entries.map((entry) => entry.id));
|
|
5
|
+
const found = [];
|
|
6
|
+
entries.forEach((entry, index) => {
|
|
7
|
+
if (entry.parentId && !ids.has(entry.parentId))
|
|
8
|
+
found.push({ index, entryId: entry.id, missingParentId: entry.parentId });
|
|
9
|
+
});
|
|
10
|
+
return found;
|
|
11
|
+
}
|
|
12
|
+
function toolCallIds(entry) {
|
|
13
|
+
if (entry?.type !== 'message')
|
|
14
|
+
return [];
|
|
15
|
+
const message = entry.message;
|
|
16
|
+
if (message.role !== 'assistant' || !Array.isArray(message.content))
|
|
17
|
+
return [];
|
|
18
|
+
return message.content.filter((block) => block.type === 'toolCall' && block.id).map((block) => block.id);
|
|
19
|
+
}
|
|
20
|
+
/** Relink each orphan onto the entry before it in file order — the leaf pi held when the lost
|
|
21
|
+
* entry was appended. A lost toolResult leaves a tool call without a result, which pi-ai's
|
|
22
|
+
* message transform answers with a synthetic "No result provided" result; a lost toolCall
|
|
23
|
+
* would leave a toolResult with no call, which the transform does not fix, so that case is
|
|
24
|
+
* reported in `note` for the caller to resolve. Returns new entry objects; the input is untouched. */
|
|
25
|
+
export function relinkOrphanedParents(entries) {
|
|
26
|
+
const orphans = new Map(findOrphanedParents(entries).map((orphan) => [orphan.index, orphan]));
|
|
27
|
+
const relinks = [];
|
|
28
|
+
const next = entries.map((entry, index) => {
|
|
29
|
+
const orphan = orphans.get(index);
|
|
30
|
+
if (!orphan)
|
|
31
|
+
return entry;
|
|
32
|
+
const prior = entries[index - 1];
|
|
33
|
+
const relink = { ...orphan, relinkedTo: prior?.id ?? null };
|
|
34
|
+
if (entry.type === 'message' && entry.message.role === 'toolResult') {
|
|
35
|
+
const callId = entry.message.toolCallId;
|
|
36
|
+
if (!callId || !toolCallIds(prior).includes(callId))
|
|
37
|
+
relink.note = `toolResult ${callId ?? '?'} has no tool call on the entry before it`;
|
|
38
|
+
}
|
|
39
|
+
relinks.push(relink);
|
|
40
|
+
return { ...entry, parentId: prior?.id ?? null };
|
|
41
|
+
});
|
|
42
|
+
return { entries: next, relinks };
|
|
43
|
+
}
|
|
44
|
+
/** Serialize a conversation file: header first, one JSON line per entry. */
|
|
45
|
+
export function conversationLines(header, entries) {
|
|
46
|
+
return [header, ...entries].map((line) => `${JSON.stringify(line)}\n`).join('');
|
|
47
|
+
}
|
|
48
|
+
function branchLength(entries) {
|
|
49
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
50
|
+
let length = 0;
|
|
51
|
+
for (let current = entries.at(-1); current; current = current.parentId ? byId.get(current.parentId) : undefined)
|
|
52
|
+
length++;
|
|
53
|
+
return length;
|
|
54
|
+
}
|
|
55
|
+
/** Relink one conversation file's orphaned entries in place, after copying it to `<path>.bak-orphans`.
|
|
56
|
+
* The caller must have stopped the owning node's broker: a running broker holds the conversation in
|
|
57
|
+
* memory and appends to the file. `write: false` reports what would change and touches nothing. */
|
|
58
|
+
export function repairConversationFile(path, options) {
|
|
59
|
+
const lines = [];
|
|
60
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
61
|
+
if (!line.trim())
|
|
62
|
+
continue;
|
|
63
|
+
try {
|
|
64
|
+
lines.push(JSON.parse(line));
|
|
65
|
+
}
|
|
66
|
+
catch { /* an interrupted tail line */ }
|
|
67
|
+
}
|
|
68
|
+
const header = lines.shift();
|
|
69
|
+
if (header?.type !== 'session')
|
|
70
|
+
throw new Error(`no session header: ${path}`);
|
|
71
|
+
const entries = lines;
|
|
72
|
+
const { entries: relinked, relinks } = relinkOrphanedParents(entries);
|
|
73
|
+
const result = { path, entries: entries.length, branchBefore: branchLength(entries), branchAfter: branchLength(relinked), relinks };
|
|
74
|
+
if (relinks.length === 0 || !options.write)
|
|
75
|
+
return result;
|
|
76
|
+
const backup = `${path}.bak-orphans`;
|
|
77
|
+
if (existsSync(backup))
|
|
78
|
+
throw new Error(`backup already exists: ${backup}`);
|
|
79
|
+
copyFileSync(path, backup);
|
|
80
|
+
const temp = `${path}.${process.pid}.repair.tmp`;
|
|
81
|
+
writeFileSync(temp, conversationLines(header, relinked), { mode: 0o600 });
|
|
82
|
+
renameSync(temp, path);
|
|
83
|
+
return { ...result, backup };
|
|
84
|
+
}
|
package/dist/core/env-name.d.ts
CHANGED
package/dist/core/env-name.js
CHANGED
|
@@ -7,3 +7,19 @@ export function validateEnvVarName(name) {
|
|
|
7
7
|
errors.push('env var name must start with letter/underscore, followed by alphanumeric/underscore');
|
|
8
8
|
return { isValid: errors.length === 0, errors };
|
|
9
9
|
}
|
|
10
|
+
/** crtr's own identity/routing env names — reserved so a stored profile value
|
|
11
|
+
* can never redirect a broker to a different canvas home/db/socket dir or
|
|
12
|
+
* masquerade as its own process identity. `HOME` is reserved alongside the
|
|
13
|
+
* `CRTR_*` family because crtr has no independent authoritative scope root
|
|
14
|
+
* yet; every path in `core/canvas/paths.ts` ultimately derives from it.
|
|
15
|
+
* Enforced at write (`setProfileEnvVar` throws below) AND defensively at
|
|
16
|
+
* read (`readProfileEnvVars`), so even a reserved name that reached a row some
|
|
17
|
+
* other way is uniformly invisible rather than trusted.
|
|
18
|
+
*
|
|
19
|
+
* The deployment-wide feedback names are the only `CRTR_*` values stored
|
|
20
|
+
* anywhere; they live in their own store (`profiles/app-env-store.ts`). */
|
|
21
|
+
const RESERVED_ENV_PREFIX = 'CRTR_';
|
|
22
|
+
const RESERVED_ENV_EXACT_NAMES = new Set(['HOME']);
|
|
23
|
+
export function isReservedEnvName(name) {
|
|
24
|
+
return name.startsWith(RESERVED_ENV_PREFIX) || RESERVED_ENV_EXACT_NAMES.has(name);
|
|
25
|
+
}
|
package/dist/core/events/emit.js
CHANGED
|
@@ -2,11 +2,13 @@ import { appendBufferedDaemonRecord, appendRotatingRecord } from '../log.js';
|
|
|
2
2
|
import { buildEnvelope } from './envelope.js';
|
|
3
3
|
import { serializeEventRecord } from './serialize.js';
|
|
4
4
|
import { eventSource } from './source.js';
|
|
5
|
-
import {
|
|
5
|
+
import { isSandboxedNode } from '../../shared/env.js';
|
|
6
6
|
import { appendBrokerLog } from '../runtime/broker/daemon-ops.js';
|
|
7
|
-
// A
|
|
8
|
-
// the daemon in emit order.
|
|
9
|
-
//
|
|
7
|
+
// A broker confined by the hosted VMs' Linux launcher cannot open its node's records (spaces/mounts.ts refuses to
|
|
8
|
+
// mount them), so its events append through the daemon in emit order. Every other broker, a Mac's or a cloud
|
|
9
|
+
// sandbox's, appends its own log.jsonl on its own disk. A record the daemon did not append goes to stderr, which
|
|
10
|
+
// the daemon-opened broker.log fd captures. Cycle 6 deletes the launcher and with it this route.
|
|
11
|
+
let launcherConfined;
|
|
10
12
|
let brokerLogTail = Promise.resolve();
|
|
11
13
|
/** Resolves after every broker event emitted so far has been appended or written to stderr. */
|
|
12
14
|
export function flushBrokerLog() { return brokerLogTail; }
|
|
@@ -34,7 +36,7 @@ export function emitEvent(input) {
|
|
|
34
36
|
return false;
|
|
35
37
|
const envelope = buildEnvelope({ ...input, component: source.component, ...(source.node_id === undefined ? {} : { node_id: source.node_id }), ...(source.stream_id === undefined ? {} : { stream_id: source.stream_id }) });
|
|
36
38
|
const record = serializeEventRecord(envelope);
|
|
37
|
-
if (source.component === 'broker' && source.node_id !== undefined &&
|
|
39
|
+
if (source.component === 'broker' && source.node_id !== undefined && (launcherConfined ??= isSandboxedNode())) {
|
|
38
40
|
queueBrokerRecord(source.node_id, record);
|
|
39
41
|
return true;
|
|
40
42
|
}
|