@zq-silk/yui 0.15.9 → 0.15.11

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 (100) hide show
  1. package/dist/agent/launchEnvironment.js +7 -0
  2. package/dist/cli/commandCatalog.js +26 -6
  3. package/dist/cli/interactionPolicy.js +3 -3
  4. package/dist/cli/updateOrchestrator.js +24 -1
  5. package/dist/cli/updatePorts.js +7 -3
  6. package/dist/cli/upgradeCommand.js +42 -2
  7. package/dist/cli.js +336 -93
  8. package/dist/commands/globalRoleCommands.js +314 -4
  9. package/dist/commands/projectCommands.js +6 -7
  10. package/dist/commands/releaseCommands.js +18 -0
  11. package/dist/commands/taskActor.js +25 -0
  12. package/dist/commands/taskCommands.js +511 -83
  13. package/dist/commands/taskIntegrationCommands.js +16 -38
  14. package/dist/commands/taskIntegrationQueueCommands.js +1 -1
  15. package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
  16. package/dist/commands/taskRoleRuntimeStatus.js +35 -0
  17. package/dist/context/taskContext.js +38 -2
  18. package/dist/controller/agentHostObservation.js +155 -0
  19. package/dist/controller/clientRuntime.js +17 -2
  20. package/dist/controller/controller.js +3 -0
  21. package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
  22. package/dist/controller/globalInputDelivery.js +119 -0
  23. package/dist/controller/jobControl.js +6 -2
  24. package/dist/controller/resourceInventory.js +14 -4
  25. package/dist/controller/resourceInventoryLinux.js +2 -6
  26. package/dist/controller/runtime.js +81 -6
  27. package/dist/controller/runtimeEventInbox.js +32 -3
  28. package/dist/controller/runtimeEventProcessor.js +26 -6
  29. package/dist/controller/runtimeHookRunFence.js +75 -19
  30. package/dist/controller/structuredProviderObservation.js +133 -70
  31. package/dist/coordination/workMailboxQueue.js +5 -0
  32. package/dist/execution/workItemExecutionProjection.js +1 -1
  33. package/dist/executor/agentExecutor.js +64 -4
  34. package/dist/executor/executorRegistry.js +3 -0
  35. package/dist/executor/fileRoleLaunchPlanner.js +78 -118
  36. package/dist/integration/deliveryObligation.js +2 -1
  37. package/dist/integration/gitIntegrationService.js +312 -382
  38. package/dist/integration/integrationAttempt.js +30 -4
  39. package/dist/integration/integrationQueueService.js +7 -7
  40. package/dist/integration/integrationSourceApplication.js +323 -0
  41. package/dist/message/globalInterrupt.js +33 -0
  42. package/dist/message/inputControlResolution.js +106 -0
  43. package/dist/message/message.js +367 -0
  44. package/dist/message/messageContinuation.js +126 -3
  45. package/dist/message/taskInterrupt.js +34 -0
  46. package/dist/observability/orchestrationMetrics.js +1 -1
  47. package/dist/release/releaseHandover.js +22 -0
  48. package/dist/release/releaseWorkflowPorts.js +15 -7
  49. package/dist/repository/gitWorkspace.js +72 -15
  50. package/dist/repository/taskWorkspaceCoordinator.js +134 -0
  51. package/dist/repository/taskWorkspacePreparer.js +120 -49
  52. package/dist/repository/workItemCandidateSnapshot.js +34 -0
  53. package/dist/resources/resourceDiscovery.js +3 -2
  54. package/dist/runtime/agentHost.js +152 -72
  55. package/dist/runtime/agentHostCompatibility.js +127 -0
  56. package/dist/runtime/agentHostProtocol.js +53 -0
  57. package/dist/runtime/executionEnvironment.js +0 -19
  58. package/dist/runtime/launchBroker.js +6 -0
  59. package/dist/runtime/sessionReconciliation.js +4 -4
  60. package/dist/runtime/taskRuntimeIsolation.js +30 -6
  61. package/dist/runtime/tmuxAdapters.js +5 -3
  62. package/dist/scheduler/operatorEvent.js +4 -0
  63. package/dist/scheduler/taskExecutionProjection.js +12 -1
  64. package/dist/scheduler/wakeReason.js +7 -1
  65. package/dist/scheduler/wakeupQueue.js +2 -0
  66. package/dist/setup/setupCommand.js +26 -8
  67. package/dist/storage/homeLayout.js +130 -0
  68. package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
  69. package/dist/storage/migrations/integrationContinuation.js +104 -0
  70. package/dist/storage/migrations/unifyHomeLayout.js +925 -0
  71. package/dist/storage/sqliteSchema.js +136 -4
  72. package/dist/storage/sqliteStore.js +40 -1
  73. package/dist/storage/storageVersions.js +1 -1
  74. package/dist/storage/storeRpc.js +1 -0
  75. package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
  76. package/dist/task/archiveDiagnostics.js +128 -0
  77. package/dist/task/nextAction.js +44 -11
  78. package/dist/web/assets/client/app.js +55 -0
  79. package/dist/web/assets/client/components.js +1 -0
  80. package/dist/web/assets/client/i18n.js +6 -0
  81. package/dist/web/assets/client/taskSurface.js +106 -0
  82. package/dist/web/assets/client/view.js +7 -4
  83. package/dist/web/assets/shell.js +23 -0
  84. package/dist/web/assets/styles/layout.js +1 -1
  85. package/dist/web/assets/styles/widgets.js +12 -0
  86. package/dist/web/webServer.js +117 -1
  87. package/dist/web/webSnapshot.js +4 -3
  88. package/dist/web/webTaskSurface.js +222 -5
  89. package/dist/workspace/workItemChangeSetManager.js +18 -2
  90. package/docs/release-workflow.md +39 -0
  91. package/docs/release-workflow.zh-CN.md +29 -0
  92. package/docs/sqlite-control-plane-design.md +223 -1
  93. package/docs/testing/verification-levels.md +24 -0
  94. package/docs/testing/verification-levels.zh-CN.md +11 -0
  95. package/package.json +1 -1
  96. package/skills/yui-leader/references/execution.md +151 -49
  97. package/skills/yui-leader/references/integration.md +52 -2
  98. package/skills/yui-operator/SKILL.md +6 -1
  99. package/skills/yui-runtime/SKILL.md +27 -0
  100. package/skills/yui-runtime/references/publication.md +20 -0
@@ -0,0 +1,963 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ /**
7
+ * Storage 24 -> 25: collapse the two-layer managed workspace layout into a
8
+ * single layer of real Git worktrees addressed by Task/owner and Project.
9
+ *
10
+ * Before this migration each managed Git worktree lived under a per-Project
11
+ * physical root (`<home>/workspaces/worktree/<projectName>/<taskKey>/<roleKey>`)
12
+ * and every Task-owned view (`<home>/workspaces/tasks/<taskId>/<owner>/`) was a
13
+ * directory of SYMLINKS whose names were the bound Project directory and whose
14
+ * targets were those physical worktrees. Two families of paths therefore
15
+ * described one worktree: the physical location and the symlink view.
16
+ *
17
+ * This migration removes that indirection. Every real worktree is relocated to
18
+ * the location the view symlink used to point the runtime at —
19
+ * `<home>/workspaces/tasks/<taskId>/<owner>/<projectDirectory>` — so the logical
20
+ * entry path and the physical Git worktree become the same directory. The owner
21
+ * root is the SAME per-Task directory the v19 view used (`main`,
22
+ * `work-items/<id>`, `reviews/<name>`, `execution-lanes/<g>/<l>`); an
23
+ * Integration attempt, whose v19 workspace root WAS its physical worktree under
24
+ * `worktree/`, moves under a new owner root
25
+ * `<home>/workspaces/tasks/<taskId>/integrations/<integrationId>/`.
26
+ *
27
+ * ONLY the durable managed Git worktrees are physically relocated — the sole
28
+ * subtree that holds non-regenerable committed AND uncommitted work. Each is
29
+ * COPIED (never renamed away), content-verified by digest, and atomically
30
+ * published; the original under `worktree/` is PRESERVED as the rollback anchor
31
+ * and is never removed by this data step. The v19 view symlink that occupies the
32
+ * exact publish target is a regenerable pointer: it is unlinked immediately
33
+ * before the real worktree is published there. Read-only context entries (a
34
+ * WorkItem/lane view onto the shared Task main) are NOT relocated — their stored
35
+ * pointer is rewritten to the new Task-main path and their on-disk symlink is
36
+ * rebuilt by `ensureWorkspaceView` at the next launch.
37
+ *
38
+ * The pointer rewrite is SURGICAL, mirroring the 18->19 migration: only the
39
+ * records the runtime dereferences as live launch pointers are touched
40
+ * (`managed_workspaces`, active `turns`, `role_session_sets` /
41
+ * `global_role_session_sets`, live `review_rounds`, open lanes in `work_items`,
42
+ * `task_roles.workspace`, `task_records.cwd`). Frozen evidence (Context
43
+ * snapshots, terminal Run Git evidence, candidate snapshots, terminal lanes and
44
+ * Jobs, reports) is left byte-for-byte intact, and the Git ref names — which are
45
+ * path-independent — are never rewritten.
46
+ *
47
+ * A queued or running `durable_jobs` step runs in a detached process that
48
+ * SURVIVES the Controller quiesce fence, so a live cwd bound under a relocating
49
+ * worktree would be an in-flight silent migration (forbidden). Such a Job is
50
+ * refused up front rather than relocated.
51
+ *
52
+ * OFFLINE migration by design, applied by the standalone `yui upgrade` mutation
53
+ * boundary AFTER the operator has stopped this Home's writers. Its only runtime
54
+ * precondition is the minimal in-flight-Job conflict check. On any failure it
55
+ * throws, the upgrade transaction rolls back to its starting version, and the preserved
56
+ * source under `worktree/` plus the fenced DB backup are the recovery pair for a
57
+ * manual re-run.
58
+ *
59
+ * FROZEN migration. Its behaviour must never change once released, so it inlines
60
+ * the v19 and v20 layouts instead of importing runtime path helpers, and runtime
61
+ * code must not depend on this module.
62
+ */
63
+ export const COLLAPSE_WORKTREE_LAYOUT_SQL = "SELECT 1; -- data/fs-transform: collapse managed worktrees into single-layer Task/owner paths";
64
+ /** DurableJob statuses that are not terminal: a runner may still be executing. */
65
+ const NON_TERMINAL_JOB_STATUSES = ["queued", "running"];
66
+ /**
67
+ * Copy every managed worktree into its single-layer Task/owner location
68
+ * (verifying each replica and preserving the original), repair the copied Git
69
+ * worktrees, then rewrite only the persisted pointers the runtime trusts as
70
+ * live. Runs inside the upgrade transaction: any throw rolls the schema back to
71
+ * 19, and because each source is copied (never renamed away) and content-
72
+ * verified before publish, a failed run leaves the originals intact.
73
+ */
74
+ export function migrateCollapseWorktreeLayout(db) {
75
+ const home = resolve(dirname(db.name));
76
+ const rewrites = planCollapseRewrites(db, home);
77
+ if (rewrites.length === 0)
78
+ return;
79
+ // Offline precheck — no filesystem or row mutation happens before this passes.
80
+ const oldRoots = rewrites.map((move) => move.from);
81
+ assertNoInFlightJobUnderRoots(db, oldRoots);
82
+ // Physically relocate each durable worktree: unlink the regenerable v19 view
83
+ // symlink occupying the target, then copy -> digest-verify -> atomic publish,
84
+ // preserving the source. A throw before publish leaves the source intact.
85
+ for (const move of rewrites) {
86
+ relocateWorktree(move.from, move.to);
87
+ }
88
+ if (rewrites.some((move) => existsSync(move.to))) {
89
+ repairRelocatedWorktrees(db, rewrites);
90
+ }
91
+ // Rewrite every LIVE launch pointer consistently; frozen evidence is untouched.
92
+ rewriteManagedWorkspaces(db, rewrites);
93
+ rewriteActiveRunWorkspaces(db, rewrites);
94
+ rewriteRoleSessionSets(db, rewrites);
95
+ rewriteReviewRoundWorkspaces(db, rewrites);
96
+ rewriteWorkItemExecutionGroups(db, rewrites);
97
+ rewriteRoleWorkspaces(db, rewrites);
98
+ rewriteTaskCwd(db, rewrites);
99
+ }
100
+ /**
101
+ * Read-only counterpart to {@link migrateCollapseWorktreeLayout}: derive the
102
+ * SAME plan and evaluate the SAME blocking conditions WITHOUT mutating the
103
+ * database or the filesystem, so `yui upgrade --dry-run` and the updater's
104
+ * `--update-preflight` can report a trustworthy verdict before the Controller is
105
+ * stopped. Every branch collects rather than throws, so one run reports all
106
+ * independent blockers.
107
+ */
108
+ export function preflightCollapseWorktreeLayout(db) {
109
+ const home = resolve(dirname(db.name));
110
+ const rewrites = planCollapseRewrites(db, home);
111
+ if (rewrites.length === 0) {
112
+ return Object.freeze({ noop: true, plannedRelocations: 0, plannedRewrites: 0, blockers: [] });
113
+ }
114
+ const blockers = [];
115
+ const oldRoots = rewrites.map((move) => move.from);
116
+ for (const detail of collectInFlightJobBlockers(db, oldRoots)) {
117
+ blockers.push({ reason: "in-flight-job", detail });
118
+ }
119
+ for (const detail of collectRelocationConflicts(rewrites)) {
120
+ blockers.push(detail);
121
+ }
122
+ return Object.freeze({
123
+ noop: false,
124
+ plannedRelocations: rewrites.length,
125
+ plannedRewrites: rewrites.length,
126
+ blockers: Object.freeze(blockers)
127
+ });
128
+ }
129
+ /**
130
+ * Derive the structural relocation plan from the authoritative workspace
131
+ * registry. Pure except for reading `managed_workspaces` and `task_records`.
132
+ * Every WRITE entry whose recorded path still lies under the legacy `worktree/`
133
+ * root yields one relocation from that physical path to `join(ownerRoot,
134
+ * segment)`. For a Task/WorkItem/Review/lane the owner root is the v19 view root
135
+ * (unchanged) and the segment is the stored `entry.directory`, which v19 already
136
+ * recorded as the BOUND directory — so the target reproduces the native v20 path
137
+ * even when the Task used `--directory`. An Integration attempt is the sole
138
+ * exception: v19 stored its `entry.directory` as the PROJECT NAME while the v20
139
+ * runtime addresses it by the bound directory, so its owner root is a NEW
140
+ * `integrations/<id>` directory and its segment is the bound directory looked up
141
+ * from the Task (falling back to the stored value when the binding is gone).
142
+ * Read entries reference a Task-main write entry already in the plan, so they add
143
+ * nothing. The list doubles as the physical copy list and the pointer-prefix map.
144
+ */
145
+ function planCollapseRewrites(db, home) {
146
+ if (!tableExists(db, "managed_workspaces"))
147
+ return [];
148
+ const legacyWorktreeRoot = join(home, "workspaces", "worktree");
149
+ const bound = boundDirectories(db);
150
+ const rewrites = [];
151
+ const seen = new Set();
152
+ const rows = db
153
+ .prepare("SELECT owner_kind, payload FROM managed_workspaces")
154
+ .all();
155
+ for (const row of rows) {
156
+ const workspace = parseWorkspace(row.payload);
157
+ if (workspace === undefined)
158
+ continue;
159
+ const ownerRoot = newOwnerRoot(home, workspace);
160
+ if (ownerRoot === undefined)
161
+ continue;
162
+ const taskId = typeof workspace.owner?.taskId === "string" ? workspace.owner.taskId : undefined;
163
+ const isIntegration = workspace.owner?.type === "integration-attempt";
164
+ for (const entry of workspace.entries) {
165
+ if (entry.access !== "write")
166
+ continue;
167
+ if (typeof entry.path !== "string" || typeof entry.directory !== "string")
168
+ continue;
169
+ const from = resolve(entry.path);
170
+ // Only relocate worktrees still under the legacy physical root; anything
171
+ // already single-layer (a re-run, or a Home built fresh at v20) is skipped
172
+ // so the plan is a clean no-op rather than touching a foreign path.
173
+ if (!isUnderRoot(from, legacyWorktreeRoot))
174
+ continue;
175
+ // Non-integration owners already store the bound directory; only an
176
+ // integration attempt must be remapped from its project-name segment to
177
+ // the bound directory the v20 runtime re-derives on re-entry.
178
+ const segment = isIntegration && taskId !== undefined && typeof entry.projectId === "string"
179
+ ? bound.get(`${taskId} ${entry.projectId}`) ?? entry.directory
180
+ : entry.directory;
181
+ const to = join(ownerRoot, segment);
182
+ if (seen.has(from))
183
+ continue;
184
+ seen.add(from);
185
+ rewrites.push({ from, to });
186
+ }
187
+ }
188
+ return rewrites;
189
+ }
190
+ /**
191
+ * The bound Project directory for every (taskId, projectId), read from the
192
+ * authoritative `task_records.projectBindings`. This is the identity the v20
193
+ * runtime uses to address an integration worktree, so the migration relocates to
194
+ * the same segment rather than guessing from the Project name.
195
+ */
196
+ function boundDirectories(db) {
197
+ const bound = new Map();
198
+ if (!tableExists(db, "task_records"))
199
+ return bound;
200
+ const rows = db
201
+ .prepare("SELECT task_id, payload FROM task_records")
202
+ .all();
203
+ for (const row of rows) {
204
+ let task;
205
+ try {
206
+ task = JSON.parse(row.payload);
207
+ }
208
+ catch {
209
+ continue;
210
+ }
211
+ if (!Array.isArray(task.projectBindings))
212
+ continue;
213
+ for (const binding of task.projectBindings) {
214
+ if (typeof binding.projectId === "string" && typeof binding.directory === "string") {
215
+ bound.set(`${row.task_id} ${binding.projectId}`, binding.directory);
216
+ }
217
+ }
218
+ }
219
+ return bound;
220
+ }
221
+ /**
222
+ * The single-layer owner root for a workspace. For every owner except an
223
+ * Integration attempt this is exactly the recorded v19 view root (`main`,
224
+ * `work-items/<id>`, `reviews/<name>`, `execution-lanes/<g>/<l>`), which becomes
225
+ * the real worktree's parent unchanged. An Integration attempt's v19 root was
226
+ * its physical worktree under `worktree/`, so it is remapped to a NEW owner root
227
+ * `<home>/workspaces/tasks/<taskId>/integrations/<integrationId>`.
228
+ */
229
+ function newOwnerRoot(home, workspace) {
230
+ const owner = workspace.owner;
231
+ if (owner === undefined || typeof owner.taskId !== "string")
232
+ return undefined;
233
+ if (owner.type === "integration-attempt") {
234
+ if (typeof owner.integrationAttemptId !== "string")
235
+ return undefined;
236
+ return join(home, "workspaces", "tasks", owner.taskId, "integrations", owner.integrationAttemptId);
237
+ }
238
+ return typeof workspace.root === "string" ? resolve(workspace.root) : undefined;
239
+ }
240
+ function parseWorkspace(payload) {
241
+ let parsed;
242
+ try {
243
+ parsed = JSON.parse(payload);
244
+ }
245
+ catch {
246
+ return undefined;
247
+ }
248
+ if (parsed === null || typeof parsed !== "object")
249
+ return undefined;
250
+ const entries = parsed.entries;
251
+ return {
252
+ owner: parsed.owner,
253
+ root: parsed.root,
254
+ entries: Array.isArray(entries) ? entries : []
255
+ };
256
+ }
257
+ /** True when a table currently exists (a read-only preflight may run at v19). */
258
+ function tableExists(db, name) {
259
+ return (db
260
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
261
+ .get(name) !== undefined);
262
+ }
263
+ /**
264
+ * Refuse to migrate a pointer a non-terminal Job is bound to: a queued/running
265
+ * `durable_jobs` whose workspace (or a step cwd) lies under a relocating
266
+ * worktree, whose detached runner survives the Controller fence. `active_turns`
267
+ * is deliberately NOT consulted — it holds steady-state pointers for every
268
+ * active Task and would over-refuse.
269
+ */
270
+ function assertNoInFlightJobUnderRoots(db, roots) {
271
+ const blockers = collectInFlightJobBlockers(db, roots);
272
+ if (blockers.length > 0)
273
+ throw new Error(blockers[0]);
274
+ }
275
+ function collectInFlightJobBlockers(db, roots) {
276
+ if (!tableExists(db, "durable_jobs"))
277
+ return [];
278
+ const placeholders = NON_TERMINAL_JOB_STATUSES.map(() => "?").join(", ");
279
+ const rows = db
280
+ .prepare(`SELECT payload FROM durable_jobs WHERE status IN (${placeholders})`)
281
+ .all(...NON_TERMINAL_JOB_STATUSES);
282
+ const blockers = [];
283
+ for (const row of rows) {
284
+ let job;
285
+ try {
286
+ job = JSON.parse(row.payload);
287
+ }
288
+ catch {
289
+ continue;
290
+ }
291
+ const candidates = [];
292
+ if (typeof job.workspace === "string")
293
+ candidates.push(job.workspace);
294
+ if (Array.isArray(job.steps)) {
295
+ for (const step of job.steps) {
296
+ if (typeof step.cwd === "string")
297
+ candidates.push(step.cwd);
298
+ }
299
+ }
300
+ for (const candidate of candidates) {
301
+ if (isUnderAnyRoot(resolve(candidate), roots)) {
302
+ blockers.push("Refusing to collapse the managed worktree layout: a queued or running durable Job"
303
+ + `${typeof job.id === "string" ? ` (${job.id})` : ""} is bound to a managed workspace `
304
+ + `under relocation (${candidate}). Let it drain or cancel it (\`yui job ...\`), then `
305
+ + "retry the upgrade.");
306
+ break;
307
+ }
308
+ }
309
+ }
310
+ return blockers;
311
+ }
312
+ /**
313
+ * Read-only detection of the filesystem condition that would make the relocate
314
+ * step REFUSE: a publish target that already exists AND is not the regenerable
315
+ * v19 view symlink this migration is entitled to replace. A symlink target is
316
+ * expected (it is the view pointer the real worktree supersedes); a real
317
+ * directory or file there is a foreign conflict or residue from a failed run.
318
+ */
319
+ function collectRelocationConflicts(relocations) {
320
+ const blockers = [];
321
+ for (const move of relocations) {
322
+ if (!targetConflicts(move.to))
323
+ continue;
324
+ blockers.push({
325
+ reason: "target-conflict",
326
+ detail: `the relocation target ${move.to} already exists and is not the expected v19 view `
327
+ + `symlink; it is a foreign directory or residue from a failed prior run. Confirm the `
328
+ + `source at ${move.from} is intact, then move or remove ${move.to} before retrying the upgrade`
329
+ });
330
+ }
331
+ return blockers;
332
+ }
333
+ /** True when the publish target exists as something other than a symlink. */
334
+ function targetConflicts(to) {
335
+ let stat;
336
+ try {
337
+ stat = lstatSync(to);
338
+ }
339
+ catch {
340
+ return false;
341
+ }
342
+ return !stat.isSymbolicLink();
343
+ }
344
+ /**
345
+ * A content-addressed inventory digest of a tree: for every path in
346
+ * deterministic order, the relative path, kind, and either the file size + byte
347
+ * content or the symlink target. Two trees with an identical digest are byte-for
348
+ * -byte identical in structure and content — the proof a replica is faithful.
349
+ */
350
+ function treeInventoryDigest(root) {
351
+ const hash = createHash("sha256");
352
+ const walk = (dir, rel) => {
353
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
354
+ for (const entry of entries) {
355
+ const abs = join(dir, entry.name);
356
+ const relPath = rel === "" ? entry.name : `${rel}/${entry.name}`;
357
+ const stat = lstatSync(abs);
358
+ if (stat.isSymbolicLink()) {
359
+ hash.update(`L ${relPath}\0${readlinkSync(abs)}\0`);
360
+ }
361
+ else if (stat.isDirectory()) {
362
+ hash.update(`D ${relPath}\0`);
363
+ walk(abs, relPath);
364
+ }
365
+ else if (stat.isFile()) {
366
+ hash.update(`F ${relPath}\0${stat.size}\0`);
367
+ hash.update(readFileSync(abs));
368
+ hash.update("\0");
369
+ }
370
+ else {
371
+ hash.update(`O ${relPath}\0`);
372
+ }
373
+ }
374
+ };
375
+ walk(root, "");
376
+ return hash.digest("hex");
377
+ }
378
+ /**
379
+ * Copy one managed worktree into its single-layer location, NON-DESTRUCTIVELY to
380
+ * the source. The regenerable v19 view symlink occupying the exact target is
381
+ * unlinked first (it is a pointer the real worktree supersedes); a real
382
+ * directory or file there is a foreign conflict and is refused. The source is
383
+ * copied to a same-filesystem staging directory, the replica's content digest is
384
+ * verified against the source, and only a verified replica is atomically renamed
385
+ * into place. The original under `worktree/` is PRESERVED as the rollback anchor.
386
+ *
387
+ * Offline and single-shot. Any throw happens before the atomic rename, so the
388
+ * source is left intact and nothing is published; recovery is a manual re-run.
389
+ */
390
+ function relocateWorktree(from, to) {
391
+ const staging = `${to}.incoming`;
392
+ // The v19 view symlink at the target is the regenerable pointer the real
393
+ // worktree replaces: unlink it so the publish rename has a clear target. A
394
+ // non-symlink at the target is never silently adopted.
395
+ let existing;
396
+ try {
397
+ existing = lstatSync(to);
398
+ }
399
+ catch {
400
+ existing = undefined;
401
+ }
402
+ if (existing !== undefined) {
403
+ if (!existing.isSymbolicLink()) {
404
+ throw new Error(`Refusing to collapse the managed worktree layout: the relocation target ${to} already `
405
+ + `exists and is not the expected v19 view symlink. It is a foreign directory or residue `
406
+ + `from a failed prior run. Confirm the source at ${from} is intact, then move or remove `
407
+ + `${to} and re-run the upgrade.`);
408
+ }
409
+ unlinkSync(to);
410
+ }
411
+ if (!existsSync(from)) {
412
+ // The source worktree was never materialised on disk (a stale registry row);
413
+ // nothing to copy. Its pointer is still rewritten and re-prepared at launch.
414
+ return;
415
+ }
416
+ const sourceDigest = treeInventoryDigest(from);
417
+ mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
418
+ rmSync(staging, { recursive: true, force: true });
419
+ // Copy (never move) so the original survives as the rollback anchor even if
420
+ // this transaction later throws. Symlinks and Git admin files are verbatim.
421
+ cpSync(from, staging, { recursive: true, verbatimSymlinks: true });
422
+ if (treeInventoryDigest(staging) !== sourceDigest) {
423
+ rmSync(staging, { recursive: true, force: true });
424
+ throw new Error(`Refusing to collapse the managed worktree layout: the relocated copy of ${from} did not `
425
+ + `match the source content after copying. The original is untouched; re-run the upgrade.`);
426
+ }
427
+ renameSync(staging, to);
428
+ }
429
+ /**
430
+ * After the worktrees are copied, the copies' absolute Git pointers still
431
+ * reference the OLD `worktree/` locations. They are corrected in two steps, per
432
+ * (Task, Project) group: (1) deterministically REWRITE, in the NEW copy only,
433
+ * each worktree cross-reference pointer file (a linked worktree's `.git` stub
434
+ * and each main clone's `.git/worktrees/<name>/gitdir`) from the OLD path to the
435
+ * NEW path, so no native git command chases a stale pointer back into and
436
+ * mutates the preserved OLD source; then (2) run `git worktree repair` from the
437
+ * main clone at its NEW path as a belt-and-braces reconciliation, now confined
438
+ * to the NEW tree. A repair FAILURE IS FATAL: the transaction rolls back.
439
+ */
440
+ function repairRelocatedWorktrees(db, rewrites) {
441
+ const groups = new Map();
442
+ const rows = db
443
+ .prepare("SELECT owner_kind, payload FROM managed_workspaces")
444
+ .all();
445
+ for (const row of rows) {
446
+ const workspace = parseWorkspace(row.payload);
447
+ if (workspace === undefined)
448
+ continue;
449
+ const taskId = typeof workspace.owner?.taskId === "string" ? workspace.owner.taskId : undefined;
450
+ if (taskId === undefined)
451
+ continue;
452
+ for (const entry of workspace.entries) {
453
+ // Only real worktrees (write entries) are relocated and repaired; a
454
+ // read-only alias is a symlink view rebuilt at launch, never a Git tree.
455
+ if (entry.access !== "write")
456
+ continue;
457
+ if (typeof entry.projectId !== "string" || typeof entry.path !== "string")
458
+ continue;
459
+ // Derive the new path from the SAME relocation map used to copy the tree,
460
+ // so plan and repair can never disagree. A write entry already single-layer
461
+ // (no map match) yields undefined and is skipped.
462
+ const newPath = applyPrefix(resolve(entry.path), rewrites);
463
+ if (newPath === undefined)
464
+ continue;
465
+ const key = `${taskId} ${entry.projectId}`;
466
+ const group = groups.get(key) ?? { linked: new Set() };
467
+ if (row.owner_kind === "task")
468
+ group.main = newPath;
469
+ else
470
+ group.linked.add(newPath);
471
+ groups.set(key, group);
472
+ }
473
+ }
474
+ for (const group of groups.values()) {
475
+ const main = group.main;
476
+ // A group whose main clone was never materialised on disk (a stale registry
477
+ // row) has nothing to repair and is re-prepared lazily on next launch. A
478
+ // main that DOES exist but fails to repair is a real integrity failure.
479
+ if (main === undefined || !existsSync(main))
480
+ continue;
481
+ const linked = [...group.linked].filter((path) => existsSync(path));
482
+ relinkWorktreePointers(main, linked, rewrites);
483
+ execFileSync("git", ["-C", main, "worktree", "repair", ...linked], {
484
+ stdio: "ignore",
485
+ timeout: 60_000
486
+ });
487
+ }
488
+ }
489
+ /**
490
+ * Rewrite, in the NEW copy only, the worktree cross-reference pointer files so
491
+ * every path under a relocating OLD worktree is repointed to its NEW location:
492
+ * each main clone's `.git/worktrees/<name>/gitdir` (-> `<linked>/.git`) and each
493
+ * linked worktree's `.git` stub (-> `<main>/.git/worktrees/<name>`). Only OLD->
494
+ * NEW prefixes are substituted, so a pointer already NEW or outside every
495
+ * relocating root is left untouched. Nothing outside the NEW copy is touched.
496
+ */
497
+ function relinkWorktreePointers(main, linked, rewrites) {
498
+ const worktreesDir = join(main, ".git", "worktrees");
499
+ if (existsSync(worktreesDir)) {
500
+ let names;
501
+ try {
502
+ names = readdirSync(worktreesDir);
503
+ }
504
+ catch {
505
+ names = [];
506
+ }
507
+ for (const name of names) {
508
+ relinkPointerFile(join(worktreesDir, name, "gitdir"), rewrites);
509
+ }
510
+ }
511
+ for (const worktree of linked) {
512
+ relinkPointerFile(join(worktree, ".git"), rewrites);
513
+ }
514
+ }
515
+ /**
516
+ * Rewrite a single Git pointer file's stored path with the OLD->NEW prefix map,
517
+ * preserving any `gitdir: ` prefix and trailing newline. A file that is absent,
518
+ * unreadable, a directory (a real repository, never a pointer), or whose path is
519
+ * not under any relocating root is left exactly as-is.
520
+ */
521
+ function relinkPointerFile(pointerPath, rewrites) {
522
+ let raw;
523
+ try {
524
+ if (lstatSync(pointerPath).isDirectory())
525
+ return;
526
+ raw = readFileSync(pointerPath, "utf8");
527
+ }
528
+ catch {
529
+ return;
530
+ }
531
+ const trailing = raw.endsWith("\n") ? "\n" : "";
532
+ const body = trailing === "\n" ? raw.slice(0, -1) : raw;
533
+ const marker = "gitdir: ";
534
+ const hasMarker = body.startsWith(marker);
535
+ const pathToken = hasMarker ? body.slice(marker.length) : body;
536
+ const rewritten = applyPrefix(pathToken.trim(), rewrites);
537
+ if (rewritten === undefined || rewritten === pathToken.trim())
538
+ return;
539
+ const next = `${hasMarker ? marker : ""}${rewritten}${trailing}`;
540
+ writeFileSync(pointerPath, next);
541
+ }
542
+ /**
543
+ * Rewrite the authoritative workspace registry: the `path` column plus the
544
+ * payload `root` and every `entries[].path`, all via the SAME OLD->NEW leaf map.
545
+ * Every surviving row is a live workspace, so all are rewritten. The map is keyed
546
+ * on each write worktree's old physical path, which makes the rewrite uniform
547
+ * across owners: an Integration attempt (whose v19 `root` and single write entry
548
+ * BOTH hold the old leaf path) and a read-only alias (whose entry path is the
549
+ * Task main's old leaf) are both rewritten by the same prefix substitution, while
550
+ * a Task/WorkItem/Review/lane `root` already addresses the unchanged Task view
551
+ * directory and is left as-is because no map key matches it. Timestamps are
552
+ * untouched so a live mirror stays deep-equal to its row.
553
+ */
554
+ function rewriteManagedWorkspaces(db, rewrites) {
555
+ const rows = db
556
+ .prepare("SELECT owner_kind, owner_id, path, payload FROM managed_workspaces")
557
+ .all();
558
+ const update = db.prepare("UPDATE managed_workspaces SET path = ?, payload = ? WHERE owner_kind = ? AND owner_id = ?");
559
+ for (const row of rows) {
560
+ const newPath = applyPrefix(row.path, rewrites) ?? row.path;
561
+ let workspace;
562
+ try {
563
+ workspace = JSON.parse(row.payload);
564
+ }
565
+ catch {
566
+ continue;
567
+ }
568
+ const rewritten = rewriteRegistryWorkspace(workspace, rewrites);
569
+ const newPayload = JSON.stringify(rewritten);
570
+ if (newPath === row.path && newPayload === row.payload)
571
+ continue;
572
+ update.run(newPath, newPayload, row.owner_kind, row.owner_id);
573
+ }
574
+ }
575
+ /**
576
+ * Rewrite the live launch cwd carried by each active AgentRun. The actual OS cwd
577
+ * is derived from `run.effective.workspace`, and `validateRun` requires
578
+ * `run.workspace` (when present) to stay identical to it, so BOTH are rewritten
579
+ * with the same prefix map — never one without the other. Terminal runs and
580
+ * `.result.systemEvidence.workspaceSnapshot` are frozen evidence.
581
+ */
582
+ function rewriteActiveRunWorkspaces(db, rewrites) {
583
+ const rows = db
584
+ .prepare("SELECT task_id, turn_id, payload FROM turns WHERE status = 'active'")
585
+ .all();
586
+ const update = db.prepare("UPDATE turns SET payload = ? WHERE task_id = ? AND turn_id = ?");
587
+ for (const row of rows) {
588
+ let run;
589
+ try {
590
+ run = JSON.parse(row.payload);
591
+ }
592
+ catch {
593
+ continue;
594
+ }
595
+ let next = run;
596
+ const effective = run.effective;
597
+ if (effective !== null && typeof effective === "object") {
598
+ const effectiveWorkspace = effective.workspace;
599
+ if (effectiveWorkspace !== null && typeof effectiveWorkspace === "object") {
600
+ const rewritten = rewriteWorkspaceObject(effectiveWorkspace, rewrites);
601
+ if (!isDeepStrictEqual(rewritten, effectiveWorkspace)) {
602
+ next = {
603
+ ...next,
604
+ effective: { ...effective, workspace: rewritten }
605
+ };
606
+ }
607
+ }
608
+ }
609
+ const workspace = run.workspace;
610
+ if (workspace !== null && typeof workspace === "object") {
611
+ const rewritten = rewriteWorkspaceObject(workspace, rewrites);
612
+ if (!isDeepStrictEqual(rewritten, workspace)) {
613
+ next = { ...next, workspace: rewritten };
614
+ }
615
+ }
616
+ if (next === run)
617
+ continue;
618
+ update.run(JSON.stringify(next), row.task_id, row.turn_id);
619
+ }
620
+ }
621
+ /**
622
+ * Rewrite each live native session's frozen launch cwd. A session in the
623
+ * `sessions` map is a resumable live binding whose `effective.workspace` is the
624
+ * cwd a resume would relaunch under. Terminal sessions in `history` are frozen
625
+ * evidence. Both the per-Task and global session stores carry the same shape.
626
+ */
627
+ function rewriteRoleSessionSets(db, rewrites) {
628
+ rewriteSessionSetTable(db, "SELECT task_id, role_name, payload FROM role_session_sets", "UPDATE role_session_sets SET payload = ? WHERE task_id = ? AND role_name = ?", (row) => [row.task_id, row.role_name], rewrites);
629
+ rewriteSessionSetTable(db, "SELECT name, payload FROM global_role_session_sets", "UPDATE global_role_session_sets SET payload = ? WHERE name = ?", (row) => [row.name], rewrites);
630
+ }
631
+ function rewriteSessionSetTable(db, select, updateSql, keyOf, rewrites) {
632
+ const rows = db.prepare(select).all();
633
+ const update = db.prepare(updateSql);
634
+ for (const row of rows) {
635
+ let set;
636
+ try {
637
+ set = JSON.parse(row.payload);
638
+ }
639
+ catch {
640
+ continue;
641
+ }
642
+ const sessions = set.sessions;
643
+ if (sessions === null || typeof sessions !== "object")
644
+ continue;
645
+ let changed = false;
646
+ const nextSessions = {};
647
+ for (const [id, session] of Object.entries(sessions)) {
648
+ const rewrittenSession = rewriteSessionEffectiveWorkspace(session, rewrites);
649
+ if (rewrittenSession !== session)
650
+ changed = true;
651
+ nextSessions[id] = rewrittenSession;
652
+ }
653
+ if (!changed)
654
+ continue;
655
+ update.run(JSON.stringify({ ...set, sessions: nextSessions }), ...keyOf(row));
656
+ }
657
+ }
658
+ function rewriteSessionEffectiveWorkspace(session, rewrites) {
659
+ if (session === null || typeof session !== "object")
660
+ return session;
661
+ const effective = session.effective;
662
+ if (effective === null || typeof effective !== "object")
663
+ return session;
664
+ const workspace = effective.workspace;
665
+ if (workspace === null || typeof workspace !== "object")
666
+ return session;
667
+ const rewritten = rewriteWorkspaceObject(workspace, rewrites);
668
+ if (isDeepStrictEqual(rewritten, workspace))
669
+ return session;
670
+ return {
671
+ ...session,
672
+ effective: { ...effective, workspace: rewritten }
673
+ };
674
+ }
675
+ /**
676
+ * Rewrite the ManagedWorkspace a ReviewRound mirrors (while its registry row
677
+ * still exists) and each OPEN execution lane's live launch pointers. An orphaned
678
+ * mirror and any terminal lane are frozen evidence.
679
+ */
680
+ function rewriteReviewRoundWorkspaces(db, rewrites) {
681
+ const liveReviewOwners = liveOwnerKeys(db, "review-round", (owner) => typeof owner.taskId === "string" && typeof owner.reviewRoundId === "string"
682
+ ? `${owner.taskId} ${owner.reviewRoundId}`
683
+ : undefined);
684
+ const rows = db
685
+ .prepare("SELECT task_id, review_round_id, payload FROM review_rounds")
686
+ .all();
687
+ const update = db.prepare("UPDATE review_rounds SET payload = ? WHERE task_id = ? AND review_round_id = ?");
688
+ for (const row of rows) {
689
+ let round;
690
+ try {
691
+ round = JSON.parse(row.payload);
692
+ }
693
+ catch {
694
+ continue;
695
+ }
696
+ let next = round;
697
+ const workspace = round.workspace;
698
+ if (workspace !== null && typeof workspace === "object") {
699
+ const owner = workspace.owner;
700
+ const ownerKey = owner !== undefined
701
+ && typeof owner.taskId === "string" && typeof owner.reviewRoundId === "string"
702
+ ? `${owner.taskId} ${owner.reviewRoundId}`
703
+ : undefined;
704
+ if (ownerKey !== undefined && liveReviewOwners.has(ownerKey)) {
705
+ const rewritten = rewriteWorkspaceObject(workspace, rewrites);
706
+ if (!isDeepStrictEqual(rewritten, workspace))
707
+ next = { ...next, workspace: rewritten };
708
+ }
709
+ }
710
+ const group = round.executionGroup;
711
+ if (group !== null && typeof group === "object") {
712
+ const rewrittenGroup = rewriteExecutionGroupOpenLanes(group, rewrites);
713
+ if (rewrittenGroup !== group)
714
+ next = { ...next, executionGroup: rewrittenGroup };
715
+ }
716
+ if (next === round)
717
+ continue;
718
+ update.run(JSON.stringify(next), row.task_id, row.review_round_id);
719
+ }
720
+ }
721
+ /**
722
+ * Rewrite the OPEN execution lanes inside each WorkItem's `executionGroups`. A
723
+ * WorkItemCandidate snapshot is frozen evidence; only an open lane is live.
724
+ */
725
+ function rewriteWorkItemExecutionGroups(db, rewrites) {
726
+ const rows = db
727
+ .prepare("SELECT task_id, work_item_id, payload FROM work_items")
728
+ .all();
729
+ const update = db.prepare("UPDATE work_items SET payload = ? WHERE task_id = ? AND work_item_id = ?");
730
+ for (const row of rows) {
731
+ let item;
732
+ try {
733
+ item = JSON.parse(row.payload);
734
+ }
735
+ catch {
736
+ continue;
737
+ }
738
+ const groups = item.executionGroups;
739
+ if (!Array.isArray(groups))
740
+ continue;
741
+ let changed = false;
742
+ const nextGroups = groups.map((group) => {
743
+ const rewritten = rewriteExecutionGroupOpenLanes(group, rewrites);
744
+ if (rewritten !== group)
745
+ changed = true;
746
+ return rewritten;
747
+ });
748
+ if (!changed)
749
+ continue;
750
+ update.run(JSON.stringify({ ...item, executionGroups: nextGroups }), row.task_id, row.work_item_id);
751
+ }
752
+ }
753
+ function rewriteExecutionGroupOpenLanes(group, rewrites) {
754
+ if (group === null || typeof group !== "object")
755
+ return group;
756
+ const lanes = group.lanes;
757
+ if (!Array.isArray(lanes))
758
+ return group;
759
+ let changed = false;
760
+ const nextLanes = lanes.map((lane) => {
761
+ if (lane === null || typeof lane !== "object")
762
+ return lane;
763
+ if (lane.disposition !== "open")
764
+ return lane;
765
+ const rewritten = rewriteLaneLaunchPointers(lane, rewrites);
766
+ if (rewritten !== lane)
767
+ changed = true;
768
+ return rewritten;
769
+ });
770
+ if (!changed)
771
+ return group;
772
+ return { ...group, lanes: nextLanes };
773
+ }
774
+ /**
775
+ * Rewrite one lane's `effective.workspace` (root + entries) and `workspace.root`.
776
+ * A lane's root already addresses the Task `execution-lanes/<g>/<l>` view
777
+ * directory and is unchanged by the map; its writable entries' paths are the
778
+ * relocating worktrees. Returns the same reference when unchanged.
779
+ */
780
+ function rewriteLaneLaunchPointers(lane, rewrites) {
781
+ let next = lane;
782
+ const effective = lane.effective;
783
+ if (effective !== null && typeof effective === "object") {
784
+ const workspace = effective.workspace;
785
+ if (workspace !== null && typeof workspace === "object") {
786
+ const rewritten = rewriteWorkspaceObject(workspace, rewrites);
787
+ if (!isDeepStrictEqual(rewritten, workspace)) {
788
+ next = {
789
+ ...next,
790
+ effective: { ...effective, workspace: rewritten }
791
+ };
792
+ }
793
+ }
794
+ }
795
+ const workspace = next.workspace;
796
+ if (workspace !== null && typeof workspace === "object"
797
+ && typeof workspace.root === "string") {
798
+ const root = workspace.root;
799
+ const rewritten = applyPrefix(root, rewrites);
800
+ if (rewritten !== undefined && rewritten !== root) {
801
+ next = { ...next, workspace: { ...workspace, root: rewritten } };
802
+ }
803
+ }
804
+ return next;
805
+ }
806
+ /**
807
+ * Rewrite the live launch cwd on each Role. Active Roles under a Task root also
808
+ * self-heal via `prepareTaskWorkspace`, but a Draft's planning Role is never
809
+ * prepared until activation, so rewriting here is its only correction. A cwd
810
+ * outside every relocated root is left as-is.
811
+ */
812
+ function rewriteRoleWorkspaces(db, rewrites) {
813
+ const rows = db
814
+ .prepare("SELECT task_id, role_name, payload FROM task_roles")
815
+ .all();
816
+ const update = db.prepare("UPDATE task_roles SET payload = ? WHERE task_id = ? AND role_name = ?");
817
+ for (const row of rows) {
818
+ let role;
819
+ try {
820
+ role = JSON.parse(row.payload);
821
+ }
822
+ catch {
823
+ continue;
824
+ }
825
+ if (typeof role.workspace !== "string")
826
+ continue;
827
+ const next = applyPrefix(role.workspace, rewrites);
828
+ if (next === undefined || next === role.workspace)
829
+ continue;
830
+ update.run(JSON.stringify({ ...role, workspace: next }), row.task_id, row.role_name);
831
+ }
832
+ }
833
+ /**
834
+ * Defensively rewrite `task_records.cwd`. It self-heals to the freshly resolved
835
+ * Task root on the next `prepareTaskWorkspace`, but rewriting here closes the
836
+ * window in which a reader could observe a stale cwd. Not gate-validated.
837
+ */
838
+ function rewriteTaskCwd(db, rewrites) {
839
+ const rows = db
840
+ .prepare("SELECT task_id, payload FROM task_records")
841
+ .all();
842
+ const update = db.prepare("UPDATE task_records SET payload = ? WHERE task_id = ?");
843
+ for (const row of rows) {
844
+ let task;
845
+ try {
846
+ task = JSON.parse(row.payload);
847
+ }
848
+ catch {
849
+ continue;
850
+ }
851
+ if (typeof task.cwd !== "string")
852
+ continue;
853
+ const next = applyPrefix(task.cwd, rewrites);
854
+ if (next === undefined || next === task.cwd)
855
+ continue;
856
+ update.run(JSON.stringify({ ...task, cwd: next }), row.task_id);
857
+ }
858
+ }
859
+ /**
860
+ * The live owner identity keys present in `managed_workspaces` for one owner
861
+ * kind. Distinguishes a live embedded mirror (row present) from frozen evidence.
862
+ */
863
+ function liveOwnerKeys(db, ownerKind, keyOf) {
864
+ const keys = new Set();
865
+ const rows = db
866
+ .prepare("SELECT payload FROM managed_workspaces WHERE owner_kind = ?")
867
+ .all(ownerKind);
868
+ for (const row of rows) {
869
+ let workspace;
870
+ try {
871
+ workspace = JSON.parse(row.payload);
872
+ }
873
+ catch {
874
+ continue;
875
+ }
876
+ if (workspace.owner === undefined)
877
+ continue;
878
+ const key = keyOf(workspace.owner);
879
+ if (key !== undefined)
880
+ keys.add(key);
881
+ }
882
+ return keys;
883
+ }
884
+ /**
885
+ * Return a copy of a workspace-shaped object (ManagedWorkspace or
886
+ * EffectiveLaunchWorkspace) with `root` and every `entries[].path` prefix-
887
+ * rewritten. Key order and untouched fields are preserved. Used for launch
888
+ * pointers OUTSIDE `managed_workspaces`; the registry rewriter handles the
889
+ * Integration root exception itself.
890
+ */
891
+ function rewriteWorkspaceObject(workspace, rewrites) {
892
+ const next = { ...workspace };
893
+ if (typeof workspace.root === "string") {
894
+ next.root = applyPrefix(workspace.root, rewrites) ?? workspace.root;
895
+ }
896
+ if (Array.isArray(workspace.entries)) {
897
+ next.entries = workspace.entries.map((entry) => {
898
+ if (entry !== null && typeof entry === "object" && typeof entry.path === "string") {
899
+ const path = entry.path;
900
+ return { ...entry, path: applyPrefix(path, rewrites) ?? path };
901
+ }
902
+ return entry;
903
+ });
904
+ }
905
+ return next;
906
+ }
907
+ /**
908
+ * Return a copy of a registry ManagedWorkspace with `root` and every
909
+ * `entries[].path` prefix-rewritten, AND every `entries[].directory` resynced to
910
+ * the basename of its rewritten path. In the single-layer v20 layout a managed
911
+ * worktree always lives at `join(root, directory)`, so `directory` must equal
912
+ * `basename(path)`; consumers reconstruct the worktree location as
913
+ * `join(dirname(entry.path), entry.directory)`. For a Task/WorkItem/Review/lane
914
+ * write entry and every read alias the basename already equals the stored
915
+ * directory, so the resync is a no-op; for an Integration attempt (whose v19
916
+ * `directory` was the Project name but whose relocated path now ends in the bound
917
+ * directory) it rewrites the segment to match, reproducing the native v20 record.
918
+ * Used ONLY for `managed_workspaces`; launch-pointer mirrors elsewhere carry no
919
+ * integration entry and use {@link rewriteWorkspaceObject}.
920
+ */
921
+ function rewriteRegistryWorkspace(workspace, rewrites) {
922
+ const next = { ...workspace };
923
+ if (typeof workspace.root === "string") {
924
+ next.root = applyPrefix(workspace.root, rewrites) ?? workspace.root;
925
+ }
926
+ if (Array.isArray(workspace.entries)) {
927
+ next.entries = workspace.entries.map((entry) => {
928
+ if (entry === null || typeof entry !== "object")
929
+ return entry;
930
+ const path = entry.path;
931
+ if (typeof path !== "string")
932
+ return entry;
933
+ const newPath = applyPrefix(path, rewrites);
934
+ if (newPath === undefined)
935
+ return entry;
936
+ return { ...entry, path: newPath, directory: basename(newPath) };
937
+ });
938
+ }
939
+ return next;
940
+ }
941
+ /** Substitute the first matching root prefix, or undefined when none applies. */
942
+ function applyPrefix(value, rewrites) {
943
+ for (const { from, to } of rewrites) {
944
+ if (value === from)
945
+ return to;
946
+ const nested = relative(from, value);
947
+ if (nested.length > 0 && !nested.startsWith("..") && !isAbsolute(nested)) {
948
+ return join(to, nested);
949
+ }
950
+ }
951
+ return undefined;
952
+ }
953
+ /** True when `value` is `root` or lies beneath it. */
954
+ function isUnderRoot(value, root) {
955
+ if (value === root)
956
+ return true;
957
+ const nested = relative(root, value);
958
+ return nested.length > 0 && !nested.startsWith("..") && !isAbsolute(nested);
959
+ }
960
+ /** True when `value` is one of `roots` or lies beneath one of them. */
961
+ function isUnderAnyRoot(value, roots) {
962
+ return roots.some((root) => isUnderRoot(value, root));
963
+ }