@zq-silk/yui 0.15.8 → 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 (151) hide show
  1. package/ARCHITECTURE.md +2 -0
  2. package/ARCHITECTURE.zh-CN.md +151 -0
  3. package/README.md +211 -14
  4. package/dist/agent/launchEnvironment.js +7 -0
  5. package/dist/artifacts/artifactCapability.js +74 -0
  6. package/dist/artifacts/artifactCommitLock.js +249 -0
  7. package/dist/artifacts/artifactPaths.js +151 -0
  8. package/dist/artifacts/gitArtifactRef.js +146 -0
  9. package/dist/artifacts/managedGit.js +332 -0
  10. package/dist/artifacts/taskArtifactRepository.js +277 -0
  11. package/dist/cli/commandCatalog.js +40 -16
  12. package/dist/cli/interactionPolicy.js +3 -3
  13. package/dist/cli/updateOrchestrator.js +24 -1
  14. package/dist/cli/updatePorts.js +7 -3
  15. package/dist/cli/upgradeCommand.js +42 -2
  16. package/dist/cli.js +403 -93
  17. package/dist/commands/globalRoleCommands.js +314 -4
  18. package/dist/commands/operatorCommands.js +33 -2
  19. package/dist/commands/projectCommands.js +6 -7
  20. package/dist/commands/releaseCommands.js +18 -0
  21. package/dist/commands/taskActivationCommands.js +22 -0
  22. package/dist/commands/taskActor.js +25 -0
  23. package/dist/commands/taskCommands.js +846 -155
  24. package/dist/commands/taskIntegrationCommands.js +16 -38
  25. package/dist/commands/taskIntegrationQueueCommands.js +1 -1
  26. package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
  27. package/dist/commands/taskRoleRuntimeStatus.js +35 -0
  28. package/dist/context/runContextPack.js +28 -16
  29. package/dist/context/taskContext.js +64 -5
  30. package/dist/controller/agentHostObservation.js +155 -0
  31. package/dist/controller/clientRuntime.js +17 -2
  32. package/dist/controller/controller.js +11 -2
  33. package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
  34. package/dist/controller/globalInputDelivery.js +119 -0
  35. package/dist/controller/jobControl.js +6 -2
  36. package/dist/controller/resourceInventory.js +14 -4
  37. package/dist/controller/resourceInventoryLinux.js +2 -6
  38. package/dist/controller/runtime.js +81 -6
  39. package/dist/controller/runtimeEventInbox.js +32 -3
  40. package/dist/controller/runtimeEventProcessor.js +26 -6
  41. package/dist/controller/runtimeHookRunFence.js +75 -19
  42. package/dist/controller/structuredProviderObservation.js +133 -70
  43. package/dist/coordination/workMailboxQueue.js +5 -0
  44. package/dist/execution/workItemExecutionProjection.js +1 -1
  45. package/dist/executor/agentExecutor.js +64 -4
  46. package/dist/executor/executorRegistry.js +3 -0
  47. package/dist/executor/fileRoleLaunchPlanner.js +78 -118
  48. package/dist/integration/deliveryObligation.js +2 -1
  49. package/dist/integration/gitIntegrationService.js +312 -382
  50. package/dist/integration/integrationAttempt.js +30 -4
  51. package/dist/integration/integrationQueueService.js +7 -7
  52. package/dist/integration/integrationSourceApplication.js +323 -0
  53. package/dist/kernel/builtinCapabilities.js +32 -24
  54. package/dist/message/globalInterrupt.js +33 -0
  55. package/dist/message/inputControlResolution.js +106 -0
  56. package/dist/message/message.js +423 -0
  57. package/dist/message/messageContinuation.js +126 -3
  58. package/dist/message/taskInterrupt.js +34 -0
  59. package/dist/observability/orchestrationMetrics.js +1 -1
  60. package/dist/plugins/pluginService.js +11 -3
  61. package/dist/release/releaseHandover.js +22 -0
  62. package/dist/release/releaseWorkflowPorts.js +15 -7
  63. package/dist/repository/gitWorkspace.js +72 -15
  64. package/dist/repository/taskWorkspaceCoordinator.js +134 -0
  65. package/dist/repository/taskWorkspacePreparer.js +120 -49
  66. package/dist/repository/workItemCandidateSnapshot.js +34 -0
  67. package/dist/resources/projectResource.js +0 -48
  68. package/dist/resources/projectResourceService.js +3 -81
  69. package/dist/resources/resourceDiscovery.js +3 -2
  70. package/dist/runtime/agentHost.js +152 -72
  71. package/dist/runtime/agentHostCompatibility.js +127 -0
  72. package/dist/runtime/agentHostProtocol.js +53 -0
  73. package/dist/runtime/executionEnvironment.js +0 -19
  74. package/dist/runtime/launchBroker.js +6 -0
  75. package/dist/runtime/sessionReconciliation.js +4 -4
  76. package/dist/runtime/taskRuntimeIsolation.js +30 -6
  77. package/dist/runtime/tmuxAdapters.js +5 -3
  78. package/dist/scheduler/operatorEvent.js +4 -0
  79. package/dist/scheduler/taskExecutionProjection.js +12 -1
  80. package/dist/scheduler/wakeReason.js +7 -1
  81. package/dist/scheduler/wakeupQueue.js +2 -0
  82. package/dist/setup/setupCommand.js +29 -16
  83. package/dist/storage/homeLayout.js +130 -0
  84. package/dist/storage/migrations/artifactsToGit.js +338 -0
  85. package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
  86. package/dist/storage/migrations/integrationContinuation.js +104 -0
  87. package/dist/storage/migrations/submitIntent.js +126 -0
  88. package/dist/storage/migrations/unifyHomeLayout.js +925 -0
  89. package/dist/storage/sqliteSchema.js +173 -7
  90. package/dist/storage/sqliteStore.js +41 -22
  91. package/dist/storage/storageVersions.js +1 -1
  92. package/dist/storage/storeRpc.js +2 -1
  93. package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
  94. package/dist/task/archiveDiagnostics.js +128 -0
  95. package/dist/task/nextAction.js +44 -11
  96. package/dist/task/taskActivation.js +26 -0
  97. package/dist/task/taskActivationService.js +85 -69
  98. package/dist/task/taskSubmission.js +236 -0
  99. package/dist/web/assets/client/app.js +58 -2
  100. package/dist/web/assets/client/components.js +1 -0
  101. package/dist/web/assets/client/i18n.js +6 -0
  102. package/dist/web/assets/client/taskSurface.js +202 -7
  103. package/dist/web/assets/client/view.js +7 -4
  104. package/dist/web/assets/shell.js +23 -0
  105. package/dist/web/assets/styles/layout.js +1 -1
  106. package/dist/web/assets/styles/widgets.js +12 -0
  107. package/dist/web/webServer.js +135 -4
  108. package/dist/web/webSnapshot.js +4 -3
  109. package/dist/web/webTaskSurface.js +225 -8
  110. package/dist/workItem/workItem.js +14 -10
  111. package/dist/workspace/workItemChangeSetManager.js +18 -2
  112. package/docs/agent-result-consumption.md +2 -0
  113. package/docs/agent-result-consumption.zh-CN.md +81 -0
  114. package/docs/agent-runtime-drivers.md +2 -0
  115. package/docs/agent-runtime-drivers.zh-CN.md +77 -0
  116. package/docs/architecture/README.md +44 -32
  117. package/docs/architecture/README.zh-CN.md +43 -0
  118. package/docs/architecture/capabilities-and-resources.md +118 -79
  119. package/docs/architecture/capabilities-and-resources.zh-CN.md +83 -0
  120. package/docs/managed-turn-and-session-runtime.md +2 -0
  121. package/docs/managed-turn-and-session-runtime.zh-CN.md +180 -0
  122. package/docs/observability/README.md +2 -0
  123. package/docs/observability/README.zh-CN.md +71 -0
  124. package/docs/plugin-sdk.md +320 -217
  125. package/docs/plugin-sdk.zh-CN.md +293 -0
  126. package/docs/provider-runtime.md +2 -0
  127. package/docs/provider-runtime.zh-CN.md +132 -0
  128. package/docs/release-workflow.md +41 -0
  129. package/docs/release-workflow.zh-CN.md +266 -0
  130. package/docs/roles-and-configuration.md +2 -0
  131. package/docs/roles-and-configuration.zh-CN.md +96 -0
  132. package/docs/sqlite-control-plane-design.md +225 -1
  133. package/docs/sqlite-control-plane-design.zh-CN.md +62 -0
  134. package/docs/task-dag-semantics.md +80 -57
  135. package/docs/task-dag-semantics.zh-CN.md +59 -0
  136. package/docs/task-delivery.md +2 -0
  137. package/docs/task-delivery.zh-CN.md +82 -0
  138. package/docs/task-local-identity.md +2 -0
  139. package/docs/task-local-identity.zh-CN.md +58 -0
  140. package/docs/testing/verification-levels.md +26 -0
  141. package/docs/testing/verification-levels.zh-CN.md +80 -0
  142. package/i18n/README.zh-CN.md +199 -10
  143. package/package.json +2 -1
  144. package/skills/yui-leader/SKILL.md +88 -331
  145. package/skills/yui-leader/references/execution.md +405 -0
  146. package/skills/yui-leader/references/integration.md +52 -2
  147. package/skills/yui-leader/references/planning.md +109 -0
  148. package/skills/yui-leader/references/task-plugins.md +8 -4
  149. package/skills/yui-operator/SKILL.md +22 -4
  150. package/skills/yui-runtime/SKILL.md +27 -0
  151. package/skills/yui-runtime/references/publication.md +20 -0
@@ -0,0 +1,925 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { isDeepStrictEqual } from "node:util";
6
+ /**
7
+ * Storage 23 -> 24: unify every Yui self-managed path under a single canonical
8
+ * YUI_HOME.
9
+ *
10
+ * Before this migration the managed Git worktrees lived under the user-facing
11
+ * `defaultWorkspace` (`<ws>/worktree`, `<ws>/tasks`) and the provider runtimes
12
+ * under a string-built Home sibling (`<home>.task-runtimes`). This unifies the
13
+ * layout under Home (`<home>/workspaces/{worktree,tasks}`,
14
+ * `<home>/runtime/task-runtimes`) and rewrites the persisted absolute pointers
15
+ * the runtime dereferences as LIVE, without re-cloning Git content or rewriting
16
+ * the path-independent Git ref names.
17
+ *
18
+ * ONLY ONE tree is physically relocated: the managed Git worktree tree, the sole
19
+ * subtree that holds durable, non-regenerable content (committed AND uncommitted
20
+ * work). It is COPIED (never renamed away), content-verified, and atomically
21
+ * published; the original is PRESERVED as the rollback anchor and is never
22
+ * removed by this data step. The regenerable per-Task symlink views and the
23
+ * disposable provider runtimes are NOT copied — their pointers are rewritten and
24
+ * the trees are rebuilt/recreated at the next launch, leaving the old locations
25
+ * inert until an authorized, post-restart cleanup removes them.
26
+ *
27
+ * The pointer rewrite is deliberately SURGICAL, not a table sweep. Only records
28
+ * the runtime dereferences as live launch pointers are touched:
29
+ *
30
+ * - `managed_workspaces` — the authoritative workspace registry. Workspace
31
+ * preparation trusts a stored `entry.path` directly, so a stale path is a
32
+ * hard failure. A dispositioned row is deleted at cleanup, so every
33
+ * surviving row is live.
34
+ * - active (`status = 'active'`) `turns` — the AgentRun's live launch cwd. The
35
+ * actual OS cwd is derived from `run.effective.workspace`, and `validateRun`
36
+ * binds `run.workspace` to `run.effective.workspace`, so the two are
37
+ * rewritten together or neither. `.result.systemEvidence.workspaceSnapshot`
38
+ * is frozen Git evidence and is left byte-for-byte intact.
39
+ * - `role_session_sets` / `global_role_session_sets` — each live session's
40
+ * `effective.workspace` in the `sessions` map (a resumable native session's
41
+ * frozen launch cwd). `history` is terminal evidence and is preserved.
42
+ * - `review_rounds` — the mirrored ManagedWorkspace while its registry row
43
+ * still exists, plus each OPEN execution lane's `effective.workspace` and
44
+ * `workspace.root`. A terminal lane and an orphaned mirror are frozen
45
+ * evidence and are left untouched.
46
+ * - `work_items` — each OPEN execution lane inside `executionGroups`. Candidate
47
+ * snapshots (`work_item_candidates`) are frozen evidence and are preserved.
48
+ * - `task_roles.workspace` — a live, mutable launch cwd, including a Draft's
49
+ * planning Role under the old runtime sibling (never self-healed until
50
+ * activation).
51
+ * - `task_records.cwd` — self-heals from the registry on the next
52
+ * `prepareTaskWorkspace`, but is rewritten defensively so no reader observes
53
+ * a stale cwd between migration and the first preparation pass.
54
+ *
55
+ * Everything else is preserved on purpose. `context_snapshots`, terminal
56
+ * `turns` (with their system evidence), terminal sessions in `history`,
57
+ * `work_item_candidates`, terminal execution lanes, terminal `durable_jobs`,
58
+ * `events` and reports are frozen historical evidence (criterion 2).
59
+ * `resource_registry` is re-discovered from disk. `projects.path` is an
60
+ * external, user-owned checkout.
61
+ *
62
+ * A queued or running `durable_jobs` step runs in a detached process that
63
+ * SURVIVES the Controller quiesce fence, so rewriting a live cwd bound under a
64
+ * relocating root would be an in-flight silent migration (forbidden by
65
+ * criterion 2). Such a job is refused up front rather than rewritten.
66
+ *
67
+ * OFFLINE migration by design. It is applied by the standalone `yui upgrade`
68
+ * mutation boundary AFTER the operator has stopped the Controller, Agent Host,
69
+ * and any execution/Job writers for this Home — it does NOT orchestrate that
70
+ * shutdown, coordinate an online write-stop, or migrate a live Session. Its only
71
+ * runtime precondition is the minimal in-flight-Job conflict check above; a
72
+ * writer the operator failed to stop is out of its contract. On any failure it
73
+ * throws, the upgrade transaction rolls back to its starting version, and the preserved
74
+ * source plus the fenced DB backup are the recovery pair for a manual re-run — it
75
+ * does NOT claim automatic idempotent recovery or take over partial residue with
76
+ * a manifest state machine.
77
+ *
78
+ * FROZEN migration. Its behaviour must never change once released, so it inlines
79
+ * the target layout instead of importing runtime path helpers, and runtime code
80
+ * must not depend on this module. The approved short-path IPC sockets
81
+ * (Controller, tmux, Agent Host, and the integration runtime under `/tmp`) are
82
+ * never touched: their paths do not start with a relocated managed root, so the
83
+ * prefix-anchored rewrite leaves them exactly as they were.
84
+ */
85
+ export const UNIFY_HOME_LAYOUT_SQL = "SELECT 1; -- data/fs-transform: unify self-managed paths under canonical YUI_HOME";
86
+ /** DurableJob statuses that are not terminal: a runner may still be executing. */
87
+ const NON_TERMINAL_JOB_STATUSES = ["queued", "running"];
88
+ /**
89
+ * Copy the one durable managed tree into Home (verifying the replica and
90
+ * preserving the original), repair the copied Git worktrees, then rewrite only
91
+ * the persisted pointers the runtime trusts as live. Runs inside the upgrade
92
+ * transaction: any throw rolls back to its starting version, and because the source is
93
+ * copied (never renamed away) and the copy is content-verified before publish, a
94
+ * failed run leaves the original content intact for a manual re-run.
95
+ */
96
+ export function migrateUnifyHomeLayout(db) {
97
+ const home = resolve(dirname(db.name));
98
+ const { relocations, rewrites } = planUnifyHomeRewrites(home, readDefaultWorkspace(db));
99
+ if (rewrites.length === 0)
100
+ return;
101
+ // Offline precheck — no filesystem or row mutation happens before this passes.
102
+ // The migration runs AFTER the operator has stopped this Home's writers, so it
103
+ // does not scan for live processes; the one residual runtime signal it still
104
+ // guards is a queued/running durable Job bound under a relocating root, whose
105
+ // detached runner could outlive the Controller quiesce fence and turn the copy
106
+ // into an in-flight silent move. Let the Job drain or cancel it, then retry.
107
+ const oldRoots = rewrites.map((move) => move.from);
108
+ assertNoInFlightJobUnderRoots(db, oldRoots);
109
+ // Physically relocate the durable tree(s). The copy is non-destructive and
110
+ // verified (copy -> digest-verify -> atomic publish -> preserve source), so a
111
+ // throw before publish leaves the source intact and nothing published. There is
112
+ // no relocation manifest: the fenced upgrade backs up the DB and the preserved
113
+ // source is the on-disk rollback anchor, and a failed run is recovered by a
114
+ // manual re-run, not by an idempotent-resume state machine.
115
+ for (const move of relocations) {
116
+ relocateTree(move.from, move.to);
117
+ }
118
+ if (relocations.some((move) => existsSync(move.to))) {
119
+ repairRelocatedWorktrees(db, rewrites);
120
+ }
121
+ // Rewrite every LIVE launch pointer consistently; frozen evidence is untouched.
122
+ rewriteManagedWorkspaces(db, rewrites);
123
+ rewriteActiveRunWorkspaces(db, rewrites);
124
+ rewriteRoleSessionSets(db, rewrites);
125
+ rewriteReviewRoundWorkspaces(db, rewrites);
126
+ rewriteWorkItemExecutionGroups(db, rewrites);
127
+ rewriteRoleWorkspaces(db, rewrites);
128
+ rewriteTaskCwd(db, rewrites);
129
+ }
130
+ /**
131
+ * Read-only counterpart to {@link migrateUnifyHomeLayout}: derive the SAME plan
132
+ * and evaluate the SAME blocking conditions WITHOUT mutating the database or the
133
+ * filesystem. This is what lets `yui upgrade --dry-run` and the updater's
134
+ * `--update-preflight` report a trustworthy verdict — a target conflict or an
135
+ * in-flight Job is surfaced before the Controller is stopped, not discovered only
136
+ * inside the apply transaction.
137
+ *
138
+ * Every branch collects rather than throws, so one run reports all independent
139
+ * blockers. The checks mirror the offline execute path exactly (in-flight Job,
140
+ * target conflict), keeping the two in lockstep. It does NOT scan for live
141
+ * processes: the migration is applied offline, after the operator has stopped
142
+ * this Home's writers.
143
+ */
144
+ export function preflightUnifyHomeLayout(db) {
145
+ const home = resolve(dirname(db.name));
146
+ const { relocations, rewrites } = planUnifyHomeRewrites(home, readDefaultWorkspace(db));
147
+ if (rewrites.length === 0) {
148
+ return Object.freeze({ noop: true, plannedRelocations: 0, plannedRewrites: 0, blockers: [] });
149
+ }
150
+ const blockers = [];
151
+ const oldRoots = rewrites.map((move) => move.from);
152
+ for (const detail of collectInFlightJobBlockers(db, oldRoots)) {
153
+ blockers.push({ reason: "in-flight-job", detail });
154
+ }
155
+ // The target-conflict check reads the same filesystem state relocateTree acts
156
+ // on, without copying or writing anything.
157
+ for (const detail of collectRelocationConflicts(relocations)) {
158
+ blockers.push(detail);
159
+ }
160
+ return Object.freeze({
161
+ noop: false,
162
+ plannedRelocations: relocations.length,
163
+ plannedRewrites: rewrites.length,
164
+ blockers: Object.freeze(blockers)
165
+ });
166
+ }
167
+ /**
168
+ * Derive the relocation (physically copied) and rewrite (pointer-prefix) plans
169
+ * from the canonical Home and the configured out-of-Home workspace. Pure: no IO,
170
+ * no DB. Shared by the execute path and the read-only preflight so the two can
171
+ * never diverge on what would move.
172
+ */
173
+ function planUnifyHomeRewrites(home, defaultWorkspace) {
174
+ const relocations = [];
175
+ const rewrites = [];
176
+ if (defaultWorkspace !== undefined) {
177
+ const oldWorktree = join(defaultWorkspace, "worktree");
178
+ const newWorktree = join(home, "workspaces", "worktree");
179
+ const oldTasks = join(defaultWorkspace, "tasks");
180
+ const newTasks = join(home, "workspaces", "tasks");
181
+ if (oldWorktree !== newWorktree) {
182
+ // The Git clones/worktrees hold durable committed AND uncommitted content;
183
+ // they are the only subtree copied on disk.
184
+ relocations.push({ from: oldWorktree, to: newWorktree });
185
+ rewrites.push({ from: oldWorktree, to: newWorktree });
186
+ // The per-Task view directories are regenerable symlink trees: pointer
187
+ // rewrite only, rebuilt by `ensureWorkspaceView` on next launch.
188
+ rewrites.push({ from: oldTasks, to: newTasks });
189
+ }
190
+ }
191
+ // Provider runtime roots (data/cache/tmp + planning cwd) are disposable and
192
+ // recreated at launch: pointer rewrite only, never copied.
193
+ const oldRuntime = `${home}.task-runtimes`;
194
+ const newRuntime = join(home, "runtime", "task-runtimes");
195
+ if (oldRuntime !== newRuntime) {
196
+ rewrites.push({ from: oldRuntime, to: newRuntime });
197
+ }
198
+ return { relocations, rewrites };
199
+ }
200
+ /** The configured out-of-Home workspace root, if any Project was ever set up. */
201
+ function readDefaultWorkspace(db) {
202
+ // The read-only preflight may open a Home still at an older schema version in
203
+ // which `config` predates its current shape — but the table itself has existed
204
+ // since the first version, so a missing table only means "no configuration yet".
205
+ if (!tableExists(db, "config"))
206
+ return undefined;
207
+ const row = db.prepare("SELECT payload FROM config WHERE id = 1").get();
208
+ if (row === undefined)
209
+ return undefined;
210
+ try {
211
+ const config = JSON.parse(row.payload);
212
+ return typeof config.defaultWorkspace === "string" && config.defaultWorkspace.length > 0
213
+ ? resolve(config.defaultWorkspace)
214
+ : undefined;
215
+ }
216
+ catch {
217
+ return undefined;
218
+ }
219
+ }
220
+ /** True when a table currently exists, so a read-only preflight against an older
221
+ * schema version never throws on a table introduced by a later migration. */
222
+ function tableExists(db, name) {
223
+ return (db
224
+ .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
225
+ .get(name) !== undefined);
226
+ }
227
+ /**
228
+ * Refuse to migrate a pointer a non-terminal Job is bound to. `active_turns` is
229
+ * NOT consulted: it holds durable, steady-state run pointers for every active
230
+ * Task and is present on nearly every live Home, so keying in-flight detection
231
+ * off it would over-refuse. The precise signal is a queued/running `durable_jobs`
232
+ * whose workspace (or a step cwd) lies under a root about to change; its detached
233
+ * runner survives the Controller fence and could hold an open handle.
234
+ */
235
+ function assertNoInFlightJobUnderRoots(db, roots) {
236
+ const blockers = collectInFlightJobBlockers(db, roots);
237
+ if (blockers.length > 0)
238
+ throw new Error(blockers[0]);
239
+ }
240
+ /**
241
+ * Read-only enumeration of the queued/running durable Jobs bound under a
242
+ * relocating root. Returns one operator-facing message per offending Job (the
243
+ * execute path throws the first). No DB mutation.
244
+ */
245
+ function collectInFlightJobBlockers(db, roots) {
246
+ if (!tableExists(db, "durable_jobs"))
247
+ return [];
248
+ const placeholders = NON_TERMINAL_JOB_STATUSES.map(() => "?").join(", ");
249
+ const rows = db
250
+ .prepare(`SELECT payload FROM durable_jobs WHERE status IN (${placeholders})`)
251
+ .all(...NON_TERMINAL_JOB_STATUSES);
252
+ const blockers = [];
253
+ for (const row of rows) {
254
+ let job;
255
+ try {
256
+ job = JSON.parse(row.payload);
257
+ }
258
+ catch {
259
+ continue;
260
+ }
261
+ const candidates = [];
262
+ if (typeof job.workspace === "string")
263
+ candidates.push(job.workspace);
264
+ if (Array.isArray(job.steps)) {
265
+ for (const step of job.steps) {
266
+ if (typeof step.cwd === "string")
267
+ candidates.push(step.cwd);
268
+ }
269
+ }
270
+ for (const candidate of candidates) {
271
+ if (isUnderAnyRoot(resolve(candidate), roots)) {
272
+ blockers.push("Refusing to unify YUI_HOME layout: a queued or running durable Job"
273
+ + `${typeof job.id === "string" ? ` (${job.id})` : ""} is bound to a managed workspace `
274
+ + `under relocation (${candidate}). Let it drain or cancel it (\`yui job ...\`), then `
275
+ + "retry the upgrade.");
276
+ break;
277
+ }
278
+ }
279
+ }
280
+ return blockers;
281
+ }
282
+ /**
283
+ * Read-only detection of the filesystem condition that would make the relocate
284
+ * step REFUSE: a relocation target that already exists. In the offline model a
285
+ * present target is either a foreign directory or residue from a failed prior run
286
+ * — never something to silently adopt — so `relocateTree` refuses it and the
287
+ * operator resolves it before a manual re-run. This mirrors that refusal WITHOUT
288
+ * copying, staging, or writing anything, so a dry-run/preflight verdict reflects
289
+ * the same decision execute would make.
290
+ */
291
+ function collectRelocationConflicts(relocations) {
292
+ const blockers = [];
293
+ for (const move of relocations) {
294
+ if (!existsSync(move.to))
295
+ continue;
296
+ blockers.push({
297
+ reason: "target-conflict",
298
+ detail: `the relocation target ${move.to} already exists; it is a foreign directory or `
299
+ + `residue from a failed prior run. Confirm the source at ${move.from} is intact, then `
300
+ + `move or remove ${move.to} before retrying the upgrade`
301
+ });
302
+ }
303
+ return blockers;
304
+ }
305
+ /**
306
+ * A content-addressed inventory digest of a tree: for every path in
307
+ * deterministic order, the relative path, kind, and either the file size + byte
308
+ * content or the symlink target. Two trees with an identical digest are byte-for
309
+ * -byte identical in structure and content, which is what proves a relocation
310
+ * replica is faithful and complete before it is published.
311
+ */
312
+ function treeInventoryDigest(root) {
313
+ const hash = createHash("sha256");
314
+ const walk = (dir, rel) => {
315
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
316
+ for (const entry of entries) {
317
+ const abs = join(dir, entry.name);
318
+ const relPath = rel === "" ? entry.name : `${rel}/${entry.name}`;
319
+ const stat = lstatSync(abs);
320
+ if (stat.isSymbolicLink()) {
321
+ hash.update(`L ${relPath}\0${readlinkSync(abs)}\0`);
322
+ }
323
+ else if (stat.isDirectory()) {
324
+ hash.update(`D ${relPath}\0`);
325
+ walk(abs, relPath);
326
+ }
327
+ else if (stat.isFile()) {
328
+ hash.update(`F ${relPath}\0${stat.size}\0`);
329
+ hash.update(readFileSync(abs));
330
+ hash.update("\0");
331
+ }
332
+ else {
333
+ // FIFO/socket/device node: record its presence and kind, never read it.
334
+ hash.update(`O ${relPath}\0`);
335
+ }
336
+ }
337
+ };
338
+ walk(root, "");
339
+ return hash.digest("hex");
340
+ }
341
+ /**
342
+ * Copy one managed subtree into Home, NON-DESTRUCTIVELY to the source. The source
343
+ * is copied (never renamed away) to a same-filesystem staging directory, the
344
+ * replica's content digest is verified against the source, and only a verified
345
+ * replica is atomically renamed into place. The original is PRESERVED as the
346
+ * rollback anchor; its removal is a later, authorized cleanup step, never part of
347
+ * this transaction.
348
+ *
349
+ * Offline and single-shot — NOT an idempotent resumable move. A relocation target
350
+ * that already exists is refused outright: in the offline model it is either a
351
+ * foreign directory or residue from a failed prior run, and this migration never
352
+ * adopts or re-verifies a pre-existing target (the operator resolves it and
353
+ * re-runs). When neither source nor target exists the tree was never created and
354
+ * there is nothing to do. Any throw happens before the atomic rename, so the
355
+ * source is left intact and nothing is published; recovery is a manual re-run
356
+ * after the operator clears the conflict, backed by the fenced DB backup.
357
+ */
358
+ function relocateTree(from, to) {
359
+ const staging = `${to}.incoming`;
360
+ if (existsSync(to)) {
361
+ // A pre-existing target is never silently adopted: it is a foreign directory
362
+ // or residue from a failed prior run. Refuse so the transaction rolls back;
363
+ // the operator inspects it, confirms the source is intact, removes the
364
+ // conflict, and re-runs the upgrade.
365
+ throw new Error(`Refusing to unify YUI_HOME layout: the relocation target ${to} already exists. It is a `
366
+ + `foreign directory or residue from a failed prior run. Confirm the source at ${from} is `
367
+ + `intact, then move or remove ${to} and re-run the upgrade.`);
368
+ }
369
+ if (!existsSync(from)) {
370
+ // Neither source nor target exists: the tree was never created. Nothing to do.
371
+ return;
372
+ }
373
+ const sourceDigest = treeInventoryDigest(from);
374
+ mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
375
+ rmSync(staging, { recursive: true, force: true });
376
+ // Copy (never move) so the original survives as the rollback anchor even if
377
+ // this transaction later throws. Symlinks and Git administrative files are
378
+ // preserved verbatim.
379
+ cpSync(from, staging, { recursive: true, verbatimSymlinks: true });
380
+ // Prove the replica is faithful and complete BEFORE publishing it.
381
+ if (treeInventoryDigest(staging) !== sourceDigest) {
382
+ rmSync(staging, { recursive: true, force: true });
383
+ throw new Error(`Refusing to unify YUI_HOME layout: the relocated copy of ${from} did not match the `
384
+ + `source content after copying. The original is untouched; re-run the upgrade.`);
385
+ }
386
+ // Atomic publish: staging and target share the Home filesystem.
387
+ renameSync(staging, to);
388
+ }
389
+ /**
390
+ * After the worktree subtree is copied into Home, the copies' absolute Git
391
+ * pointers still reference the OLD source location. They are corrected in TWO
392
+ * steps, in this order:
393
+ *
394
+ * 1. Deterministically REWRITE, in the NEW copy only, the two worktree cross-
395
+ * reference pointer files (a linked worktree's `.git` stub and each main
396
+ * clone's `.git/worktrees/<name>/gitdir`) from the OLD prefix to the NEW
397
+ * prefix. This is the crucial step for preserved-source independence: it
398
+ * makes the copy self-referential BEFORE any native git command runs, so
399
+ * git never follows a stale absolute pointer back into the OLD tree and
400
+ * mutates it. (Empirically, `git worktree repair` invoked on a verbatim copy
401
+ * whose pointers still address the source WILL rewrite the OLD source's
402
+ * `.git` files — corrupting the rollback anchor. Pre-rewriting prevents it.)
403
+ * 2. Run `git worktree repair` from each main clone at its NEW path as a
404
+ * belt-and-braces reconciliation (it also fixes any pointer we did not model,
405
+ * e.g. an unexpected nesting), now guaranteed to operate only within the NEW
406
+ * tree because step 1 already repointed every cross-reference into it.
407
+ *
408
+ * Committed and uncommitted content is preserved throughout. A repair FAILURE IS
409
+ * FATAL: it aborts the migration so the transaction rolls back and the fenced
410
+ * backup is restored, rather than advancing the version over unrepaired
411
+ * worktrees. The new paths are derived from the registry entries as they will be
412
+ * rewritten.
413
+ */
414
+ function repairRelocatedWorktrees(db, rewrites) {
415
+ const groups = new Map();
416
+ const rows = db
417
+ .prepare("SELECT owner_kind, payload FROM managed_workspaces")
418
+ .all();
419
+ for (const row of rows) {
420
+ let workspace;
421
+ try {
422
+ workspace = JSON.parse(row.payload);
423
+ }
424
+ catch {
425
+ continue;
426
+ }
427
+ const taskId = typeof workspace.owner?.taskId === "string" ? workspace.owner.taskId : undefined;
428
+ if (taskId === undefined || !Array.isArray(workspace.entries))
429
+ continue;
430
+ for (const entry of workspace.entries) {
431
+ if (typeof entry.projectId !== "string" || typeof entry.path !== "string")
432
+ continue;
433
+ const newPath = applyPrefix(entry.path, rewrites);
434
+ if (newPath === undefined)
435
+ continue;
436
+ const key = `${taskId} ${entry.projectId}`;
437
+ const group = groups.get(key) ?? { linked: new Set() };
438
+ if (row.owner_kind === "task")
439
+ group.main = newPath;
440
+ else
441
+ group.linked.add(newPath);
442
+ groups.set(key, group);
443
+ }
444
+ }
445
+ for (const group of groups.values()) {
446
+ const main = group.main;
447
+ // A group whose main clone was never materialised on disk (a stale registry
448
+ // row pointing at a path that never existed) has nothing to repair and is
449
+ // re-prepared lazily on next launch. A main that DOES exist but fails to
450
+ // repair is a real integrity failure and is allowed to throw.
451
+ if (main === undefined || !existsSync(main))
452
+ continue;
453
+ const linked = [...group.linked].filter((path) => existsSync(path));
454
+ // Step 1: repoint the copy's cross-reference pointer files into the NEW tree
455
+ // BEFORE git runs, so no native command can chase a stale pointer into — and
456
+ // rewrite — the preserved OLD source.
457
+ relinkWorktreePointers(main, linked, rewrites);
458
+ // Step 2: reconcile with git as a safety net (now confined to the NEW tree).
459
+ execFileSync("git", ["-C", main, "worktree", "repair", ...linked], {
460
+ stdio: "ignore",
461
+ timeout: 60_000
462
+ });
463
+ }
464
+ }
465
+ /**
466
+ * Rewrite, in place and in the NEW copy only, the worktree cross-reference
467
+ * pointer files so every path they contain that fell under a relocating OLD root
468
+ * is repointed to its NEW location. Two file kinds carry such absolute paths:
469
+ *
470
+ * - each linked worktree's `.git` stub file: `gitdir: <main>/.git/worktrees/<n>`
471
+ * - each main clone's `.git/worktrees/<name>/gitdir`: `<linked>/.git`
472
+ *
473
+ * Only the OLD→NEW prefixes are substituted (via {@link applyPrefix} on the exact
474
+ * path token), so a pointer already addressing the NEW tree, or one outside every
475
+ * relocating root, is left untouched. A missing or malformed pointer file is left
476
+ * for the subsequent `git worktree repair` to reconstruct. Nothing outside the
477
+ * NEW copy is read or written, so the preserved OLD source is never touched.
478
+ */
479
+ function relinkWorktreePointers(main, linked, rewrites) {
480
+ // Main clone's back-pointers: .git/worktrees/<name>/gitdir -> <linked>/.git
481
+ const worktreesDir = join(main, ".git", "worktrees");
482
+ if (existsSync(worktreesDir)) {
483
+ let names;
484
+ try {
485
+ names = readdirSync(worktreesDir);
486
+ }
487
+ catch {
488
+ names = [];
489
+ }
490
+ for (const name of names) {
491
+ relinkPointerFile(join(worktreesDir, name, "gitdir"), rewrites);
492
+ }
493
+ }
494
+ // Each linked worktree's stub: .git file -> gitdir: <main>/.git/worktrees/<name>
495
+ for (const worktree of linked) {
496
+ relinkPointerFile(join(worktree, ".git"), rewrites);
497
+ }
498
+ }
499
+ /**
500
+ * Rewrite a single Git pointer file's stored path with the OLD→NEW prefix map,
501
+ * preserving any `gitdir: ` prefix and trailing newline. A file that is absent,
502
+ * unreadable, a directory (a real repository, never a pointer), or whose path is
503
+ * not under any relocating root is left exactly as-is.
504
+ */
505
+ function relinkPointerFile(pointerPath, rewrites) {
506
+ let raw;
507
+ try {
508
+ if (lstatSync(pointerPath).isDirectory())
509
+ return;
510
+ raw = readFileSync(pointerPath, "utf8");
511
+ }
512
+ catch {
513
+ return;
514
+ }
515
+ const trailing = raw.endsWith("\n") ? "\n" : "";
516
+ const body = trailing === "\n" ? raw.slice(0, -1) : raw;
517
+ const marker = "gitdir: ";
518
+ const hasMarker = body.startsWith(marker);
519
+ const pathToken = hasMarker ? body.slice(marker.length) : body;
520
+ const rewritten = applyPrefix(pathToken.trim(), rewrites);
521
+ if (rewritten === undefined || rewritten === pathToken.trim())
522
+ return;
523
+ const next = `${hasMarker ? marker : ""}${rewritten}${trailing}`;
524
+ writeFileSync(pointerPath, next);
525
+ }
526
+ /**
527
+ * Rewrite the authoritative workspace registry: the `path` column plus the
528
+ * payload `root` and every `entries[].path`. Every surviving row is a live
529
+ * workspace (dispositioned rows are deleted at cleanup), so all are rewritten.
530
+ * Timestamps are left untouched so a live mirror stays deep-equal to its row.
531
+ */
532
+ function rewriteManagedWorkspaces(db, rewrites) {
533
+ const rows = db
534
+ .prepare("SELECT owner_kind, owner_id, path, payload FROM managed_workspaces")
535
+ .all();
536
+ const update = db.prepare("UPDATE managed_workspaces SET path = ?, payload = ? WHERE owner_kind = ? AND owner_id = ?");
537
+ for (const row of rows) {
538
+ const newPath = applyPrefix(row.path, rewrites) ?? row.path;
539
+ let workspace;
540
+ try {
541
+ workspace = JSON.parse(row.payload);
542
+ }
543
+ catch {
544
+ continue;
545
+ }
546
+ const rewritten = rewriteWorkspaceObject(workspace, rewrites);
547
+ const newPayload = JSON.stringify(rewritten);
548
+ if (newPath === row.path && newPayload === row.payload)
549
+ continue;
550
+ update.run(newPath, newPayload, row.owner_kind, row.owner_id);
551
+ }
552
+ }
553
+ /**
554
+ * Rewrite the live launch cwd carried by each active AgentRun. The actual OS
555
+ * cwd is derived from `run.effective.workspace`, and `validateRun` requires
556
+ * `run.workspace` (when present) to stay identical to it, so BOTH are rewritten
557
+ * with the same prefix map — never one without the other. Terminal runs keep
558
+ * their old (mutually consistent) paths and are left as frozen evidence, as is
559
+ * `.result.systemEvidence.workspaceSnapshot`.
560
+ */
561
+ function rewriteActiveRunWorkspaces(db, rewrites) {
562
+ const rows = db
563
+ .prepare("SELECT task_id, turn_id, payload FROM turns WHERE status = 'active'")
564
+ .all();
565
+ const update = db.prepare("UPDATE turns SET payload = ? WHERE task_id = ? AND turn_id = ?");
566
+ for (const row of rows) {
567
+ let run;
568
+ try {
569
+ run = JSON.parse(row.payload);
570
+ }
571
+ catch {
572
+ continue;
573
+ }
574
+ let next = run;
575
+ // The required launch snapshot: the actual cwd source.
576
+ const effective = run.effective;
577
+ if (effective !== null && typeof effective === "object") {
578
+ const effectiveWorkspace = effective.workspace;
579
+ if (effectiveWorkspace !== null && typeof effectiveWorkspace === "object") {
580
+ const rewritten = rewriteWorkspaceObject(effectiveWorkspace, rewrites);
581
+ if (!isDeepStrictEqual(rewritten, effectiveWorkspace)) {
582
+ next = {
583
+ ...next,
584
+ effective: { ...effective, workspace: rewritten }
585
+ };
586
+ }
587
+ }
588
+ }
589
+ // The optional mirror pointer, kept identical to `effective.workspace`.
590
+ const workspace = run.workspace;
591
+ if (workspace !== null && typeof workspace === "object") {
592
+ const rewritten = rewriteWorkspaceObject(workspace, rewrites);
593
+ if (!isDeepStrictEqual(rewritten, workspace)) {
594
+ next = { ...next, workspace: rewritten };
595
+ }
596
+ }
597
+ if (next === run)
598
+ continue;
599
+ update.run(JSON.stringify(next), row.task_id, row.turn_id);
600
+ }
601
+ }
602
+ /**
603
+ * Rewrite each live native session's frozen launch cwd. A `RoleAgentSession`
604
+ * in the `sessions` map is a resumable live binding whose `effective.workspace`
605
+ * is the cwd a resume would relaunch under. Terminal sessions archived in
606
+ * `history` are frozen evidence and are preserved. Both the per-Task
607
+ * (`role_session_sets`) and global (`global_role_session_sets`) stores carry
608
+ * the same session shape.
609
+ */
610
+ function rewriteRoleSessionSets(db, rewrites) {
611
+ 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);
612
+ rewriteSessionSetTable(db, "SELECT name, payload FROM global_role_session_sets", "UPDATE global_role_session_sets SET payload = ? WHERE name = ?", (row) => [row.name], rewrites);
613
+ }
614
+ function rewriteSessionSetTable(db, select, updateSql, keyOf, rewrites) {
615
+ const rows = db.prepare(select).all();
616
+ const update = db.prepare(updateSql);
617
+ for (const row of rows) {
618
+ let set;
619
+ try {
620
+ set = JSON.parse(row.payload);
621
+ }
622
+ catch {
623
+ continue;
624
+ }
625
+ const sessions = set.sessions;
626
+ if (sessions === null || typeof sessions !== "object")
627
+ continue;
628
+ let changed = false;
629
+ const nextSessions = {};
630
+ for (const [id, session] of Object.entries(sessions)) {
631
+ const rewrittenSession = rewriteSessionEffectiveWorkspace(session, rewrites);
632
+ if (rewrittenSession !== session)
633
+ changed = true;
634
+ nextSessions[id] = rewrittenSession;
635
+ }
636
+ if (!changed)
637
+ continue;
638
+ update.run(JSON.stringify({ ...set, sessions: nextSessions }), ...keyOf(row));
639
+ }
640
+ }
641
+ /** Rewrite one session's `effective.workspace`, returning the same reference when unchanged. */
642
+ function rewriteSessionEffectiveWorkspace(session, rewrites) {
643
+ if (session === null || typeof session !== "object")
644
+ return session;
645
+ const effective = session.effective;
646
+ if (effective === null || typeof effective !== "object")
647
+ return session;
648
+ const workspace = effective.workspace;
649
+ if (workspace === null || typeof workspace !== "object")
650
+ return session;
651
+ const rewritten = rewriteWorkspaceObject(workspace, rewrites);
652
+ if (isDeepStrictEqual(rewritten, workspace))
653
+ return session;
654
+ return {
655
+ ...session,
656
+ effective: { ...effective, workspace: rewritten }
657
+ };
658
+ }
659
+ /**
660
+ * Rewrite the ManagedWorkspace a ReviewRound mirrors (while its registry row
661
+ * still exists) and each OPEN execution lane's live launch pointers. Re-
662
+ * preparation refuses on any deep divergence between the mirror and its row, so
663
+ * the mirror is kept equal to its rewritten row; an orphaned mirror and any
664
+ * terminal lane are frozen evidence and are left as-is.
665
+ */
666
+ function rewriteReviewRoundWorkspaces(db, rewrites) {
667
+ const liveReviewOwners = liveOwnerKeys(db, "review-round", (owner) => typeof owner.taskId === "string" && typeof owner.reviewRoundId === "string"
668
+ ? `${owner.taskId} ${owner.reviewRoundId}`
669
+ : undefined);
670
+ const rows = db
671
+ .prepare("SELECT task_id, review_round_id, payload FROM review_rounds")
672
+ .all();
673
+ const update = db.prepare("UPDATE review_rounds SET payload = ? WHERE task_id = ? AND review_round_id = ?");
674
+ for (const row of rows) {
675
+ let round;
676
+ try {
677
+ round = JSON.parse(row.payload);
678
+ }
679
+ catch {
680
+ continue;
681
+ }
682
+ let next = round;
683
+ const workspace = round.workspace;
684
+ if (workspace !== null && typeof workspace === "object") {
685
+ const owner = workspace.owner;
686
+ const ownerKey = owner !== undefined
687
+ && typeof owner.taskId === "string" && typeof owner.reviewRoundId === "string"
688
+ ? `${owner.taskId} ${owner.reviewRoundId}`
689
+ : undefined;
690
+ if (ownerKey !== undefined && liveReviewOwners.has(ownerKey)) {
691
+ const rewritten = rewriteWorkspaceObject(workspace, rewrites);
692
+ if (!isDeepStrictEqual(rewritten, workspace))
693
+ next = { ...next, workspace: rewritten };
694
+ }
695
+ }
696
+ const group = round.executionGroup;
697
+ if (group !== null && typeof group === "object") {
698
+ const rewrittenGroup = rewriteExecutionGroupOpenLanes(group, rewrites);
699
+ if (rewrittenGroup !== group)
700
+ next = { ...next, executionGroup: rewrittenGroup };
701
+ }
702
+ if (next === round)
703
+ continue;
704
+ update.run(JSON.stringify(next), row.task_id, row.review_round_id);
705
+ }
706
+ }
707
+ /**
708
+ * Rewrite the OPEN execution lanes inside each WorkItem's `executionGroups`. A
709
+ * WorkItemCandidate snapshot (`work_item_candidates`) is frozen evidence and is
710
+ * left untouched; only an open lane is a live launch pointer.
711
+ */
712
+ function rewriteWorkItemExecutionGroups(db, rewrites) {
713
+ const rows = db
714
+ .prepare("SELECT task_id, work_item_id, payload FROM work_items")
715
+ .all();
716
+ const update = db.prepare("UPDATE work_items SET payload = ? WHERE task_id = ? AND work_item_id = ?");
717
+ for (const row of rows) {
718
+ let item;
719
+ try {
720
+ item = JSON.parse(row.payload);
721
+ }
722
+ catch {
723
+ continue;
724
+ }
725
+ const groups = item.executionGroups;
726
+ if (!Array.isArray(groups))
727
+ continue;
728
+ let changed = false;
729
+ const nextGroups = groups.map((group) => {
730
+ const rewritten = rewriteExecutionGroupOpenLanes(group, rewrites);
731
+ if (rewritten !== group)
732
+ changed = true;
733
+ return rewritten;
734
+ });
735
+ if (!changed)
736
+ continue;
737
+ update.run(JSON.stringify({ ...item, executionGroups: nextGroups }), row.task_id, row.work_item_id);
738
+ }
739
+ }
740
+ /**
741
+ * Rewrite the live launch pointers of every OPEN lane in an execution group,
742
+ * returning the same reference when nothing changed. A lane is live only while
743
+ * its disposition is `open`; a `succeeded`/`failed` lane is terminal evidence.
744
+ */
745
+ function rewriteExecutionGroupOpenLanes(group, rewrites) {
746
+ if (group === null || typeof group !== "object")
747
+ return group;
748
+ const lanes = group.lanes;
749
+ if (!Array.isArray(lanes))
750
+ return group;
751
+ let changed = false;
752
+ const nextLanes = lanes.map((lane) => {
753
+ if (lane === null || typeof lane !== "object")
754
+ return lane;
755
+ if (lane.disposition !== "open")
756
+ return lane;
757
+ const rewritten = rewriteLaneLaunchPointers(lane, rewrites);
758
+ if (rewritten !== lane)
759
+ changed = true;
760
+ return rewritten;
761
+ });
762
+ if (!changed)
763
+ return group;
764
+ return { ...group, lanes: nextLanes };
765
+ }
766
+ /**
767
+ * Rewrite one lane's `effective.workspace` (root + entries) and `workspace.root`
768
+ * (the lane workspace carries only a root path plus writable project ids).
769
+ * Returns the same reference when unchanged.
770
+ */
771
+ function rewriteLaneLaunchPointers(lane, rewrites) {
772
+ let next = lane;
773
+ const effective = lane.effective;
774
+ if (effective !== null && typeof effective === "object") {
775
+ const workspace = effective.workspace;
776
+ if (workspace !== null && typeof workspace === "object") {
777
+ const rewritten = rewriteWorkspaceObject(workspace, rewrites);
778
+ if (!isDeepStrictEqual(rewritten, workspace)) {
779
+ next = {
780
+ ...next,
781
+ effective: { ...effective, workspace: rewritten }
782
+ };
783
+ }
784
+ }
785
+ }
786
+ const workspace = next.workspace;
787
+ if (workspace !== null && typeof workspace === "object"
788
+ && typeof workspace.root === "string") {
789
+ const root = workspace.root;
790
+ const rewritten = applyPrefix(root, rewrites);
791
+ if (rewritten !== undefined && rewritten !== root) {
792
+ next = { ...next, workspace: { ...workspace, root: rewritten } };
793
+ }
794
+ }
795
+ return next;
796
+ }
797
+ /**
798
+ * Rewrite the live launch cwd on each Role. Active Roles under the task root are
799
+ * also self-healed by `prepareTaskWorkspace`, but a Draft's planning Role points
800
+ * at the old runtime root and is never prepared until activation, so rewriting
801
+ * here is the only correction it receives. A Role workspace is a mutable pointer,
802
+ * never historical evidence; a cwd outside every relocated root is left as-is.
803
+ */
804
+ function rewriteRoleWorkspaces(db, rewrites) {
805
+ const rows = db
806
+ .prepare("SELECT task_id, role_name, payload FROM task_roles")
807
+ .all();
808
+ const update = db.prepare("UPDATE task_roles SET payload = ? WHERE task_id = ? AND role_name = ?");
809
+ for (const row of rows) {
810
+ let role;
811
+ try {
812
+ role = JSON.parse(row.payload);
813
+ }
814
+ catch {
815
+ continue;
816
+ }
817
+ if (typeof role.workspace !== "string")
818
+ continue;
819
+ const next = applyPrefix(role.workspace, rewrites);
820
+ if (next === undefined || next === role.workspace)
821
+ continue;
822
+ update.run(JSON.stringify({ ...role, workspace: next }), row.task_id, row.role_name);
823
+ }
824
+ }
825
+ /**
826
+ * Defensively rewrite `task_records.cwd`. This field self-heals to the freshly
827
+ * resolved task root on the next `prepareTaskWorkspace` (which runs for every
828
+ * active Task at Controller startup, before any launch), but rewriting it here
829
+ * closes the window in which a reader could observe a stale cwd, and corrects a
830
+ * Task that is not prepared this boot. It is not gate-validated, so the rewrite
831
+ * carries no cross-field obligation.
832
+ */
833
+ function rewriteTaskCwd(db, rewrites) {
834
+ const rows = db
835
+ .prepare("SELECT task_id, payload FROM task_records")
836
+ .all();
837
+ const update = db.prepare("UPDATE task_records SET payload = ? WHERE task_id = ?");
838
+ for (const row of rows) {
839
+ let task;
840
+ try {
841
+ task = JSON.parse(row.payload);
842
+ }
843
+ catch {
844
+ continue;
845
+ }
846
+ if (typeof task.cwd !== "string")
847
+ continue;
848
+ const next = applyPrefix(task.cwd, rewrites);
849
+ if (next === undefined || next === task.cwd)
850
+ continue;
851
+ update.run(JSON.stringify({ ...task, cwd: next }), row.task_id);
852
+ }
853
+ }
854
+ /**
855
+ * The live owner identity keys present in `managed_workspaces` for one owner
856
+ * kind. Used to tell a live embedded mirror (row still present) from frozen
857
+ * evidence (row already deleted at disposition).
858
+ */
859
+ function liveOwnerKeys(db, ownerKind, keyOf) {
860
+ const keys = new Set();
861
+ const rows = db
862
+ .prepare("SELECT payload FROM managed_workspaces WHERE owner_kind = ?")
863
+ .all(ownerKind);
864
+ for (const row of rows) {
865
+ let workspace;
866
+ try {
867
+ workspace = JSON.parse(row.payload);
868
+ }
869
+ catch {
870
+ continue;
871
+ }
872
+ if (workspace.owner === undefined)
873
+ continue;
874
+ const key = keyOf(workspace.owner);
875
+ if (key !== undefined)
876
+ keys.add(key);
877
+ }
878
+ return keys;
879
+ }
880
+ /**
881
+ * Return a copy of a workspace-shaped object (ManagedWorkspace or
882
+ * EffectiveLaunchWorkspace) with `root` and every `entries[].path` prefix-
883
+ * rewritten. Key order and untouched fields (owner, timestamps, entry metadata)
884
+ * are preserved, so an unchanged root/entry leaves the object structurally
885
+ * identical.
886
+ */
887
+ function rewriteWorkspaceObject(workspace, rewrites) {
888
+ const next = { ...workspace };
889
+ if (typeof workspace.root === "string") {
890
+ next.root = applyPrefix(workspace.root, rewrites) ?? workspace.root;
891
+ }
892
+ if (Array.isArray(workspace.entries)) {
893
+ next.entries = workspace.entries.map((entry) => {
894
+ if (entry !== null && typeof entry === "object" && typeof entry.path === "string") {
895
+ const path = entry.path;
896
+ return { ...entry, path: applyPrefix(path, rewrites) ?? path };
897
+ }
898
+ return entry;
899
+ });
900
+ }
901
+ return next;
902
+ }
903
+ /** Substitute the first matching root prefix, or undefined when none applies. */
904
+ function applyPrefix(value, rewrites) {
905
+ for (const { from, to } of rewrites) {
906
+ if (value === from)
907
+ return to;
908
+ const nested = relative(from, value);
909
+ if (nested.length > 0 && !nested.startsWith("..") && !isAbsolute(nested)) {
910
+ // `relative` yields a non-escaping, non-absolute path only when `value`
911
+ // is genuinely beneath `from`; join preserves the trailing structure.
912
+ return join(to, nested);
913
+ }
914
+ }
915
+ return undefined;
916
+ }
917
+ /** True when `value` is one of `roots` or lies beneath one of them. */
918
+ function isUnderAnyRoot(value, roots) {
919
+ return roots.some((root) => {
920
+ if (value === root)
921
+ return true;
922
+ const nested = relative(root, value);
923
+ return nested.length > 0 && !nested.startsWith("..") && !isAbsolute(nested);
924
+ });
925
+ }