@arnilo/prism-coding-agent 0.2.4 → 0.2.6

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +10 -0
  3. package/dist/coding-checkpoint.js +4 -0
  4. package/dist/diagnostics.d.ts +83 -0
  5. package/dist/diagnostics.js +179 -0
  6. package/dist/git.d.ts +27 -6
  7. package/dist/git.js +58 -1
  8. package/dist/index.d.ts +13 -4
  9. package/dist/index.js +13 -2
  10. package/dist/language/client.d.ts +25 -0
  11. package/dist/language/client.js +54 -0
  12. package/dist/language/framing.d.ts +9 -1
  13. package/dist/language/framing.js +89 -17
  14. package/dist/language/index.d.ts +1 -1
  15. package/dist/language/intelligence.js +58 -0
  16. package/dist/language/types.d.ts +32 -0
  17. package/dist/limits.d.ts +59 -0
  18. package/dist/limits.js +59 -0
  19. package/dist/process/index.d.ts +4 -1
  20. package/dist/process/index.js +1 -0
  21. package/dist/process/recovery.d.ts +174 -0
  22. package/dist/process/recovery.js +320 -0
  23. package/dist/process/sessions.js +714 -25
  24. package/dist/process/types.d.ts +128 -4
  25. package/dist/process/types.js +7 -1
  26. package/dist/repository/glob.d.ts +4 -0
  27. package/dist/repository/glob.js +143 -0
  28. package/dist/repository/indexed-search.d.ts +121 -0
  29. package/dist/repository/indexed-search.js +313 -0
  30. package/dist/repository/list.d.ts +3 -0
  31. package/dist/repository/list.js +119 -0
  32. package/dist/repository/operations.d.ts +5 -0
  33. package/dist/repository/operations.js +14 -0
  34. package/dist/repository/path.d.ts +18 -0
  35. package/dist/repository/path.js +91 -0
  36. package/dist/repository/search.d.ts +9 -0
  37. package/dist/repository/search.js +284 -0
  38. package/dist/repository/types.d.ts +138 -0
  39. package/dist/repository/types.js +31 -0
  40. package/dist/repository/walk.d.ts +22 -0
  41. package/dist/repository/walk.js +99 -0
  42. package/dist/repository.d.ts +11 -172
  43. package/dist/repository.js +11 -748
  44. package/dist/review.d.ts +150 -0
  45. package/dist/review.js +222 -0
  46. package/dist/search.d.ts +3 -1
  47. package/dist/search.js +42 -7
  48. package/dist/workspace-lifecycle.d.ts +153 -0
  49. package/dist/workspace-lifecycle.js +629 -0
  50. package/package.json +3 -3
@@ -0,0 +1,629 @@
1
+ /**
2
+ * Ownership-scoped multi-repository and worktree lifecycle (plan 026 Task 3).
3
+ *
4
+ * A durable coding workspace correlates task/session/run identity with host
5
+ * repositories and linked worktrees. The lifecycle composes existing bounded
6
+ * primitives only: `CheckpointStore` CAS records (versioned namespace, never
7
+ * `CodingCheckpointMetadata` v1), `LeaseStore` fencing, and cwd-bound
8
+ * `GitOperations` runners. There is no clone manager, Git library, watcher,
9
+ * new database schema, or second task runtime.
10
+ *
11
+ * The main worktree of every registered repository is immutable through this
12
+ * service: only linked worktrees created by `create` are ever locked,
13
+ * verified, or removed. Roots, worktree destinations, and loaded records are
14
+ * canonicalized and containment-checked under host-approved roots; remote
15
+ * identity is stored as a credential-free fingerprint, never a URL.
16
+ */
17
+ import { access, realpath } from "node:fs/promises";
18
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
19
+ import { HARD_MAX_CODING_ARTIFACTS, HARD_MAX_WORKSPACE_CLEANUP_OPERATIONS, HARD_MAX_WORKSPACE_LEASE_TTL_MS, HARD_MAX_WORKSPACE_RECORD_BYTES, HARD_MAX_WORKSPACE_REPOSITORIES, HARD_MAX_WORKSPACE_WORKTREES, DEFAULT_MAX_CODING_ARTIFACTS, DEFAULT_MAX_WORKSPACE_CLEANUP_OPERATIONS, DEFAULT_MAX_WORKSPACE_LEASE_TTL_MS, DEFAULT_MAX_WORKSPACE_RECORD_BYTES, DEFAULT_MAX_WORKSPACE_REPOSITORIES, DEFAULT_MAX_WORKSPACE_WORKTREES, validateCodingLimit, } from "./limits.js";
20
+ import { createHash } from "node:crypto";
21
+ /** Separate versioned namespace: never collides with coding checkpoint v1 keys. */
22
+ export const WORKSPACE_NAMESPACE = "prism.coding-agent.workspace.v1";
23
+ export const WORKSPACE_SCHEMA_VERSION = 1;
24
+ export const WORKSPACE_LOCK_REASON_PREFIX = "prism-workspace:";
25
+ export const WORKSPACE_STATES = ["active", "cleaning", "closed", "unknown"];
26
+ export class WorkspaceError extends Error {
27
+ code;
28
+ constructor(code, message) {
29
+ super(message);
30
+ this.name = "WorkspaceError";
31
+ this.code = code;
32
+ }
33
+ }
34
+ export function resolveWorkspaceLimits(options) {
35
+ return {
36
+ maxRepositories: validateCodingLimit("maxRepositories", options?.maxRepositories ?? DEFAULT_MAX_WORKSPACE_REPOSITORIES, HARD_MAX_WORKSPACE_REPOSITORIES),
37
+ maxWorktrees: validateCodingLimit("maxWorktrees", options?.maxWorktrees ?? DEFAULT_MAX_WORKSPACE_WORKTREES, HARD_MAX_WORKSPACE_WORKTREES),
38
+ maxRecordBytes: validateCodingLimit("maxRecordBytes", options?.maxRecordBytes ?? DEFAULT_MAX_WORKSPACE_RECORD_BYTES, HARD_MAX_WORKSPACE_RECORD_BYTES),
39
+ leaseTtlMs: validateCodingLimit("leaseTtlMs", options?.leaseTtlMs ?? DEFAULT_MAX_WORKSPACE_LEASE_TTL_MS, HARD_MAX_WORKSPACE_LEASE_TTL_MS),
40
+ maxCleanupOperations: validateCodingLimit("maxCleanupOperations", options?.maxCleanupOperations ?? DEFAULT_MAX_WORKSPACE_CLEANUP_OPERATIONS, HARD_MAX_WORKSPACE_CLEANUP_OPERATIONS),
41
+ };
42
+ }
43
+ const TASK_ID = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$/;
44
+ const REPOSITORY_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
45
+ const BRANCH = /^[^\s]{1,255}$/;
46
+ const SHA_HEX = /^[0-9a-f]{40,64}$/i;
47
+ const FINGERPRINT_HEX = /^[0-9a-f]{64}$/i;
48
+ const ARTIFACT_KINDS = new Set(["patch", "bundle", "diff", "other"]);
49
+ const MAX_ARTIFACT_URI_BYTES = 2048;
50
+ const MAX_OWNER_ID_BYTES = 512;
51
+ function workspaceIdForTask(taskId) {
52
+ return `ws-${createHash("sha256").update(taskId).digest("hex").slice(0, 24)}`;
53
+ }
54
+ function isInside(parent, child) {
55
+ const rel = relative(parent, child);
56
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
57
+ }
58
+ export function createCodingWorkspaceLifecycle(options) {
59
+ const limits = resolveWorkspaceLimits(options.limits);
60
+ const policy = {
61
+ allowDirtyCleanup: options.policy?.allowDirtyCleanup === true,
62
+ allowLockedCleanup: options.policy?.allowLockedCleanup === true,
63
+ allowMissingCleanup: options.policy?.allowMissingCleanup === true,
64
+ allowUnownedCleanup: options.policy?.allowUnownedCleanup === true,
65
+ allowMismatchedCleanup: options.policy?.allowMismatchedCleanup === true,
66
+ };
67
+ if (typeof options.ownerId !== "string" ||
68
+ options.ownerId.length === 0 ||
69
+ Buffer.byteLength(options.ownerId, "utf8") > MAX_OWNER_ID_BYTES) {
70
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", "ownerId must be a non-empty bounded string");
71
+ }
72
+ const registrations = new Map(Object.entries(options.repositories).map(([id, registration]) => {
73
+ if (!REPOSITORY_ID.test(id))
74
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", `invalid repositoryId: ${id}`);
75
+ if (typeof registration?.root !== "string" || !isAbsolute(registration.root)) {
76
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_PATH_ESCAPE", `repository ${id} root must be an absolute path`);
77
+ }
78
+ if (!registration.git || typeof registration.git.worktree !== "function") {
79
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", `repository ${id} has no bounded GitOperations`);
80
+ }
81
+ return [id, registration];
82
+ }));
83
+ if (registrations.size === 0)
84
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", "at least one repository registration is required");
85
+ /** Sync resolve-based containment roots; realpath hardening runs on verify/cleanup. */
86
+ const resolvedWorktreeRoots = (options.worktreeRoots ?? []).map((root) => resolve(root));
87
+ /** Canonicalized host-approved worktree destinations; captured lazily and cached. */
88
+ let canonicalWorktreeRoots;
89
+ async function worktreeRoots() {
90
+ if (canonicalWorktreeRoots)
91
+ return canonicalWorktreeRoots;
92
+ if (!options.worktreeRoots || options.worktreeRoots.length === 0) {
93
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", "at least one worktree root is required");
94
+ }
95
+ const roots = [];
96
+ for (const raw of options.worktreeRoots) {
97
+ if (typeof raw !== "string" || !isAbsolute(raw)) {
98
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_PATH_ESCAPE", "worktree roots must be absolute paths");
99
+ }
100
+ let canon;
101
+ try {
102
+ canon = await realpath(raw);
103
+ }
104
+ catch {
105
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_PATH_ESCAPE", `worktree root does not exist: ${raw}`);
106
+ }
107
+ if (!roots.some((existing) => existing === canon))
108
+ roots.push(canon);
109
+ }
110
+ canonicalWorktreeRoots = roots;
111
+ return roots;
112
+ }
113
+ async function canonicalRepositoryRoots() {
114
+ const roots = new Map();
115
+ for (const [id, registration] of registrations) {
116
+ let canon;
117
+ try {
118
+ canon = await realpath(registration.root);
119
+ }
120
+ catch {
121
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", `repository ${id} root does not exist`);
122
+ }
123
+ roots.set(id, canon);
124
+ }
125
+ return roots;
126
+ }
127
+ async function assertWorktreeContained(worktreePath) {
128
+ const roots = await worktreeRoots();
129
+ const resolved = resolve(worktreePath);
130
+ for (const root of roots) {
131
+ if (isInside(root, resolved))
132
+ return root;
133
+ }
134
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_PATH_ESCAPE", `worktree path escapes the approved roots: ${worktreePath}`);
135
+ }
136
+ function leaseKey(workspaceId) {
137
+ return { namespace: WORKSPACE_NAMESPACE, key: workspaceId, ...options.ownership };
138
+ }
139
+ function checkpointKey(workspaceId) {
140
+ return { namespace: WORKSPACE_NAMESPACE, key: workspaceId, ...options.ownership };
141
+ }
142
+ async function acquireLease(workspaceId, signal) {
143
+ let lease;
144
+ try {
145
+ lease = await options.leases.tryAcquireLease({
146
+ ...leaseKey(workspaceId),
147
+ ownerId: options.ownerId,
148
+ ttlMs: limits.leaseTtlMs,
149
+ signal,
150
+ });
151
+ }
152
+ catch (error) {
153
+ if (error instanceof WorkspaceError)
154
+ throw error;
155
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_OWNERSHIP", "workspace lease ownership mismatch");
156
+ }
157
+ if (!lease)
158
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FENCE", "another worker holds the workspace lease");
159
+ return lease;
160
+ }
161
+ async function releaseLease(workspaceId, token) {
162
+ await options.leases.releaseLease({ ...leaseKey(workspaceId), ownerId: options.ownerId, token });
163
+ }
164
+ function validateArtifactRefs(refs) {
165
+ if (!Array.isArray(refs) || refs.length > DEFAULT_MAX_CODING_ARTIFACTS) {
166
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", `artifact refs exceed ${DEFAULT_MAX_CODING_ARTIFACTS} (hard ${HARD_MAX_CODING_ARTIFACTS})`);
167
+ }
168
+ for (const ref of refs) {
169
+ if (!ref || !ARTIFACT_KINDS.has(ref.kind))
170
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", "invalid artifact kind");
171
+ if (typeof ref.uri !== "string" || Buffer.byteLength(ref.uri, "utf8") > MAX_ARTIFACT_URI_BYTES) {
172
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", "artifact uri must be a bounded string");
173
+ }
174
+ if (typeof ref.sha256 !== "string" || !FINGERPRINT_HEX.test(ref.sha256)) {
175
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", "artifact sha256 must be a sha256 hex digest");
176
+ }
177
+ if (!Number.isSafeInteger(ref.bytes) || ref.bytes < 0) {
178
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", "artifact bytes must be a non-negative safe integer");
179
+ }
180
+ }
181
+ }
182
+ function validateRecord(value) {
183
+ if (!value || typeof value !== "object")
184
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed workspace record");
185
+ const record = value;
186
+ if (record.schemaVersion !== WORKSPACE_SCHEMA_VERSION) {
187
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", `unsupported workspace schemaVersion: ${String(record.schemaVersion)}`);
188
+ }
189
+ if (typeof record.workspaceId !== "string" || !record.workspaceId.startsWith("ws-")) {
190
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed workspaceId");
191
+ }
192
+ if (typeof record.taskId !== "string" || !TASK_ID.test(record.taskId)) {
193
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed taskId");
194
+ }
195
+ if (typeof record.ownerId !== "string" || record.ownerId.length === 0 || record.ownerId.length > MAX_OWNER_ID_BYTES) {
196
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed ownerId");
197
+ }
198
+ if (!WORKSPACE_STATES.includes(record.state))
199
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", `unknown workspace state: ${String(record.state)}`);
200
+ if (!Array.isArray(record.repositories) || record.repositories.length === 0 || record.repositories.length > limits.maxRepositories) {
201
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed repository list");
202
+ }
203
+ const seen = new Set();
204
+ for (const repo of record.repositories) {
205
+ if (typeof repo?.repositoryId !== "string" || !REPOSITORY_ID.test(repo.repositoryId)) {
206
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed repositoryId");
207
+ }
208
+ if (seen.has(repo.repositoryId))
209
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "duplicate repositoryId");
210
+ seen.add(repo.repositoryId);
211
+ if (typeof repo.root !== "string" || !isAbsolute(repo.root))
212
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed repository root");
213
+ if (typeof repo.remoteFingerprint !== "string" || !FINGERPRINT_HEX.test(repo.remoteFingerprint)) {
214
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed remote fingerprint");
215
+ }
216
+ if (repo.defaultBranch !== undefined && (typeof repo.defaultBranch !== "string" || !BRANCH.test(repo.defaultBranch))) {
217
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed default branch");
218
+ }
219
+ if (typeof repo.branch !== "string" || !BRANCH.test(repo.branch))
220
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed branch");
221
+ if (typeof repo.base !== "string" || !SHA_HEX.test(repo.base))
222
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed base");
223
+ if (typeof repo.head !== "string" || !SHA_HEX.test(repo.head))
224
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed head");
225
+ if (typeof repo.worktreeId !== "string" || !repo.worktreeId.startsWith(record.workspaceId)) {
226
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed worktreeId");
227
+ }
228
+ if (typeof repo.worktreePath !== "string" || !isAbsolute(repo.worktreePath)) {
229
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed worktree path");
230
+ }
231
+ if (!resolvedWorktreeRoots.some((root) => isInside(root, resolve(repo.worktreePath)))) {
232
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_PATH_ESCAPE", "worktree path escapes the approved roots");
233
+ }
234
+ if (!["active", "removed", "unknown"].includes(repo.state)) {
235
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", `unknown repository state: ${String(repo.state)}`);
236
+ }
237
+ if (typeof repo.createdAt !== "string" || Number.isNaN(Date.parse(repo.createdAt))) {
238
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed createdAt");
239
+ }
240
+ }
241
+ validateArtifactRefs(record.artifactRefs ?? []);
242
+ if (!Number.isSafeInteger(record.fencingToken) || record.fencingToken < 0) {
243
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed fencing token");
244
+ }
245
+ if (typeof record.createdAt !== "string" || Number.isNaN(Date.parse(record.createdAt))) {
246
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed createdAt");
247
+ }
248
+ if (typeof record.updatedAt !== "string" || Number.isNaN(Date.parse(record.updatedAt))) {
249
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed updatedAt");
250
+ }
251
+ if (record.cleanupAt !== undefined && (typeof record.cleanupAt !== "string" || Number.isNaN(Date.parse(record.cleanupAt)))) {
252
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "malformed cleanupAt");
253
+ }
254
+ const encoded = Buffer.byteLength(JSON.stringify(record), "utf8");
255
+ if (encoded > limits.maxRecordBytes) {
256
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", `workspace record exceeds ${limits.maxRecordBytes} bytes`);
257
+ }
258
+ return record;
259
+ }
260
+ async function loadCheckpointRecord(workspaceId, signal) {
261
+ let checkpoint;
262
+ try {
263
+ checkpoint = await options.checkpoints.loadCheckpoint({ ...checkpointKey(workspaceId), signal });
264
+ }
265
+ catch (error) {
266
+ if (error instanceof WorkspaceError)
267
+ throw error;
268
+ // Ownership mismatch and store conflicts fail closed under one stable code.
269
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_OWNERSHIP", "workspace record access failed (ownership or store conflict)");
270
+ }
271
+ if (!checkpoint)
272
+ return null;
273
+ return { record: validateRecord(checkpoint.value), version: checkpoint.version };
274
+ }
275
+ async function loadRecord(workspaceId, signal) {
276
+ const loaded = await loadCheckpointRecord(workspaceId, signal);
277
+ return loaded ? loaded.record : null;
278
+ }
279
+ async function saveRecord(record, version, expectedVersion, fencingToken, signal) {
280
+ const encoded = Buffer.byteLength(JSON.stringify(record), "utf8");
281
+ if (encoded > limits.maxRecordBytes) {
282
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", `workspace record exceeds ${limits.maxRecordBytes} bytes`);
283
+ }
284
+ try {
285
+ await options.checkpoints.saveCheckpoint({
286
+ ...checkpointKey(record.workspaceId),
287
+ version,
288
+ expectedVersion,
289
+ fencingToken,
290
+ value: record,
291
+ category: "coding-workspace",
292
+ signal,
293
+ });
294
+ }
295
+ catch {
296
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FENCE", "workspace checkpoint CAS or fencing conflict");
297
+ }
298
+ return record;
299
+ }
300
+ async function verifyRepositoryIdentity(repo, signal) {
301
+ const registration = registrations.get(repo.repositoryId);
302
+ if (!registration)
303
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", `repository ${repo.repositoryId} is not registered on this host`);
304
+ let rootNow;
305
+ try {
306
+ rootNow = await realpath(registration.root);
307
+ }
308
+ catch {
309
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FINGERPRINT", `repository ${repo.repositoryId} root is gone`);
310
+ }
311
+ if (rootNow !== repo.root) {
312
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_PATH_ESCAPE", `repository ${repo.repositoryId} root moved (${rootNow})`);
313
+ }
314
+ await assertWorktreeContained(repo.worktreePath);
315
+ const fingerprint = await registration.git.fingerprint({ signal });
316
+ if (fingerprint.remoteFingerprint !== repo.remoteFingerprint ||
317
+ (fingerprint.defaultBranch ?? undefined) !== (repo.defaultBranch ?? undefined)) {
318
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FINGERPRINT", `repository ${repo.repositoryId} remote/default-branch fingerprint changed`);
319
+ }
320
+ const listed = await registration.git.worktree({ action: "list", signal });
321
+ const entry = listed.worktrees.find((worktree) => worktree.path === repo.worktreePath);
322
+ if (!entry)
323
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FINGERPRINT", `worktree ${repo.worktreePath} is not registered to repository ${repo.repositoryId}`);
324
+ if (entry.head && entry.head !== repo.head) {
325
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FINGERPRINT", `worktree ${repo.worktreePath} head changed`);
326
+ }
327
+ }
328
+ async function create(request) {
329
+ if (!request || typeof request.taskId !== "string" || !TASK_ID.test(request.taskId)) {
330
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", "taskId has invalid format");
331
+ }
332
+ if (!Array.isArray(request.repositories) || request.repositories.length === 0) {
333
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", "at least one repository is required");
334
+ }
335
+ if (request.repositories.length > limits.maxRepositories) {
336
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", `workspace exceeds ${limits.maxRepositories} repositories`);
337
+ }
338
+ if (request.repositories.length > limits.maxWorktrees) {
339
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", `workspace exceeds ${limits.maxWorktrees} worktrees`);
340
+ }
341
+ const requested = request.repositories.map((item) => {
342
+ if (!REPOSITORY_ID.test(item.repositoryId))
343
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", "invalid repositoryId");
344
+ if (typeof item.branch !== "string" || !BRANCH.test(item.branch) || item.branch.includes("..") || item.branch.startsWith("-")) {
345
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", "invalid branch name");
346
+ }
347
+ return { repositoryId: item.repositoryId, branch: item.branch };
348
+ });
349
+ const repositoryIds = new Set(requested.map((item) => item.repositoryId));
350
+ if (repositoryIds.size !== requested.length) {
351
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", "duplicate repositoryId in request");
352
+ }
353
+ for (const id of repositoryIds) {
354
+ if (!registrations.has(id))
355
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", `unknown repositoryId: ${id}`);
356
+ }
357
+ validateArtifactRefs(request.artifactRefs ?? []);
358
+ const workspaceId = workspaceIdForTask(request.taskId);
359
+ const existing = await loadRecord(workspaceId, request.signal);
360
+ if (existing) {
361
+ const sameSet = existing.repositories.length === requested.length &&
362
+ requested.every((item) => existing.repositories.some((repo) => repo.repositoryId === item.repositoryId && repo.branch === item.branch && repo.state === "active"));
363
+ if (existing.state === "active" && sameSet)
364
+ return existing; // idempotent duplicate create
365
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FENCE", `workspace exists in state ${existing.state}; remove or clean it first`);
366
+ }
367
+ const lease = await acquireLease(workspaceId, request.signal);
368
+ try {
369
+ const roots = await canonicalRepositoryRoots();
370
+ const now = new Date().toISOString();
371
+ const repositories = [];
372
+ const worktreeRoot = (await worktreeRoots())[0];
373
+ for (const item of requested) {
374
+ const registration = registrations.get(item.repositoryId);
375
+ const root = roots.get(item.repositoryId);
376
+ const fingerprint = await registration.git.fingerprint({ signal: request.signal });
377
+ const worktreeId = `${workspaceId}-${item.repositoryId}`;
378
+ const worktreePath = join(worktreeRoot, worktreeId);
379
+ await assertWorktreeContained(worktreePath);
380
+ // Retry-after-crash: a worktree already added by a previous attempt is reused.
381
+ const listed = await registration.git.worktree({ action: "list", signal: request.signal });
382
+ const existingTree = listed.worktrees.find((entry) => entry.path === worktreePath);
383
+ let head = existingTree?.head;
384
+ if (!existingTree) {
385
+ await registration.git.worktree({ action: "add", path: worktreePath, branch: item.branch, signal: request.signal });
386
+ const afterAdd = await registration.git.worktree({ action: "list", signal: request.signal });
387
+ head = afterAdd.worktrees.find((entry) => entry.path === worktreePath)?.head;
388
+ }
389
+ await registration.git.worktree({
390
+ action: "lock",
391
+ path: worktreePath,
392
+ reason: `${WORKSPACE_LOCK_REASON_PREFIX}${workspaceId}`,
393
+ signal: request.signal,
394
+ });
395
+ if (!head)
396
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FINGERPRINT", `could not determine head for ${worktreePath}`);
397
+ repositories.push({
398
+ repositoryId: item.repositoryId,
399
+ root,
400
+ remoteFingerprint: fingerprint.remoteFingerprint,
401
+ defaultBranch: fingerprint.defaultBranch,
402
+ branch: item.branch,
403
+ base: head,
404
+ head,
405
+ worktreeId,
406
+ worktreePath,
407
+ state: "active",
408
+ createdAt: now,
409
+ });
410
+ }
411
+ const record = validateRecord({
412
+ schemaVersion: WORKSPACE_SCHEMA_VERSION,
413
+ workspaceId,
414
+ taskId: request.taskId,
415
+ ownerId: options.ownerId,
416
+ state: "active",
417
+ repositories,
418
+ artifactRefs: request.artifactRefs ?? [],
419
+ fencingToken: lease.fencingToken,
420
+ createdAt: now,
421
+ updatedAt: now,
422
+ });
423
+ await saveRecord(record, 1, 0, lease.fencingToken, request.signal);
424
+ return record;
425
+ }
426
+ finally {
427
+ await releaseLease(workspaceId, lease.token);
428
+ }
429
+ }
430
+ async function get(input) {
431
+ if (typeof input?.taskId !== "string" || !TASK_ID.test(input.taskId)) {
432
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", "taskId has invalid format");
433
+ }
434
+ return loadRecord(workspaceIdForTask(input.taskId), input.signal);
435
+ }
436
+ async function list(input) {
437
+ const page = await options.checkpoints.listCheckpoints({
438
+ ...options.ownership,
439
+ namespace: WORKSPACE_NAMESPACE,
440
+ category: "coding-workspace",
441
+ cursor: input?.cursor,
442
+ limit: input?.limit ?? 100,
443
+ signal: input?.signal,
444
+ });
445
+ return { items: page.items.map((item) => validateRecord(item.value)), nextCursor: page.nextCursor };
446
+ }
447
+ async function verify(input) {
448
+ const workspaceId = workspaceIdForTask(input.taskId);
449
+ const record = await loadRecord(workspaceId, input.signal);
450
+ if (!record)
451
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", `workspace for task ${input.taskId} not found`);
452
+ if (record.state !== "active") {
453
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FENCE", `workspace state ${record.state} does not allow resume verification`);
454
+ }
455
+ for (const repo of record.repositories) {
456
+ if (repo.state === "removed")
457
+ continue;
458
+ await verifyRepositoryIdentity(repo, input.signal);
459
+ }
460
+ return record;
461
+ }
462
+ async function attachArtifacts(input) {
463
+ validateArtifactRefs(input.artifactRefs ?? []);
464
+ const workspaceId = workspaceIdForTask(input.taskId);
465
+ const existing = await loadRecord(workspaceId, input.signal);
466
+ if (!existing)
467
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", `workspace for task ${input.taskId} not found`);
468
+ if (existing.state !== "active") {
469
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FENCE", `workspace state ${existing.state} does not allow artifact attachment`);
470
+ }
471
+ const lease = await acquireLease(workspaceId, input.signal);
472
+ try {
473
+ const loaded = await loadCheckpointRecord(workspaceId, input.signal);
474
+ if (!loaded)
475
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FENCE", "workspace record vanished during artifact attach");
476
+ const merged = [...loaded.record.artifactRefs, ...input.artifactRefs];
477
+ if (merged.length > HARD_MAX_CODING_ARTIFACTS) {
478
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", `artifact refs exceed hard cap ${HARD_MAX_CODING_ARTIFACTS}`);
479
+ }
480
+ const updated = validateRecord({
481
+ ...loaded.record,
482
+ artifactRefs: merged,
483
+ updatedAt: new Date().toISOString(),
484
+ });
485
+ return await saveRecord(updated, loaded.version + 1, loaded.version, lease.fencingToken, input.signal);
486
+ }
487
+ finally {
488
+ await releaseLease(workspaceId, lease.token);
489
+ }
490
+ }
491
+ async function cleanup(input) {
492
+ const workspaceId = workspaceIdForTask(input.taskId);
493
+ const existing = await loadRecord(workspaceId, input.signal);
494
+ if (!existing)
495
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", `workspace for task ${input.taskId} not found`);
496
+ if (existing.state === "closed")
497
+ return existing; // idempotent
498
+ if (existing.state === "cleaning") {
499
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FENCE", "another worker is already cleaning this workspace");
500
+ }
501
+ const lease = await acquireLease(workspaceId, input.signal);
502
+ try {
503
+ const loaded = await loadCheckpointRecord(workspaceId, input.signal);
504
+ if (!loaded)
505
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FENCE", "workspace record vanished during cleanup");
506
+ const current = loaded.record;
507
+ const cleaning = validateRecord({ ...current, state: "cleaning", updatedAt: new Date().toISOString() });
508
+ await saveRecord(cleaning, loaded.version + 1, loaded.version, lease.fencingToken, input.signal);
509
+ let operations = 0;
510
+ const failures = [];
511
+ const nextRepos = [];
512
+ for (const repo of current.repositories) {
513
+ if (repo.state === "removed") {
514
+ nextRepos.push(repo);
515
+ continue;
516
+ }
517
+ operations += 1;
518
+ if (operations > limits.maxCleanupOperations) {
519
+ failures.push(new WorkspaceError("ERR_PRISM_WORKSPACE_LIMIT", `cleanup exceeds ${limits.maxCleanupOperations} operations`));
520
+ nextRepos.push({ ...repo, state: "unknown" });
521
+ continue;
522
+ }
523
+ try {
524
+ await removeWorktree(repo, input.signal);
525
+ nextRepos.push({ ...repo, state: "removed" });
526
+ }
527
+ catch (error) {
528
+ const failure = error instanceof WorkspaceError ? error : new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", "cleanup failed");
529
+ failures.push(failure);
530
+ nextRepos.push({ ...repo, state: "unknown" });
531
+ }
532
+ }
533
+ const succeeded = failures.length === 0;
534
+ const updated = validateRecord({
535
+ ...current,
536
+ state: succeeded ? "closed" : "unknown",
537
+ repositories: nextRepos,
538
+ cleanupAt: succeeded ? new Date().toISOString() : undefined,
539
+ updatedAt: new Date().toISOString(),
540
+ });
541
+ await saveRecord(updated, loaded.version + 2, loaded.version + 1, lease.fencingToken, input.signal);
542
+ if (failures.length > 0)
543
+ throw failures[0];
544
+ return updated;
545
+ }
546
+ finally {
547
+ await releaseLease(workspaceId, lease.token);
548
+ }
549
+ }
550
+ async function removeWorktree(repo, signal) {
551
+ const registration = registrations.get(repo.repositoryId);
552
+ if (!registration)
553
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", `repository ${repo.repositoryId} is not registered on this host`);
554
+ if (repo.worktreePath === repo.root) {
555
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_MAIN", `refusing to remove the main worktree of repository ${repo.repositoryId}`);
556
+ }
557
+ await assertWorktreeContained(repo.worktreePath);
558
+ const listed = await registration.git.worktree({ action: "list", signal });
559
+ const entry = listed.worktrees.find((worktree) => worktree.path === repo.worktreePath);
560
+ let existsOnDisk = true;
561
+ try {
562
+ await access(repo.worktreePath);
563
+ }
564
+ catch {
565
+ existsOnDisk = false;
566
+ }
567
+ if (!entry) {
568
+ if (!existsOnDisk) {
569
+ if (!policy.allowMissingCleanup) {
570
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", `worktree ${repo.worktreePath} is missing; cleanup refused (allowMissingCleanup)`);
571
+ }
572
+ return; // claimed as removed; nothing on disk or in git
573
+ }
574
+ if (!policy.allowUnownedCleanup) {
575
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_UNKNOWN", `path ${repo.worktreePath} exists but is not a registered worktree; cleanup refused (allowUnownedCleanup)`);
576
+ }
577
+ return; // documented action: unclaim without touching the foreign directory
578
+ }
579
+ if (entry.locked && !isOwnLock(entry.lockReason, repo.worktreePath)) {
580
+ if (!policy.allowLockedCleanup) {
581
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_LOCKED", `worktree ${repo.worktreePath} is locked by an external actor`);
582
+ }
583
+ await registration.git.worktree({ action: "unlock", path: repo.worktreePath, signal });
584
+ }
585
+ else if (entry.locked) {
586
+ await registration.git.worktree({ action: "unlock", path: repo.worktreePath, signal });
587
+ }
588
+ if (entry.head && entry.head !== repo.head && !policy.allowMismatchedCleanup) {
589
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FINGERPRINT", `worktree ${repo.worktreePath} head no longer matches the record; cleanup refused (allowMismatchedCleanup)`);
590
+ }
591
+ try {
592
+ await registration.git.worktree({ action: "remove", path: repo.worktreePath, signal });
593
+ }
594
+ catch {
595
+ if (policy.allowDirtyCleanup) {
596
+ await registration.git.worktree({ action: "remove", path: repo.worktreePath, force: true, signal });
597
+ return;
598
+ }
599
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_DIRTY", `worktree ${repo.worktreePath} is dirty; cleanup refused (allowDirtyCleanup)`);
600
+ }
601
+ }
602
+ function isOwnLock(reason, worktreePath) {
603
+ if (!reason)
604
+ return false;
605
+ // Reconstruct the workspace id from the worktree path (ws-<sha>-<repoId>).
606
+ const base = worktreePath.split(sep).pop() ?? "";
607
+ const dash = base.lastIndexOf("-");
608
+ const workspaceId = dash > 0 ? base.slice(0, dash) : "";
609
+ return workspaceId.startsWith("ws-") && reason === `${WORKSPACE_LOCK_REASON_PREFIX}${workspaceId}`;
610
+ }
611
+ async function remove(input) {
612
+ const workspaceId = workspaceIdForTask(input.taskId);
613
+ const existing = await loadRecord(workspaceId, input.signal);
614
+ if (!existing)
615
+ return false;
616
+ const lease = await acquireLease(workspaceId, input.signal);
617
+ try {
618
+ const deleted = await options.checkpoints.deleteCheckpoint({ ...checkpointKey(workspaceId), signal: input.signal });
619
+ if (!deleted)
620
+ throw new WorkspaceError("ERR_PRISM_WORKSPACE_FENCE", "workspace record vanished during remove");
621
+ return true;
622
+ }
623
+ finally {
624
+ await releaseLease(workspaceId, lease.token);
625
+ }
626
+ }
627
+ return { create, get, list, verify, attachArtifacts, cleanup, remove };
628
+ }
629
+ //# sourceMappingURL=workspace-lifecycle.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arnilo/prism-coding-agent",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Optional coding-agent tools (shell, read, write, edit, repo_list, repo_search, glob, delete, move, opt-in Git/check/ask-user-decision, and durable plan/checkpoint helpers) package for Prism.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,8 +28,8 @@
28
28
  "diff": "^9.0.0"
29
29
  },
30
30
  "peerDependencies": {
31
- "@arnilo/prism": "0.2.4",
32
- "@arnilo/prism-workflows": "0.2.4"
31
+ "@arnilo/prism": "0.2.6",
32
+ "@arnilo/prism-workflows": "0.2.6"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@arnilo/prism": "file:../..",