@zq-silk/yui 0.5.3 → 0.6.1

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 (157) hide show
  1. package/README.md +5 -5
  2. package/dist/agent/managedRuntimeEnvironment.js +2 -1
  3. package/dist/cli/agentConfigurationPicker.js +1 -1
  4. package/dist/cli/commandCatalog.js +251 -13
  5. package/dist/cli/updateOrchestrator.js +8 -0
  6. package/dist/cli/updatePorts.js +76 -22
  7. package/dist/cli.js +264 -20
  8. package/dist/commands/configCommands.js +83 -9
  9. package/dist/commands/controllerCommands.js +103 -0
  10. package/dist/commands/deliveryGuardPreflight.js +35 -0
  11. package/dist/commands/durableJobCommands.js +231 -0
  12. package/dist/commands/executionAuditCommands.js +193 -0
  13. package/dist/commands/grantCommands.js +374 -0
  14. package/dist/commands/projectCommands.js +119 -81
  15. package/dist/commands/releaseCommands.js +444 -0
  16. package/dist/commands/resourcesCommands.js +274 -0
  17. package/dist/commands/sessionCommands.js +104 -0
  18. package/dist/commands/taskActor.js +117 -0
  19. package/dist/commands/taskChangeSetCommands.js +60 -0
  20. package/dist/commands/taskCommands.js +610 -201
  21. package/dist/commands/taskCompletionGate.js +78 -1
  22. package/dist/commands/taskContextCommand.js +24 -6
  23. package/dist/commands/taskInputCommands.js +1 -1
  24. package/dist/commands/taskIntegrationCommands.js +136 -33
  25. package/dist/commands/taskIntegrationQueueCommands.js +228 -0
  26. package/dist/commands/taskNextActionCommand.js +85 -0
  27. package/dist/commands/taskOverlapCommands.js +120 -0
  28. package/dist/commands/taskOverviewCommand.js +36 -8
  29. package/dist/commands/telemetryCommands.js +330 -0
  30. package/dist/commands/workflowCommands.js +415 -0
  31. package/dist/config/yuiConfig.js +60 -0
  32. package/dist/controller/clientRuntime.js +42 -1
  33. package/dist/controller/controller.js +413 -61
  34. package/dist/controller/controllerMain.js +25 -2
  35. package/dist/controller/domainIdentity.js +16 -8
  36. package/dist/controller/ephemeralResourceReaper.js +2 -1
  37. package/dist/controller/fileSchedulerStoreAdapter.js +423 -31
  38. package/dist/controller/handoverCandidate.js +168 -0
  39. package/dist/controller/jobClient.js +102 -0
  40. package/dist/controller/jobControl.js +613 -0
  41. package/dist/controller/jobSupervisor.js +498 -0
  42. package/dist/controller/providerHookRunFence.js +34 -5
  43. package/dist/controller/resourceCleanupLinux.js +18 -9
  44. package/dist/controller/resourceInventoryLinux.js +90 -39
  45. package/dist/controller/resourceInventoryRpc.js +85 -0
  46. package/dist/controller/resourceInventoryWorker.js +50 -0
  47. package/dist/controller/runtime.js +238 -22
  48. package/dist/controller/runtimeEventInbox.js +234 -57
  49. package/dist/controller/runtimeEventProcessor.js +549 -42
  50. package/dist/controller/sessionOwnerReconciliation.js +321 -0
  51. package/dist/core/boundedRpc.js +475 -0
  52. package/dist/core/controllerServer.js +416 -27
  53. package/dist/core/controllerTelemetry.js +167 -0
  54. package/dist/doctor/doctor.js +113 -16
  55. package/dist/domain/validation.js +9 -0
  56. package/dist/execution/executionGroup.js +40 -3
  57. package/dist/executor/agentExecutor.js +6 -3
  58. package/dist/executor/effectiveLaunch.js +52 -0
  59. package/dist/executor/executorRegistry.js +50 -0
  60. package/dist/executor/fileRoleLaunchPlanner.js +61 -6
  61. package/dist/grant/capabilityGrant.js +282 -0
  62. package/dist/integration/changeSet.js +16 -3
  63. package/dist/integration/changeSetManifest.js +46 -0
  64. package/dist/integration/gitIntegrationService.js +528 -147
  65. package/dist/integration/integrationAttempt.js +54 -5
  66. package/dist/integration/integrationQueueEntry.js +221 -0
  67. package/dist/integration/integrationQueueService.js +955 -0
  68. package/dist/integration/manifestTags.js +99 -0
  69. package/dist/integration/overlapDiagnostics.js +211 -0
  70. package/dist/job/durableJob.js +449 -0
  71. package/dist/job/jobRunner.js +350 -0
  72. package/dist/lifecycle/exactRunTerminalization.js +24 -2
  73. package/dist/lifecycle/providerErrorClass.js +126 -0
  74. package/dist/message/message.js +16 -3
  75. package/dist/observability/executionAudit.js +545 -0
  76. package/dist/observability/faultClassification.js +160 -0
  77. package/dist/observability/runtimeIdentity.js +367 -0
  78. package/dist/release/fakeReleasePorts.js +55 -0
  79. package/dist/release/releaseHandover.js +475 -0
  80. package/dist/release/releaseIdempotencyStore.js +165 -0
  81. package/dist/release/releaseWorkflow.js +459 -0
  82. package/dist/release/releaseWorkflowEngine.js +688 -0
  83. package/dist/release/releaseWorkflowPorts.js +1720 -0
  84. package/dist/release/runtimeRelease.js +495 -0
  85. package/dist/release/workflowFileLock.js +218 -0
  86. package/dist/repository/gitWorkspace.js +177 -1
  87. package/dist/repository/projectMaintenanceLock.js +315 -0
  88. package/dist/repository/taskWorkspaceCoordinator.js +87 -17
  89. package/dist/repository/taskWorkspacePreparer.js +1091 -517
  90. package/dist/resources/autoResourceGc.js +116 -0
  91. package/dist/resources/liveReferences.js +574 -0
  92. package/dist/resources/resourceDiscovery.js +477 -0
  93. package/dist/resources/resourceGc.js +645 -0
  94. package/dist/resources/resourceRegistrar.js +256 -0
  95. package/dist/resources/resourceRegistry.js +150 -0
  96. package/dist/resources/resourceRegistryStore.js +41 -0
  97. package/dist/resources/resourceTypes.js +42 -0
  98. package/dist/resources/sqliteResourceRegistry.js +111 -0
  99. package/dist/review/reviewConfig.js +10 -0
  100. package/dist/review/reviewFinding.js +240 -0
  101. package/dist/review/reviewFindingLedger.js +545 -0
  102. package/dist/review/reviewOutcomeClassifier.js +61 -0
  103. package/dist/review/reviewRound.js +56 -4
  104. package/dist/run/agentRun.js +80 -4
  105. package/dist/run/providerRetry.js +84 -0
  106. package/dist/run/providerRetryConfig.js +63 -0
  107. package/dist/run/yieldReceipt.js +65 -0
  108. package/dist/runtime/exactControlPlane.js +79 -2
  109. package/dist/runtime/index.js +4 -0
  110. package/dist/runtime/sessionOwnerIdentity.js +269 -0
  111. package/dist/runtime/sessionOwnerRegistry.js +132 -0
  112. package/dist/runtime/sessionReconciliation.js +93 -0
  113. package/dist/runtime/sessionTerminationGuard.js +211 -0
  114. package/dist/runtime/taskRuntimeIsolation.js +13 -0
  115. package/dist/runtime/tmuxAdapters.js +34 -1
  116. package/dist/scheduler/actionability.js +155 -0
  117. package/dist/scheduler/activeRoleRunDelivery.js +14 -5
  118. package/dist/scheduler/activeTaskProgress.js +60 -0
  119. package/dist/scheduler/leaderWakeupProcessor.js +22 -11
  120. package/dist/scheduler/roleRunStall.js +135 -29
  121. package/dist/scheduler/taskExecutionProjection.js +11 -0
  122. package/dist/setup/setupCommand.js +27 -4
  123. package/dist/storage/compatibleTaskStore.js +112 -5
  124. package/dist/storage/migration/productionRegistry.js +769 -1
  125. package/dist/storage/persistenceWorker.js +194 -0
  126. package/dist/storage/sqliteSchema.js +705 -0
  127. package/dist/storage/sqliteStore.js +1695 -0
  128. package/dist/storage/storageVersions.js +9 -2
  129. package/dist/storage/storeRpc.js +298 -0
  130. package/dist/storage/taskStore.js +982 -21
  131. package/dist/storage/upgrade/homeClassification.js +157 -12
  132. package/dist/storage/upgrade/migrationReceipt.js +67 -0
  133. package/dist/storage/upgrade/pseudoLayoutRepair.js +241 -0
  134. package/dist/storage/upgrade/recordVersions.js +10 -1
  135. package/dist/storage/upgrade/sqliteMigrationTarget.js +351 -0
  136. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +290 -0
  137. package/dist/storage/upgrade/sqliteStateMigration.js +713 -0
  138. package/dist/storage/upgrade/upgradeOrchestrator.js +510 -18
  139. package/dist/task/deliveryGuard.js +226 -0
  140. package/dist/task/nextAction.js +343 -0
  141. package/dist/task/repairWave.js +137 -0
  142. package/dist/task/taskRecordReference.js +6 -1
  143. package/dist/telemetry/sqliteTelemetryStore.js +387 -0
  144. package/dist/telemetry/telemetryCompaction.js +251 -0
  145. package/dist/telemetry/telemetryConfig.js +64 -0
  146. package/dist/telemetry/telemetryRouter.js +32 -0
  147. package/dist/telemetry/telemetryStore.js +19 -0
  148. package/dist/telemetry/telemetryWiring.js +33 -0
  149. package/dist/tmux/tmuxManager.js +20 -1
  150. package/dist/tmux/tmuxSocketEndpoint.js +20 -0
  151. package/dist/verification/gateArtifact.js +216 -0
  152. package/dist/verification/gateArtifactStore.js +87 -0
  153. package/dist/verification/verificationGateService.js +414 -0
  154. package/dist/verification/verificationPlan.js +308 -0
  155. package/dist/workspace/gitChangeSetCapture.js +12 -2
  156. package/dist/workspace/workItemChangeSetManager.js +60 -3
  157. package/package.json +2 -1
@@ -23,10 +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, readFileSync } from "node:fs";
27
+ import { join } from "node:path";
28
+ import Database from "better-sqlite3";
26
29
  import { classifyStorage } from "../migration/index.js";
27
30
  import { inspectStorageSchema } from "../storageSchema.js";
28
- import { FileTaskStore, StorageRecordError } from "../taskStore.js";
31
+ import { FileTaskStore, STORAGE_STATE_FILE, StorageRecordError } from "../taskStore.js";
32
+ import { SqliteTaskStore } from "../sqliteStore.js";
29
33
  import { inspectSourceVersionState } from "./homeMigrationTarget.js";
34
+ import { readMigrationReceipt } from "./migrationReceipt.js";
30
35
  /**
31
36
  * Classify a real Home. Reads `schema.json` (and, for a current Home,
32
37
  * `state.json` through the strict loader) read-only; never mutates the Home.
@@ -76,6 +81,36 @@ export function classifyHome(options) {
76
81
  };
77
82
  }
78
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
+ }
79
114
  // The reference graph can only be validated by the strict loader, which only
80
115
  // understands the current versions. So run it exactly when every axis is
81
116
  // already current (the plan would be a no-op); a throw there is genuine
@@ -102,26 +137,51 @@ export function classifyHome(options) {
102
137
  };
103
138
  }
104
139
  /**
105
- * Load a Home whose every axis is already current through the strict
106
- * `FileTaskStore` gate to detect real structural/reference corruption. This is
107
- * only ever called when the source equals `latest` across all three axes, so a
108
- * version error cannot occur here and any throw is genuine corruption (bad
109
- * record shape, a broken reference graph).
140
+ * Load a Home whose every axis is already current through the strict store
141
+ * gate to detect real structural/reference corruption. This is only ever
142
+ * called when the source equals `latest` across all three axes, so a version
143
+ * error cannot occur here and any throw is genuine corruption (bad record
144
+ * shape, a broken reference graph, or a damaged SQLite database).
145
+ *
146
+ * A layout-7 Home whose authoritative store is `yui.db` is verified through
147
+ * {@link SqliteTaskStore}; a layout-7 Home that still uses the aggregate
148
+ * `state.json` (or a layout-6 Home in tests) is verified through
149
+ * {@link FileTaskStore}.
110
150
  */
111
151
  function detectCurrentHomeCorruption(home) {
112
152
  try {
113
- const store = new FileTaskStore(home);
114
- store.getConfig();
115
- store.listTasks();
116
- store.listProjects();
117
- store.listConfiguredAgents();
118
- store.listWorkMailboxes();
153
+ if (existsSync(`${home}/yui.db`)) {
154
+ const store = new SqliteTaskStore(home);
155
+ try {
156
+ store.getConfig();
157
+ store.listTasks();
158
+ store.listProjects();
159
+ store.listConfiguredAgents();
160
+ store.listWorkMailboxes();
161
+ }
162
+ finally {
163
+ store.close();
164
+ }
165
+ }
166
+ else {
167
+ const store = new FileTaskStore(home);
168
+ store.getConfig();
169
+ store.listTasks();
170
+ store.listProjects();
171
+ store.listConfiguredAgents();
172
+ store.listWorkMailboxes();
173
+ }
119
174
  return undefined;
120
175
  }
121
176
  catch (error) {
122
177
  if (error instanceof StorageRecordError) {
123
178
  return { corrupted: true, detail: error.message };
124
179
  }
180
+ // A SQLite-level error (corrupt database file, I/O fault) is structural
181
+ // damage, not a version mismatch.
182
+ if (error instanceof Error && (error.name === "SqliteError" || error.message.includes("SQLite"))) {
183
+ return { corrupted: true, detail: error.message };
184
+ }
125
185
  // A non-record error (e.g. an unexpected I/O fault) is surfaced, not
126
186
  // silently swallowed as "usable".
127
187
  throw error;
@@ -150,6 +210,91 @@ function isFullyCurrent(source, latest) {
150
210
  function incompatibleComponentOf(schema) {
151
211
  return schema.status === "unsupported" ? schema.incompatibleComponent : undefined;
152
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
+ }
153
298
  /** A structural corruption check over an already-read snapshot (for tests/reports). */
154
299
  export function snapshotHasState(snapshot) {
155
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")