@skastr0/prism-sdk 0.3.0
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/LICENSE +21 -0
- package/README.md +12 -0
- package/dist/compile-manifest.d.ts +293 -0
- package/dist/compile-manifest.js +232 -0
- package/dist/mcp/daemon-resolver.d.ts +100 -0
- package/dist/mcp/daemon-resolver.js +185 -0
- package/dist/mcp/shim.d.ts +155 -0
- package/dist/mcp/shim.js +434 -0
- package/dist/mcp/uds-path.d.ts +26 -0
- package/dist/mcp/uds-path.js +70 -0
- package/dist/mcp/uds-registry.d.ts +109 -0
- package/dist/mcp/uds-registry.js +270 -0
- package/dist/mcp/uds-singleton.d.ts +124 -0
- package/dist/mcp/uds-singleton.js +360 -0
- package/dist/refs.d.ts +15 -0
- package/dist/refs.js +26 -0
- package/dist/snapshot.d.ts +92 -0
- package/dist/snapshot.js +74 -0
- package/dist/stable-json.d.ts +8 -0
- package/dist/stable-json.js +27 -0
- package/package.json +73 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { mkdir, unlink, readFile, open } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { Socket } from "node:net";
|
|
5
|
+
/**
|
|
6
|
+
* Error type for singleton/stale-socket violations.
|
|
7
|
+
*/
|
|
8
|
+
export class UDSSingletonError extends Error {
|
|
9
|
+
context;
|
|
10
|
+
kind = "uds-singleton-error";
|
|
11
|
+
constructor(message, context) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.context = context;
|
|
14
|
+
this.name = "UDSSingletonError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Attempt to connect to an existing Unix domain socket to detect a live daemon.
|
|
19
|
+
*
|
|
20
|
+
* Returns "live" if a connection succeeds (live daemon).
|
|
21
|
+
* Returns "stale" if connection is refused/times out (socket file exists but no daemon).
|
|
22
|
+
* Returns "available" if the socket file does not exist.
|
|
23
|
+
*
|
|
24
|
+
* @param socketPath Absolute path to the UDS socket
|
|
25
|
+
* @param timeoutMs How long to wait before considering a connection attempt stale
|
|
26
|
+
*/
|
|
27
|
+
export async function probeSocketLiveness(socketPath, timeoutMs = 500) {
|
|
28
|
+
// Socket file does not exist — path is available
|
|
29
|
+
if (!existsSync(socketPath)) {
|
|
30
|
+
return "available";
|
|
31
|
+
}
|
|
32
|
+
// Socket file exists; try connecting to detect a live daemon
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const socket = new Socket();
|
|
35
|
+
let resolved = false;
|
|
36
|
+
const timeout = setTimeout(() => {
|
|
37
|
+
if (!resolved) {
|
|
38
|
+
resolved = true;
|
|
39
|
+
socket.destroy();
|
|
40
|
+
resolve("stale");
|
|
41
|
+
}
|
|
42
|
+
}, timeoutMs);
|
|
43
|
+
socket.on("connect", () => {
|
|
44
|
+
if (!resolved) {
|
|
45
|
+
resolved = true;
|
|
46
|
+
clearTimeout(timeout);
|
|
47
|
+
socket.destroy();
|
|
48
|
+
resolve("live");
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
socket.on("error", () => {
|
|
52
|
+
if (!resolved) {
|
|
53
|
+
resolved = true;
|
|
54
|
+
clearTimeout(timeout);
|
|
55
|
+
resolve("stale");
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
socket.connect({ path: socketPath });
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Returns true if a process with the given pid is currently alive.
|
|
63
|
+
*
|
|
64
|
+
* Uses the `kill(pid, 0)` idiom: no signal is actually delivered, but the
|
|
65
|
+
* OS still validates that a process with this pid exists and reports
|
|
66
|
+
* permission errors for processes that exist but are owned by another
|
|
67
|
+
* user (EPERM means "alive, just not signalable by us" — still alive).
|
|
68
|
+
*/
|
|
69
|
+
function isProcessAlive(pid) {
|
|
70
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
71
|
+
return false;
|
|
72
|
+
try {
|
|
73
|
+
process.kill(pid, 0);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
return error.code === "EPERM";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Best-effort read of the holder recorded in a lock file. Returns undefined
|
|
82
|
+
* if the file is missing, unreadable, or does not contain a well-formed
|
|
83
|
+
* holder record — callers must treat that as "unknown holder" and never
|
|
84
|
+
* infer liveness from it.
|
|
85
|
+
*/
|
|
86
|
+
async function readLockHolder(lockPath) {
|
|
87
|
+
try {
|
|
88
|
+
const content = await readFile(lockPath, "utf8");
|
|
89
|
+
const parsed = JSON.parse(content);
|
|
90
|
+
if (parsed &&
|
|
91
|
+
typeof parsed === "object" &&
|
|
92
|
+
typeof parsed.pid === "number" &&
|
|
93
|
+
typeof parsed.startedAt === "number") {
|
|
94
|
+
return parsed;
|
|
95
|
+
}
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Attempt to exclusively create the lock file in one atomic step (open with
|
|
104
|
+
* the `wx` flag: O_CREAT | O_EXCL). Unlike rename-based "locking", this
|
|
105
|
+
* fails with EEXIST when the destination already exists instead of
|
|
106
|
+
* silently replacing it — exactly one concurrent caller can ever succeed
|
|
107
|
+
* for a given lockPath.
|
|
108
|
+
*
|
|
109
|
+
* Returns true if this call created the file (lock acquired), false if the
|
|
110
|
+
* file already existed (EEXIST). Any other error propagates.
|
|
111
|
+
*/
|
|
112
|
+
async function tryCreateLockFile(lockPath, pid) {
|
|
113
|
+
let handle;
|
|
114
|
+
try {
|
|
115
|
+
handle = await open(lockPath, "wx");
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
if (error.code === "EEXIST") {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const payload = { pid, startedAt: Date.now() };
|
|
125
|
+
await handle.writeFile(JSON.stringify(payload), "utf8");
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
await handle.close();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Acquire a lock via exclusive file creation (fs `wx` open), with
|
|
134
|
+
* dead-holder reclamation and retry.
|
|
135
|
+
*
|
|
136
|
+
* Only one concurrent caller can ever hold the lock: the underlying
|
|
137
|
+
* `open(lockPath, "wx")` is an atomic O_CREAT|O_EXCL create that fails with
|
|
138
|
+
* EEXIST for every caller after the first, unlike a rename-based scheme
|
|
139
|
+
* (which silently replaces the destination and lets every racer "succeed").
|
|
140
|
+
*
|
|
141
|
+
* On EEXIST, the recorded holder's pid is liveness-checked via
|
|
142
|
+
* `process.kill(pid, 0)`: a dead holder's lock file is reclaimed (unlinked)
|
|
143
|
+
* and creation is retried once immediately; a live holder means the lock is
|
|
144
|
+
* genuinely held elsewhere, so this attempt waits out the retry interval
|
|
145
|
+
* and tries again until timeoutMs elapses.
|
|
146
|
+
*
|
|
147
|
+
* @param lockPath Path to the lock file
|
|
148
|
+
* @param timeoutMs How long to wait
|
|
149
|
+
* @param pid Process ID to write into the lock file
|
|
150
|
+
*/
|
|
151
|
+
export async function acquireLock(lockPath, timeoutMs, pid) {
|
|
152
|
+
const lockDir = dirname(lockPath);
|
|
153
|
+
const startTime = Date.now();
|
|
154
|
+
const retryIntervalMs = 10;
|
|
155
|
+
// Ensure lock directory exists
|
|
156
|
+
try {
|
|
157
|
+
await mkdir(lockDir, { recursive: true });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Directory may already exist
|
|
161
|
+
}
|
|
162
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
163
|
+
if (await tryCreateLockFile(lockPath, pid)) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
// Lock file exists (EEXIST). Inspect the holder before deciding to wait.
|
|
167
|
+
const holder = await readLockHolder(lockPath);
|
|
168
|
+
if (holder && !isProcessAlive(holder.pid)) {
|
|
169
|
+
// Stale lock left by a dead process: reclaim it and retry immediately,
|
|
170
|
+
// once, before falling back to the normal wait-and-retry loop.
|
|
171
|
+
try {
|
|
172
|
+
await unlink(lockPath);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// Another process may have already reclaimed/removed it; fall
|
|
176
|
+
// through to the retry loop below.
|
|
177
|
+
}
|
|
178
|
+
if (await tryCreateLockFile(lockPath, pid)) {
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
await new Promise((resolve) => setTimeout(resolve, retryIntervalMs));
|
|
183
|
+
}
|
|
184
|
+
return false; // Timeout reached
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Atomically probe and recover from a stale socket using a lock file for race safety.
|
|
188
|
+
*
|
|
189
|
+
* On the first call (or after lock acquisition), probes for a live daemon:
|
|
190
|
+
* - If live: returns "live" immediately (this process should exit)
|
|
191
|
+
* - If stale: unlinks the socket file and returns "stale-recovered"
|
|
192
|
+
* - If available: returns "available" immediately
|
|
193
|
+
*
|
|
194
|
+
* The lock file (at `<socketPath>.lock`) ensures only one process enters the
|
|
195
|
+
* probe-unlink-bind critical section at a time. Two simultaneous spawners will
|
|
196
|
+
* race to acquire the lock; the winner probes/unlinks/binds, the loser re-probes
|
|
197
|
+
* and discovers "live" (sees the winner's bound socket) and exits.
|
|
198
|
+
*
|
|
199
|
+
* @param socketPath Absolute path to the UDS socket
|
|
200
|
+
* @param lockTimeoutMs How long to wait acquiring the lock file
|
|
201
|
+
* @returns "live" (other daemon won the race), "stale-recovered" (unlinked stale socket), or "available" (free to bind)
|
|
202
|
+
* @throws UDSSingletonError if lock acquisition fails or other I/O errors
|
|
203
|
+
*/
|
|
204
|
+
export async function probeAndRecoverWithLock(socketPath, lockTimeoutMs = 1000) {
|
|
205
|
+
const lockPath = `${socketPath}.lock`;
|
|
206
|
+
// Try to acquire lock
|
|
207
|
+
const lockAcquired = await acquireLock(lockPath, lockTimeoutMs, process.pid);
|
|
208
|
+
if (!lockAcquired) {
|
|
209
|
+
// Lock acquisition timed out; re-probe to see if another process succeeded
|
|
210
|
+
const probeResult = await probeSocketLiveness(socketPath);
|
|
211
|
+
if (probeResult === "live") {
|
|
212
|
+
return "live";
|
|
213
|
+
}
|
|
214
|
+
// Stale or available; try again (may get lock next time)
|
|
215
|
+
throw new UDSSingletonError(`Failed to acquire lock on ${lockPath} within ${lockTimeoutMs}ms; socket state is ${probeResult}`);
|
|
216
|
+
}
|
|
217
|
+
try {
|
|
218
|
+
// Locked section: probe for a live daemon
|
|
219
|
+
const probeResult = await probeSocketLiveness(socketPath);
|
|
220
|
+
if (probeResult === "live") {
|
|
221
|
+
// Another daemon owns this socket
|
|
222
|
+
return "live";
|
|
223
|
+
}
|
|
224
|
+
if (probeResult === "stale") {
|
|
225
|
+
// Socket file is stale; unlink it so bind() can proceed
|
|
226
|
+
try {
|
|
227
|
+
await unlink(socketPath);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// May have already been unlinked by another process; safe to ignore
|
|
231
|
+
}
|
|
232
|
+
return "stale-recovered";
|
|
233
|
+
}
|
|
234
|
+
// Socket path is free
|
|
235
|
+
return "available";
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
// Release lock by deleting the lock file
|
|
239
|
+
try {
|
|
240
|
+
await unlink(lockPath);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Lock file cleanup failures are non-fatal
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Ensure the daemon can bind to the given socket path, handling stale sockets.
|
|
249
|
+
*
|
|
250
|
+
* This is the high-level entry point for singleton + stale-socket recovery.
|
|
251
|
+
* Call this before binding to a UDS socket in your server:
|
|
252
|
+
*
|
|
253
|
+
* ```
|
|
254
|
+
* const result = await ensureSocketBindability(socketPath);
|
|
255
|
+
* if (result === "live") {
|
|
256
|
+
* // Another daemon already owns this socket; exit gracefully
|
|
257
|
+
* process.exit(0);
|
|
258
|
+
* }
|
|
259
|
+
* // result is "stale-recovered" or "available"; proceed with binding
|
|
260
|
+
* ```
|
|
261
|
+
*
|
|
262
|
+
* @param socketPath Absolute path to the UDS socket
|
|
263
|
+
* @returns "live" if another daemon owns the socket (exit immediately),
|
|
264
|
+
* or "stale-recovered"/"available" (safe to bind)
|
|
265
|
+
* @throws UDSSingletonError on unrecoverable errors (lock acquisition, etc.)
|
|
266
|
+
*/
|
|
267
|
+
export async function ensureSocketBindability(socketPath) {
|
|
268
|
+
return probeAndRecoverWithLock(socketPath);
|
|
269
|
+
}
|
|
270
|
+
function isAddrInUseError(error) {
|
|
271
|
+
return (typeof error === "object" &&
|
|
272
|
+
error !== null &&
|
|
273
|
+
"code" in error &&
|
|
274
|
+
error.code === "EADDRINUSE");
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Probe, recover from a stale socket, and invoke `bind()` — all inside the
|
|
278
|
+
* single critical section held by the `<socketPath>.lock` file.
|
|
279
|
+
*
|
|
280
|
+
* This closes the race the two-step "check bindability, then bind()
|
|
281
|
+
* separately" pattern leaves open: between releasing the probe/unlink lock
|
|
282
|
+
* and actually calling `bind()`, another process could probe the same
|
|
283
|
+
* now-empty path, also decide it is free, and race this one to `bind()`.
|
|
284
|
+
* Here `bind()` itself runs while the lock is still held, so at most one
|
|
285
|
+
* process per lock ever reaches it while the path is in the
|
|
286
|
+
* stale/available state.
|
|
287
|
+
*
|
|
288
|
+
* `bind()` is also wrapped in try/catch: if it still throws EADDRINUSE
|
|
289
|
+
* (e.g. a process outside this locking scheme bound the path, or a timing
|
|
290
|
+
* gap before the lock was acquired), the live owner is re-probed — if it
|
|
291
|
+
* responds, this process reports "already-served" instead of crashing
|
|
292
|
+
* uncaught; if it does not respond, the socket is stale, and `bind()` is
|
|
293
|
+
* retried exactly once after unlinking it.
|
|
294
|
+
*
|
|
295
|
+
* @param socketPath Absolute path to the UDS socket
|
|
296
|
+
* @param bind Callback that performs the actual bind (e.g. `() => Bun.serve(...)`)
|
|
297
|
+
* @param lockTimeoutMs How long to wait acquiring the lock file
|
|
298
|
+
*/
|
|
299
|
+
export async function bindUnixSocketSingleton(socketPath, bind, lockTimeoutMs = 1000) {
|
|
300
|
+
const lockPath = `${socketPath}.lock`;
|
|
301
|
+
const lockAcquired = await acquireLock(lockPath, lockTimeoutMs, process.pid);
|
|
302
|
+
if (!lockAcquired) {
|
|
303
|
+
// Could not get the bind lock in time; check whether someone else is
|
|
304
|
+
// actually serving before giving up entirely.
|
|
305
|
+
const probeResult = await probeSocketLiveness(socketPath);
|
|
306
|
+
if (probeResult === "live") {
|
|
307
|
+
return { kind: "already-served" };
|
|
308
|
+
}
|
|
309
|
+
throw new UDSSingletonError(`Failed to acquire bind lock on ${lockPath} within ${lockTimeoutMs}ms; socket state is ${probeResult}`);
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
return await bindWithRecovery(socketPath, bind);
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
try {
|
|
316
|
+
await unlink(lockPath);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
// Lock file cleanup failures are non-fatal.
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
async function bindWithRecovery(socketPath, bind) {
|
|
324
|
+
const initialProbe = await probeSocketLiveness(socketPath);
|
|
325
|
+
if (initialProbe === "live") {
|
|
326
|
+
return { kind: "already-served" };
|
|
327
|
+
}
|
|
328
|
+
if (initialProbe === "stale") {
|
|
329
|
+
try {
|
|
330
|
+
await unlink(socketPath);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// May already be gone.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
const server = await bind();
|
|
338
|
+
return { kind: "bound", server };
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
if (!isAddrInUseError(error)) {
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
// Something bound the path between our probe and our bind() attempt.
|
|
345
|
+
const raceProbe = await probeSocketLiveness(socketPath);
|
|
346
|
+
if (raceProbe === "live") {
|
|
347
|
+
return { kind: "already-served" };
|
|
348
|
+
}
|
|
349
|
+
// Stale socket file left behind by a process that died mid-race;
|
|
350
|
+
// reclaim it and retry exactly once.
|
|
351
|
+
try {
|
|
352
|
+
await unlink(socketPath);
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
// May already be gone.
|
|
356
|
+
}
|
|
357
|
+
const server = await bind();
|
|
358
|
+
return { kind: "bound", server };
|
|
359
|
+
}
|
|
360
|
+
}
|
package/dist/refs.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ParsedNamedRef {
|
|
2
|
+
readonly pluginPrefix: string | undefined;
|
|
3
|
+
readonly name: string;
|
|
4
|
+
}
|
|
5
|
+
export interface ParsedSpaceItemRef {
|
|
6
|
+
readonly pluginPrefix: string | undefined;
|
|
7
|
+
readonly space: string;
|
|
8
|
+
readonly name: string;
|
|
9
|
+
}
|
|
10
|
+
export interface RegistryWithDeps<TSelf> {
|
|
11
|
+
readonly deps: ReadonlyMap<string, TSelf>;
|
|
12
|
+
}
|
|
13
|
+
export declare const parseNamedRef: (ref: string) => ParsedNamedRef;
|
|
14
|
+
export declare const parseSpaceItemRef: (ref: string, separator: "/" | "#") => ParsedSpaceItemRef | undefined;
|
|
15
|
+
export declare const registryForRef: <TRegistry extends RegistryWithDeps<TRegistry>>(ref: string, registry: TRegistry) => TRegistry | undefined;
|
package/dist/refs.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export const parseNamedRef = (ref) => {
|
|
2
|
+
const colon = ref.indexOf(":");
|
|
3
|
+
if (colon === -1)
|
|
4
|
+
return { pluginPrefix: undefined, name: ref };
|
|
5
|
+
return {
|
|
6
|
+
pluginPrefix: ref.slice(0, colon),
|
|
7
|
+
name: ref.slice(colon + 1),
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
export const parseSpaceItemRef = (ref, separator) => {
|
|
11
|
+
const parsed = parseNamedRef(ref);
|
|
12
|
+
const split = parsed.name.indexOf(separator);
|
|
13
|
+
if (split === -1)
|
|
14
|
+
return undefined;
|
|
15
|
+
const space = parsed.name.slice(0, split);
|
|
16
|
+
const name = parsed.name.slice(split + 1);
|
|
17
|
+
if (space.length === 0 || name.length === 0)
|
|
18
|
+
return undefined;
|
|
19
|
+
return { pluginPrefix: parsed.pluginPrefix, space, name };
|
|
20
|
+
};
|
|
21
|
+
export const registryForRef = (ref, registry) => {
|
|
22
|
+
const parsed = parseNamedRef(ref);
|
|
23
|
+
if (!parsed.pluginPrefix)
|
|
24
|
+
return registry;
|
|
25
|
+
return registry.deps.get(parsed.pluginPrefix);
|
|
26
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot manifest — the ONLY persistent state the sync engine keeps per
|
|
3
|
+
* harness root (docs/overhaul-one-writer-plan.md, WS4). It replaces all
|
|
4
|
+
* per-harness managed ledgers.
|
|
5
|
+
*
|
|
6
|
+
* The manifest is a disposable cache, never the source of truth: it exists
|
|
7
|
+
* solely to answer "may I skip this write?" and "what do I prune?". Deleting
|
|
8
|
+
* it must always converge on the next refresh (state-is-disposable gate).
|
|
9
|
+
*
|
|
10
|
+
* Identity discipline: entries are keyed by `targetPath` alone within a
|
|
11
|
+
* manifest (one manifest per harness root). Artifact kinds, source paths,
|
|
12
|
+
* and scopes are never identity — that scheme is what produced 646 zombie
|
|
13
|
+
* duplicate ledger entries. `plugin` is diagnostic attribution only.
|
|
14
|
+
*
|
|
15
|
+
* Determinism discipline: the manifest carries no timestamps and no run ids,
|
|
16
|
+
* and entries are sorted by `targetPath` on commit — a converged run leaves
|
|
17
|
+
* the manifest byte-identical.
|
|
18
|
+
*/
|
|
19
|
+
import { Schema } from "effect";
|
|
20
|
+
export declare const SNAPSHOT_MANIFEST_VERSION: 1;
|
|
21
|
+
/**
|
|
22
|
+
* How the sync engine owns the entry's target:
|
|
23
|
+
* - `owned`: a whole file Prism owns outright (rebuild/prune authority).
|
|
24
|
+
* - `region`: a prism-named fragment inside a shared user file; `regionKey`
|
|
25
|
+
* names the fragment and `contentHash` hashes the rendered fragment, not
|
|
26
|
+
* the whole file.
|
|
27
|
+
*/
|
|
28
|
+
export declare const SnapshotEntryMode: Schema.Literal<["owned", "region"]>;
|
|
29
|
+
export type SnapshotEntryMode = typeof SnapshotEntryMode.Type;
|
|
30
|
+
export declare const SnapshotEntrySchema: Schema.Struct<{
|
|
31
|
+
targetPath: typeof Schema.String;
|
|
32
|
+
contentHash: typeof Schema.String;
|
|
33
|
+
mode: Schema.Literal<["owned", "region"]>;
|
|
34
|
+
regionKey: Schema.optional<typeof Schema.String>;
|
|
35
|
+
plugin: typeof Schema.String;
|
|
36
|
+
}>;
|
|
37
|
+
export type SnapshotEntry = typeof SnapshotEntrySchema.Type;
|
|
38
|
+
/**
|
|
39
|
+
* Versioned union. Future versions join this union; readers decode the union
|
|
40
|
+
* and migrate forward in code. There is exactly one version today.
|
|
41
|
+
*/
|
|
42
|
+
export declare const SnapshotManifestSchema: Schema.Struct<{
|
|
43
|
+
version: Schema.Literal<[1]>;
|
|
44
|
+
harness: typeof Schema.String;
|
|
45
|
+
root: typeof Schema.String;
|
|
46
|
+
entries: Schema.Array$<Schema.Struct<{
|
|
47
|
+
targetPath: typeof Schema.String;
|
|
48
|
+
contentHash: typeof Schema.String;
|
|
49
|
+
mode: Schema.Literal<["owned", "region"]>;
|
|
50
|
+
regionKey: Schema.optional<typeof Schema.String>;
|
|
51
|
+
plugin: typeof Schema.String;
|
|
52
|
+
}>>;
|
|
53
|
+
}>;
|
|
54
|
+
export type SnapshotManifest = typeof SnapshotManifestSchema.Type;
|
|
55
|
+
export declare const decodeSnapshotManifest: (u: unknown, overrideOptions?: import("effect/SchemaAST").ParseOptions) => import("effect/Either").Either<{
|
|
56
|
+
readonly entries: readonly {
|
|
57
|
+
readonly plugin: string;
|
|
58
|
+
readonly targetPath: string;
|
|
59
|
+
readonly contentHash: string;
|
|
60
|
+
readonly mode: "owned" | "region";
|
|
61
|
+
readonly regionKey?: string | undefined;
|
|
62
|
+
}[];
|
|
63
|
+
readonly version: 1;
|
|
64
|
+
readonly harness: string;
|
|
65
|
+
readonly root: string;
|
|
66
|
+
}, import("effect/ParseResult").ParseError>;
|
|
67
|
+
declare const decodeSnapshotManifestUnknown: (u: unknown, overrideOptions?: import("effect/SchemaAST").ParseOptions) => import("effect/Either").Either<{
|
|
68
|
+
readonly entries: readonly {
|
|
69
|
+
readonly plugin: string;
|
|
70
|
+
readonly targetPath: string;
|
|
71
|
+
readonly contentHash: string;
|
|
72
|
+
readonly mode: "owned" | "region";
|
|
73
|
+
readonly regionKey?: string | undefined;
|
|
74
|
+
}[];
|
|
75
|
+
readonly version: 1;
|
|
76
|
+
readonly harness: string;
|
|
77
|
+
readonly root: string;
|
|
78
|
+
}, import("effect/ParseResult").ParseError>;
|
|
79
|
+
/**
|
|
80
|
+
* Migrate a parsed snapshot manifest to the latest version.
|
|
81
|
+
*
|
|
82
|
+
* Today only version 1 exists, so this is the identity for valid v1 payloads.
|
|
83
|
+
* When a v2 format is introduced, this function will branch on `input.version`
|
|
84
|
+
* and transform older shapes forward before the final schema decode.
|
|
85
|
+
*/
|
|
86
|
+
export declare const migrateSnapshotManifest: (input: unknown) => ReturnType<typeof decodeSnapshotManifestUnknown>;
|
|
87
|
+
export declare const encodeSnapshotManifest: (manifest: SnapshotManifest) => string;
|
|
88
|
+
export declare const emptySnapshotManifest: (options: {
|
|
89
|
+
readonly harness: string;
|
|
90
|
+
readonly root: string;
|
|
91
|
+
}) => SnapshotManifest;
|
|
92
|
+
export {};
|
package/dist/snapshot.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshot manifest — the ONLY persistent state the sync engine keeps per
|
|
3
|
+
* harness root (docs/overhaul-one-writer-plan.md, WS4). It replaces all
|
|
4
|
+
* per-harness managed ledgers.
|
|
5
|
+
*
|
|
6
|
+
* The manifest is a disposable cache, never the source of truth: it exists
|
|
7
|
+
* solely to answer "may I skip this write?" and "what do I prune?". Deleting
|
|
8
|
+
* it must always converge on the next refresh (state-is-disposable gate).
|
|
9
|
+
*
|
|
10
|
+
* Identity discipline: entries are keyed by `targetPath` alone within a
|
|
11
|
+
* manifest (one manifest per harness root). Artifact kinds, source paths,
|
|
12
|
+
* and scopes are never identity — that scheme is what produced 646 zombie
|
|
13
|
+
* duplicate ledger entries. `plugin` is diagnostic attribution only.
|
|
14
|
+
*
|
|
15
|
+
* Determinism discipline: the manifest carries no timestamps and no run ids,
|
|
16
|
+
* and entries are sorted by `targetPath` on commit — a converged run leaves
|
|
17
|
+
* the manifest byte-identical.
|
|
18
|
+
*/
|
|
19
|
+
import { Schema } from "effect";
|
|
20
|
+
export const SNAPSHOT_MANIFEST_VERSION = 1;
|
|
21
|
+
/**
|
|
22
|
+
* How the sync engine owns the entry's target:
|
|
23
|
+
* - `owned`: a whole file Prism owns outright (rebuild/prune authority).
|
|
24
|
+
* - `region`: a prism-named fragment inside a shared user file; `regionKey`
|
|
25
|
+
* names the fragment and `contentHash` hashes the rendered fragment, not
|
|
26
|
+
* the whole file.
|
|
27
|
+
*/
|
|
28
|
+
export const SnapshotEntryMode = Schema.Literal("owned", "region");
|
|
29
|
+
export const SnapshotEntrySchema = Schema.Struct({
|
|
30
|
+
targetPath: Schema.String,
|
|
31
|
+
contentHash: Schema.String,
|
|
32
|
+
mode: SnapshotEntryMode,
|
|
33
|
+
regionKey: Schema.optional(Schema.String),
|
|
34
|
+
plugin: Schema.String,
|
|
35
|
+
});
|
|
36
|
+
const SnapshotManifestV1 = Schema.Struct({
|
|
37
|
+
version: Schema.Literal(SNAPSHOT_MANIFEST_VERSION),
|
|
38
|
+
harness: Schema.String,
|
|
39
|
+
root: Schema.String,
|
|
40
|
+
entries: Schema.Array(SnapshotEntrySchema),
|
|
41
|
+
});
|
|
42
|
+
/**
|
|
43
|
+
* Versioned union. Future versions join this union; readers decode the union
|
|
44
|
+
* and migrate forward in code. There is exactly one version today.
|
|
45
|
+
*/
|
|
46
|
+
export const SnapshotManifestSchema = SnapshotManifestV1;
|
|
47
|
+
export const decodeSnapshotManifest = Schema.decodeUnknownEither(Schema.parseJson(SnapshotManifestSchema));
|
|
48
|
+
const decodeSnapshotManifestUnknown = Schema.decodeUnknownEither(SnapshotManifestSchema);
|
|
49
|
+
/**
|
|
50
|
+
* Migrate a parsed snapshot manifest to the latest version.
|
|
51
|
+
*
|
|
52
|
+
* Today only version 1 exists, so this is the identity for valid v1 payloads.
|
|
53
|
+
* When a v2 format is introduced, this function will branch on `input.version`
|
|
54
|
+
* and transform older shapes forward before the final schema decode.
|
|
55
|
+
*/
|
|
56
|
+
export const migrateSnapshotManifest = (input) => {
|
|
57
|
+
// Future migration hook: inspect input.version and apply v2/v3/etc. forward transforms.
|
|
58
|
+
return decodeSnapshotManifestUnknown(input);
|
|
59
|
+
};
|
|
60
|
+
export const encodeSnapshotManifest = (manifest) => {
|
|
61
|
+
const sorted = {
|
|
62
|
+
...manifest,
|
|
63
|
+
entries: [...manifest.entries].sort((left, right) => left.targetPath === right.targetPath
|
|
64
|
+
? (left.regionKey ?? "").localeCompare(right.regionKey ?? "")
|
|
65
|
+
: left.targetPath.localeCompare(right.targetPath)),
|
|
66
|
+
};
|
|
67
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
68
|
+
};
|
|
69
|
+
export const emptySnapshotManifest = (options) => ({
|
|
70
|
+
version: SNAPSHOT_MANIFEST_VERSION,
|
|
71
|
+
harness: options.harness,
|
|
72
|
+
root: options.root,
|
|
73
|
+
entries: [],
|
|
74
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type StableJsonValue = null | boolean | number | string | readonly StableJsonValue[] | {
|
|
2
|
+
readonly [key: string]: StableJsonValue | undefined;
|
|
3
|
+
};
|
|
4
|
+
export declare const compareCodePoint: (left: string, right: string) => number;
|
|
5
|
+
export declare const sortStableStrings: <T extends string>(values: Iterable<T>) => T[];
|
|
6
|
+
export declare const stableJsonValue: (value: StableJsonValue) => StableJsonValue;
|
|
7
|
+
export declare const stableJsonStringify: (value: StableJsonValue) => string;
|
|
8
|
+
export declare const stableJsonHash: (value: StableJsonValue) => string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const compareCodePoint = (left, right) => {
|
|
3
|
+
const normalizedLeft = left.normalize("NFC");
|
|
4
|
+
const normalizedRight = right.normalize("NFC");
|
|
5
|
+
if (normalizedLeft === normalizedRight)
|
|
6
|
+
return 0;
|
|
7
|
+
return normalizedLeft < normalizedRight ? -1 : 1;
|
|
8
|
+
};
|
|
9
|
+
export const sortStableStrings = (values) => [...values].sort(compareCodePoint);
|
|
10
|
+
export const stableJsonValue = (value) => {
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
return value.map((item) => stableJsonValue(item));
|
|
13
|
+
}
|
|
14
|
+
if (value && typeof value === "object") {
|
|
15
|
+
const record = value;
|
|
16
|
+
const sorted = {};
|
|
17
|
+
for (const key of sortStableStrings(Object.keys(record))) {
|
|
18
|
+
const entry = record[key];
|
|
19
|
+
if (entry !== undefined)
|
|
20
|
+
sorted[key] = stableJsonValue(entry);
|
|
21
|
+
}
|
|
22
|
+
return sorted;
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
};
|
|
26
|
+
export const stableJsonStringify = (value) => JSON.stringify(stableJsonValue(value));
|
|
27
|
+
export const stableJsonHash = (value) => createHash("sha256").update(stableJsonStringify(value)).digest("hex");
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skastr0/prism-sdk",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Core Prism contracts, refs, stable JSON, and manifest codecs for Prism runtimes and workflow tooling.",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/skastr0/prism.git",
|
|
10
|
+
"directory": "packages/prism-sdk"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/skastr0/prism#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/skastr0/prism/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"prism",
|
|
18
|
+
"ai-coding",
|
|
19
|
+
"agents",
|
|
20
|
+
"workflows",
|
|
21
|
+
"manifest"
|
|
22
|
+
],
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"exports": {
|
|
28
|
+
"./compile-manifest": {
|
|
29
|
+
"types": "./dist/compile-manifest.d.ts",
|
|
30
|
+
"import": "./dist/compile-manifest.js"
|
|
31
|
+
},
|
|
32
|
+
"./refs": {
|
|
33
|
+
"types": "./dist/refs.d.ts",
|
|
34
|
+
"import": "./dist/refs.js"
|
|
35
|
+
},
|
|
36
|
+
"./snapshot": {
|
|
37
|
+
"types": "./dist/snapshot.d.ts",
|
|
38
|
+
"import": "./dist/snapshot.js"
|
|
39
|
+
},
|
|
40
|
+
"./stable-json": {
|
|
41
|
+
"types": "./dist/stable-json.d.ts",
|
|
42
|
+
"import": "./dist/stable-json.js"
|
|
43
|
+
},
|
|
44
|
+
"./mcp/shim": {
|
|
45
|
+
"types": "./dist/mcp/shim.d.ts",
|
|
46
|
+
"import": "./dist/mcp/shim.js"
|
|
47
|
+
},
|
|
48
|
+
"./mcp/uds-registry": {
|
|
49
|
+
"types": "./dist/mcp/uds-registry.d.ts",
|
|
50
|
+
"import": "./dist/mcp/uds-registry.js"
|
|
51
|
+
},
|
|
52
|
+
"./mcp/uds-singleton": {
|
|
53
|
+
"types": "./dist/mcp/uds-singleton.d.ts",
|
|
54
|
+
"import": "./dist/mcp/uds-singleton.js"
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
"files": [
|
|
58
|
+
"dist",
|
|
59
|
+
"README.md",
|
|
60
|
+
"LICENSE"
|
|
61
|
+
],
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "tsc -p tsconfig.json",
|
|
64
|
+
"prepack": "bun run build"
|
|
65
|
+
},
|
|
66
|
+
"engines": {
|
|
67
|
+
"node": ">=18"
|
|
68
|
+
},
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"effect": "^3.21.1",
|
|
71
|
+
"@modelcontextprotocol/sdk": "1.29.0"
|
|
72
|
+
}
|
|
73
|
+
}
|