@zq-silk/yui 0.5.2 → 0.6.0

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.
@@ -0,0 +1,457 @@
1
+ /**
2
+ * state.json -> SQLite staged migration (task-21 §8, work-item-4).
3
+ *
4
+ * This module is the document-to-database population and verification seam for
5
+ * the layout 6 -> 7 offline migration. It is deliberately side-effect-light:
6
+ *
7
+ * - `populateSqliteFromState` opens a {@link SqliteTaskStore} on the sidecar
8
+ * `yui.db.staged` and bulk-loads every record family from the parsed
9
+ * `state.json` document, preserving the Home identity, revision, and ID
10
+ * high-water marks. The source document is never written.
11
+ * - `computeStateFamilyChecksums` / `computeDbFamilyChecksums` compute a
12
+ * per-family `{ count, hash }` over the canonical JSON of every record, so
13
+ * the staged database can be verified against an independent re-read of
14
+ * `state.json` (the Verify phase of §8.2).
15
+ * - `verifySqliteMigration` compares the two checksum maps and throws on any
16
+ * count or content mismatch, leaving the source untouched.
17
+ *
18
+ * The checksum is content-based: each record is canonicalised (sorted keys,
19
+ * stable array ordering) and hashed individually; the per-family hash is the
20
+ * sha256 of the sorted record hashes. This is order-independent (a map
21
+ * serialised in any key order produces the same checksum) and catches any
22
+ * dropped, duplicated, or mutated record.
23
+ */
24
+ import { createHash } from "node:crypto";
25
+ import { join } from "node:path";
26
+ import Database from "better-sqlite3";
27
+ import { SqliteTaskStore } from "../sqliteStore.js";
28
+ /** The sidecar database filename used during staging. */
29
+ export const STAGED_DATABASE_FILENAME = "yui.db.staged";
30
+ /** The committed database filename. */
31
+ export const COMMITTED_DATABASE_FILENAME = "yui.db";
32
+ // ---------------------------------------------------------------------------
33
+ // Canonical JSON and hashing
34
+ // ---------------------------------------------------------------------------
35
+ /**
36
+ * Deterministic JSON serialisation: object keys are sorted recursively so that
37
+ * two semantically-equal objects serialise identically regardless of key
38
+ * insertion order. Arrays preserve their order.
39
+ */
40
+ function canonicalJson(value) {
41
+ if (value === null || typeof value !== "object")
42
+ return JSON.stringify(value);
43
+ if (Array.isArray(value)) {
44
+ return `[${value.map(canonicalJson).join(",")}]`;
45
+ }
46
+ const record = value;
47
+ const keys = Object.keys(record).sort();
48
+ return `{${keys
49
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`)
50
+ .join(",")}}`;
51
+ }
52
+ /**
53
+ * Compute a per-family checksum over a set of records. Each record is hashed
54
+ * individually (over its canonical JSON); the family hash is the sha256 of
55
+ * the sorted per-record hashes, making the result independent of record
56
+ * ordering.
57
+ */
58
+ function hashRecords(records) {
59
+ const hashes = records
60
+ .map((record) => createHash("sha256").update(canonicalJson(record)).digest("hex"))
61
+ .sort();
62
+ const hash = createHash("sha256").update(hashes.join("\n")).digest("hex");
63
+ return { count: records.length, hash };
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // Document shape helpers
67
+ // ---------------------------------------------------------------------------
68
+ function asObject(value) {
69
+ if (value === null || typeof value !== "object" || Array.isArray(value))
70
+ return {};
71
+ return value;
72
+ }
73
+ function asObjectMap(value) {
74
+ const record = asObject(value);
75
+ const result = {};
76
+ for (const [key, entry] of Object.entries(record)) {
77
+ if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) {
78
+ result[key] = entry;
79
+ }
80
+ }
81
+ return result;
82
+ }
83
+ function asNullableObject(value) {
84
+ if (value === null || value === undefined)
85
+ return null;
86
+ if (typeof value !== "object" || Array.isArray(value))
87
+ return null;
88
+ return value;
89
+ }
90
+ function asStoredTask(value) {
91
+ const record = asObject(value);
92
+ return {
93
+ task: asObject(record.task),
94
+ brief: asNullableObject(record.brief),
95
+ roles: asObjectMap(record.roles),
96
+ managedWorkspaces: asObjectMap(record.managedWorkspaces),
97
+ roleSessionSets: asObjectMap(record.roleSessionSets),
98
+ workItems: asObjectMap(record.workItems),
99
+ agentRuns: asObjectMap(record.agentRuns),
100
+ reviewRounds: asObjectMap(record.reviewRounds),
101
+ changeSets: asObjectMap(record.changeSets),
102
+ integrationAttempts: asObjectMap(record.integrationAttempts),
103
+ activeRuns: asObjectMap(record.activeRuns),
104
+ messages: asObjectMap(record.messages),
105
+ inputRequests: asObjectMap(record.inputRequests),
106
+ decisions: asObjectMap(record.decisions),
107
+ milestones: asObjectMap(record.milestones),
108
+ events: asObjectMap(record.events),
109
+ leaderFailure: asNullableObject(record.leaderFailure),
110
+ operatorNotification: asNullableObject(record.operatorNotification),
111
+ idHighWaterMarks: asObject(record.idHighWaterMarks)
112
+ };
113
+ }
114
+ function tasksOf(state) {
115
+ const result = {};
116
+ for (const [taskId, raw] of Object.entries(asObject(state.tasks))) {
117
+ result[taskId] = asStoredTask(raw);
118
+ }
119
+ return result;
120
+ }
121
+ // ---------------------------------------------------------------------------
122
+ // Population (Snapshot -> Stage)
123
+ // ---------------------------------------------------------------------------
124
+ /**
125
+ * Populate a fresh SQLite database from the parsed `state.json` document.
126
+ *
127
+ * The database is opened with the `migration` option (fence bypass) because
128
+ * the staged load runs while the upgrade fence is active. Every record family
129
+ * is saved through the canonical {@link SqliteTaskStore} methods so the
130
+ * typed-column projections and payload contents match what a live store would
131
+ * produce. The Home identity, revision, and ID high-water marks are preserved
132
+ * so the opened store continues from the same counters.
133
+ *
134
+ * This function NEVER writes `state.json`; it only creates/populates the
135
+ * sidecar database file.
136
+ */
137
+ export function populateSqliteFromState(home, state, databaseFilename) {
138
+ const store = new SqliteTaskStore(home, { databaseFilename, migration: true });
139
+ try {
140
+ store.transaction(() => {
141
+ // Home identity + revision continuity.
142
+ const identity = asNullableObject(state.homeIdentity);
143
+ const revision = typeof state.revision === "number" ? state.revision : 0;
144
+ if (identity !== null) {
145
+ store.migrationSetHomeMeta(identity, revision);
146
+ }
147
+ // Config singleton.
148
+ const config = asNullableObject(state.config);
149
+ if (config !== null)
150
+ store.saveConfig(config);
151
+ // Global record families.
152
+ for (const agent of Object.values(asObjectMap(state.configuredAgents))) {
153
+ store.saveConfiguredAgent(agent);
154
+ }
155
+ for (const project of Object.values(asObjectMap(state.projects))) {
156
+ store.saveProject(project);
157
+ }
158
+ for (const profile of Object.values(asObjectMap(state.agentProfiles))) {
159
+ store.saveAgentProfile(profile);
160
+ }
161
+ for (const role of Object.values(asObjectMap(state.globalRoles))) {
162
+ store.saveGlobalRole(role);
163
+ }
164
+ for (const sessions of Object.values(asObjectMap(state.globalRoleSessionSets))) {
165
+ store.saveGlobalRoleSessionSet(sessions);
166
+ }
167
+ // Tasks and their per-task families.
168
+ const tasks = tasksOf(state);
169
+ for (const [taskId, stored] of Object.entries(tasks)) {
170
+ store.saveTask(stored.task);
171
+ if (stored.brief !== null) {
172
+ store.saveTaskBrief(taskId, stored.brief);
173
+ }
174
+ for (const role of Object.values(stored.roles)) {
175
+ store.saveRole(taskId, role);
176
+ }
177
+ for (const workspace of Object.values(stored.managedWorkspaces)) {
178
+ store.saveManagedWorkspace(workspace);
179
+ }
180
+ for (const sessions of Object.values(stored.roleSessionSets)) {
181
+ store.saveRoleSessionSet(sessions);
182
+ }
183
+ for (const item of Object.values(stored.workItems)) {
184
+ store.saveWorkItem(taskId, item);
185
+ }
186
+ for (const run of Object.values(stored.agentRuns)) {
187
+ store.saveAgentRun(run);
188
+ }
189
+ for (const round of Object.values(stored.reviewRounds)) {
190
+ store.saveReviewRound(taskId, round);
191
+ }
192
+ for (const changeSet of Object.values(stored.changeSets)) {
193
+ store.saveChangeSet(taskId, changeSet);
194
+ }
195
+ for (const attempt of Object.values(stored.integrationAttempts)) {
196
+ store.saveIntegrationAttempt(taskId, attempt);
197
+ }
198
+ // Active-run pointers: the document stores { schemaVersion, runId }
199
+ // keyed by pointer; the store derives the pointer from the Run.
200
+ for (const [pointer, value] of Object.entries(stored.activeRuns)) {
201
+ const run = stored.agentRuns[value.runId];
202
+ if (run === undefined) {
203
+ throw new Error(`Active run pointer ${taskId}/${pointer} references missing agent run ${value.runId}.`);
204
+ }
205
+ if (pointer.startsWith("/execution-lane/")) {
206
+ store.saveActiveExecutionLaneRun(run);
207
+ }
208
+ else {
209
+ store.saveActiveAgentRun(run);
210
+ }
211
+ }
212
+ for (const message of Object.values(stored.messages)) {
213
+ store.saveMessage(taskId, message);
214
+ }
215
+ for (const request of Object.values(stored.inputRequests)) {
216
+ store.saveInputRequest(taskId, request);
217
+ }
218
+ for (const decision of Object.values(stored.decisions)) {
219
+ store.saveDecision(taskId, decision);
220
+ }
221
+ for (const milestone of Object.values(stored.milestones)) {
222
+ store.saveMilestone(taskId, milestone);
223
+ }
224
+ for (const event of Object.values(stored.events)) {
225
+ store.saveEvent(taskId, event);
226
+ }
227
+ if (stored.leaderFailure !== null) {
228
+ store.saveLeaderFailure(stored.leaderFailure);
229
+ }
230
+ if (stored.operatorNotification !== null) {
231
+ store.saveOperatorNotification(stored.operatorNotification);
232
+ }
233
+ // Per-task ID high-water marks.
234
+ for (const [kind, highWater] of Object.entries(stored.idHighWaterMarks)) {
235
+ if (typeof highWater === "number" && Number.isFinite(highWater)) {
236
+ store.migrationSeedIdSequence(taskId, kind, highWater);
237
+ }
238
+ }
239
+ }
240
+ // Work mailboxes.
241
+ for (const mailbox of Object.values(asObjectMap(state.mailboxes))) {
242
+ store.saveWorkMailbox(mailbox);
243
+ }
244
+ // Global ID high-water marks, derived from existing task/project ids.
245
+ seedGlobalSequences(store, state);
246
+ });
247
+ }
248
+ finally {
249
+ store.close();
250
+ }
251
+ }
252
+ /**
253
+ * Seed `global_sequences` from the numeric suffixes of existing task and
254
+ * project ids so the next allocated id never collides with a historical one.
255
+ * Ids that do not match `<kind>-<n>` are ignored (legacy formats).
256
+ */
257
+ function seedGlobalSequences(store, state) {
258
+ const taskMax = maxIdSuffix(Object.keys(asObject(state.tasks)), "task");
259
+ const projectMax = maxIdSuffix(Object.keys(asObject(state.projects)), "project");
260
+ if (taskMax > 0)
261
+ store.migrationSeedGlobalSequence("task", taskMax);
262
+ if (projectMax > 0)
263
+ store.migrationSeedGlobalSequence("project", projectMax);
264
+ }
265
+ function maxIdSuffix(ids, prefix) {
266
+ let max = 0;
267
+ const pattern = new RegExp(`^${prefix}-(\\d+)$`, "u");
268
+ for (const id of ids) {
269
+ const match = pattern.exec(id);
270
+ if (match !== null) {
271
+ const value = Number.parseInt(match[1], 10);
272
+ if (Number.isSafeInteger(value))
273
+ max = Math.max(max, value);
274
+ }
275
+ }
276
+ return max;
277
+ }
278
+ // ---------------------------------------------------------------------------
279
+ // State-side checksums (the source of truth)
280
+ // ---------------------------------------------------------------------------
281
+ /**
282
+ * Compute per-family checksums from the parsed `state.json` document. This is
283
+ * the expected checksum set; the staged database is verified against it.
284
+ */
285
+ export function computeStateFamilyChecksums(state) {
286
+ const checksums = {};
287
+ checksums.config = hashRecords(asNullableObject(state.config) === null ? [] : [asObject(state.config)]);
288
+ checksums.configuredAgent = hashRecords(Object.values(asObjectMap(state.configuredAgents)));
289
+ checksums.project = hashRecords(Object.values(asObjectMap(state.projects)));
290
+ checksums.agentProfile = hashRecords(Object.values(asObjectMap(state.agentProfiles)));
291
+ checksums.globalRole = hashRecords(Object.values(asObjectMap(state.globalRoles)));
292
+ checksums.globalRoleSessionSet = hashRecords(Object.values(asObjectMap(state.globalRoleSessionSets)));
293
+ // Flatten the per-task families across all tasks.
294
+ const tasks = tasksOf(state);
295
+ const taskRecords = [];
296
+ const briefs = [];
297
+ const roles = [];
298
+ const workspaces = [];
299
+ const roleSessionSets = [];
300
+ const workItems = [];
301
+ const agentRuns = [];
302
+ const reviewRounds = [];
303
+ const changeSets = [];
304
+ const integrationAttempts = [];
305
+ const activeRunPointers = [];
306
+ const messages = [];
307
+ const inputRequests = [];
308
+ const decisions = [];
309
+ const milestones = [];
310
+ const events = [];
311
+ const leaderFailures = [];
312
+ const operatorNotifications = [];
313
+ for (const stored of Object.values(tasks)) {
314
+ taskRecords.push(stored.task);
315
+ if (stored.brief !== null)
316
+ briefs.push(stored.brief);
317
+ roles.push(...Object.values(stored.roles));
318
+ workspaces.push(...Object.values(stored.managedWorkspaces));
319
+ roleSessionSets.push(...Object.values(stored.roleSessionSets));
320
+ workItems.push(...Object.values(stored.workItems));
321
+ agentRuns.push(...Object.values(stored.agentRuns));
322
+ reviewRounds.push(...Object.values(stored.reviewRounds));
323
+ changeSets.push(...Object.values(stored.changeSets));
324
+ integrationAttempts.push(...Object.values(stored.integrationAttempts));
325
+ activeRunPointers.push(...Object.values(stored.activeRuns));
326
+ messages.push(...Object.values(stored.messages));
327
+ inputRequests.push(...Object.values(stored.inputRequests));
328
+ decisions.push(...Object.values(stored.decisions));
329
+ milestones.push(...Object.values(stored.milestones));
330
+ events.push(...Object.values(stored.events));
331
+ if (stored.leaderFailure !== null)
332
+ leaderFailures.push(stored.leaderFailure);
333
+ if (stored.operatorNotification !== null)
334
+ operatorNotifications.push(stored.operatorNotification);
335
+ }
336
+ checksums.task = hashRecords(taskRecords);
337
+ checksums.taskBrief = hashRecords(briefs);
338
+ checksums.taskRole = hashRecords(roles);
339
+ checksums.managedWorkspace = hashRecords(workspaces);
340
+ checksums.taskRoleSessionSet = hashRecords(roleSessionSets);
341
+ checksums.workItem = hashRecords(workItems);
342
+ checksums.agentRun = hashRecords(agentRuns);
343
+ checksums.reviewRound = hashRecords(reviewRounds);
344
+ checksums.changeSet = hashRecords(changeSets);
345
+ checksums.integrationAttempt = hashRecords(integrationAttempts);
346
+ checksums.activeRunPointer = hashRecords(activeRunPointers);
347
+ checksums.message = hashRecords(messages);
348
+ checksums.inputRequest = hashRecords(inputRequests);
349
+ checksums.decision = hashRecords(decisions);
350
+ checksums.milestone = hashRecords(milestones);
351
+ checksums.event = hashRecords(events);
352
+ checksums.leaderFailure = hashRecords(leaderFailures);
353
+ checksums.operatorNotification = hashRecords(operatorNotifications);
354
+ checksums.workMailbox = hashRecords(Object.values(asObjectMap(state.mailboxes)));
355
+ return checksums;
356
+ }
357
+ function hashPayloadTable(db, sql) {
358
+ const rows = db.prepare(sql).all();
359
+ return hashRecords(rows.map((row) => JSON.parse(row.payload)));
360
+ }
361
+ /** Reconstruct a WorkMailbox from the normalised mailboxes table columns. */
362
+ function rowToMailbox(row) {
363
+ let target;
364
+ switch (row.target_kind) {
365
+ case "operator":
366
+ target = { kind: "operator" };
367
+ break;
368
+ case "task":
369
+ target = { kind: "task", taskId: row.task_id };
370
+ break;
371
+ case "role":
372
+ target = { kind: "role", taskId: row.task_id, roleName: row.role_name };
373
+ break;
374
+ case "role-runtime":
375
+ target = { kind: "role-runtime", taskId: row.task_id, roleName: row.role_name };
376
+ break;
377
+ case "global-role-runtime":
378
+ target = { kind: "global-role-runtime", roleName: row.role_name };
379
+ break;
380
+ default:
381
+ target = { kind: row.target_kind };
382
+ }
383
+ return {
384
+ schemaVersion: 1,
385
+ target,
386
+ nextSequence: row.next_sequence,
387
+ processing: row.processing === null ? null : JSON.parse(row.processing),
388
+ pending: row.pending === null ? null : JSON.parse(row.pending)
389
+ };
390
+ }
391
+ /**
392
+ * Compute per-family checksums from the SQLite database. The database is
393
+ * opened read-only; this is the independent verification read.
394
+ */
395
+ export function computeDbFamilyChecksums(home, databaseFilename) {
396
+ const dbPath = join(home, databaseFilename);
397
+ const db = new Database(dbPath, { readonly: true });
398
+ try {
399
+ const checksums = {};
400
+ checksums.config = hashPayloadTable(db, "SELECT payload FROM config");
401
+ checksums.configuredAgent = hashPayloadTable(db, "SELECT payload FROM configured_agents");
402
+ checksums.project = hashPayloadTable(db, "SELECT payload FROM projects");
403
+ checksums.agentProfile = hashPayloadTable(db, "SELECT payload FROM agent_profiles");
404
+ checksums.globalRole = hashPayloadTable(db, "SELECT payload FROM global_roles");
405
+ checksums.globalRoleSessionSet = hashPayloadTable(db, "SELECT payload FROM global_role_session_sets");
406
+ checksums.task = hashPayloadTable(db, "SELECT payload FROM task_records");
407
+ checksums.taskBrief = hashPayloadTable(db, "SELECT brief AS payload FROM task_records WHERE brief IS NOT NULL");
408
+ checksums.taskRole = hashPayloadTable(db, "SELECT payload FROM task_roles");
409
+ checksums.managedWorkspace = hashPayloadTable(db, "SELECT payload FROM managed_workspaces");
410
+ checksums.taskRoleSessionSet = hashPayloadTable(db, "SELECT payload FROM role_session_sets");
411
+ checksums.workItem = hashPayloadTable(db, "SELECT payload FROM work_items");
412
+ checksums.agentRun = hashPayloadTable(db, "SELECT payload FROM agent_runs");
413
+ checksums.reviewRound = hashPayloadTable(db, "SELECT payload FROM review_rounds");
414
+ checksums.changeSet = hashPayloadTable(db, "SELECT payload FROM change_sets");
415
+ checksums.integrationAttempt = hashPayloadTable(db, "SELECT payload FROM integration_attempts");
416
+ checksums.activeRunPointer = hashPayloadTable(db, "SELECT payload FROM active_runs");
417
+ checksums.message = hashPayloadTable(db, "SELECT payload FROM messages");
418
+ checksums.inputRequest = hashPayloadTable(db, "SELECT payload FROM input_requests");
419
+ checksums.decision = hashPayloadTable(db, "SELECT payload FROM decisions");
420
+ checksums.milestone = hashPayloadTable(db, "SELECT payload FROM milestones");
421
+ checksums.event = hashPayloadTable(db, "SELECT payload FROM events");
422
+ checksums.leaderFailure = hashPayloadTable(db, "SELECT payload FROM task_projections WHERE kind = 'leader-failure' AND payload IS NOT NULL");
423
+ checksums.operatorNotification = hashPayloadTable(db, "SELECT payload FROM task_projections WHERE kind = 'operator-notification' AND payload IS NOT NULL");
424
+ // Mailboxes are reconstructed from typed columns (no payload column).
425
+ const mailboxRows = db.prepare("SELECT target_kind, task_id, role_name, next_sequence, processing, pending FROM mailboxes ORDER BY target_key").all();
426
+ checksums.workMailbox = hashRecords(mailboxRows.map(rowToMailbox));
427
+ return checksums;
428
+ }
429
+ finally {
430
+ db.close();
431
+ }
432
+ }
433
+ // ---------------------------------------------------------------------------
434
+ // Verification (Verify phase of §8.2)
435
+ // ---------------------------------------------------------------------------
436
+ /**
437
+ * Compare the per-family checksums of the source document against those of the
438
+ * staged database. Throws on any count or content mismatch, naming every
439
+ * divergent family. The source document is never modified.
440
+ */
441
+ export function verifySqliteChecksums(state, home, databaseFilename) {
442
+ const expected = computeStateFamilyChecksums(state);
443
+ const actual = computeDbFamilyChecksums(home, databaseFilename);
444
+ const families = new Set([...Object.keys(expected), ...Object.keys(actual)]);
445
+ const mismatches = [];
446
+ for (const family of families) {
447
+ const e = expected[family];
448
+ const a = actual[family];
449
+ if (e === undefined || a === undefined || e.count !== a.count || e.hash !== a.hash) {
450
+ mismatches.push(`${family} (expected ${e === undefined ? "absent" : `${e.count}/${e.hash.slice(0, 12)}`}, ` +
451
+ `found ${a === undefined ? "absent" : `${a.count}/${a.hash.slice(0, 12)}`})`);
452
+ }
453
+ }
454
+ if (mismatches.length > 0) {
455
+ throw new Error(`SQLite migration checksum mismatch for ${mismatches.length} family/families: ${mismatches.join("; ")}`);
456
+ }
457
+ }
@@ -34,19 +34,21 @@
34
34
  * authoritative input byte-for-byte unchanged.
35
35
  */
36
36
  import { spawn } from "node:child_process";
37
- import { readFileSync, readdirSync } from "node:fs";
37
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
38
38
  import { join } from "node:path";
39
39
  import { describeReport, runMigration } from "../migration/index.js";
40
40
  import { validateCompatibleFileTaskStore } from "../compatibleTaskStore.js";
41
41
  import { stopFileTaskController, ensureFileTaskController, ensureFileTaskControllerIdentity } from "../../controller/clientRuntime.js";
42
42
  import { callController } from "../../core/controllerClient.js";
43
43
  import { FileTaskStore, STORAGE_STATE_FILE, withStorageWriteLock } from "../taskStore.js";
44
+ import { SqliteTaskStore } from "../sqliteStore.js";
44
45
  import { clearUpgradeFence, placeUpgradeFence, readUpgradeFence, UpgradeFenceError } from "../upgradeFence.js";
45
46
  import { withUpgradeCoordinationLock } from "../upgradeCoordination.js";
46
47
  import { clearUpgradeReceipt, writeUpgradeReceipt, upgradeReceiptPath } from "./upgradeReceipt.js";
47
48
  import { switchProgressPath } from "./switchProgress.js";
48
49
  import { classifyHome } from "./homeClassification.js";
49
50
  import { createHomeMigrationTarget, describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime } from "./homeMigrationTarget.js";
51
+ import { createSqliteMigrationTarget } from "./sqliteMigrationTarget.js";
50
52
  import { inspectOfflineUpgradeInventory } from "./offlineUpgradeInventory.js";
51
53
  /**
52
54
  * Run the storage upgrade for one Home. Never throws for an expected blocker;
@@ -160,14 +162,20 @@ export async function runStorageUpgrade(options) {
160
162
  return withClassification(offlineInventoryBlocker(inventory, true), classification);
161
163
  }
162
164
  }
163
- const target = createHomeMigrationTarget({
164
- home,
165
- latest,
166
- now,
167
- callerPid,
168
- ...(options.renameImpl === undefined ? {} : { renameImpl: options.renameImpl }),
169
- ...(options.switchFaultHook === undefined ? {} : { switchFaultHook: options.switchFaultHook })
170
- });
165
+ // Select the migration target. A layout-6 Home migrates to the SQLite
166
+ // control-plane layout (7) via the staged state.json→SQLite target; all
167
+ // other plans (record-only on layout 7) use the file-document target.
168
+ const usesSqliteTarget = classification.layoutVersion === 6 && latest.layout === 7;
169
+ const target = usesSqliteTarget
170
+ ? createSqliteMigrationTarget({ home, latest, registry, now, callerPid })
171
+ : createHomeMigrationTarget({
172
+ home,
173
+ latest,
174
+ now,
175
+ callerPid,
176
+ ...(options.renameImpl === undefined ? {} : { renameImpl: options.renameImpl }),
177
+ ...(options.switchFaultHook === undefined ? {} : { switchFaultHook: options.switchFaultHook })
178
+ });
171
179
  // 2) A USABLE (already-current) Home has nothing to migrate; the engine
172
180
  // confirms with a no-op and we never fence, drain, or switch.
173
181
  if (verdict === "USABLE") {
@@ -726,12 +734,30 @@ function readCommittedRevision(home) {
726
734
  /** Post-switch health check: a fresh loader must parse the promoted Home. */
727
735
  function postSwitchHealthCheck(home) {
728
736
  try {
729
- const store = new FileTaskStore(home);
730
- store.getConfig();
731
- store.listTasks();
732
- store.listProjects();
733
- store.listConfiguredAgents();
734
- store.listWorkMailboxes();
737
+ // A layout-7 Home that went through the SQLite staged migration has
738
+ // yui.db; verify it through the SQLite store. Otherwise fall back to the
739
+ // file-document store (record-only migrations on an existing layout-7 Home).
740
+ if (existsSync(join(home, "yui.db"))) {
741
+ const store = new SqliteTaskStore(home);
742
+ try {
743
+ store.getConfig();
744
+ store.listTasks();
745
+ store.listProjects();
746
+ store.listConfiguredAgents();
747
+ store.listWorkMailboxes();
748
+ }
749
+ finally {
750
+ store.close();
751
+ }
752
+ }
753
+ else {
754
+ const store = new FileTaskStore(home);
755
+ store.getConfig();
756
+ store.listTasks();
757
+ store.listProjects();
758
+ store.listConfiguredAgents();
759
+ store.listWorkMailboxes();
760
+ }
735
761
  return null;
736
762
  }
737
763
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zq-silk/yui",
3
- "version": "0.5.2",
3
+ "version": "0.6.0",
4
4
  "description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -48,6 +48,7 @@
48
48
  "dependencies": {
49
49
  "@xterm/addon-fit": "^0.11.0",
50
50
  "@xterm/xterm": "^6.0.0",
51
+ "better-sqlite3": "^12.11.1",
51
52
  "node-pty": "^1.1.0",
52
53
  "smol-toml": "1.7.0",
53
54
  "ws": "^8.21.1"