@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.
- package/dist/agent/launchEnvironment.js +7 -0
- package/dist/cli/commandCatalog.js +26 -6
- package/dist/cli/interactionPolicy.js +3 -3
- package/dist/cli/updateOrchestrator.js +24 -1
- package/dist/cli/updatePorts.js +7 -3
- package/dist/cli/upgradeCommand.js +42 -2
- package/dist/cli.js +336 -93
- package/dist/commands/globalRoleCommands.js +314 -4
- package/dist/commands/projectCommands.js +6 -7
- package/dist/commands/releaseCommands.js +18 -0
- package/dist/commands/taskActor.js +25 -0
- package/dist/commands/taskCommands.js +511 -83
- package/dist/commands/taskIntegrationCommands.js +16 -38
- package/dist/commands/taskIntegrationQueueCommands.js +1 -1
- package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
- package/dist/commands/taskRoleRuntimeStatus.js +35 -0
- package/dist/context/taskContext.js +38 -2
- package/dist/controller/agentHostObservation.js +155 -0
- package/dist/controller/clientRuntime.js +17 -2
- package/dist/controller/controller.js +3 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
- package/dist/controller/globalInputDelivery.js +119 -0
- package/dist/controller/jobControl.js +6 -2
- package/dist/controller/resourceInventory.js +14 -4
- package/dist/controller/resourceInventoryLinux.js +2 -6
- package/dist/controller/runtime.js +81 -6
- package/dist/controller/runtimeEventInbox.js +32 -3
- package/dist/controller/runtimeEventProcessor.js +26 -6
- package/dist/controller/runtimeHookRunFence.js +75 -19
- package/dist/controller/structuredProviderObservation.js +133 -70
- package/dist/coordination/workMailboxQueue.js +5 -0
- package/dist/execution/workItemExecutionProjection.js +1 -1
- package/dist/executor/agentExecutor.js +64 -4
- package/dist/executor/executorRegistry.js +3 -0
- package/dist/executor/fileRoleLaunchPlanner.js +78 -118
- package/dist/integration/deliveryObligation.js +2 -1
- package/dist/integration/gitIntegrationService.js +312 -382
- package/dist/integration/integrationAttempt.js +30 -4
- package/dist/integration/integrationQueueService.js +7 -7
- package/dist/integration/integrationSourceApplication.js +323 -0
- package/dist/message/globalInterrupt.js +33 -0
- package/dist/message/inputControlResolution.js +106 -0
- package/dist/message/message.js +367 -0
- package/dist/message/messageContinuation.js +126 -3
- package/dist/message/taskInterrupt.js +34 -0
- package/dist/observability/orchestrationMetrics.js +1 -1
- package/dist/release/releaseHandover.js +22 -0
- package/dist/release/releaseWorkflowPorts.js +15 -7
- package/dist/repository/gitWorkspace.js +72 -15
- package/dist/repository/taskWorkspaceCoordinator.js +134 -0
- package/dist/repository/taskWorkspacePreparer.js +120 -49
- package/dist/repository/workItemCandidateSnapshot.js +34 -0
- package/dist/resources/resourceDiscovery.js +3 -2
- package/dist/runtime/agentHost.js +152 -72
- package/dist/runtime/agentHostCompatibility.js +127 -0
- package/dist/runtime/agentHostProtocol.js +53 -0
- package/dist/runtime/executionEnvironment.js +0 -19
- package/dist/runtime/launchBroker.js +6 -0
- package/dist/runtime/sessionReconciliation.js +4 -4
- package/dist/runtime/taskRuntimeIsolation.js +30 -6
- package/dist/runtime/tmuxAdapters.js +5 -3
- package/dist/scheduler/operatorEvent.js +4 -0
- package/dist/scheduler/taskExecutionProjection.js +12 -1
- package/dist/scheduler/wakeReason.js +7 -1
- package/dist/scheduler/wakeupQueue.js +2 -0
- package/dist/setup/setupCommand.js +26 -8
- package/dist/storage/homeLayout.js +130 -0
- package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
- package/dist/storage/migrations/integrationContinuation.js +104 -0
- package/dist/storage/migrations/unifyHomeLayout.js +925 -0
- package/dist/storage/sqliteSchema.js +136 -4
- package/dist/storage/sqliteStore.js +40 -1
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +1 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
- package/dist/task/archiveDiagnostics.js +128 -0
- package/dist/task/nextAction.js +44 -11
- package/dist/web/assets/client/app.js +55 -0
- package/dist/web/assets/client/components.js +1 -0
- package/dist/web/assets/client/i18n.js +6 -0
- package/dist/web/assets/client/taskSurface.js +106 -0
- package/dist/web/assets/client/view.js +7 -4
- package/dist/web/assets/shell.js +23 -0
- package/dist/web/assets/styles/layout.js +1 -1
- package/dist/web/assets/styles/widgets.js +12 -0
- package/dist/web/webServer.js +117 -1
- package/dist/web/webSnapshot.js +4 -3
- package/dist/web/webTaskSurface.js +222 -5
- package/dist/workspace/workItemChangeSetManager.js +18 -2
- package/docs/release-workflow.md +39 -0
- package/docs/release-workflow.zh-CN.md +29 -0
- package/docs/sqlite-control-plane-design.md +223 -1
- package/docs/testing/verification-levels.md +24 -0
- package/docs/testing/verification-levels.zh-CN.md +11 -0
- package/package.json +1 -1
- package/skills/yui-leader/references/execution.md +151 -49
- package/skills/yui-leader/references/integration.md +52 -2
- package/skills/yui-operator/SKILL.md +6 -1
- package/skills/yui-runtime/SKILL.md +27 -0
- 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
|
+
}
|