opencode-muse 0.0.0-stage → 0.1.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 +120 -2
- package/dist/catalog.d.ts +47 -0
- package/dist/catalog.js +159 -0
- package/dist/errors.d.ts +92 -0
- package/dist/errors.js +181 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +17 -0
- package/dist/model/agent-context.d.ts +10 -0
- package/dist/model/agent-context.js +35 -0
- package/dist/model/context.d.ts +26 -0
- package/dist/model/context.js +87 -0
- package/dist/model/language-model.d.ts +48 -0
- package/dist/model/language-model.js +540 -0
- package/dist/model/meta.d.ts +16 -0
- package/dist/model/meta.js +171 -0
- package/dist/model/prompt.d.ts +68 -0
- package/dist/model/prompt.js +175 -0
- package/dist/model/stream.d.ts +46 -0
- package/dist/model/stream.js +235 -0
- package/dist/msp/client.d.ts +49 -0
- package/dist/msp/client.js +229 -0
- package/dist/msp/host.d.ts +55 -0
- package/dist/msp/host.js +173 -0
- package/dist/provider.d.ts +18 -0
- package/dist/provider.js +112 -0
- package/dist/session/fingerprint.d.ts +9 -0
- package/dist/session/fingerprint.js +83 -0
- package/dist/session/manager.d.ts +40 -0
- package/dist/session/manager.js +353 -0
- package/dist/session/registry.d.ts +55 -0
- package/dist/session/registry.js +330 -0
- package/dist/session/workspace-lock.d.ts +26 -0
- package/dist/session/workspace-lock.js +288 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.js +1 -0
- package/package.json +58 -4
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, chmodSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
|
|
5
|
+
import { RegistryCorruptError, RegistryStorageError, RegistryValidationError, RegistryWorkspaceMismatchError, } from "../errors.js";
|
|
6
|
+
const REGISTRY_VERSION = 1;
|
|
7
|
+
const MAX_ENTRY_BYTES = 1_048_576;
|
|
8
|
+
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
9
|
+
const UUID_V7_PATTERN = /^[a-f0-9]{8}-[a-f0-9]{4}-7[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
|
|
10
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9._:@+_-]{1,512}$/;
|
|
11
|
+
const METHOD_PATTERN = /^[a-z][A-Za-z0-9]*(?:\/[A-Za-z][A-Za-z0-9]*)+$/;
|
|
12
|
+
/** Refuse a caller-selected state root inside Muse's CLI-owned config directory. */
|
|
13
|
+
export function assertSafeStateDirectory(path, options = {}) {
|
|
14
|
+
const env = options.env ?? process.env;
|
|
15
|
+
const homeDirectory = options.homeDirectory ?? homedir();
|
|
16
|
+
const platform = options.platform ?? process.platform;
|
|
17
|
+
const windows = platform === "win32";
|
|
18
|
+
const pathJoin = windows ? win32.join : join;
|
|
19
|
+
const pathResolve = windows ? win32.resolve : resolve;
|
|
20
|
+
const pathRelative = windows ? win32.relative : relative;
|
|
21
|
+
const pathSeparator = windows ? win32.sep : sep;
|
|
22
|
+
const pathIsAbsolute = windows ? win32.isAbsolute : isAbsolute;
|
|
23
|
+
const configuredHome = env.XDG_CONFIG_HOME;
|
|
24
|
+
const configHome = requiredString(configuredHome) && pathIsAbsolute(configuredHome)
|
|
25
|
+
? configuredHome
|
|
26
|
+
: pathJoin(homeDirectory, ".config");
|
|
27
|
+
const museConfigDirectory = pathResolve(pathJoin(configHome, "muse"));
|
|
28
|
+
const relativePath = pathRelative(museConfigDirectory, pathResolve(path));
|
|
29
|
+
const outsideMuseConfig = relativePath === ".."
|
|
30
|
+
|| relativePath.startsWith(`..${pathSeparator}`)
|
|
31
|
+
|| pathIsAbsolute(relativePath);
|
|
32
|
+
if (!outsideMuseConfig)
|
|
33
|
+
throw new RegistryValidationError();
|
|
34
|
+
return path;
|
|
35
|
+
}
|
|
36
|
+
function isRecord(value) {
|
|
37
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
function assertOnlyKeys(value, allowed) {
|
|
40
|
+
if (Object.keys(value).some((key) => !allowed.includes(key)))
|
|
41
|
+
throw new RegistryValidationError();
|
|
42
|
+
}
|
|
43
|
+
function requiredString(value) {
|
|
44
|
+
return typeof value === "string" && value.length > 0;
|
|
45
|
+
}
|
|
46
|
+
function validHashList(value) {
|
|
47
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string" && HASH_PATTERN.test(item));
|
|
48
|
+
}
|
|
49
|
+
function normalizeEntry(input, now) {
|
|
50
|
+
if (!isRecord(input))
|
|
51
|
+
throw new RegistryValidationError();
|
|
52
|
+
assertOnlyKeys(input, [
|
|
53
|
+
"openCodeSessionId", "museSessionId", "hostId", "workspaceFingerprint", "providerId", "modelId", "profileId",
|
|
54
|
+
"viewCursor", "activeTurnId", "transcriptFingerprints", "compactionFingerprints", "pendingCommands", "updatedAt",
|
|
55
|
+
]);
|
|
56
|
+
if (typeof input.openCodeSessionId !== "string"
|
|
57
|
+
|| !IDENTIFIER_PATTERN.test(input.openCodeSessionId)
|
|
58
|
+
|| typeof input.museSessionId !== "string"
|
|
59
|
+
|| !IDENTIFIER_PATTERN.test(input.museSessionId)
|
|
60
|
+
|| typeof input.hostId !== "string"
|
|
61
|
+
|| !IDENTIFIER_PATTERN.test(input.hostId)
|
|
62
|
+
|| typeof input.workspaceFingerprint !== "string"
|
|
63
|
+
|| !HASH_PATTERN.test(input.workspaceFingerprint)
|
|
64
|
+
|| !validHashList(input.transcriptFingerprints)
|
|
65
|
+
|| !validHashList(input.compactionFingerprints)
|
|
66
|
+
|| !isRecord(input.pendingCommands))
|
|
67
|
+
throw new RegistryValidationError();
|
|
68
|
+
for (const optional of ["providerId", "modelId", "profileId", "viewCursor", "activeTurnId"]) {
|
|
69
|
+
if (input[optional] !== undefined && !requiredString(input[optional]))
|
|
70
|
+
throw new RegistryValidationError();
|
|
71
|
+
}
|
|
72
|
+
if (input.updatedAt !== undefined && (typeof input.updatedAt !== "number" || !Number.isFinite(input.updatedAt))) {
|
|
73
|
+
throw new RegistryValidationError();
|
|
74
|
+
}
|
|
75
|
+
const pendingCommands = Object.create(null);
|
|
76
|
+
for (const [commandId, raw] of Object.entries(input.pendingCommands)) {
|
|
77
|
+
if (!UUID_V7_PATTERN.test(commandId) || !isRecord(raw))
|
|
78
|
+
throw new RegistryValidationError();
|
|
79
|
+
assertOnlyKeys(raw, ["method", "fingerprint"]);
|
|
80
|
+
if (typeof raw.method !== "string" || !METHOD_PATTERN.test(raw.method)
|
|
81
|
+
|| typeof raw.fingerprint !== "string" || !HASH_PATTERN.test(raw.fingerprint)) {
|
|
82
|
+
throw new RegistryValidationError();
|
|
83
|
+
}
|
|
84
|
+
pendingCommands[commandId] = { method: raw.method, fingerprint: raw.fingerprint };
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
openCodeSessionId: input.openCodeSessionId,
|
|
88
|
+
museSessionId: input.museSessionId,
|
|
89
|
+
hostId: input.hostId,
|
|
90
|
+
workspaceFingerprint: input.workspaceFingerprint,
|
|
91
|
+
...(input.providerId !== undefined ? { providerId: input.providerId } : {}),
|
|
92
|
+
...(input.modelId !== undefined ? { modelId: input.modelId } : {}),
|
|
93
|
+
...(input.profileId !== undefined ? { profileId: input.profileId } : {}),
|
|
94
|
+
...(input.viewCursor !== undefined ? { viewCursor: input.viewCursor } : {}),
|
|
95
|
+
...(input.activeTurnId !== undefined ? { activeTurnId: input.activeTurnId } : {}),
|
|
96
|
+
transcriptFingerprints: [...input.transcriptFingerprints],
|
|
97
|
+
compactionFingerprints: [...input.compactionFingerprints],
|
|
98
|
+
pendingCommands,
|
|
99
|
+
updatedAt: input.updatedAt ?? now(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function sessionFileName(openCodeSessionId) {
|
|
103
|
+
return `${createHash("sha256").update(openCodeSessionId).digest("hex")}.json`;
|
|
104
|
+
}
|
|
105
|
+
function ensurePrivateDirectory(path) {
|
|
106
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
107
|
+
chmodSync(path, 0o700);
|
|
108
|
+
}
|
|
109
|
+
function syncDirectory(path) {
|
|
110
|
+
let descriptor;
|
|
111
|
+
try {
|
|
112
|
+
descriptor = openSync(path, "r");
|
|
113
|
+
fsyncSync(descriptor);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Some platforms do not permit fsync on a directory handle.
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
if (descriptor !== undefined)
|
|
120
|
+
closeSync(descriptor);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export class SessionRegistry {
|
|
124
|
+
directory;
|
|
125
|
+
now;
|
|
126
|
+
constructor(options = {}) {
|
|
127
|
+
const stateDirectory = assertSafeStateDirectory(options.stateDirectory ?? SessionRegistry.resolveStateDirectory(options), options);
|
|
128
|
+
this.directory = join(stateDirectory, "sessions");
|
|
129
|
+
this.now = options.now ?? Date.now;
|
|
130
|
+
}
|
|
131
|
+
static resolveStateDirectory(options = {}) {
|
|
132
|
+
const env = options.env ?? process.env;
|
|
133
|
+
const homeDirectory = options.homeDirectory ?? homedir();
|
|
134
|
+
const platform = options.platform ?? process.platform;
|
|
135
|
+
let stateDirectory;
|
|
136
|
+
if (platform === "win32") {
|
|
137
|
+
const base = env.LOCALAPPDATA;
|
|
138
|
+
stateDirectory = joinWin32(requiredString(base) && win32.isAbsolute(base) ? base : win32.join(homeDirectory, "AppData", "Local"), "opencode-muse");
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
const dataHome = env.XDG_DATA_HOME;
|
|
142
|
+
const base = requiredString(dataHome) && isAbsolute(dataHome) ? dataHome : join(homeDirectory, ".local", "share");
|
|
143
|
+
stateDirectory = join(base, "opencode-muse");
|
|
144
|
+
}
|
|
145
|
+
return assertSafeStateDirectory(stateDirectory, options);
|
|
146
|
+
}
|
|
147
|
+
async get(openCodeSessionId, workspaceFingerprint) {
|
|
148
|
+
this.assertLookup(openCodeSessionId, workspaceFingerprint);
|
|
149
|
+
const entry = await this.readEntry(openCodeSessionId);
|
|
150
|
+
if (entry && entry.workspaceFingerprint !== workspaceFingerprint)
|
|
151
|
+
throw new RegistryWorkspaceMismatchError();
|
|
152
|
+
return entry;
|
|
153
|
+
}
|
|
154
|
+
async getForOpenCodeSession(openCodeSessionId) {
|
|
155
|
+
if (!IDENTIFIER_PATTERN.test(openCodeSessionId))
|
|
156
|
+
throw new RegistryValidationError();
|
|
157
|
+
return this.readEntry(openCodeSessionId);
|
|
158
|
+
}
|
|
159
|
+
async recordCompactionCandidate(openCodeSessionId, fingerprint) {
|
|
160
|
+
if (!IDENTIFIER_PATTERN.test(openCodeSessionId) || !HASH_PATTERN.test(fingerprint))
|
|
161
|
+
throw new RegistryValidationError();
|
|
162
|
+
const entry = await this.readEntry(openCodeSessionId);
|
|
163
|
+
if (!entry)
|
|
164
|
+
return;
|
|
165
|
+
this.write({ ...entry, compactionFingerprints: [fingerprint], updatedAt: this.now() });
|
|
166
|
+
}
|
|
167
|
+
async clearCompactionCandidate(openCodeSessionId, fingerprint) {
|
|
168
|
+
if (!IDENTIFIER_PATTERN.test(openCodeSessionId) || !HASH_PATTERN.test(fingerprint))
|
|
169
|
+
throw new RegistryValidationError();
|
|
170
|
+
const entry = await this.readEntry(openCodeSessionId);
|
|
171
|
+
if (!entry || !entry.compactionFingerprints.includes(fingerprint))
|
|
172
|
+
return;
|
|
173
|
+
this.write({
|
|
174
|
+
...entry,
|
|
175
|
+
compactionFingerprints: entry.compactionFingerprints.filter((candidate) => candidate !== fingerprint),
|
|
176
|
+
updatedAt: this.now(),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
async readEntry(openCodeSessionId) {
|
|
180
|
+
const path = this.entryPath(openCodeSessionId);
|
|
181
|
+
if (!existsSync(path))
|
|
182
|
+
return undefined;
|
|
183
|
+
let stat;
|
|
184
|
+
try {
|
|
185
|
+
stat = statSync(path);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (isMissingFile(error))
|
|
189
|
+
return undefined;
|
|
190
|
+
throw new RegistryStorageError();
|
|
191
|
+
}
|
|
192
|
+
if (stat.size > MAX_ENTRY_BYTES)
|
|
193
|
+
return this.quarantine(path);
|
|
194
|
+
let contents;
|
|
195
|
+
try {
|
|
196
|
+
contents = readFileSync(path, "utf8");
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
if (isMissingFile(error))
|
|
200
|
+
return undefined;
|
|
201
|
+
throw new RegistryStorageError();
|
|
202
|
+
}
|
|
203
|
+
let parsed;
|
|
204
|
+
try {
|
|
205
|
+
parsed = JSON.parse(contents);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return this.quarantine(path);
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
if (!isRecord(parsed))
|
|
212
|
+
throw new RegistryValidationError();
|
|
213
|
+
assertOnlyKeys(parsed, ["version", "entry"]);
|
|
214
|
+
if (parsed.version !== REGISTRY_VERSION)
|
|
215
|
+
throw new RegistryValidationError();
|
|
216
|
+
if (!isRecord(parsed.entry) || typeof parsed.entry.updatedAt !== "number" || !Number.isFinite(parsed.entry.updatedAt)) {
|
|
217
|
+
throw new RegistryValidationError();
|
|
218
|
+
}
|
|
219
|
+
const entry = normalizeEntry(parsed.entry, this.now);
|
|
220
|
+
if (entry.openCodeSessionId !== openCodeSessionId)
|
|
221
|
+
throw new RegistryValidationError();
|
|
222
|
+
return entry;
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
return this.quarantine(path);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async put(input) {
|
|
229
|
+
if (isRecord(input) && Object.hasOwn(input, "updatedAt"))
|
|
230
|
+
throw new RegistryValidationError();
|
|
231
|
+
const entry = normalizeEntry(input, this.now);
|
|
232
|
+
const previous = await this.get(entry.openCodeSessionId, entry.workspaceFingerprint);
|
|
233
|
+
if (previous && previous.workspaceFingerprint !== entry.workspaceFingerprint) {
|
|
234
|
+
throw new RegistryWorkspaceMismatchError();
|
|
235
|
+
}
|
|
236
|
+
this.write(entry);
|
|
237
|
+
return entry;
|
|
238
|
+
}
|
|
239
|
+
async updateCursor(openCodeSessionId, workspaceFingerprint, viewCursor) {
|
|
240
|
+
if (!requiredString(viewCursor))
|
|
241
|
+
throw new RegistryValidationError();
|
|
242
|
+
const entry = await this.requireEntry(openCodeSessionId, workspaceFingerprint);
|
|
243
|
+
this.write({ ...entry, viewCursor, updatedAt: this.now() });
|
|
244
|
+
}
|
|
245
|
+
async recordPendingCommand(openCodeSessionId, workspaceFingerprint, pending) {
|
|
246
|
+
if (!UUID_V7_PATTERN.test(pending.commandId) || !METHOD_PATTERN.test(pending.method) || !HASH_PATTERN.test(pending.fingerprint)) {
|
|
247
|
+
throw new RegistryValidationError();
|
|
248
|
+
}
|
|
249
|
+
const entry = await this.requireEntry(openCodeSessionId, workspaceFingerprint);
|
|
250
|
+
this.write({
|
|
251
|
+
...entry,
|
|
252
|
+
pendingCommands: { ...entry.pendingCommands, [pending.commandId]: { method: pending.method, fingerprint: pending.fingerprint } },
|
|
253
|
+
updatedAt: this.now(),
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
async clearPendingCommand(openCodeSessionId, workspaceFingerprint, commandId) {
|
|
257
|
+
const entry = await this.requireEntry(openCodeSessionId, workspaceFingerprint);
|
|
258
|
+
if (!(commandId in entry.pendingCommands))
|
|
259
|
+
return;
|
|
260
|
+
const pendingCommands = { ...entry.pendingCommands };
|
|
261
|
+
delete pendingCommands[commandId];
|
|
262
|
+
this.write({ ...entry, pendingCommands, updatedAt: this.now() });
|
|
263
|
+
}
|
|
264
|
+
async requireEntry(openCodeSessionId, workspaceFingerprint) {
|
|
265
|
+
const entry = await this.get(openCodeSessionId, workspaceFingerprint);
|
|
266
|
+
if (!entry)
|
|
267
|
+
throw new RegistryValidationError();
|
|
268
|
+
return entry;
|
|
269
|
+
}
|
|
270
|
+
assertLookup(openCodeSessionId, workspaceFingerprint) {
|
|
271
|
+
if (!requiredString(openCodeSessionId) || !HASH_PATTERN.test(workspaceFingerprint))
|
|
272
|
+
throw new RegistryValidationError();
|
|
273
|
+
}
|
|
274
|
+
entryPath(openCodeSessionId) {
|
|
275
|
+
return join(this.directory, sessionFileName(openCodeSessionId));
|
|
276
|
+
}
|
|
277
|
+
write(entry) {
|
|
278
|
+
try {
|
|
279
|
+
ensurePrivateDirectory(dirname(this.directory));
|
|
280
|
+
ensurePrivateDirectory(this.directory);
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
throw new RegistryStorageError();
|
|
284
|
+
}
|
|
285
|
+
const path = this.entryPath(entry.openCodeSessionId);
|
|
286
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
287
|
+
let descriptor;
|
|
288
|
+
try {
|
|
289
|
+
descriptor = openSync(temporary, "wx", 0o600);
|
|
290
|
+
writeFileSync(descriptor, `${JSON.stringify({ version: REGISTRY_VERSION, entry })}\n`, "utf8");
|
|
291
|
+
fsyncSync(descriptor);
|
|
292
|
+
closeSync(descriptor);
|
|
293
|
+
descriptor = undefined;
|
|
294
|
+
renameSync(temporary, path);
|
|
295
|
+
chmodSync(path, 0o600);
|
|
296
|
+
syncDirectory(this.directory);
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
if (descriptor !== undefined) {
|
|
300
|
+
try {
|
|
301
|
+
closeSync(descriptor);
|
|
302
|
+
}
|
|
303
|
+
catch { /* preserve the original storage failure */ }
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
unlinkSync(temporary);
|
|
307
|
+
}
|
|
308
|
+
catch { /* best-effort temp cleanup */ }
|
|
309
|
+
throw new RegistryStorageError();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
quarantine(path) {
|
|
313
|
+
try {
|
|
314
|
+
const quarantinePath = `${path}.corrupt-${this.now()}-${randomUUID()}`;
|
|
315
|
+
renameSync(path, quarantinePath);
|
|
316
|
+
chmodSync(quarantinePath, 0o600);
|
|
317
|
+
syncDirectory(this.directory);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
throw new RegistryCorruptError();
|
|
321
|
+
}
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function joinWin32(base, child) {
|
|
326
|
+
return win32.join(base, child);
|
|
327
|
+
}
|
|
328
|
+
function isMissingFile(error) {
|
|
329
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
330
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ResolveStateDirectoryOptions } from "./registry.js";
|
|
2
|
+
export interface WorkspaceLockOptions extends ResolveStateDirectoryOptions {
|
|
3
|
+
stateDirectory?: string;
|
|
4
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
5
|
+
now?: () => number;
|
|
6
|
+
ownerToken?: () => string;
|
|
7
|
+
}
|
|
8
|
+
export interface LockLease {
|
|
9
|
+
readonly workspaceFingerprint: string;
|
|
10
|
+
readonly openCodeSessionId: string;
|
|
11
|
+
release(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
export declare class WorkspaceLock {
|
|
14
|
+
private readonly directory;
|
|
15
|
+
private readonly isProcessAlive;
|
|
16
|
+
private readonly now;
|
|
17
|
+
private readonly createOwnerToken;
|
|
18
|
+
constructor(options?: WorkspaceLockOptions);
|
|
19
|
+
acquire(workspaceFingerprint: string, openCodeSessionId: string): Promise<LockLease>;
|
|
20
|
+
private create;
|
|
21
|
+
private read;
|
|
22
|
+
/** Synchronous recheck narrows the stale-owner race before reclaiming its lock. */
|
|
23
|
+
private removeIfUnchanged;
|
|
24
|
+
private readRecoveryClaim;
|
|
25
|
+
private lease;
|
|
26
|
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, chmodSync, existsSync, fsyncSync, linkSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { WorkspaceBusyError, WorkspaceLockCorruptError, RegistryValidationError, } from "../errors.js";
|
|
5
|
+
import { assertSafeStateDirectory, SessionRegistry } from "./registry.js";
|
|
6
|
+
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
7
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9._:@+_-]{1,512}$/;
|
|
8
|
+
const LOCK_VERSION = 1;
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function decodeLock(value, workspaceFingerprint) {
|
|
13
|
+
if (!isRecord(value)
|
|
14
|
+
|| value.version !== LOCK_VERSION
|
|
15
|
+
|| value.workspaceFingerprint !== workspaceFingerprint
|
|
16
|
+
|| typeof value.openCodeSessionId !== "string"
|
|
17
|
+
|| !IDENTIFIER_PATTERN.test(value.openCodeSessionId)
|
|
18
|
+
|| typeof value.ownerToken !== "string"
|
|
19
|
+
|| value.ownerToken.length === 0
|
|
20
|
+
|| typeof value.pid !== "number"
|
|
21
|
+
|| !Number.isInteger(value.pid)
|
|
22
|
+
|| value.pid <= 0
|
|
23
|
+
|| typeof value.acquiredAt !== "number"
|
|
24
|
+
|| !Number.isFinite(value.acquiredAt)
|
|
25
|
+
|| Object.keys(value).some((key) => !["version", "workspaceFingerprint", "openCodeSessionId", "ownerToken", "pid", "acquiredAt"].includes(key))) {
|
|
26
|
+
throw new WorkspaceLockCorruptError();
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
function processIsAlive(pid) {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
const code = error.code;
|
|
37
|
+
return code === "EPERM" || code !== "ESRCH";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function ensurePrivateDirectory(path) {
|
|
41
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
42
|
+
chmodSync(path, 0o700);
|
|
43
|
+
}
|
|
44
|
+
function syncDirectory(path) {
|
|
45
|
+
let descriptor;
|
|
46
|
+
try {
|
|
47
|
+
descriptor = openSync(path, "r");
|
|
48
|
+
fsyncSync(descriptor);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Directory fsync is not available on every supported platform.
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
if (descriptor !== undefined)
|
|
55
|
+
closeSync(descriptor);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export class WorkspaceLock {
|
|
59
|
+
directory;
|
|
60
|
+
isProcessAlive;
|
|
61
|
+
now;
|
|
62
|
+
createOwnerToken;
|
|
63
|
+
constructor(options = {}) {
|
|
64
|
+
const stateDirectory = assertSafeStateDirectory(options.stateDirectory ?? SessionRegistry.resolveStateDirectory(options), options);
|
|
65
|
+
this.directory = join(stateDirectory, "locks");
|
|
66
|
+
this.isProcessAlive = options.isProcessAlive ?? processIsAlive;
|
|
67
|
+
this.now = options.now ?? Date.now;
|
|
68
|
+
this.createOwnerToken = options.ownerToken ?? randomUUID;
|
|
69
|
+
}
|
|
70
|
+
async acquire(workspaceFingerprint, openCodeSessionId) {
|
|
71
|
+
if (!HASH_PATTERN.test(workspaceFingerprint) || !IDENTIFIER_PATTERN.test(openCodeSessionId))
|
|
72
|
+
throw new RegistryValidationError();
|
|
73
|
+
ensurePrivateDirectory(dirname(this.directory));
|
|
74
|
+
ensurePrivateDirectory(this.directory);
|
|
75
|
+
const path = join(this.directory, `${workspaceFingerprint}.lock`);
|
|
76
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
77
|
+
const record = {
|
|
78
|
+
version: LOCK_VERSION,
|
|
79
|
+
workspaceFingerprint,
|
|
80
|
+
openCodeSessionId,
|
|
81
|
+
ownerToken: this.createOwnerToken(),
|
|
82
|
+
pid: process.pid,
|
|
83
|
+
acquiredAt: this.now(),
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
this.create(path, record);
|
|
87
|
+
return this.lease(path, record);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (!isExistsError(error))
|
|
91
|
+
throw new WorkspaceLockCorruptError();
|
|
92
|
+
}
|
|
93
|
+
const existing = this.read(path, workspaceFingerprint);
|
|
94
|
+
if (!existing)
|
|
95
|
+
continue;
|
|
96
|
+
if (this.isProcessAlive(existing.pid))
|
|
97
|
+
throw new WorkspaceBusyError();
|
|
98
|
+
if (!this.removeIfUnchanged(path, existing))
|
|
99
|
+
throw new WorkspaceBusyError();
|
|
100
|
+
}
|
|
101
|
+
throw new WorkspaceBusyError();
|
|
102
|
+
}
|
|
103
|
+
create(path, record) {
|
|
104
|
+
let descriptor;
|
|
105
|
+
const temporary = `${path}.${record.ownerToken}.tmp`;
|
|
106
|
+
try {
|
|
107
|
+
descriptor = openSync(temporary, "wx", 0o600);
|
|
108
|
+
writeFileSync(descriptor, `${JSON.stringify(record)}\n`, "utf8");
|
|
109
|
+
fsyncSync(descriptor);
|
|
110
|
+
closeSync(descriptor);
|
|
111
|
+
descriptor = undefined;
|
|
112
|
+
linkSync(temporary, path);
|
|
113
|
+
try {
|
|
114
|
+
unlinkSync(temporary);
|
|
115
|
+
}
|
|
116
|
+
catch { /* the private temporary hard link is safe to clean later */ }
|
|
117
|
+
syncDirectory(dirname(path));
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (descriptor !== undefined) {
|
|
121
|
+
try {
|
|
122
|
+
closeSync(descriptor);
|
|
123
|
+
}
|
|
124
|
+
catch { /* preserve the original failure */ }
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
unlinkSync(temporary);
|
|
128
|
+
}
|
|
129
|
+
catch { /* best-effort temp cleanup */ }
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
read(path, workspaceFingerprint) {
|
|
134
|
+
try {
|
|
135
|
+
if (!existsSync(path))
|
|
136
|
+
return undefined;
|
|
137
|
+
if (statSync(path).size > 16_384)
|
|
138
|
+
throw new WorkspaceLockCorruptError();
|
|
139
|
+
return decodeLock(JSON.parse(readFileSync(path, "utf8")), workspaceFingerprint);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (isMissingError(error))
|
|
143
|
+
return undefined;
|
|
144
|
+
if (error instanceof WorkspaceLockCorruptError)
|
|
145
|
+
throw error;
|
|
146
|
+
throw new WorkspaceLockCorruptError();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/** Synchronous recheck narrows the stale-owner race before reclaiming its lock. */
|
|
150
|
+
removeIfUnchanged(path, observed) {
|
|
151
|
+
const claimPath = `${path}.reclaim`;
|
|
152
|
+
const claimToken = this.createOwnerToken();
|
|
153
|
+
const claim = { version: LOCK_VERSION, ownerToken: claimToken, pid: process.pid };
|
|
154
|
+
let claimDescriptor;
|
|
155
|
+
const temporaryClaimPath = `${claimPath}.${claimToken}.tmp`;
|
|
156
|
+
try {
|
|
157
|
+
claimDescriptor = openSync(temporaryClaimPath, "wx", 0o600);
|
|
158
|
+
writeFileSync(claimDescriptor, `${JSON.stringify(claim)}\n`, "utf8");
|
|
159
|
+
fsyncSync(claimDescriptor);
|
|
160
|
+
closeSync(claimDescriptor);
|
|
161
|
+
claimDescriptor = undefined;
|
|
162
|
+
linkSync(temporaryClaimPath, claimPath);
|
|
163
|
+
try {
|
|
164
|
+
unlinkSync(temporaryClaimPath);
|
|
165
|
+
}
|
|
166
|
+
catch { /* the private temporary hard link is safe to clean later */ }
|
|
167
|
+
syncDirectory(dirname(path));
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (claimDescriptor !== undefined) {
|
|
171
|
+
try {
|
|
172
|
+
closeSync(claimDescriptor);
|
|
173
|
+
}
|
|
174
|
+
catch { /* preserve the original failure */ }
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
unlinkSync(temporaryClaimPath);
|
|
178
|
+
}
|
|
179
|
+
catch { /* best-effort temporary cleanup */ }
|
|
180
|
+
if (!isExistsError(error))
|
|
181
|
+
throw new WorkspaceLockCorruptError();
|
|
182
|
+
const existingClaim = this.readRecoveryClaim(claimPath);
|
|
183
|
+
if (this.isProcessAlive(existingClaim.pid))
|
|
184
|
+
throw new WorkspaceBusyError();
|
|
185
|
+
// A crashed recovery owner cannot be safely removed by a competing process;
|
|
186
|
+
// fail closed rather than risk deleting a newly acquired workspace lock.
|
|
187
|
+
throw new WorkspaceLockCorruptError();
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const before = statSync(path);
|
|
191
|
+
const current = this.read(path, observed.workspaceFingerprint);
|
|
192
|
+
if (!current)
|
|
193
|
+
return true;
|
|
194
|
+
const after = statSync(path);
|
|
195
|
+
if (current.ownerToken !== observed.ownerToken || before.dev !== after.dev || before.ino !== after.ino)
|
|
196
|
+
return false;
|
|
197
|
+
if (this.isProcessAlive(current.pid))
|
|
198
|
+
return false;
|
|
199
|
+
unlinkSync(path);
|
|
200
|
+
syncDirectory(dirname(path));
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (isMissingError(error))
|
|
205
|
+
return true;
|
|
206
|
+
if (error instanceof WorkspaceLockCorruptError)
|
|
207
|
+
throw error;
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
try {
|
|
212
|
+
const currentClaim = this.readRecoveryClaim(claimPath);
|
|
213
|
+
if (currentClaim.ownerToken === claimToken && currentClaim.pid === process.pid)
|
|
214
|
+
unlinkSync(claimPath);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Do not remove a malformed or replaced recovery claim.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
readRecoveryClaim(path) {
|
|
222
|
+
try {
|
|
223
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
224
|
+
if (!isRecord(value)
|
|
225
|
+
|| value.version !== LOCK_VERSION
|
|
226
|
+
|| typeof value.ownerToken !== "string"
|
|
227
|
+
|| typeof value.pid !== "number"
|
|
228
|
+
|| !Number.isInteger(value.pid)
|
|
229
|
+
|| Object.keys(value).some((key) => !["version", "ownerToken", "pid"].includes(key))) {
|
|
230
|
+
throw new WorkspaceLockCorruptError();
|
|
231
|
+
}
|
|
232
|
+
return { ownerToken: value.ownerToken, pid: value.pid };
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
if (error instanceof WorkspaceLockCorruptError)
|
|
236
|
+
throw error;
|
|
237
|
+
throw new WorkspaceLockCorruptError();
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
lease(path, record) {
|
|
241
|
+
let released = false;
|
|
242
|
+
return {
|
|
243
|
+
workspaceFingerprint: record.workspaceFingerprint,
|
|
244
|
+
openCodeSessionId: record.openCodeSessionId,
|
|
245
|
+
release: async () => {
|
|
246
|
+
if (released)
|
|
247
|
+
return;
|
|
248
|
+
released = true;
|
|
249
|
+
let current;
|
|
250
|
+
try {
|
|
251
|
+
current = this.read(path, record.workspaceFingerprint);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
if (!current) {
|
|
257
|
+
released = true;
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (current.ownerToken !== record.ownerToken || current.pid !== record.pid) {
|
|
261
|
+
released = true;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
unlinkSync(path);
|
|
266
|
+
syncDirectory(dirname(path));
|
|
267
|
+
released = true;
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
if (isMissingError(error)) {
|
|
271
|
+
released = true;
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
throw new WorkspaceLockCorruptError();
|
|
275
|
+
}
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
function isExistsError(error) {
|
|
281
|
+
return isNodeErrorCode(error, "EEXIST");
|
|
282
|
+
}
|
|
283
|
+
function isMissingError(error) {
|
|
284
|
+
return isNodeErrorCode(error, "ENOENT");
|
|
285
|
+
}
|
|
286
|
+
function isNodeErrorCode(error, code) {
|
|
287
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
288
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type MuseRequestKind = "primary" | "title" | "compaction" | "generate";
|
|
2
|
+
export interface MuseModelSelection {
|
|
3
|
+
providerId: string;
|
|
4
|
+
modelId: string;
|
|
5
|
+
profileId: string | null;
|
|
6
|
+
isActive: boolean;
|
|
7
|
+
isDefault: boolean;
|
|
8
|
+
defaultReasoningEffort?: string;
|
|
9
|
+
variantsKnown: boolean;
|
|
10
|
+
variants: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface RequestContext {
|
|
13
|
+
sessionID: string;
|
|
14
|
+
directory: string;
|
|
15
|
+
kind: MuseRequestKind;
|
|
16
|
+
modelID: string;
|
|
17
|
+
providerID: string;
|
|
18
|
+
variant?: string;
|
|
19
|
+
effort?: string;
|
|
20
|
+
museSelection: MuseModelSelection;
|
|
21
|
+
}
|
|
22
|
+
export interface ContextError {
|
|
23
|
+
type: "context-error";
|
|
24
|
+
code: "missing" | "invalid" | "unknown-model" | "unsupported-variant";
|
|
25
|
+
field: string;
|
|
26
|
+
}
|
|
27
|
+
export type MuseErrorKind = "credentials" | "billing" | "unsupported" | "protocol" | "unknown";
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|