@zq-silk/yui 0.6.0 → 0.6.2
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/README.md +5 -5
- package/dist/agent/managedRuntimeEnvironment.js +2 -1
- package/dist/cli/commandCatalog.js +251 -13
- package/dist/cli/updateOrchestrator.js +8 -0
- package/dist/cli/updatePorts.js +76 -22
- package/dist/cli.js +264 -20
- package/dist/commands/configCommands.js +83 -9
- package/dist/commands/controllerCommands.js +103 -0
- package/dist/commands/deliveryGuardPreflight.js +30 -0
- package/dist/commands/durableJobCommands.js +231 -0
- package/dist/commands/executionAuditCommands.js +193 -0
- package/dist/commands/grantCommands.js +374 -0
- package/dist/commands/projectCommands.js +119 -81
- package/dist/commands/releaseCommands.js +444 -0
- package/dist/commands/resourcesCommands.js +274 -0
- package/dist/commands/sessionCommands.js +104 -0
- package/dist/commands/taskActor.js +117 -0
- package/dist/commands/taskChangeSetCommands.js +60 -0
- package/dist/commands/taskCommands.js +618 -202
- package/dist/commands/taskCompletionGate.js +78 -1
- package/dist/commands/taskContextCommand.js +33 -6
- package/dist/commands/taskInputCommands.js +1 -1
- package/dist/commands/taskIntegrationCommands.js +136 -33
- package/dist/commands/taskIntegrationQueueCommands.js +228 -0
- package/dist/commands/taskNextActionCommand.js +100 -0
- package/dist/commands/taskOverlapCommands.js +120 -0
- package/dist/commands/taskOverviewCommand.js +36 -8
- package/dist/commands/telemetryCommands.js +330 -0
- package/dist/commands/workflowCommands.js +415 -0
- package/dist/config/yuiConfig.js +62 -0
- package/dist/controller/clientRuntime.js +42 -1
- package/dist/controller/controller.js +402 -56
- package/dist/controller/controllerMain.js +25 -2
- package/dist/controller/domainIdentity.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
- package/dist/controller/handoverCandidate.js +168 -0
- package/dist/controller/jobClient.js +102 -0
- package/dist/controller/jobControl.js +613 -0
- package/dist/controller/jobSupervisor.js +498 -0
- package/dist/controller/providerHookRunFence.js +34 -5
- package/dist/controller/resourceCleanupLinux.js +18 -9
- package/dist/controller/resourceInventoryLinux.js +90 -39
- package/dist/controller/runtime.js +165 -15
- package/dist/controller/runtimeEventInbox.js +234 -57
- package/dist/controller/runtimeEventProcessor.js +297 -58
- package/dist/controller/sessionOwnerReconciliation.js +321 -0
- package/dist/core/controllerServer.js +416 -27
- package/dist/core/controllerTelemetry.js +167 -0
- package/dist/doctor/doctor.js +113 -16
- package/dist/domain/validation.js +9 -0
- package/dist/execution/executionGroup.js +40 -3
- package/dist/executor/agentExecutor.js +6 -3
- package/dist/executor/effectiveLaunch.js +52 -0
- package/dist/executor/executorRegistry.js +50 -0
- package/dist/executor/fileRoleLaunchPlanner.js +61 -6
- package/dist/grant/capabilityGrant.js +282 -0
- package/dist/integration/changeSet.js +16 -3
- package/dist/integration/changeSetManifest.js +46 -0
- package/dist/integration/gitIntegrationService.js +528 -147
- package/dist/integration/integrationAttempt.js +54 -5
- package/dist/integration/integrationQueueEntry.js +221 -0
- package/dist/integration/integrationQueueService.js +955 -0
- package/dist/integration/manifestTags.js +99 -0
- package/dist/integration/overlapDiagnostics.js +211 -0
- package/dist/job/durableJob.js +449 -0
- package/dist/job/jobRunner.js +350 -0
- package/dist/lifecycle/exactRunTerminalization.js +24 -2
- package/dist/lifecycle/providerErrorClass.js +126 -0
- package/dist/message/message.js +16 -3
- package/dist/observability/executionAudit.js +545 -0
- package/dist/observability/faultClassification.js +160 -0
- package/dist/observability/runtimeIdentity.js +367 -0
- package/dist/release/fakeReleasePorts.js +55 -0
- package/dist/release/releaseHandover.js +475 -0
- package/dist/release/releaseIdempotencyStore.js +165 -0
- package/dist/release/releaseWorkflow.js +459 -0
- package/dist/release/releaseWorkflowEngine.js +688 -0
- package/dist/release/releaseWorkflowPorts.js +1720 -0
- package/dist/release/runtimeRelease.js +495 -0
- package/dist/release/workflowFileLock.js +218 -0
- package/dist/repository/gitWorkspace.js +177 -1
- package/dist/repository/projectMaintenanceLock.js +315 -0
- package/dist/repository/taskWorkspaceCoordinator.js +87 -17
- package/dist/repository/taskWorkspacePreparer.js +1091 -517
- package/dist/resources/autoResourceGc.js +116 -0
- package/dist/resources/liveReferences.js +574 -0
- package/dist/resources/resourceDiscovery.js +477 -0
- package/dist/resources/resourceGc.js +645 -0
- package/dist/resources/resourceRegistrar.js +256 -0
- package/dist/resources/resourceRegistry.js +150 -0
- package/dist/resources/resourceRegistryStore.js +41 -0
- package/dist/resources/resourceTypes.js +42 -0
- package/dist/resources/sqliteResourceRegistry.js +111 -0
- package/dist/review/reviewConfig.js +10 -0
- package/dist/review/reviewFinding.js +240 -0
- package/dist/review/reviewFindingLedger.js +545 -0
- package/dist/review/reviewOutcomeClassifier.js +61 -0
- package/dist/review/reviewRound.js +56 -4
- package/dist/run/agentRun.js +80 -4
- package/dist/run/providerRetry.js +84 -0
- package/dist/run/providerRetryConfig.js +63 -0
- package/dist/run/yieldReceipt.js +65 -0
- package/dist/runtime/exactControlPlane.js +79 -2
- package/dist/runtime/index.js +4 -0
- package/dist/runtime/sessionOwnerIdentity.js +269 -0
- package/dist/runtime/sessionOwnerRegistry.js +132 -0
- package/dist/runtime/sessionReconciliation.js +93 -0
- package/dist/runtime/sessionTerminationGuard.js +211 -0
- package/dist/runtime/taskRuntimeIsolation.js +13 -0
- package/dist/runtime/tmuxAdapters.js +34 -1
- package/dist/scheduler/actionability.js +155 -0
- package/dist/scheduler/activeRoleRunDelivery.js +14 -5
- package/dist/scheduler/activeTaskProgress.js +60 -0
- package/dist/scheduler/leaderWakeupProcessor.js +22 -11
- package/dist/scheduler/roleRunStall.js +135 -29
- package/dist/scheduler/taskExecutionProjection.js +11 -0
- package/dist/storage/compatibleTaskStore.js +112 -5
- package/dist/storage/migration/productionRegistry.js +736 -1
- package/dist/storage/sqliteSchema.js +264 -3
- package/dist/storage/sqliteStore.js +487 -13
- package/dist/storage/storeRpc.js +21 -0
- package/dist/storage/taskStore.js +974 -21
- package/dist/storage/upgrade/homeClassification.js +120 -2
- package/dist/storage/upgrade/migrationReceipt.js +67 -0
- package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
- package/dist/storage/upgrade/recordVersions.js +10 -1
- package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
- package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
- package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
- package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
- package/dist/task/deliveryGuard.js +226 -0
- package/dist/task/nextAction.js +738 -0
- package/dist/task/repairWave.js +137 -0
- package/dist/task/taskRecordReference.js +6 -1
- package/dist/telemetry/sqliteTelemetryStore.js +387 -0
- package/dist/telemetry/telemetryCompaction.js +251 -0
- package/dist/telemetry/telemetryConfig.js +64 -0
- package/dist/telemetry/telemetryRouter.js +32 -0
- package/dist/telemetry/telemetryStore.js +19 -0
- package/dist/telemetry/telemetryWiring.js +33 -0
- package/dist/tmux/tmuxManager.js +20 -1
- package/dist/tmux/tmuxSocketEndpoint.js +20 -0
- package/dist/verification/gateArtifact.js +216 -0
- package/dist/verification/gateArtifactStore.js +87 -0
- package/dist/verification/verificationGateService.js +414 -0
- package/dist/verification/verificationPlan.js +308 -0
- package/dist/workspace/gitChangeSetCapture.js +12 -2
- package/dist/workspace/workItemChangeSetManager.js +60 -3
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +8 -0
|
@@ -23,12 +23,15 @@
|
|
|
23
23
|
* non-empty families agree. This keeps a record-only-older Home on its version
|
|
24
24
|
* axis without letting an empty target family masquerade as current.
|
|
25
25
|
*/
|
|
26
|
-
import { existsSync } from "node:fs";
|
|
26
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
import Database from "better-sqlite3";
|
|
27
29
|
import { classifyStorage } from "../migration/index.js";
|
|
28
30
|
import { inspectStorageSchema } from "../storageSchema.js";
|
|
29
|
-
import { FileTaskStore, StorageRecordError } from "../taskStore.js";
|
|
31
|
+
import { FileTaskStore, STORAGE_STATE_FILE, StorageRecordError } from "../taskStore.js";
|
|
30
32
|
import { SqliteTaskStore } from "../sqliteStore.js";
|
|
31
33
|
import { inspectSourceVersionState } from "./homeMigrationTarget.js";
|
|
34
|
+
import { readMigrationReceipt } from "./migrationReceipt.js";
|
|
32
35
|
/**
|
|
33
36
|
* Classify a real Home. Reads `schema.json` (and, for a current Home,
|
|
34
37
|
* `state.json` through the strict loader) read-only; never mutates the Home.
|
|
@@ -78,6 +81,36 @@ export function classifyHome(options) {
|
|
|
78
81
|
};
|
|
79
82
|
}
|
|
80
83
|
const source = inspected.source;
|
|
84
|
+
// Layout 7 physical-backend invariant (Issue 01): the manifest claims SQLite
|
|
85
|
+
// WAL as the authoritative store, so `yui.db` must exist and be healthy. A
|
|
86
|
+
// layout-7 Home without `yui.db` is a *pseudo-layout-7* Home — repairable
|
|
87
|
+
// when `state.json` is strictly readable, corrupted otherwise. A Home with
|
|
88
|
+
// both `state.json` and `yui.db` but no persistent migration receipt is an
|
|
89
|
+
// ambiguous dual-copy conflict. These are physical-backend facts, not
|
|
90
|
+
// version verdicts, so they are decided before the pure classifier runs.
|
|
91
|
+
//
|
|
92
|
+
// The invariant binds a Home whose *layout* is current (7), regardless of
|
|
93
|
+
// whether the record/aggregate axes are also current. A layout-7 Home with
|
|
94
|
+
// older record versions and no yui.db is still a pseudo-layout-7 Home: it
|
|
95
|
+
// needs the state.json→SQLite repair first, then the record-family migration
|
|
96
|
+
// (multi-phase orchestration in the upgrade executor). Routing it to the
|
|
97
|
+
// pure version classifier would select the SQLite record target, which
|
|
98
|
+
// cannot read a yui.db that does not exist yet. A Home with an older/future
|
|
99
|
+
// *layout* is fenced by version policy below, as before.
|
|
100
|
+
if (schema.currentLayoutVersion === latest.layout && latest.layout >= 7) {
|
|
101
|
+
const physical = inspectLayout7PhysicalBackend(home);
|
|
102
|
+
if (physical !== undefined) {
|
|
103
|
+
return {
|
|
104
|
+
...base,
|
|
105
|
+
classification: physical,
|
|
106
|
+
layoutVersion: schema.currentLayoutVersion,
|
|
107
|
+
aggregateVersion: schema.currentAggregateSchemaVersion,
|
|
108
|
+
...(incompatibleComponentOf(schema) === undefined
|
|
109
|
+
? {}
|
|
110
|
+
: { incompatibleComponent: incompatibleComponentOf(schema) })
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
81
114
|
// The reference graph can only be validated by the strict loader, which only
|
|
82
115
|
// understands the current versions. So run it exactly when every axis is
|
|
83
116
|
// already current (the plan would be a no-op); a throw there is genuine
|
|
@@ -177,6 +210,91 @@ function isFullyCurrent(source, latest) {
|
|
|
177
210
|
function incompatibleComponentOf(schema) {
|
|
178
211
|
return schema.status === "unsupported" ? schema.incompatibleComponent : undefined;
|
|
179
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Inspect the physical-backend invariant of a layout-7 Home (Issue 01):
|
|
215
|
+
* `yui.db` must exist and be healthy. Returns a classification verdict when
|
|
216
|
+
* the invariant is violated, or `undefined` when the Home is physically sound
|
|
217
|
+
* (the pure classifier then decides the version verdict).
|
|
218
|
+
*
|
|
219
|
+
* - manifest=7, no `yui.db`, `state.json` strictly readable →
|
|
220
|
+
* `NEEDS_STORAGE_REPAIR` (pseudo-layout-7);
|
|
221
|
+
* - manifest=7, no `yui.db`, no readable `state.json` → `CORRUPTED`
|
|
222
|
+
* (no authoritative backend);
|
|
223
|
+
* - `yui.db` unopenable or failing `PRAGMA quick_check` → `CORRUPTED`
|
|
224
|
+
* (damaged database);
|
|
225
|
+
* - both `state.json` and `yui.db` without a persistent migration receipt →
|
|
226
|
+
* `CORRUPTED` (dual-copy conflict; never guess which copy is newer).
|
|
227
|
+
*/
|
|
228
|
+
function inspectLayout7PhysicalBackend(home) {
|
|
229
|
+
const dbPath = join(home, "yui.db");
|
|
230
|
+
const statePath = join(home, STORAGE_STATE_FILE);
|
|
231
|
+
if (!existsSync(dbPath)) {
|
|
232
|
+
if (isReadableStateObject(statePath)) {
|
|
233
|
+
return {
|
|
234
|
+
verdict: "NEEDS_STORAGE_REPAIR",
|
|
235
|
+
status: "needs-storage-repair",
|
|
236
|
+
detail: "Storage declares layout 7 but has no yui.db; state.json is the only "
|
|
237
|
+
+ "authoritative copy (pseudo-layout-7). Run `yui upgrade` to rebuild "
|
|
238
|
+
+ "the SQLite database."
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
verdict: "CORRUPTED",
|
|
243
|
+
status: "unsupported",
|
|
244
|
+
detail: "Storage declares layout 7 but has neither yui.db nor a readable "
|
|
245
|
+
+ "state.json; there is no authoritative backend."
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
// `yui.db` exists: it must open and pass an integrity check.
|
|
249
|
+
try {
|
|
250
|
+
const db = new Database(dbPath, { readonly: true });
|
|
251
|
+
try {
|
|
252
|
+
const integrity = db.pragma("quick_check", { simple: true });
|
|
253
|
+
if (integrity !== "ok") {
|
|
254
|
+
return {
|
|
255
|
+
verdict: "CORRUPTED",
|
|
256
|
+
status: "unsupported",
|
|
257
|
+
detail: `SQLite integrity check failed: ${String(integrity)}.`
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
db.close();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
return {
|
|
267
|
+
verdict: "CORRUPTED",
|
|
268
|
+
status: "unsupported",
|
|
269
|
+
detail: `SQLite database cannot be opened: ${error instanceof Error ? error.message : String(error)}.`
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
// A healthy `yui.db` plus `state.json` is only legitimate right after a
|
|
273
|
+
// certified switch; without the persistent migration receipt it is a
|
|
274
|
+
// dual-copy conflict.
|
|
275
|
+
if (existsSync(statePath) && readMigrationReceipt(home) === null) {
|
|
276
|
+
return {
|
|
277
|
+
verdict: "CORRUPTED",
|
|
278
|
+
status: "unsupported",
|
|
279
|
+
detail: "Both state.json and yui.db exist without a migration receipt; the "
|
|
280
|
+
+ "authoritative copy is ambiguous. Restore one copy from a backup; do "
|
|
281
|
+
+ "not guess which is newer."
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
/** True when `state.json` exists and parses as a strict JSON object. */
|
|
287
|
+
function isReadableStateObject(statePath) {
|
|
288
|
+
if (!existsSync(statePath))
|
|
289
|
+
return false;
|
|
290
|
+
try {
|
|
291
|
+
const value = JSON.parse(readFileSync(statePath, "utf8"));
|
|
292
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
180
298
|
/** A structural corruption check over an already-read snapshot (for tests/reports). */
|
|
181
299
|
export function snapshotHasState(snapshot) {
|
|
182
300
|
return snapshot.state !== null;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The persistent migration receipt — a durable, in-Home record of the
|
|
3
|
+
* physical-backend transition that produced the current `yui.db`.
|
|
4
|
+
*
|
|
5
|
+
* ## Why (the dual-copy ambiguity problem)
|
|
6
|
+
*
|
|
7
|
+
* A layout-7 Home may legitimately contain both `state.json` and `yui.db`
|
|
8
|
+
* during the narrow switch window, or illegitimately after a crashed/torn
|
|
9
|
+
* transition. Without durable evidence, a reader cannot tell "the SQLite
|
|
10
|
+
* database was just promoted from this exact state.json" from "two
|
|
11
|
+
* authoritative copies drifted apart". The receipt is written only after the
|
|
12
|
+
* staged database is fully verified and atomically promoted, so its presence
|
|
13
|
+
* (correlating the source revision and checksum) certifies the dual-copy state
|
|
14
|
+
* as a fresh, intentional switch rather than a conflict.
|
|
15
|
+
*
|
|
16
|
+
* Unlike the temporary upgrade completion receipt (`<home>.upgrade-receipt.json`,
|
|
17
|
+
* a sibling of the Home used by the update flow), this receipt lives INSIDE the
|
|
18
|
+
* Home at `migration-receipt.json` and persists for the Home's lifetime: it is
|
|
19
|
+
* the physical-backend provenance that `doctor` and the classifier read.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { writeTextFileAtomically } from "../durableFile.js";
|
|
24
|
+
/** The persistent receipt path inside a Home. */
|
|
25
|
+
export function migrationReceiptPath(home) {
|
|
26
|
+
return join(home, "migration-receipt.json");
|
|
27
|
+
}
|
|
28
|
+
/** Write the persistent receipt atomically, just after the database promotes. */
|
|
29
|
+
export function writeMigrationReceipt(home, receipt) {
|
|
30
|
+
writeTextFileAtomically(migrationReceiptPath(home), `${JSON.stringify(receipt, null, 2)}\n`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Read the persistent receipt, or `null` when absent. A malformed or
|
|
34
|
+
* untrustworthy receipt reads as `null`: the receipt is provenance evidence,
|
|
35
|
+
* not an authority, and a Home whose receipt cannot be parsed must fail closed
|
|
36
|
+
* to the dual-copy conflict diagnosis rather than trust a torn write.
|
|
37
|
+
*/
|
|
38
|
+
export function readMigrationReceipt(home) {
|
|
39
|
+
const path = migrationReceiptPath(home);
|
|
40
|
+
if (!existsSync(path))
|
|
41
|
+
return null;
|
|
42
|
+
try {
|
|
43
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
44
|
+
if ((value.kind !== "layout6-to-7" && value.kind !== "pseudo-layout-7-repair")
|
|
45
|
+
|| typeof value.completedAt !== "string"
|
|
46
|
+
|| !Number.isInteger(value.sourceRevision)
|
|
47
|
+
|| !Number.isInteger(value.targetLayoutVersion)
|
|
48
|
+
|| typeof value.sourceStateSha256 !== "string"
|
|
49
|
+
|| !Number.isInteger(value.verifiedFamilies)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
kind: value.kind,
|
|
54
|
+
completedAt: value.completedAt,
|
|
55
|
+
sourceRevision: value.sourceRevision,
|
|
56
|
+
targetLayoutVersion: value.targetLayoutVersion,
|
|
57
|
+
sourceStateSha256: value.sourceStateSha256,
|
|
58
|
+
verifiedFamilies: value.verifiedFamilies,
|
|
59
|
+
...(typeof value.stateBackupPath === "string"
|
|
60
|
+
? { stateBackupPath: value.stateBackupPath }
|
|
61
|
+
: {})
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic repair for a *pseudo-layout-7* Home (Issue 01).
|
|
3
|
+
*
|
|
4
|
+
* A pseudo-layout-7 Home declares layout 7 in `schema.json` but has no `yui.db`;
|
|
5
|
+
* its `state.json` is still the only authoritative copy. The classifier reports
|
|
6
|
+
* this as `NEEDS_STORAGE_REPAIR`. This module rebuilds the SQLite database from
|
|
7
|
+
* the pinned `state.json`, verifies every record family against an independent
|
|
8
|
+
* re-read, promotes the staged database atomically, certifies the switch with a
|
|
9
|
+
* persistent migration receipt, read-backs through a fresh store, and archives
|
|
10
|
+
* `state.json` to a timestamped backup so it can never serve as a writable
|
|
11
|
+
* fallback again.
|
|
12
|
+
*
|
|
13
|
+
* Failure semantics (issue: 最简失败语义):
|
|
14
|
+
* - staging/verification failure: `state.json` stays authoritative, the staged
|
|
15
|
+
* database is discarded, the manifest is untouched;
|
|
16
|
+
* - a stale staged database from a crashed attempt is always rebuilt, never
|
|
17
|
+
* reused;
|
|
18
|
+
* - any failure after the atomic promote quarantines the promoted database and
|
|
19
|
+
* removes the receipt, returning the Home to its exact pre-repair shape;
|
|
20
|
+
* - the one non-fatal tail is archiving `state.json`: if that rename fails the
|
|
21
|
+
* database is already promoted, verified, and receipt-certified, and the
|
|
22
|
+
* result is `blocked` with the exact manual finishing step.
|
|
23
|
+
*/
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { existsSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { readStorageSchemaManifest } from "../storageSchema.js";
|
|
28
|
+
import { STORAGE_STATE_FILE } from "../taskStore.js";
|
|
29
|
+
import { SqliteTaskStore } from "../sqliteStore.js";
|
|
30
|
+
import { migrationReceiptPath, writeMigrationReceipt } from "./migrationReceipt.js";
|
|
31
|
+
import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, computeDbFamilyChecksums, populateSqliteFromState, verifySqliteChecksums } from "./sqliteStateMigration.js";
|
|
32
|
+
/**
|
|
33
|
+
* Run the staged state.json→SQLite repair. Never throws for an expected
|
|
34
|
+
* blocker; a malformed manifest or an unreadable `state.json` is a `blocked`
|
|
35
|
+
* result, not an exception.
|
|
36
|
+
*/
|
|
37
|
+
export function repairPseudoLayout7(options) {
|
|
38
|
+
const { home, latest, mode } = options;
|
|
39
|
+
const now = options.now ?? (() => new Date());
|
|
40
|
+
const statePath = join(home, STORAGE_STATE_FILE);
|
|
41
|
+
const stagedPath = join(home, STAGED_DATABASE_FILENAME);
|
|
42
|
+
const committedPath = join(home, COMMITTED_DATABASE_FILENAME);
|
|
43
|
+
// 1. Re-verify the preconditions fail-closed; the repair never trusts a
|
|
44
|
+
// classifier verdict produced by an earlier process.
|
|
45
|
+
let manifest;
|
|
46
|
+
try {
|
|
47
|
+
manifest = readStorageSchemaManifest(home);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return blocked("validate", `The storage manifest could not be read: ${messageOf(error)}`, "Restore schema.json from a backup; the repair requires a readable layout-7 manifest.");
|
|
51
|
+
}
|
|
52
|
+
if (manifest.storageVersion !== latest.layout) {
|
|
53
|
+
return blocked("validate", `Pseudo-layout-7 repair requires a layout-${latest.layout} manifest; found layout ${manifest.storageVersion}.`, "Re-run `yui doctor`; this repair only applies to a pseudo-layout-7 Home.");
|
|
54
|
+
}
|
|
55
|
+
if (existsSync(committedPath)) {
|
|
56
|
+
return blocked("validate", `Refusing to repair: ${COMMITTED_DATABASE_FILENAME} already exists.`, "The Home already has a SQLite database. If it is damaged, restore it from a backup; do not rebuild over it.");
|
|
57
|
+
}
|
|
58
|
+
if (!existsSync(statePath)) {
|
|
59
|
+
return blocked("validate", `Pseudo-layout-7 repair requires a readable ${STORAGE_STATE_FILE}; none exists.`, `Restore ${STORAGE_STATE_FILE} from a backup; the repair cannot rebuild a database without its source.`);
|
|
60
|
+
}
|
|
61
|
+
// 2. Pin state.json (revision, size, sha256). A document that is not a
|
|
62
|
+
// strictly readable JSON object fails the repair.
|
|
63
|
+
const pin = pinStateFile(statePath);
|
|
64
|
+
if (pin === null) {
|
|
65
|
+
return blocked("validate", `${STORAGE_STATE_FILE} is not a strictly readable JSON object.`, "The source document is damaged; restore it from a backup before repairing.");
|
|
66
|
+
}
|
|
67
|
+
// 3. A stale staged database from a crashed attempt is rebuilt, never reused.
|
|
68
|
+
discardStaged(stagedPath);
|
|
69
|
+
// 4. Stage: populate yui.db.staged from the pinned document.
|
|
70
|
+
try {
|
|
71
|
+
populateSqliteFromState(home, pin.state, STAGED_DATABASE_FILENAME);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
discardStaged(stagedPath);
|
|
75
|
+
return blocked("validate", `Staging the SQLite database failed: ${messageOf(error)}`, `${STORAGE_STATE_FILE} remains the authoritative store; the staged database was discarded.`);
|
|
76
|
+
}
|
|
77
|
+
// 5. Verify: the pinned bytes must be unchanged (no concurrent writer), and
|
|
78
|
+
// every record family must match an independent state.json re-read by
|
|
79
|
+
// count and content checksum.
|
|
80
|
+
try {
|
|
81
|
+
if (sha256(readFileSync(statePath, "utf8")) !== pin.sha256) {
|
|
82
|
+
discardStaged(stagedPath);
|
|
83
|
+
return blocked("validate", `${STORAGE_STATE_FILE} changed during the repair; a concurrent writer is active.`, "Quiesce all writers and retry; the staged database was discarded and state.json remains authoritative.");
|
|
84
|
+
}
|
|
85
|
+
verifySqliteChecksums(pin.state, home, STAGED_DATABASE_FILENAME);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
discardStaged(stagedPath);
|
|
89
|
+
return blocked("validate", `Staged database verification failed: ${messageOf(error)}`, `${STORAGE_STATE_FILE} remains the authoritative store; the staged database was discarded.`);
|
|
90
|
+
}
|
|
91
|
+
const verifiedFamilies = Object.keys(computeDbFamilyChecksums(home, STAGED_DATABASE_FILENAME)).length;
|
|
92
|
+
if (mode === "dry-run") {
|
|
93
|
+
discardStaged(stagedPath);
|
|
94
|
+
return { outcome: "dry-run", verifiedFamilies, sourceRevision: pin.revision };
|
|
95
|
+
}
|
|
96
|
+
// 6. Promote: atomic rename staged -> yui.db. From here the database exists;
|
|
97
|
+
// any failure rolls the promotion back so the Home keeps its pre-repair
|
|
98
|
+
// shape (manifest 7, no yui.db, state.json authoritative).
|
|
99
|
+
try {
|
|
100
|
+
renameSync(stagedPath, committedPath);
|
|
101
|
+
// The staged connection may leave empty WAL/SHM sidecars behind even
|
|
102
|
+
// after a clean close; they are dead once the main file is promoted.
|
|
103
|
+
rmSync(`${stagedPath}-wal`, { force: true });
|
|
104
|
+
rmSync(`${stagedPath}-shm`, { force: true });
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
discardStaged(stagedPath);
|
|
108
|
+
return blocked("switch", `Promoting the staged database failed: ${messageOf(error)}`, `${STORAGE_STATE_FILE} remains the authoritative store; the staged database was discarded.`);
|
|
109
|
+
}
|
|
110
|
+
// 7. Write the persistent migration receipt. A dual-copy Home without a
|
|
111
|
+
// receipt is a conflict, so a receipt-write failure rolls the promotion
|
|
112
|
+
// back rather than leaving an uncertified database behind.
|
|
113
|
+
try {
|
|
114
|
+
writeMigrationReceipt(home, {
|
|
115
|
+
kind: "pseudo-layout-7-repair",
|
|
116
|
+
completedAt: now().toISOString(),
|
|
117
|
+
sourceRevision: pin.revision,
|
|
118
|
+
targetLayoutVersion: latest.layout,
|
|
119
|
+
sourceStateSha256: pin.sha256,
|
|
120
|
+
verifiedFamilies
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
rollbackPromotion(home, committedPath, now);
|
|
125
|
+
return blocked("post-verify", `The database was promoted but the migration receipt could not be written: ${messageOf(error)}`, `The promoted database was quarantined and the receipt removed; ${STORAGE_STATE_FILE} remains authoritative. Retry the repair.`);
|
|
126
|
+
}
|
|
127
|
+
// 8. Read-back through a fresh store, including revision continuity.
|
|
128
|
+
try {
|
|
129
|
+
const store = new SqliteTaskStore(home);
|
|
130
|
+
try {
|
|
131
|
+
store.getConfig();
|
|
132
|
+
store.listTasks();
|
|
133
|
+
store.listProjects();
|
|
134
|
+
store.listConfiguredAgents();
|
|
135
|
+
store.listWorkMailboxes();
|
|
136
|
+
const dbRevision = store.getRevision();
|
|
137
|
+
if (dbRevision !== pin.revision) {
|
|
138
|
+
throw new Error(`revision mismatch: ${STORAGE_STATE_FILE}=${pin.revision} database=${dbRevision}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
store.close();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
rollbackPromotion(home, committedPath, now);
|
|
147
|
+
return blocked("post-verify", `Post-promote read-back failed: ${messageOf(error)}`, `The promoted database was quarantined and the receipt removed; ${STORAGE_STATE_FILE} remains authoritative. Retry the repair.`);
|
|
148
|
+
}
|
|
149
|
+
// 9. Archive state.json so it can never serve as a writable fallback. The
|
|
150
|
+
// receipt certifies the dual-copy window, so a failure here leaves a
|
|
151
|
+
// usable Home: the database is authoritative and the repair is finished by
|
|
152
|
+
// moving the file manually.
|
|
153
|
+
const stamp = now().toISOString().replace(/[:.]/g, "-");
|
|
154
|
+
const stateBackupPath = join(home, `${STORAGE_STATE_FILE}.backup-${stamp}`);
|
|
155
|
+
try {
|
|
156
|
+
if (existsSync(stateBackupPath)) {
|
|
157
|
+
throw new Error(`refusing to overwrite an existing state backup: ${stateBackupPath}`);
|
|
158
|
+
}
|
|
159
|
+
renameSync(statePath, stateBackupPath);
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
return blocked("post-verify", `The database was promoted and verified, but ${STORAGE_STATE_FILE} could not be archived: ${messageOf(error)}`, `The database is authoritative and the migration receipt certifies it. Move ${STORAGE_STATE_FILE} to ${stateBackupPath} manually to finish the repair.`);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
outcome: "repaired",
|
|
166
|
+
stateBackupPath,
|
|
167
|
+
verifiedFamilies,
|
|
168
|
+
sourceRevision: pin.revision
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/** The newest `state.json.backup-*` path in a Home, or `null` when none exists. */
|
|
172
|
+
export function latestStateBackupPath(home) {
|
|
173
|
+
const entries = listStateBackups(home);
|
|
174
|
+
return entries.length === 0 ? null : entries[entries.length - 1];
|
|
175
|
+
}
|
|
176
|
+
/** All `state.json.backup-*` paths in a Home, sorted oldest-first. */
|
|
177
|
+
export function listStateBackups(home) {
|
|
178
|
+
let names;
|
|
179
|
+
try {
|
|
180
|
+
names = readdirSync(home);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
return names
|
|
186
|
+
.filter((name) => name.startsWith(`${STORAGE_STATE_FILE}.backup-`))
|
|
187
|
+
.sort()
|
|
188
|
+
.map((name) => join(home, name));
|
|
189
|
+
}
|
|
190
|
+
function pinStateFile(statePath) {
|
|
191
|
+
let raw;
|
|
192
|
+
try {
|
|
193
|
+
raw = readFileSync(statePath, "utf8");
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
let parsed;
|
|
199
|
+
try {
|
|
200
|
+
parsed = JSON.parse(raw);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
const state = parsed;
|
|
209
|
+
return {
|
|
210
|
+
state,
|
|
211
|
+
revision: typeof state.revision === "number" ? state.revision : 0,
|
|
212
|
+
sha256: createHash("sha256").update(raw, "utf8").digest("hex"),
|
|
213
|
+
size: Buffer.byteLength(raw, "utf8")
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
function sha256(raw) {
|
|
217
|
+
return createHash("sha256").update(raw, "utf8").digest("hex");
|
|
218
|
+
}
|
|
219
|
+
function discardStaged(stagedPath) {
|
|
220
|
+
rmSync(stagedPath, { force: true });
|
|
221
|
+
rmSync(`${stagedPath}-wal`, { force: true });
|
|
222
|
+
rmSync(`${stagedPath}-shm`, { force: true });
|
|
223
|
+
}
|
|
224
|
+
function quarantine(committedPath, now) {
|
|
225
|
+
const stamp = now().toISOString().replace(/[:.]/g, "-");
|
|
226
|
+
const quarantinePath = `${committedPath}.quarantine-${stamp}`;
|
|
227
|
+
renameSync(committedPath, quarantinePath);
|
|
228
|
+
rmSync(`${committedPath}-wal`, { force: true });
|
|
229
|
+
rmSync(`${committedPath}-shm`, { force: true });
|
|
230
|
+
return quarantinePath;
|
|
231
|
+
}
|
|
232
|
+
function rollbackPromotion(home, committedPath, now) {
|
|
233
|
+
quarantine(committedPath, now);
|
|
234
|
+
rmSync(migrationReceiptPath(home), { force: true });
|
|
235
|
+
}
|
|
236
|
+
function blocked(stage, message, action) {
|
|
237
|
+
return { outcome: "blocked", stage, message, action };
|
|
238
|
+
}
|
|
239
|
+
function messageOf(error) {
|
|
240
|
+
return error instanceof Error ? error.message : String(error);
|
|
241
|
+
}
|
|
@@ -16,9 +16,10 @@
|
|
|
16
16
|
* transform in the production migration graph.
|
|
17
17
|
*/
|
|
18
18
|
import { CURRENT_AGGREGATE_SCHEMA_VERSION, CURRENT_STORAGE_LAYOUT_VERSION } from "../storageVersions.js";
|
|
19
|
-
import { CURRENT_AGENT_RUN_SCHEMA_VERSION, CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION, CURRENT_AGENT_PROFILE_SCHEMA_VERSION, CURRENT_CONFIG_SCHEMA_VERSION, CURRENT_CONFIGURED_AGENT_SCHEMA_VERSION, CURRENT_CHANGE_SET_SCHEMA_VERSION, CURRENT_DECISION_SCHEMA_VERSION, CURRENT_EVENT_SCHEMA_VERSION, CURRENT_GLOBAL_ROLE_SCHEMA_VERSION, CURRENT_GLOBAL_ROLE_SESSION_SET_SCHEMA_VERSION, CURRENT_INPUT_REQUEST_SCHEMA_VERSION, CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION, CURRENT_MANAGED_WORKSPACE_SCHEMA_VERSION, CURRENT_MESSAGE_SCHEMA_VERSION, CURRENT_MILESTONE_SCHEMA_VERSION, CURRENT_PROJECT_SCHEMA_VERSION, CURRENT_REVIEW_ROUND_SCHEMA_VERSION, CURRENT_STORED_TASK_SCHEMA_VERSION, CURRENT_TASK_BRIEF_SCHEMA_VERSION, CURRENT_TASK_ROLE_SCHEMA_VERSION, CURRENT_TASK_ROLE_SESSION_SET_SCHEMA_VERSION, CURRENT_TASK_SCHEMA_VERSION, CURRENT_WORK_ITEM_SCHEMA_VERSION, CURRENT_WORK_MAILBOX_SCHEMA_VERSION } from "../taskStore.js";
|
|
19
|
+
import { CURRENT_AGENT_RUN_SCHEMA_VERSION, CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION, CURRENT_AGENT_PROFILE_SCHEMA_VERSION, CURRENT_CAPABILITY_GRANT_SCHEMA_VERSION, CURRENT_CONFIG_SCHEMA_VERSION, CURRENT_CONFIGURED_AGENT_SCHEMA_VERSION, CURRENT_CHANGE_SET_SCHEMA_VERSION, CURRENT_DECISION_SCHEMA_VERSION, CURRENT_EVENT_SCHEMA_VERSION, CURRENT_GLOBAL_ROLE_SCHEMA_VERSION, CURRENT_GLOBAL_ROLE_SESSION_SET_SCHEMA_VERSION, CURRENT_INPUT_REQUEST_SCHEMA_VERSION, CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION, CURRENT_INTEGRATION_QUEUE_SCHEMA_VERSION, CURRENT_MANAGED_WORKSPACE_SCHEMA_VERSION, CURRENT_MESSAGE_SCHEMA_VERSION, CURRENT_MILESTONE_SCHEMA_VERSION, CURRENT_PROJECT_SCHEMA_VERSION, CURRENT_RELEASE_WORKFLOW_SCHEMA_VERSION, CURRENT_REVIEW_ROUND_SCHEMA_VERSION, CURRENT_STORED_TASK_SCHEMA_VERSION, CURRENT_TASK_BRIEF_SCHEMA_VERSION, CURRENT_TASK_ROLE_SCHEMA_VERSION, CURRENT_TASK_ROLE_SESSION_SET_SCHEMA_VERSION, CURRENT_TASK_SCHEMA_VERSION, CURRENT_WORK_ITEM_SCHEMA_VERSION, CURRENT_WORK_MAILBOX_SCHEMA_VERSION } from "../taskStore.js";
|
|
20
20
|
import { CURRENT_LEADER_FAILURE_SCHEMA_VERSION } from "../../scheduler/leaderFailure.js";
|
|
21
21
|
import { CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION } from "../../scheduler/operatorNotification.js";
|
|
22
|
+
import { CURRENT_DURABLE_JOB_SCHEMA_VERSION } from "../../job/durableJob.js";
|
|
22
23
|
function descriptor(version, path) {
|
|
23
24
|
return Object.freeze({ version, path });
|
|
24
25
|
}
|
|
@@ -46,12 +47,16 @@ const EXPECTED_DIRECT_RECORD_LOCATORS = Object.freeze({
|
|
|
46
47
|
reviewRound: "state.json#/tasks/*/reviewRounds",
|
|
47
48
|
changeSet: "state.json#/tasks/*/changeSets",
|
|
48
49
|
integrationAttempt: "state.json#/tasks/*/integrationAttempts",
|
|
50
|
+
integrationQueue: "state.json#/tasks/*/integrationQueue",
|
|
51
|
+
durableJob: "state.json#/tasks/*/durableJobs",
|
|
49
52
|
activeRunPointer: "state.json#/tasks/*/activeRuns",
|
|
50
53
|
message: "state.json#/tasks/*/messages",
|
|
51
54
|
inputRequest: "state.json#/tasks/*/inputRequests",
|
|
52
55
|
decision: "state.json#/tasks/*/decisions",
|
|
53
56
|
milestone: "state.json#/tasks/*/milestones",
|
|
54
57
|
event: "state.json#/tasks/*/events",
|
|
58
|
+
capabilityGrant: "state.json#/tasks/*/capabilityGrants",
|
|
59
|
+
releaseWorkflow: "state.json#/tasks/*/releaseWorkflows",
|
|
55
60
|
leaderFailure: "state.json#/tasks/*/leaderFailure",
|
|
56
61
|
operatorNotification: "state.json#/tasks/*/operatorNotification",
|
|
57
62
|
workMailbox: "state.json#/mailboxes"
|
|
@@ -95,12 +100,16 @@ function getCurrentRecordDescriptors() {
|
|
|
95
100
|
reviewRound: descriptor(CURRENT_REVIEW_ROUND_SCHEMA_VERSION, "state.json#/tasks/*/reviewRounds"),
|
|
96
101
|
changeSet: descriptor(CURRENT_CHANGE_SET_SCHEMA_VERSION, "state.json#/tasks/*/changeSets"),
|
|
97
102
|
integrationAttempt: descriptor(CURRENT_INTEGRATION_ATTEMPT_SCHEMA_VERSION, "state.json#/tasks/*/integrationAttempts"),
|
|
103
|
+
integrationQueue: descriptor(CURRENT_INTEGRATION_QUEUE_SCHEMA_VERSION, "state.json#/tasks/*/integrationQueue"),
|
|
104
|
+
durableJob: descriptor(CURRENT_DURABLE_JOB_SCHEMA_VERSION, "state.json#/tasks/*/durableJobs"),
|
|
98
105
|
activeRunPointer: descriptor(CURRENT_ACTIVE_RUN_POINTER_SCHEMA_VERSION, "state.json#/tasks/*/activeRuns"),
|
|
99
106
|
message: descriptor(CURRENT_MESSAGE_SCHEMA_VERSION, "state.json#/tasks/*/messages"),
|
|
100
107
|
inputRequest: descriptor(CURRENT_INPUT_REQUEST_SCHEMA_VERSION, "state.json#/tasks/*/inputRequests"),
|
|
101
108
|
decision: descriptor(CURRENT_DECISION_SCHEMA_VERSION, "state.json#/tasks/*/decisions"),
|
|
102
109
|
milestone: descriptor(CURRENT_MILESTONE_SCHEMA_VERSION, "state.json#/tasks/*/milestones"),
|
|
103
110
|
event: descriptor(CURRENT_EVENT_SCHEMA_VERSION, "state.json#/tasks/*/events"),
|
|
111
|
+
capabilityGrant: descriptor(CURRENT_CAPABILITY_GRANT_SCHEMA_VERSION, "state.json#/tasks/*/capabilityGrants"),
|
|
112
|
+
releaseWorkflow: descriptor(CURRENT_RELEASE_WORKFLOW_SCHEMA_VERSION, "state.json#/tasks/*/releaseWorkflows"),
|
|
104
113
|
leaderFailure: descriptor(CURRENT_LEADER_FAILURE_SCHEMA_VERSION, "state.json#/tasks/*/leaderFailure"),
|
|
105
114
|
operatorNotification: descriptor(CURRENT_OPERATOR_NOTIFICATION_SCHEMA_VERSION, "state.json#/tasks/*/operatorNotification"),
|
|
106
115
|
workMailbox: descriptor(CURRENT_WORK_MAILBOX_SCHEMA_VERSION, "state.json#/mailboxes")
|
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
* a timestamped backup of any prior database) and advances
|
|
21
21
|
* `schema.json` to layout 7.
|
|
22
22
|
* - Rollback: `rollbackSqliteMigration` quarantines `yui.db` and flips
|
|
23
|
-
* `schema.json` back to layout 6
|
|
23
|
+
* `schema.json` back to layout 6. A layout-6→7 migration retains
|
|
24
|
+
* `state.json` in place (never touched); a pseudo-layout-7 repair
|
|
25
|
+
* archived it to `state.json.backup-*`, so rollback restores the
|
|
26
|
+
* newest backup when `state.json` is absent.
|
|
24
27
|
*
|
|
25
28
|
* The source `state.json` is retained read-only throughout: it is never
|
|
26
29
|
* overwritten, truncated, or deleted by the migration. This preserves the
|
|
@@ -29,6 +32,7 @@
|
|
|
29
32
|
*/
|
|
30
33
|
import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
|
|
31
34
|
import { join } from "node:path";
|
|
35
|
+
import { createHash } from "node:crypto";
|
|
32
36
|
import { writeTextFileAtomically } from "../durableFile.js";
|
|
33
37
|
import { STORAGE_SCHEMA_FILE } from "../storageSchema.js";
|
|
34
38
|
import { STORAGE_STATE_FILE } from "../taskStore.js";
|
|
@@ -36,6 +40,8 @@ import { planMigration } from "../migration/planner.js";
|
|
|
36
40
|
import { AmbiguousSwitchError } from "../migration/index.js";
|
|
37
41
|
import { describeActiveRuntime, homeRuntimeIsActive, inspectHomeRuntime, inspectSourceVersionState, inspectSnapshotVersionState } from "./homeMigrationTarget.js";
|
|
38
42
|
import { COMMITTED_DATABASE_FILENAME, STAGED_DATABASE_FILENAME, computeDbFamilyChecksums, computeStateFamilyChecksums, populateSqliteFromState } from "./sqliteStateMigration.js";
|
|
43
|
+
import { latestStateBackupPath } from "./pseudoLayoutRepair.js";
|
|
44
|
+
import { migrationReceiptPath, writeMigrationReceipt } from "./migrationReceipt.js";
|
|
39
45
|
/** Build the SQLite-backed migration target. */
|
|
40
46
|
export function createSqliteMigrationTarget(options) {
|
|
41
47
|
const home = options.home;
|
|
@@ -48,6 +54,11 @@ export function createSqliteMigrationTarget(options) {
|
|
|
48
54
|
// The transformed schema manifest is cached during writeFreshOutput so the
|
|
49
55
|
// switch can advance schema.json without re-reading or re-deriving it.
|
|
50
56
|
let stagedSchemaManifest = null;
|
|
57
|
+
// The source document's revision and sha256, cached during writeFreshOutput
|
|
58
|
+
// so the switch can certify the dual-copy state with a persistent receipt
|
|
59
|
+
// (Issue 01: a layout-7 Home that retains state.json must carry the receipt
|
|
60
|
+
// that proves yui.db was promoted from that exact document).
|
|
61
|
+
let stagedReceiptSource = null;
|
|
51
62
|
return {
|
|
52
63
|
stagedDbPath,
|
|
53
64
|
inspectVersions() {
|
|
@@ -87,10 +98,19 @@ export function createSqliteMigrationTarget(options) {
|
|
|
87
98
|
updatedAt: now().toISOString()
|
|
88
99
|
};
|
|
89
100
|
if (snapshot.state !== null) {
|
|
101
|
+
// Hash the actual on-disk bytes (the file is retained read-only
|
|
102
|
+
// throughout the migration), not a re-serialization of the parsed
|
|
103
|
+
// snapshot, so the receipt can be compared against the file itself.
|
|
104
|
+
const stateRaw = readFileSync(join(home, STORAGE_STATE_FILE), "utf8");
|
|
105
|
+
stagedReceiptSource = {
|
|
106
|
+
revision: typeof snapshot.state.revision === "number" ? snapshot.state.revision : 0,
|
|
107
|
+
sha256: createHash("sha256").update(stateRaw, "utf8").digest("hex")
|
|
108
|
+
};
|
|
90
109
|
populateSqliteFromState(home, snapshot.state, STAGED_DATABASE_FILENAME);
|
|
91
110
|
}
|
|
92
111
|
else {
|
|
93
112
|
// An empty Home (no state.json) still gets a schema-ready database.
|
|
113
|
+
stagedReceiptSource = { revision: 0, sha256: "" };
|
|
94
114
|
populateSqliteFromState(home, {}, STAGED_DATABASE_FILENAME);
|
|
95
115
|
}
|
|
96
116
|
},
|
|
@@ -138,6 +158,10 @@ export function createSqliteMigrationTarget(options) {
|
|
|
138
158
|
renameSync(committedDbPath, backupPath);
|
|
139
159
|
}
|
|
140
160
|
renameSync(stagedDbPath, committedDbPath);
|
|
161
|
+
// The staged connection may leave empty WAL/SHM sidecars behind
|
|
162
|
+
// even after a clean close; they are dead once promoted.
|
|
163
|
+
rmSync(`${stagedDbPath}-wal`, { force: true });
|
|
164
|
+
rmSync(`${stagedDbPath}-shm`, { force: true });
|
|
141
165
|
}
|
|
142
166
|
catch (error) {
|
|
143
167
|
// Pre-promotion failure: restore the original database if we moved it.
|
|
@@ -162,6 +186,21 @@ export function createSqliteMigrationTarget(options) {
|
|
|
162
186
|
if (stagedSchemaManifest !== null) {
|
|
163
187
|
writeTextFileAtomically(join(home, STORAGE_SCHEMA_FILE), `${JSON.stringify(stagedSchemaManifest, null, 2)}\n`);
|
|
164
188
|
}
|
|
189
|
+
// Certify the dual-copy state (Issue 01): the 6→7 migration retains
|
|
190
|
+
// state.json read-only, so the Home legitimately holds both copies.
|
|
191
|
+
// The persistent receipt is the evidence that lets the classifier and
|
|
192
|
+
// doctor distinguish this certified switch from a drifted conflict.
|
|
193
|
+
if (stagedReceiptSource !== null) {
|
|
194
|
+
const familyCount = Object.keys(computeDbFamilyChecksums(home, COMMITTED_DATABASE_FILENAME)).length;
|
|
195
|
+
writeMigrationReceipt(home, {
|
|
196
|
+
kind: "layout6-to-7",
|
|
197
|
+
completedAt: now().toISOString(),
|
|
198
|
+
sourceRevision: stagedReceiptSource.revision,
|
|
199
|
+
targetLayoutVersion: latest.layout,
|
|
200
|
+
sourceStateSha256: stagedReceiptSource.sha256,
|
|
201
|
+
verifiedFamilies: familyCount
|
|
202
|
+
});
|
|
203
|
+
}
|
|
165
204
|
}
|
|
166
205
|
catch (error) {
|
|
167
206
|
// The database is promoted but schema.json could not be advanced.
|
|
@@ -255,12 +294,14 @@ export function createSqliteMigrationTarget(options) {
|
|
|
255
294
|
}
|
|
256
295
|
/**
|
|
257
296
|
* Roll back a committed layout-7 SQLite migration: quarantine `yui.db` and
|
|
258
|
-
* flip `schema.json` back to layout 6.
|
|
259
|
-
*
|
|
260
|
-
*
|
|
297
|
+
* flip `schema.json` back to layout 6. A layout-6→7 migration retained
|
|
298
|
+
* `state.json` read-only in place, so it is untouched there. A pseudo-layout-7
|
|
299
|
+
* repair archived it to `state.json.backup-*`; when `state.json` is absent the
|
|
300
|
+
* newest backup is restored so the layout-6 File store recovers every
|
|
301
|
+
* pre-switch committed revision. The persistent migration receipt is removed.
|
|
261
302
|
*
|
|
262
|
-
* Returns the quarantine path. Throws if the Home is not at layout 7
|
|
263
|
-
*
|
|
303
|
+
* Returns the quarantine path. Throws if the Home is not at layout 7, has no
|
|
304
|
+
* `yui.db` to quarantine, or has neither `state.json` nor a backup to restore.
|
|
264
305
|
*/
|
|
265
306
|
export function rollbackSqliteMigration(home, options = {}) {
|
|
266
307
|
const now = options.now ?? (() => new Date());
|
|
@@ -282,6 +323,17 @@ export function rollbackSqliteMigration(home, options = {}) {
|
|
|
282
323
|
// Clean up WAL/SHM sidecars.
|
|
283
324
|
rmSync(`${dbPath}-wal`, { force: true });
|
|
284
325
|
rmSync(`${dbPath}-shm`, { force: true });
|
|
326
|
+
// Restore the file-store authoritative document when the repair archived it.
|
|
327
|
+
const statePath = join(home, STORAGE_STATE_FILE);
|
|
328
|
+
if (!existsSync(statePath)) {
|
|
329
|
+
const backupPath = latestStateBackupPath(home);
|
|
330
|
+
if (backupPath === null) {
|
|
331
|
+
throw new Error(`Rollback requires ${STORAGE_STATE_FILE} or a state.json.backup-* archive; neither exists.`);
|
|
332
|
+
}
|
|
333
|
+
renameSync(backupPath, statePath);
|
|
334
|
+
}
|
|
335
|
+
// The persistent receipt certified the switch being rolled back.
|
|
336
|
+
rmSync(migrationReceiptPath(home), { force: true });
|
|
285
337
|
// Flip schema.json back to layout 6.
|
|
286
338
|
const rolledBack = { ...manifest, storageVersion: 6 };
|
|
287
339
|
writeTextFileAtomically(schemaPath, `${JSON.stringify(rolledBack, null, 2)}\n`);
|