@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.
- package/ARCHITECTURE.md +2 -0
- package/ARCHITECTURE.zh-CN.md +151 -0
- package/README.md +211 -14
- package/dist/agent/launchEnvironment.js +7 -0
- package/dist/artifacts/artifactCapability.js +74 -0
- package/dist/artifacts/artifactCommitLock.js +249 -0
- package/dist/artifacts/artifactPaths.js +151 -0
- package/dist/artifacts/gitArtifactRef.js +146 -0
- package/dist/artifacts/managedGit.js +332 -0
- package/dist/artifacts/taskArtifactRepository.js +277 -0
- package/dist/cli/commandCatalog.js +40 -16
- 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 +403 -93
- package/dist/commands/globalRoleCommands.js +314 -4
- package/dist/commands/operatorCommands.js +33 -2
- package/dist/commands/projectCommands.js +6 -7
- package/dist/commands/releaseCommands.js +18 -0
- package/dist/commands/taskActivationCommands.js +22 -0
- package/dist/commands/taskActor.js +25 -0
- package/dist/commands/taskCommands.js +846 -155
- 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/runContextPack.js +28 -16
- package/dist/context/taskContext.js +64 -5
- package/dist/controller/agentHostObservation.js +155 -0
- package/dist/controller/clientRuntime.js +17 -2
- package/dist/controller/controller.js +11 -2
- 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/kernel/builtinCapabilities.js +32 -24
- package/dist/message/globalInterrupt.js +33 -0
- package/dist/message/inputControlResolution.js +106 -0
- package/dist/message/message.js +423 -0
- package/dist/message/messageContinuation.js +126 -3
- package/dist/message/taskInterrupt.js +34 -0
- package/dist/observability/orchestrationMetrics.js +1 -1
- package/dist/plugins/pluginService.js +11 -3
- 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/projectResource.js +0 -48
- package/dist/resources/projectResourceService.js +3 -81
- 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 +29 -16
- package/dist/storage/homeLayout.js +130 -0
- package/dist/storage/migrations/artifactsToGit.js +338 -0
- package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
- package/dist/storage/migrations/integrationContinuation.js +104 -0
- package/dist/storage/migrations/submitIntent.js +126 -0
- package/dist/storage/migrations/unifyHomeLayout.js +925 -0
- package/dist/storage/sqliteSchema.js +173 -7
- package/dist/storage/sqliteStore.js +41 -22
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +2 -1
- package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
- package/dist/task/archiveDiagnostics.js +128 -0
- package/dist/task/nextAction.js +44 -11
- package/dist/task/taskActivation.js +26 -0
- package/dist/task/taskActivationService.js +85 -69
- package/dist/task/taskSubmission.js +236 -0
- package/dist/web/assets/client/app.js +58 -2
- 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 +202 -7
- 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 +135 -4
- package/dist/web/webSnapshot.js +4 -3
- package/dist/web/webTaskSurface.js +225 -8
- package/dist/workItem/workItem.js +14 -10
- package/dist/workspace/workItemChangeSetManager.js +18 -2
- package/docs/agent-result-consumption.md +2 -0
- package/docs/agent-result-consumption.zh-CN.md +81 -0
- package/docs/agent-runtime-drivers.md +2 -0
- package/docs/agent-runtime-drivers.zh-CN.md +77 -0
- package/docs/architecture/README.md +44 -32
- package/docs/architecture/README.zh-CN.md +43 -0
- package/docs/architecture/capabilities-and-resources.md +118 -79
- package/docs/architecture/capabilities-and-resources.zh-CN.md +83 -0
- package/docs/managed-turn-and-session-runtime.md +2 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +180 -0
- package/docs/observability/README.md +2 -0
- package/docs/observability/README.zh-CN.md +71 -0
- package/docs/plugin-sdk.md +320 -217
- package/docs/plugin-sdk.zh-CN.md +293 -0
- package/docs/provider-runtime.md +2 -0
- package/docs/provider-runtime.zh-CN.md +132 -0
- package/docs/release-workflow.md +41 -0
- package/docs/release-workflow.zh-CN.md +266 -0
- package/docs/roles-and-configuration.md +2 -0
- package/docs/roles-and-configuration.zh-CN.md +96 -0
- package/docs/sqlite-control-plane-design.md +225 -1
- package/docs/sqlite-control-plane-design.zh-CN.md +62 -0
- package/docs/task-dag-semantics.md +80 -57
- package/docs/task-dag-semantics.zh-CN.md +59 -0
- package/docs/task-delivery.md +2 -0
- package/docs/task-delivery.zh-CN.md +82 -0
- package/docs/task-local-identity.md +2 -0
- package/docs/task-local-identity.zh-CN.md +58 -0
- package/docs/testing/verification-levels.md +26 -0
- package/docs/testing/verification-levels.zh-CN.md +80 -0
- package/i18n/README.zh-CN.md +199 -10
- package/package.json +2 -1
- package/skills/yui-leader/SKILL.md +88 -331
- package/skills/yui-leader/references/execution.md +405 -0
- package/skills/yui-leader/references/integration.md +52 -2
- package/skills/yui-leader/references/planning.md +109 -0
- package/skills/yui-leader/references/task-plugins.md +8 -4
- package/skills/yui-operator/SKILL.md +22 -4
- package/skills/yui-runtime/SKILL.md +27 -0
- 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
|
+
}
|