@anhlp/alp-client 0.0.1-beta.3 → 0.0.1-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,179 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, open, rename, rmdir, unlink } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { assertIntegrationName, bootTime } from './index.js';
5
+
6
+ /**
7
+ * @typedef {{ pid: number, bootTime: number, startedAt: string, ready: boolean, lockVersion?: number, owner?: string }} IntegrationLockRecord
8
+ * @typedef {{
9
+ * acquire(): Promise<{ acquired: boolean, lock: IntegrationLockRecord }>,
10
+ * setReady(ready: boolean): Promise<boolean>,
11
+ * release(): Promise<boolean>,
12
+ * }} IntegrationLock
13
+ */
14
+
15
+ /**
16
+ * The single-owner lock of one integration service, at `<home>/integrations/<name>.lock`, with its
17
+ * mutation guard `<name>.lock.reclaim`. Each instance is single-use: acquire once, then setReady and
18
+ * release while it owns the lock.
19
+ *
20
+ * Single-host, same PID namespace, private local filesystem only. Never reap the mutation guard.
21
+ * @param {string} home the ALP_HOME the integration runs under
22
+ * @param {string} name the integration name, for example `web`
23
+ * @returns {IntegrationLock}
24
+ */
25
+ export function createIntegrationLock(home, name) {
26
+ const file = path.join(home, 'integrations', `${assertIntegrationName(name)}.lock`);
27
+ const guard = `${file}.reclaim`;
28
+ /** @type {'new' | 'acquiring' | 'owned' | 'retired'} */
29
+ let phase = 'new';
30
+ /** This instance's lock while it owns it: the lifetime nonce and the file's identity. @type {{ owner: string, dev: number, ino: number } | undefined} */
31
+ let owned;
32
+ // The lock file as read: its record and the stats of the handle it was read through. Kept inside
33
+ // the factory so the published declarations name only IntegrationLock and its record.
34
+ /** @typedef {{ value: any, info: import('node:fs').Stats }} Snapshot */
35
+ /** @param {string} reason */
36
+ const recovery = reason => new Error(`${reason}. Stop ALL integration processes using this ALP_HOME before manual lock-only recovery of ${file} and ${guard}; see docs/modular-monorepo.md`);
37
+
38
+ /**
39
+ * @template T
40
+ * @param {() => Promise<T>} run
41
+ * @returns {Promise<T>}
42
+ */
43
+ async function guarded(run) {
44
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
45
+ const deadline = Date.now() + 20_000;
46
+ for (;;) {
47
+ try { await mkdir(guard, { mode: 0o700 }); break; }
48
+ catch (error) {
49
+ if (/** @type {any} */ (error)?.code !== 'EEXIST') throw error;
50
+ if (Date.now() >= deadline) throw recovery('Integration mutation guard remains busy or abandoned');
51
+ await new Promise(resolve => setTimeout(resolve, 25));
52
+ }
53
+ }
54
+ const token = path.join(guard, `owner-${process.pid}-${randomUUID()}`);
55
+ // If token initialization/close fails, leave the guard: no speculative cleanup of shared paths.
56
+ try {
57
+ const handle = await open(token, 'wx', 0o600);
58
+ await handle.close();
59
+ } catch (error) { throw recovery(`Integration guard initialization failed: ${String(error)}`); }
60
+ try { return await run(); }
61
+ finally {
62
+ // Only this successful mkdir owner finalizes, once. Missing token may mean a replacement
63
+ // generation: never rmdir in that case. No contender removes any other owner's token.
64
+ try { await unlink(token); }
65
+ catch (error) { throw recovery(/** @type {any} */ (error)?.code === 'ENOENT' ? 'Integration guard ownership was lost' : `Integration guard token cleanup failed: ${String(error)}`); }
66
+ try { await rmdir(guard); }
67
+ catch (error) { throw recovery(`Integration guard cleanup failed: ${String(error)}`); }
68
+ }
69
+ }
70
+
71
+ /** @returns {Promise<Snapshot | undefined>} */
72
+ async function snapshot() {
73
+ let handle;
74
+ try { handle = await open(file, 'r'); }
75
+ catch (error) { if (/** @type {any} */ (error)?.code === 'ENOENT') return undefined; throw error; }
76
+ try {
77
+ const info = await handle.stat();
78
+ const raw = await handle.readFile('utf8');
79
+ let value;
80
+ try { value = JSON.parse(raw); } catch { value = undefined; }
81
+ return { value, info };
82
+ } finally { await handle.close(); }
83
+ }
84
+
85
+ /**
86
+ * @param {any} value
87
+ * @returns {value is IntegrationLockRecord}
88
+ */
89
+ function complete(value) {
90
+ return value && Number.isSafeInteger(value.pid) && value.pid > 0
91
+ && typeof value.bootTime === 'number' && Number.isFinite(value.bootTime) && value.bootTime > 0
92
+ && typeof value.startedAt === 'string' && Number.isFinite(Date.parse(value.startedAt))
93
+ && typeof value.ready === 'boolean'
94
+ && (value.lockVersion === undefined && value.owner === undefined
95
+ || value.lockVersion === 1 && typeof value.owner === 'string' && /^[a-f0-9-]{36}$/.test(value.owner));
96
+ }
97
+
98
+ /**
99
+ * @param {IntegrationLockRecord} value
100
+ * @returns {boolean}
101
+ */
102
+ function dead(value) {
103
+ try { process.kill(value.pid, 0); return false; }
104
+ catch (error) {
105
+ if (/** @type {any} */ (error)?.code !== 'ESRCH') return false; // EPERM and unknown errors never grant takeover.
106
+ // Approximate wall-clock boot metadata is context, never affirmative death evidence.
107
+ if (Math.abs(value.bootTime - bootTime()) > 30) throw recovery('Integration owner boot context is uncertain');
108
+ return true;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * @param {Snapshot | undefined} current
114
+ * @returns {boolean}
115
+ */
116
+ function matches(current) {
117
+ return !!owned && current?.value?.owner === owned.owner
118
+ && current.info.dev === owned.dev && current.info.ino === owned.ino;
119
+ }
120
+
121
+ return {
122
+ async acquire() {
123
+ if (phase !== 'new') throw new Error('Integration lock instance has already been used');
124
+ phase = 'acquiring';
125
+ try {
126
+ return await guarded(async () => {
127
+ const current = await snapshot();
128
+ if (current) {
129
+ if (!complete(current.value)) throw recovery('Integration lock is malformed, incomplete or unsupported');
130
+ if (!dead(current.value)) { phase = 'retired'; return { acquired: false, lock: current.value }; }
131
+ await unlink(file);
132
+ }
133
+ /** @type {IntegrationLockRecord & { owner: string }} */
134
+ const value = { pid: process.pid, bootTime: bootTime(), startedAt: new Date().toISOString(), ready: false, lockVersion: 1, owner: randomUUID() };
135
+ const handle = await open(file, 'wx', 0o600);
136
+ let info;
137
+ try { await handle.writeFile(JSON.stringify(value)); info = await handle.stat(); }
138
+ finally { await handle.close(); }
139
+ owned = { owner: value.owner, dev: info.dev, ino: info.ino };
140
+ phase = 'owned';
141
+ return { acquired: true, lock: value };
142
+ });
143
+ } catch (error) { phase = 'retired'; owned = undefined; throw error; }
144
+ },
145
+
146
+ async setReady(ready) {
147
+ if (phase !== 'owned') return false;
148
+ return guarded(async () => {
149
+ if (phase !== 'owned') return false;
150
+ const current = await snapshot();
151
+ if (!current || !matches(current)) { phase = 'retired'; owned = undefined; return false; }
152
+ const value = { ...current.value, ready };
153
+ const tmp = `${file}.${randomUUID()}.tmp`;
154
+ const handle = await open(tmp, 'wx', 0o600);
155
+ let info;
156
+ try {
157
+ try { await handle.writeFile(JSON.stringify(value)); info = await handle.stat(); }
158
+ finally { await handle.close(); }
159
+ await rename(tmp, file);
160
+ owned = { owner: value.owner, dev: info.dev, ino: info.ino };
161
+ return true;
162
+ } finally { await unlink(tmp).catch(error => { if (error?.code !== 'ENOENT') throw error; }); }
163
+ });
164
+ },
165
+
166
+ async release() {
167
+ if (phase !== 'owned') return false;
168
+ return guarded(async () => {
169
+ if (phase !== 'owned') return false;
170
+ const current = await snapshot();
171
+ const ours = matches(current);
172
+ phase = 'retired'; owned = undefined;
173
+ if (!ours) return false;
174
+ await unlink(file);
175
+ return true;
176
+ });
177
+ },
178
+ };
179
+ }
package/src/portable.js CHANGED
@@ -25,3 +25,4 @@ export { createLineDecoder, utf8Length } from './rpc/framing.js';
25
25
  export { MAX_TIMER_MS, timerDelay } from './rpc/timers.js';
26
26
  export { hello } from './rpc/handshake.js';
27
27
  export { backoffDelay, createReconnectingClient, retryable } from './rpc/reconnect.js';
28
+ export { attachments, base64Encode, conversation, sha256Hex, workspaceRegistry } from './rpc/registry.js';
@@ -0,0 +1,161 @@
1
+ /** Typed registry methods for portable clients. Mutations are sent once: callers retain requestId
2
+ * and ifRevision to reconcile a timeout by repeating the identical request. @param {{request<T = any>(method: string, params?: unknown): Promise<T>}} client */
3
+ export function workspaceRegistry(client) {
4
+ return {
5
+ /** @param {string} workspaceId @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.options']['result']>} */
6
+ worktreeOptions: workspaceId => client.request('workspace.worktree.options', { workspaceId }),
7
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.create']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.create']['result']>} */
8
+ createWorktree: params => client.request('workspace.worktree.create', params),
9
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.remove']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.worktree.remove']['result']>} */
10
+ removeWorktree: params => client.request('workspace.worktree.remove', params),
11
+ /** Read-only Git status of a registry workspace (capability `workspace.git`, §12.5). @param {string} workspaceId @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.git.status']['result']>} */
12
+ gitStatus: workspaceId => client.request('workspace.git.status', { workspaceId }),
13
+ /** One page of a read-only Git diff; pass `nextCursor` back as `cursor` with the same comparison and path. @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.git.diff']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.git.diff']['result']>} */
14
+ gitDiff: params => client.request('workspace.git.diff', params),
15
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['project.list']['params']} [params] @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['project.list']['result']>} */
16
+ projects: (params = {}) => client.request('project.list', params),
17
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.list']['params']} [params] @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.list']['result']>} */
18
+ workspaces: (params = {}) => client.request('workspace.list', params),
19
+ /** @param {string} workspaceId @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.get']['result']>} */
20
+ get: workspaceId => client.request('workspace.get', { workspaceId }),
21
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['project.register']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['project.register']['result']>} */
22
+ register: params => client.request('project.register', params),
23
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['project.remove']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['project.remove']['result']>} */
24
+ remove: params => client.request('project.remove', params),
25
+ /** @param {import('@anhlp/alp-protocol').RpcMethodMap['workspace.update']['params']} params @returns {Promise<import('@anhlp/alp-protocol').RpcMethodMap['workspace.update']['result']>} */
26
+ update: params => client.request('workspace.update', params),
27
+ };
28
+ }
29
+
30
+ const BASE64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
31
+
32
+ /** Canonical base64 of `bytes`, without Buffer or btoa, so it runs in browsers and Node alike. @param {Uint8Array} bytes */
33
+ export function base64Encode(bytes) {
34
+ let out = '';
35
+ let index = 0;
36
+ for (; index + 2 < bytes.length; index += 3) {
37
+ const n = (bytes[index] << 16) | (bytes[index + 1] << 8) | bytes[index + 2];
38
+ out += BASE64[n >> 18] + BASE64[(n >> 12) & 63] + BASE64[(n >> 6) & 63] + BASE64[n & 63];
39
+ }
40
+ if (index < bytes.length) {
41
+ const n = (bytes[index] << 16) | ((index + 1 < bytes.length ? bytes[index + 1] : 0) << 8);
42
+ out += BASE64[n >> 18] + BASE64[(n >> 12) & 63] + (index + 1 < bytes.length ? BASE64[(n >> 6) & 63] : '=') + '=';
43
+ }
44
+ return out;
45
+ }
46
+
47
+ /** Lowercase hex SHA-256 of `bytes` through WebCrypto (browsers' secure contexts, Node 19+). @param {Uint8Array} bytes */
48
+ export async function sha256Hex(bytes) {
49
+ const subtle = globalThis.crypto?.subtle;
50
+ if (!subtle) throw new Error('SHA-256 needs WebCrypto here; pass sha256 yourself');
51
+ const digest = new Uint8Array(await subtle.digest('SHA-256', bytes));
52
+ return Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
53
+ }
54
+
55
+ /**
56
+ * Typed attachment methods (capability `attachments`, docs/protocol/alp-json-rpc-v1.md §12.6) and a
57
+ * chunked upload that keeps to the limits the daemon (or gateway) advertised. Mutations are sent
58
+ * once: `upload` resumes an interrupted upload by repeating its begin with the same requestId and
59
+ * continues from what the daemon holds.
60
+ * @param {{request<T = any>(method: string, params?: unknown): Promise<T>}} client
61
+ */
62
+ export function attachments(client) {
63
+ /** @typedef {import('@anhlp/alp-protocol').RpcMethodMap} Map */
64
+ const api = {
65
+ /** @param {Map['attachment.begin']['params']} params @returns {Promise<Map['attachment.begin']['result']>} */
66
+ begin: params => client.request('attachment.begin', params),
67
+ /** @param {Map['attachment.chunk']['params']} params @returns {Promise<Map['attachment.chunk']['result']>} */
68
+ chunk: params => client.request('attachment.chunk', params),
69
+ /** @param {Map['attachment.commit']['params']} params @returns {Promise<Map['attachment.commit']['result']>} */
70
+ commit: params => client.request('attachment.commit', params),
71
+ /** @param {string} uploadId @returns {Promise<Map['attachment.abort']['result']>} */
72
+ abort: uploadId => client.request('attachment.abort', { uploadId }),
73
+ /** Metadata only (no bytes, no path). @param {string} attachmentId @returns {Promise<Map['attachment.get']['result']>} */
74
+ get: attachmentId => client.request('attachment.get', { attachmentId }),
75
+ /** Refused (1004 attachment_referenced) while a prompt references it. @param {string} attachmentId @returns {Promise<Map['attachment.delete']['result']>} */
76
+ delete: attachmentId => client.request('attachment.delete', { attachmentId }),
77
+ /**
78
+ * Uploads `data` and commits it: begin (or resume, with the same requestId), chunks of at most
79
+ * the smaller of `maxChunkBytes` and what begin answered, commit with its SHA-256. A chunk
80
+ * refused `offset_mismatch` continues from the daemon's `received`. Returns the attachment.
81
+ * @param {{ sessionId: string, name: string, mimeType: string, data: Uint8Array, requestId: string, sha256?: string, maxChunkBytes?: number, onProgress?: (received: number, size: number) => void }} options
82
+ */
83
+ async upload({ sessionId, name, mimeType, data, requestId, sha256, maxChunkBytes, onProgress }) {
84
+ const digest = sha256 ?? await sha256Hex(data);
85
+ const begun = await api.begin({ sessionId, name, size: data.length, mimeType, sha256: digest, requestId });
86
+ if (begun.state === 'committed' && begun.attachmentId) return (await api.get(begun.attachmentId)).attachment;
87
+ const size = Math.max(1, Math.min(begun.maxChunkBytes, maxChunkBytes ?? Infinity));
88
+ let offset = begun.received;
89
+ for (let realigned = 0; offset < data.length;) {
90
+ const end = Math.min(data.length, offset + size);
91
+ try {
92
+ const { received } = await api.chunk({ uploadId: begun.uploadId, offset, data: base64Encode(data.subarray(offset, end)) });
93
+ offset = received;
94
+ } catch (error) {
95
+ const held = /** @type {any} */ (error)?.data;
96
+ // Another attempt got further (or less far) than this one knew: continue where the daemon is.
97
+ if (held?.reason !== 'offset_mismatch' || !Number.isSafeInteger(held.received) || ++realigned > 3) throw error;
98
+ offset = held.received;
99
+ continue;
100
+ }
101
+ onProgress?.(offset, data.length);
102
+ }
103
+ return (await api.commit({ uploadId: begun.uploadId, sha256: digest })).attachment;
104
+ },
105
+ };
106
+ return api;
107
+ }
108
+
109
+ /**
110
+ * Typed conversation methods (capabilities `session.fork` and `session.rewind`, docs/protocol/
111
+ * alp-json-rpc-v1.md §12.7; `session.reload` and `session.resumeCommand`, §12.8). Both change the conversation only, never a file. Mutations are sent
112
+ * once: keep `requestId` (and `ifGeneration`) to repeat the identical request after a timeout, which
113
+ * answers the first result with `duplicate: true`. After a rewind, attached clients get a
114
+ * `history.reset` event: drop cached history, fetch a new tail and attach with its generation.
115
+ * @param {{request<T = any>(method: string, params?: unknown): Promise<T>}} client
116
+ */
117
+ export function conversation(client) {
118
+ /** @typedef {import('@anhlp/alp-protocol').RpcMethodMap} Map */
119
+ return {
120
+ /**
121
+ * A new closed root whose conversation is the source root's through or before a turn (`boundary`),
122
+ * or a branch a rewind set aside (`branchId`); resume it with session.create { sessionId, resume: true }.
123
+ * Principal fields are set by a trusted gateway on its own connection, never by a remote client.
124
+ * @param {Map['session.fork']['params']} params @returns {Promise<Map['session.fork']['result']>}
125
+ */
126
+ fork: params => client.request('session.fork', params),
127
+ /**
128
+ * Continues the root's conversation from before `beforeTurnId` under the same session id; the
129
+ * later turns stay a branch (`branch.branchId`) that `fork({ branchId })` recovers.
130
+ * @param {Map['session.rewind']['params']} params @returns {Promise<Map['session.rewind']['result']>}
131
+ */
132
+ rewind: params => client.request('session.rewind', params),
133
+ /**
134
+ * Restarts a live root's native process on its own conversation and configuration at the idle
135
+ * barrier (capability `session.reload`, §12.8); id, history and receipts stay. Sent once: repeat
136
+ * the same `requestId` after a lost answer.
137
+ * @param {Map['session.reload']['params']} params @returns {Promise<Map['session.reload']['result']>}
138
+ */
139
+ reload: params => client.request('session.reload', params),
140
+ /**
141
+ * The ALP CLI command that follows the session in a terminal (capability `session.resumeCommand`,
142
+ * §12.8), built by alpd: for the clipboard only, never to run.
143
+ * @param {Map['session.resumeCommand']['params']} params @returns {Promise<Map['session.resumeCommand']['result']>}
144
+ */
145
+ resumeCommand: params => client.request('session.resumeCommand', params),
146
+ /**
147
+ * Changes a live root's model, effort or team (capability `session.configure.live`, §12.9) at the
148
+ * idle barrier, through a restart of its native process on the same conversation; `configured` says
149
+ * what it runs with now. Sent once: repeat the same `requestId` after a lost answer. A change to
150
+ * another runtime is refused (1005 runtime_mismatch, `data.action: 'session.create'`).
151
+ * @param {Map['session.configure']['params'] & { requestId: string; ifGeneration: string }} params @returns {Promise<Map['session.configure']['result']>}
152
+ */
153
+ configure: params => client.request('session.configure', params),
154
+ /**
155
+ * What a change of model, effort, team or mode would run a live root with, and whether it would be
156
+ * taken now (capability `session.configure.live`, §12.9). Reads only.
157
+ * @param {Map['session.configure.preview']['params']} params @returns {Promise<Map['session.configure.preview']['result']>}
158
+ */
159
+ configurePreview: params => client.request('session.configure.preview', params),
160
+ };
161
+ }
@@ -9,4 +9,6 @@ export function superviseDaemon(options: {
9
9
  intervalMs?: number;
10
10
  misses?: number;
11
11
  log?: (message: string) => void;
12
+ /** Who keeps alpd up, for the log: "alpd started with <by>". */
13
+ by?: string;
12
14
  }): (() => Promise<void>) & { started: Promise<void> };
package/src/supervise.js CHANGED
@@ -61,14 +61,16 @@ export async function startDaemon({ home, entry, env = process.env, timeoutMs =
61
61
  * Starts alpd now and starts it again whenever it is found down on `misses` checks in a
62
62
  * row, `intervalMs` apart, unless the user holds it. Two misses let `alp daemon restart`
63
63
  * and `install` finish their own stop and start first. Returns a function that stops
64
- * watching, and resolves once a start it began has settled; alpd keeps running.
64
+ * watching, and resolves once a start it began has settled; alpd keeps running. `by` names the
65
+ * caller in the log line of the first start ("alpd started with <by>").
65
66
  */
66
- export function superviseDaemon({ home, entry, env = process.env, intervalMs = 5_000, misses = 2, log = () => {} }) {
67
+ export function superviseDaemon({ home, entry, env = process.env, intervalMs = 5_000, misses = 2, log = () => {}, by }) {
67
68
  let stopped = false;
68
69
  let missed = 0;
69
70
  let starting;
70
- const start = reason => {
71
- starting ??= startDaemon({ home, entry: typeof entry === 'function' ? entry() : entry, env, automatic: reason !== 'started with Paseo' })
71
+ // The first start is the caller's own request (it lifts a hold); later ones are automatic.
72
+ const start = (reason, automatic = true) => {
73
+ starting ??= startDaemon({ home, entry: typeof entry === 'function' ? entry() : entry, env, automatic })
72
74
  .then(socket => { if (socket) log(`alpd ${reason}`); }, error => log(`could not start alpd: ${error?.message ?? error}`))
73
75
  .finally(() => { starting = undefined; missed = 0; });
74
76
  return starting;
@@ -80,7 +82,8 @@ export function superviseDaemon({ home, entry, env = process.env, intervalMs = 5
80
82
  // Stopped while this check looked: start nothing.
81
83
  if (++missed >= misses && !stopped) await start('was down; started it again');
82
84
  };
83
- const initial = start('started with Paseo');
85
+ // `by` names the supervising service in the log, for example `Paseo` or `alp web`.
86
+ const initial = start(by ? `started with ${by}` : 'started', false);
84
87
  const timer = setInterval(() => { void check(); }, intervalMs);
85
88
  timer.unref?.();
86
89
  const stop = () => { stopped = true; clearInterval(timer); return starting ?? Promise.resolve(); };