@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.
Files changed (150) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/commandCatalog.js +251 -13
  4. package/dist/cli/updateOrchestrator.js +8 -0
  5. package/dist/cli/updatePorts.js +76 -22
  6. package/dist/cli.js +264 -20
  7. package/dist/commands/configCommands.js +83 -9
  8. package/dist/commands/controllerCommands.js +103 -0
  9. package/dist/commands/deliveryGuardPreflight.js +30 -0
  10. package/dist/commands/durableJobCommands.js +231 -0
  11. package/dist/commands/executionAuditCommands.js +193 -0
  12. package/dist/commands/grantCommands.js +374 -0
  13. package/dist/commands/projectCommands.js +119 -81
  14. package/dist/commands/releaseCommands.js +444 -0
  15. package/dist/commands/resourcesCommands.js +274 -0
  16. package/dist/commands/sessionCommands.js +104 -0
  17. package/dist/commands/taskActor.js +117 -0
  18. package/dist/commands/taskChangeSetCommands.js +60 -0
  19. package/dist/commands/taskCommands.js +618 -202
  20. package/dist/commands/taskCompletionGate.js +78 -1
  21. package/dist/commands/taskContextCommand.js +33 -6
  22. package/dist/commands/taskInputCommands.js +1 -1
  23. package/dist/commands/taskIntegrationCommands.js +136 -33
  24. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  25. package/dist/commands/taskNextActionCommand.js +100 -0
  26. package/dist/commands/taskOverlapCommands.js +120 -0
  27. package/dist/commands/taskOverviewCommand.js +36 -8
  28. package/dist/commands/telemetryCommands.js +330 -0
  29. package/dist/commands/workflowCommands.js +415 -0
  30. package/dist/config/yuiConfig.js +62 -0
  31. package/dist/controller/clientRuntime.js +42 -1
  32. package/dist/controller/controller.js +402 -56
  33. package/dist/controller/controllerMain.js +25 -2
  34. package/dist/controller/domainIdentity.js +16 -8
  35. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  36. package/dist/controller/handoverCandidate.js +168 -0
  37. package/dist/controller/jobClient.js +102 -0
  38. package/dist/controller/jobControl.js +613 -0
  39. package/dist/controller/jobSupervisor.js +498 -0
  40. package/dist/controller/providerHookRunFence.js +34 -5
  41. package/dist/controller/resourceCleanupLinux.js +18 -9
  42. package/dist/controller/resourceInventoryLinux.js +90 -39
  43. package/dist/controller/runtime.js +165 -15
  44. package/dist/controller/runtimeEventInbox.js +234 -57
  45. package/dist/controller/runtimeEventProcessor.js +297 -58
  46. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  47. package/dist/core/controllerServer.js +416 -27
  48. package/dist/core/controllerTelemetry.js +167 -0
  49. package/dist/doctor/doctor.js +113 -16
  50. package/dist/domain/validation.js +9 -0
  51. package/dist/execution/executionGroup.js +40 -3
  52. package/dist/executor/agentExecutor.js +6 -3
  53. package/dist/executor/effectiveLaunch.js +52 -0
  54. package/dist/executor/executorRegistry.js +50 -0
  55. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  56. package/dist/grant/capabilityGrant.js +282 -0
  57. package/dist/integration/changeSet.js +16 -3
  58. package/dist/integration/changeSetManifest.js +46 -0
  59. package/dist/integration/gitIntegrationService.js +528 -147
  60. package/dist/integration/integrationAttempt.js +54 -5
  61. package/dist/integration/integrationQueueEntry.js +221 -0
  62. package/dist/integration/integrationQueueService.js +955 -0
  63. package/dist/integration/manifestTags.js +99 -0
  64. package/dist/integration/overlapDiagnostics.js +211 -0
  65. package/dist/job/durableJob.js +449 -0
  66. package/dist/job/jobRunner.js +350 -0
  67. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  68. package/dist/lifecycle/providerErrorClass.js +126 -0
  69. package/dist/message/message.js +16 -3
  70. package/dist/observability/executionAudit.js +545 -0
  71. package/dist/observability/faultClassification.js +160 -0
  72. package/dist/observability/runtimeIdentity.js +367 -0
  73. package/dist/release/fakeReleasePorts.js +55 -0
  74. package/dist/release/releaseHandover.js +475 -0
  75. package/dist/release/releaseIdempotencyStore.js +165 -0
  76. package/dist/release/releaseWorkflow.js +459 -0
  77. package/dist/release/releaseWorkflowEngine.js +688 -0
  78. package/dist/release/releaseWorkflowPorts.js +1720 -0
  79. package/dist/release/runtimeRelease.js +495 -0
  80. package/dist/release/workflowFileLock.js +218 -0
  81. package/dist/repository/gitWorkspace.js +177 -1
  82. package/dist/repository/projectMaintenanceLock.js +315 -0
  83. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  84. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  85. package/dist/resources/autoResourceGc.js +116 -0
  86. package/dist/resources/liveReferences.js +574 -0
  87. package/dist/resources/resourceDiscovery.js +477 -0
  88. package/dist/resources/resourceGc.js +645 -0
  89. package/dist/resources/resourceRegistrar.js +256 -0
  90. package/dist/resources/resourceRegistry.js +150 -0
  91. package/dist/resources/resourceRegistryStore.js +41 -0
  92. package/dist/resources/resourceTypes.js +42 -0
  93. package/dist/resources/sqliteResourceRegistry.js +111 -0
  94. package/dist/review/reviewConfig.js +10 -0
  95. package/dist/review/reviewFinding.js +240 -0
  96. package/dist/review/reviewFindingLedger.js +545 -0
  97. package/dist/review/reviewOutcomeClassifier.js +61 -0
  98. package/dist/review/reviewRound.js +56 -4
  99. package/dist/run/agentRun.js +80 -4
  100. package/dist/run/providerRetry.js +84 -0
  101. package/dist/run/providerRetryConfig.js +63 -0
  102. package/dist/run/yieldReceipt.js +65 -0
  103. package/dist/runtime/exactControlPlane.js +79 -2
  104. package/dist/runtime/index.js +4 -0
  105. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  106. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  107. package/dist/runtime/sessionReconciliation.js +93 -0
  108. package/dist/runtime/sessionTerminationGuard.js +211 -0
  109. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  110. package/dist/runtime/tmuxAdapters.js +34 -1
  111. package/dist/scheduler/actionability.js +155 -0
  112. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  113. package/dist/scheduler/activeTaskProgress.js +60 -0
  114. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  115. package/dist/scheduler/roleRunStall.js +135 -29
  116. package/dist/scheduler/taskExecutionProjection.js +11 -0
  117. package/dist/storage/compatibleTaskStore.js +112 -5
  118. package/dist/storage/migration/productionRegistry.js +736 -1
  119. package/dist/storage/sqliteSchema.js +264 -3
  120. package/dist/storage/sqliteStore.js +487 -13
  121. package/dist/storage/storeRpc.js +21 -0
  122. package/dist/storage/taskStore.js +974 -21
  123. package/dist/storage/upgrade/homeClassification.js +120 -2
  124. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  125. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  126. package/dist/storage/upgrade/recordVersions.js +10 -1
  127. package/dist/storage/upgrade/sqliteMigrationTarget.js +58 -6
  128. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  129. package/dist/storage/upgrade/sqliteStateMigration.js +258 -2
  130. package/dist/storage/upgrade/upgradeOrchestrator.js +482 -16
  131. package/dist/task/deliveryGuard.js +226 -0
  132. package/dist/task/nextAction.js +738 -0
  133. package/dist/task/repairWave.js +137 -0
  134. package/dist/task/taskRecordReference.js +6 -1
  135. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  136. package/dist/telemetry/telemetryCompaction.js +251 -0
  137. package/dist/telemetry/telemetryConfig.js +64 -0
  138. package/dist/telemetry/telemetryRouter.js +32 -0
  139. package/dist/telemetry/telemetryStore.js +19 -0
  140. package/dist/telemetry/telemetryWiring.js +33 -0
  141. package/dist/tmux/tmuxManager.js +20 -1
  142. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  143. package/dist/verification/gateArtifact.js +216 -0
  144. package/dist/verification/gateArtifactStore.js +87 -0
  145. package/dist/verification/verificationGateService.js +414 -0
  146. package/dist/verification/verificationPlan.js +308 -0
  147. package/dist/workspace/gitChangeSetCapture.js +12 -2
  148. package/dist/workspace/workItemChangeSetManager.js +60 -3
  149. package/package.json +1 -1
  150. 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; `state.json` is never touched.
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. The `state.json` document is never
259
- * touched it was retained read-only during the migration and remains the
260
- * authoritative source after rollback.
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 or has
263
- * no `yui.db` to quarantine.
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`);