@zq-silk/yui 0.12.0 → 0.12.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 (53) hide show
  1. package/README.md +8 -9
  2. package/dist/cli/commandCatalog.js +11 -6
  3. package/dist/cli/interactionPolicy.js +5 -6
  4. package/dist/cli/updateCommand.js +3 -1
  5. package/dist/cli/updateOrchestrator.js +173 -28
  6. package/dist/cli/updatePorts.js +137 -8
  7. package/dist/cli/upgradeCommand.js +19 -9
  8. package/dist/cli.js +51 -14
  9. package/dist/commands/configCommands.js +1 -1
  10. package/dist/commands/executionAuditCommands.js +2 -1
  11. package/dist/commands/taskCommands.js +80 -29
  12. package/dist/commands/taskContextCommand.js +6 -2
  13. package/dist/commands/taskRoleRuntimeStatus.js +31 -7
  14. package/dist/config/configCatalog.js +1 -1
  15. package/dist/controller/clientRuntime.js +38 -2
  16. package/dist/controller/controller.js +23 -15
  17. package/dist/controller/fileSchedulerStoreAdapter.js +248 -138
  18. package/dist/controller/runtime.js +56 -1
  19. package/dist/controller/runtimeHookRunFence.js +19 -4
  20. package/dist/controller/structuredProviderObservation.js +20 -3
  21. package/dist/core/controllerClient.js +20 -2
  22. package/dist/core/controllerServer.js +1 -0
  23. package/dist/executor/agentExecutor.js +48 -46
  24. package/dist/executor/fileRoleLaunchPlanner.js +94 -30
  25. package/dist/lifecycle/exactRunTerminalization.js +68 -3
  26. package/dist/observability/executionAudit.js +5 -0
  27. package/dist/release/runtimeRelease.js +20 -0
  28. package/dist/run/recoveryProjection.js +45 -6
  29. package/dist/runtime/agentHost.js +159 -85
  30. package/dist/runtime/conversationSwitch.js +277 -0
  31. package/dist/runtime/index.js +1 -1
  32. package/dist/runtime/launchBroker.js +12 -0
  33. package/dist/runtime/processExitOutbox.js +88 -0
  34. package/dist/runtime/providerRuntimeIdentity.js +29 -1
  35. package/dist/runtime/runtimeHealthPolicy.js +5 -5
  36. package/dist/runtime/runtimeObservation.js +15 -0
  37. package/dist/runtime/runtimeProjection.js +6 -7
  38. package/dist/runtime/tmuxAdapters.js +4 -1
  39. package/dist/scheduler/activeRoleRunDelivery.js +31 -196
  40. package/dist/scheduler/leaderWakeupProcessor.js +39 -133
  41. package/dist/scheduler/roleRunStall.js +53 -17
  42. package/dist/storage/sqliteSchema.js +62 -26
  43. package/dist/storage/sqliteStore.js +23 -4
  44. package/dist/storage/upgrade/homeClassification.js +52 -0
  45. package/dist/storage/upgrade/offlineUpgradeInventory.js +145 -7
  46. package/dist/storage/upgrade/upgradeOrchestrator.js +333 -12
  47. package/dist/task/nextAction.js +0 -34
  48. package/dist/web/webSnapshot.js +3 -1
  49. package/package.json +1 -1
  50. package/skills/yui-leader/SKILL.md +7 -4
  51. package/skills/yui-operator/SKILL.md +4 -4
  52. package/skills/yui-reviewer/SKILL.md +7 -4
  53. package/dist/lifecycle/taskRoleSessionReset.js +0 -118
@@ -37,6 +37,10 @@ export const TELEMETRY_RUN_CAP = 50_000;
37
37
  * `synchronous`/`foreign_keys`/`busy_timeout` are per-connection PRAGMAs set by
38
38
  * the store; `journal_mode=WAL` is a persistent database property set on open.
39
39
  * The migration itself only contains schema objects.
40
+ *
41
+ * This released SQL string, including its comments, is checksummed persistent
42
+ * history and must remain byte-for-byte unchanged. The `operator-notification`
43
+ * projection kind it creates is retained only for old layout-7 Homes.
40
44
  */
41
45
  const MIGRATION_1_SQL = `
42
46
  -- Global catalog and coordination (§4.1) -------------------------------------
@@ -327,8 +331,7 @@ CREATE TABLE IF NOT EXISTS events (
327
331
  );
328
332
  CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(task_id, type, occurred_at);
329
333
 
330
- -- Per-task scheduler projections. operator-notification is a retired legacy
331
- -- kind retained only so old layout-7 Homes can be read by the upgrade path.
334
+ -- Per-task scheduler projections (leaderFailure, operatorNotification).
332
335
  CREATE TABLE IF NOT EXISTS task_projections (
333
336
  task_id TEXT NOT NULL,
334
337
  kind TEXT NOT NULL CHECK (kind IN ('leader-failure','operator-notification')),
@@ -931,6 +934,12 @@ CREATE TABLE IF NOT EXISTS context_snapshots (
931
934
  CREATE UNIQUE INDEX IF NOT EXISTS idx_context_snapshots_scope_sequence
932
935
  ON context_snapshots(task_id, scope, COALESCE(scope_ref, ''), sequence);
933
936
  `;
937
+ /**
938
+ * The single forward migration history for current SQLite Homes. New durable
939
+ * layout, aggregate, and record changes belong here and must be expressible as
940
+ * one atomic in-place transaction. The separate logical migration registry is
941
+ * only the bridge from valid pre-SQLite Homes and is allowed to rebuild them.
942
+ */
934
943
  const MIGRATIONS = [
935
944
  { version: 1, axis: "layout", sql: MIGRATION_1_SQL },
936
945
  { version: 2, axis: "record", recordKind: "durableJob+capability-grant+release-workflow", sql: MIGRATION_2_SQL },
@@ -1137,41 +1146,68 @@ function validatePendingProjectionBackfillSources(db) {
1137
1146
  throw new SqliteSchemaMigrationError(`pending Turn completion source is invalid for ${mismatch.task_id}/${mismatch.role_name}`, "backfill");
1138
1147
  }
1139
1148
  }
1149
+ /**
1150
+ * Inspect a database's checksummed migration prefix without changing it.
1151
+ * Pending versions are a supported upgrade state; malformed, gapped, changed,
1152
+ * or future ledger entries remain explicit integrity failures.
1153
+ */
1154
+ export function inspectSqliteSchemaMigrations(db) {
1155
+ const ledgerWasCreated = ensureMigrationLedger(db, "validate");
1156
+ const applied = validateAppliedMigrations(db, ledgerWasCreated);
1157
+ const pendingVersions = MIGRATIONS
1158
+ .filter((migration) => !applied.has(migration.version))
1159
+ .map((migration) => migration.version);
1160
+ if (pendingVersions.length === 0)
1161
+ validateSchemaObjects(db);
1162
+ const current = MIGRATIONS[applied.size - 1];
1163
+ const target = MIGRATIONS.at(-1);
1164
+ if (current === undefined) {
1165
+ throw new SqliteSchemaMigrationError("schema_migrations ledger has no current head");
1166
+ }
1167
+ return {
1168
+ currentVersion: applied.size,
1169
+ currentChecksum: checksum(current.sql),
1170
+ targetVersion: SQLITE_SCHEMA_VERSION,
1171
+ targetChecksum: checksum(target.sql),
1172
+ pendingVersions
1173
+ };
1174
+ }
1140
1175
  /**
1141
1176
  * Apply or validate schema migrations without letting an ordinary open mutate
1142
1177
  * an existing authoritative database.
1143
1178
  *
1144
- * In `apply` mode each migration runs in its own transaction: the DDL and the
1145
- * `schema_migrations` bookkeeping commit atomically, so a crash mid-migration
1146
- * rolls back and the disposable staged database can be rebuilt cleanly.
1147
- * `validate` mode rejects a pending version before executing any migration.
1179
+ * In `apply` mode every pending DDL/data step and every ledger row runs in one
1180
+ * outer transaction. The database therefore advances to the release version
1181
+ * as one commit or remains entirely at its previous version. `validate` mode
1182
+ * rejects a pending version before executing any migration.
1148
1183
  */
1149
1184
  export function migrateSqliteSchema(db, options) {
1150
- const ledgerWasCreated = ensureMigrationLedger(db, options.mode);
1151
- // Validate the complete ledger before touching any pending migration. This
1152
- // prevents a manually altered or partially recorded ledger from silently
1153
- // skipping the partial-index/projection migrations added after a valid Home.
1154
- const applied = validateAppliedMigrations(db, ledgerWasCreated);
1155
- const pending = MIGRATIONS.filter((migration) => !applied.has(migration.version));
1156
- if (options.mode === "validate" && pending.length > 0) {
1157
- throw new SqliteSchemaMigrationError(`pending SQLite schema migration ${pending[0].version}; `
1158
- + "run the offline staged upgrade before opening this database", "admission");
1159
- }
1160
- const newlyApplied = [];
1161
- for (const migration of MIGRATIONS) {
1162
- if (applied.has(migration.version))
1163
- continue;
1164
- const apply = db.transaction(() => {
1185
+ const migrate = () => {
1186
+ const ledgerWasCreated = ensureMigrationLedger(db, options.mode);
1187
+ // Validate the complete ledger before touching any pending migration. This
1188
+ // prevents a manually altered or partially recorded ledger from silently
1189
+ // skipping a later schema/data step.
1190
+ const applied = validateAppliedMigrations(db, ledgerWasCreated);
1191
+ const pending = MIGRATIONS.filter((migration) => !applied.has(migration.version));
1192
+ if (options.mode === "validate" && pending.length > 0) {
1193
+ throw new SqliteSchemaMigrationError(`pending SQLite schema migration ${pending[0].version}; `
1194
+ + "run the explicit storage upgrade before opening this database", "admission");
1195
+ }
1196
+ const newlyApplied = [];
1197
+ for (const migration of pending) {
1165
1198
  if (migration.version === 13)
1166
1199
  validatePendingProjectionBackfillSources(db);
1167
1200
  db.exec(migration.sql);
1168
1201
  db.prepare(`INSERT INTO schema_migrations (version, axis, record_kind, applied_at, checksum)
1169
1202
  VALUES (?, ?, ?, ?, ?)`).run(migration.version, migration.axis, migration.recordKind ?? null, new Date().toISOString(), checksum(migration.sql));
1170
- });
1171
- apply();
1172
- newlyApplied.push(migration.version);
1173
- }
1174
- validateSchemaObjects(db);
1203
+ newlyApplied.push(migration.version);
1204
+ }
1205
+ validateSchemaObjects(db);
1206
+ return newlyApplied;
1207
+ };
1208
+ const newlyApplied = options.mode === "apply" && !db.inTransaction
1209
+ ? db.transaction(migrate)()
1210
+ : migrate();
1175
1211
  return { applied: newlyApplied, version: SQLITE_SCHEMA_VERSION };
1176
1212
  }
1177
1213
  /** The names of every table the schema creates (for tests/introspection). */
@@ -55,7 +55,7 @@ import { assertHomeWritable } from "./upgradeFence.js";
55
55
  import { CURRENT_CONFIG_SCHEMA_VERSION, CURRENT_PENDING_WAKEUP_SCHEMA_VERSION, CURRENT_WORK_MAILBOX_SCHEMA_VERSION, executionLaneActiveRunKey, executionLaneActiveRunKeyParts, StorageConflictError, StorageCancelledError, StorageRecordError, FileTaskStore, storedCapabilityGrant, storedPublicationReference, storedReleaseWorkflow, isValidCapabilityGrantTransition, isValidReleaseWorkflowTransition, validateYuiConfig } from "./taskStore.js";
56
56
  import { publicationExternalKey } from "../task/publicationReference.js";
57
57
  import { gateArtifactKey, validateGateArtifact } from "../verification/gateArtifact.js";
58
- import { migrateSqliteSchema, SqliteSchemaMigrationError, SQLITE_AGGREGATE_VERSION, SQLITE_LAYOUT_VERSION, TELEMETRY_KEEP_PER_GENERATION, TELEMETRY_RUN_CAP } from "./sqliteSchema.js";
58
+ import { inspectSqliteSchemaMigrations, migrateSqliteSchema, SqliteSchemaMigrationError, SQLITE_AGGREGATE_VERSION, SQLITE_LAYOUT_VERSION, TELEMETRY_KEEP_PER_GENERATION, TELEMETRY_RUN_CAP } from "./sqliteSchema.js";
59
59
  import { inspectStorageSchema } from "./storageSchema.js";
60
60
  /** Read the immutable Home identity without opening a writable Store connection. */
61
61
  export function readSqliteHomeIdentity(rootDir, databaseFilename = "yui.db") {
@@ -136,6 +136,7 @@ export class SqliteTaskStore {
136
136
  #db;
137
137
  #rootDir;
138
138
  #migration;
139
+ #openedSchemaHead;
139
140
  #inTransaction = false;
140
141
  #dirty = false;
141
142
  constructor(rootDir, _options = {}) {
@@ -159,6 +160,11 @@ export class SqliteTaskStore {
159
160
  migrateSqliteSchema(this.#db, {
160
161
  mode: this.#migration || !databaseExisted ? "apply" : "validate"
161
162
  });
163
+ const schema = inspectSqliteSchemaMigrations(this.#db);
164
+ this.#openedSchemaHead = {
165
+ version: schema.currentVersion,
166
+ checksum: schema.currentChecksum
167
+ };
162
168
  this.#seedHomeMeta();
163
169
  this.#seedConfig();
164
170
  }
@@ -215,6 +221,13 @@ export class SqliteTaskStore {
215
221
  if (this.#migration)
216
222
  return;
217
223
  assertHomeWritable(this.#rootDir);
224
+ const current = this.#db.prepare("SELECT version, checksum FROM schema_migrations ORDER BY version DESC LIMIT 1").get();
225
+ if (current?.version !== this.#openedSchemaHead.version
226
+ || current.checksum !== this.#openedSchemaHead.checksum) {
227
+ throw new SqliteSchemaMigrationError(`open Store schema head ${this.#openedSchemaHead.version}/${this.#openedSchemaHead.checksum} `
228
+ + `changed to ${String(current?.version)}/${String(current?.checksum)}; `
229
+ + "reopen the Store with the active Yui version before writing", "admission");
230
+ }
218
231
  }
219
232
  #bumpRevision() {
220
233
  this.#db.prepare("UPDATE home_meta SET revision = revision + 1, updated_at = ? WHERE id = 1").run(this.#now());
@@ -230,9 +243,12 @@ export class SqliteTaskStore {
230
243
  this.#dirty = true;
231
244
  return result;
232
245
  }
233
- this.#prepareWrite();
234
246
  this.#begin();
235
247
  try {
248
+ // Acquire SQLite's write reservation before checking the upgrade fence.
249
+ // A writer that started first commits before the upgrader's final
250
+ // transactional inventory; a writer that starts later sees the fence.
251
+ this.#prepareWrite();
236
252
  const result = fn();
237
253
  if (!this.#migration) {
238
254
  this.#bumpRevision();
@@ -251,9 +267,12 @@ export class SqliteTaskStore {
251
267
  return run(this);
252
268
  this.#begin();
253
269
  try {
270
+ // Pin the Store's validated schema generation before user code runs.
271
+ // This prevents a long-lived pre-upgrade Store from executing old SQL
272
+ // after an in-place migration has committed.
273
+ this.#prepareWrite();
254
274
  const result = run(this);
255
275
  if (this.#dirty) {
256
- this.#prepareWrite();
257
276
  if (options?.requestId !== undefined) {
258
277
  this.#insertOutbox(options.requestId, options.outboxCommand ?? null);
259
278
  }
@@ -278,9 +297,9 @@ export class SqliteTaskStore {
278
297
  async transactionAsync(execute) {
279
298
  if (this.#inTransaction)
280
299
  return execute(this);
281
- this.#prepareWrite();
282
300
  this.#begin();
283
301
  try {
302
+ this.#prepareWrite();
284
303
  const result = await execute(this);
285
304
  if (this.#dirty)
286
305
  this.#bumpRevision();
@@ -30,6 +30,7 @@ import { classifyStorage } from "../migration/index.js";
30
30
  import { inspectStorageSchema } from "../storageSchema.js";
31
31
  import { FileTaskStore, STORAGE_STATE_FILE, StorageRecordError } from "../taskStore.js";
32
32
  import { SqliteTaskStore } from "../sqliteStore.js";
33
+ import { inspectSqliteSchemaMigrations } from "../sqliteSchema.js";
33
34
  import { inspectSourceVersionState } from "./homeMigrationTarget.js";
34
35
  import { readMigrationReceipt } from "./migrationReceipt.js";
35
36
  /**
@@ -111,6 +112,44 @@ export function classifyHome(options) {
111
112
  };
112
113
  }
113
114
  }
115
+ // Once the logical Home axes are current, the SQLite ledger is the only
116
+ // remaining upgrade coordinate. A valid applied prefix with pending entries
117
+ // is upgradeable in place, not corruption and not a reason to rebuild the
118
+ // database. Ordinary store opens still validate-and-refuse this state; only
119
+ // the explicit updater is allowed to apply it.
120
+ if (schema.status === "current"
121
+ && schema.currentLayoutVersion >= 7
122
+ && isFullyCurrent(source, latest)) {
123
+ let sqliteMigration;
124
+ try {
125
+ sqliteMigration = inspectLayout7SqliteMigrations(home);
126
+ }
127
+ catch (error) {
128
+ return {
129
+ ...base,
130
+ classification: {
131
+ verdict: "CORRUPTED",
132
+ status: "unsupported",
133
+ detail: error instanceof Error ? error.message : String(error)
134
+ },
135
+ layoutVersion: schema.currentLayoutVersion,
136
+ aggregateVersion: schema.currentAggregateSchemaVersion
137
+ };
138
+ }
139
+ if (sqliteMigration.pendingVersions.length > 0) {
140
+ return {
141
+ ...base,
142
+ classification: {
143
+ verdict: "MIGRATABLE",
144
+ status: "migration-required",
145
+ stepCount: sqliteMigration.pendingVersions.length
146
+ },
147
+ layoutVersion: schema.currentLayoutVersion,
148
+ aggregateVersion: schema.currentAggregateSchemaVersion,
149
+ sqliteMigration
150
+ };
151
+ }
152
+ }
114
153
  // The reference graph can only be validated by the strict loader, which only
115
154
  // understands the current versions. So run it exactly when every axis is
116
155
  // already current (the plan would be a no-op); a throw there is genuine
@@ -136,6 +175,19 @@ export function classifyHome(options) {
136
175
  : { incompatibleComponent: incompatibleComponentOf(schema) })
137
176
  };
138
177
  }
178
+ function inspectLayout7SqliteMigrations(home) {
179
+ const db = new Database(join(home, "yui.db"), {
180
+ readonly: true,
181
+ fileMustExist: true
182
+ });
183
+ try {
184
+ db.pragma("query_only = ON");
185
+ return inspectSqliteSchemaMigrations(db);
186
+ }
187
+ finally {
188
+ db.close();
189
+ }
190
+ }
139
191
  /**
140
192
  * Load a Home whose every axis is already current through the strict store
141
193
  * gate to detect real structural/reference corruption. This is only ever
@@ -1,7 +1,10 @@
1
1
  import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import Database from "better-sqlite3";
3
4
  import { scanControllerResourceInventory } from "../../controller/resourceInventoryLinux.js";
4
5
  import { STORAGE_STATE_FILE } from "../taskStore.js";
6
+ import { SQLITE_LAYOUT_VERSION } from "../sqliteSchema.js";
7
+ import { inspectStorageSchema } from "../storageSchema.js";
5
8
  /** Pure blocker policy shared by synthetic tests and the real read-only scan. */
6
9
  export function classifyOfflineUpgradeFacts(facts) {
7
10
  const blockers = [];
@@ -45,14 +48,13 @@ export async function inspectOfflineUpgradeInventory(home, environment = process
45
48
  const raw = readRawFacts(home, inventory);
46
49
  return classifyOfflineUpgradeFacts(raw);
47
50
  }
48
- function readRawFacts(home, inventory) {
51
+ function readRawFacts(home, inventory, state = readDurableStateObject(home), conservativeDurableSessions = false) {
49
52
  const runs = [];
50
53
  const sessions = [];
51
54
  const inFlight = [];
52
55
  const pendingCompletions = [];
53
56
  const lifecycle = [];
54
57
  const unknownRuntime = [];
55
- const state = readStateObject(home);
56
58
  // Failure to enumerate processes, panes, or sockets means absence cannot be
57
59
  // proven. Ownership-load warnings are expected for an old record shape and
58
60
  // are handled by the raw durable scan plus unowned live-pane check below.
@@ -61,6 +63,8 @@ function readRawFacts(home, inventory) {
61
63
  }
62
64
  for (const [taskId, aggregateValue] of Object.entries(record(state.tasks))) {
63
65
  const aggregate = record(aggregateValue);
66
+ if (text(record(aggregate.task).status) === "retired")
67
+ continue;
64
68
  for (const runValue of Object.values(record(aggregate.agentRuns))) {
65
69
  const run = record(runValue);
66
70
  const roleName = text(run.roleName);
@@ -75,6 +79,7 @@ function readRawFacts(home, inventory) {
75
79
  roleName,
76
80
  set: record(setValue),
77
81
  inventory,
82
+ conservativeDurableSessions,
78
83
  sessions,
79
84
  inFlight,
80
85
  pendingCompletions
@@ -86,6 +91,7 @@ function readRawFacts(home, inventory) {
86
91
  roleName,
87
92
  set: record(setValue),
88
93
  inventory,
94
+ conservativeDurableSessions,
89
95
  sessions,
90
96
  inFlight,
91
97
  pendingCompletions
@@ -97,7 +103,7 @@ function readRawFacts(home, inventory) {
97
103
  const kind = text(target.kind);
98
104
  if (kind !== "role-runtime" && kind !== "global-role-runtime")
99
105
  continue;
100
- if (mailbox.pending === null && mailbox.processing === null)
106
+ if (!mailboxHasRuntimeWork(mailbox))
101
107
  continue;
102
108
  lifecycle.push({
103
109
  ...(text(target.taskId) === undefined ? {} : { taskId: text(target.taskId) }),
@@ -170,7 +176,7 @@ function isUndeterminableNativeInventoryWarning(warning) {
170
176
  || warning.startsWith("Cannot inspect Unix sockets");
171
177
  }
172
178
  function collectSessionSet(options) {
173
- const { taskId, roleName, set, inventory } = options;
179
+ const { taskId, roleName, set, inventory, conservativeDurableSessions } = options;
174
180
  const currentSessions = Object.entries(record(set.sessions));
175
181
  const activeAgentId = text(set.activeAgentId);
176
182
  const historyValue = set.history;
@@ -199,7 +205,7 @@ function collectSessionSet(options) {
199
205
  };
200
206
  options.sessions.push({
201
207
  ...base,
202
- processState: processState(inventory, base)
208
+ processState: processState(inventory, base, conservativeDurableSessions)
203
209
  });
204
210
  }
205
211
  const flight = record(set.inFlight);
@@ -245,12 +251,22 @@ function activeCurrentSession(sessions, taskId, roleName) {
245
251
  return current.find(({ active }) => active === true)
246
252
  ?? (current.length === 1 ? current[0] : undefined);
247
253
  }
248
- function processState(inventory, session) {
254
+ function processState(inventory, session, conservativeDurableSessions) {
249
255
  const matching = inventory.resources.filter((resource) => (resource.kind === "agent-session" && resourceMatchesSession(resource, session)));
250
256
  if (matching.some((resource) => resource.processes.length > 0))
251
257
  return "live";
252
258
  if (matching.some((resource) => resource.paneDead === false))
253
259
  return "unknown";
260
+ // The final SQLite gate runs after BEGIN IMMEDIATE, so no older writer can
261
+ // commit another Session record behind it. Treat every current non-terminal
262
+ // durable Session as active even when the earlier native-process snapshot
263
+ // did not contain it; this closes the inventory-scan -> transaction race.
264
+ if (conservativeDurableSessions
265
+ && !session.history
266
+ && session.status !== "stopped"
267
+ && session.status !== "broken") {
268
+ return "unknown";
269
+ }
254
270
  return "stopped";
255
271
  }
256
272
  function resourceMatchesSession(resource, session) {
@@ -272,13 +288,135 @@ function resourceMatchesSession(resource, session) {
272
288
  return false;
273
289
  return true;
274
290
  }
275
- function readStateObject(home) {
291
+ function readDurableStateObject(home) {
292
+ const databasePath = join(home, "yui.db");
293
+ const schema = inspectStorageSchema(home);
294
+ const sqliteIsAuthoritative = (schema.status === "current" || schema.status === "unsupported")
295
+ && schema.currentLayoutVersion >= SQLITE_LAYOUT_VERSION;
296
+ if (sqliteIsAuthoritative && existsSync(databasePath)) {
297
+ return readSqliteStateObject(databasePath);
298
+ }
276
299
  const path = join(home, STORAGE_STATE_FILE);
277
300
  if (!existsSync(path))
278
301
  return {};
279
302
  const parsed = JSON.parse(readFileSync(path, "utf8"));
280
303
  return record(parsed);
281
304
  }
305
+ /**
306
+ * Read only the durable runtime families needed by the offline gate. This raw
307
+ * adapter deliberately does not open SqliteTaskStore: a valid pending schema
308
+ * prefix is exactly the state the explicit upgrader must be able to inspect.
309
+ */
310
+ function readSqliteStateObject(databasePath) {
311
+ const db = new Database(databasePath, { readonly: true, fileMustExist: true });
312
+ try {
313
+ db.pragma("query_only = ON");
314
+ return readSqliteStateObjectFromDatabase(db);
315
+ }
316
+ finally {
317
+ db.close();
318
+ }
319
+ }
320
+ /**
321
+ * Re-check the authoritative SQLite runtime families on an already-open
322
+ * connection. The in-place upgrader calls this inside its write transaction,
323
+ * after every older writer has either committed or rolled back.
324
+ */
325
+ export function inspectSqliteDurableUpgradeInventory(home, db) {
326
+ return classifyOfflineUpgradeFacts(readRawFacts(home, { resources: [], warnings: [] }, readSqliteStateObjectFromDatabase(db), true));
327
+ }
328
+ function readSqliteStateObjectFromDatabase(db) {
329
+ const taskRows = db.prepare("SELECT task_id, status FROM tasks_catalog").all();
330
+ // Explicit retirement is the only isolation boundary for anomalous
331
+ // historical runtime state. Completed/archived/draft Tasks still fail
332
+ // closed if they retain an active Run or unfinished lifecycle record.
333
+ const retiredTaskIds = new Set(taskRows.filter(({ status }) => status === "retired").map(({ task_id }) => task_id));
334
+ const tasks = Object.fromEntries(taskRows
335
+ .filter(({ task_id }) => !retiredTaskIds.has(task_id))
336
+ .map(({ task_id, status }) => [task_id, {
337
+ task: { status },
338
+ agentRuns: {},
339
+ roleSessionSets: {}
340
+ }]));
341
+ const taskAggregate = (taskId) => {
342
+ const existing = tasks[taskId];
343
+ if (existing !== undefined)
344
+ return existing;
345
+ const created = { task: {}, agentRuns: {}, roleSessionSets: {} };
346
+ tasks[taskId] = created;
347
+ return created;
348
+ };
349
+ for (const row of db.prepare("SELECT task_id, run_id, role_name, status, payload FROM agent_runs").all()) {
350
+ if (retiredTaskIds.has(row.task_id))
351
+ continue;
352
+ const aggregate = taskAggregate(row.task_id);
353
+ record(aggregate.agentRuns)[row.run_id] = {
354
+ ...parseJsonRecord(row.payload, "agent_runs.payload"),
355
+ id: row.run_id,
356
+ roleName: row.role_name,
357
+ status: row.status
358
+ };
359
+ }
360
+ for (const row of db.prepare("SELECT task_id, role_name, payload FROM role_session_sets").all()) {
361
+ if (retiredTaskIds.has(row.task_id))
362
+ continue;
363
+ const aggregate = taskAggregate(row.task_id);
364
+ record(aggregate.roleSessionSets)[row.role_name] = parseJsonRecord(row.payload, "role_session_sets.payload");
365
+ }
366
+ const globalRoleSessionSets = {};
367
+ for (const row of db.prepare("SELECT name, payload FROM global_role_session_sets").all()) {
368
+ globalRoleSessionSets[row.name] = parseJsonRecord(row.payload, "global_role_session_sets.payload");
369
+ }
370
+ const mailboxColumns = new Set(db.pragma("table_info(mailboxes)").map(({ name }) => name));
371
+ const hasInputDelivery = mailboxColumns.has("input_delivery");
372
+ const mailboxes = {};
373
+ for (const row of db.prepare(`SELECT target_key, target_kind, task_id, role_name, processing, pending${hasInputDelivery ? ", input_delivery" : ""} FROM mailboxes`).all()) {
374
+ if (row.task_id !== null && retiredTaskIds.has(row.task_id))
375
+ continue;
376
+ mailboxes[row.target_key] = {
377
+ target: {
378
+ kind: row.target_kind,
379
+ ...(row.task_id === null ? {} : { taskId: row.task_id }),
380
+ ...(row.role_name === null ? {} : { roleName: row.role_name })
381
+ },
382
+ processing: parseNullableJson(row.processing, "mailboxes.processing"),
383
+ pending: parseNullableJson(row.pending, "mailboxes.pending"),
384
+ inputDelivery: parseNullableJson(row.input_delivery ?? null, "mailboxes.input_delivery")
385
+ };
386
+ }
387
+ return { tasks, globalRoleSessionSets, mailboxes };
388
+ }
389
+ function mailboxHasRuntimeWork(mailbox) {
390
+ if (mailbox.processing !== null && mailbox.processing !== undefined)
391
+ return true;
392
+ if (mailbox.inputDelivery !== null && mailbox.inputDelivery !== undefined)
393
+ return true;
394
+ if (mailbox.pending === null || mailbox.pending === undefined)
395
+ return false;
396
+ const pending = record(mailbox.pending);
397
+ if (Object.hasOwn(pending, "normal") || Object.hasOwn(pending, "userCorrection")) {
398
+ return pending.normal !== null || pending.userCorrection !== null;
399
+ }
400
+ // Pre-v14 mailboxes store one pending batch directly.
401
+ return Object.keys(pending).length > 0;
402
+ }
403
+ function parseJsonRecord(raw, label) {
404
+ const parsed = JSON.parse(raw);
405
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
406
+ throw new Error(`${label} is not a JSON object.`);
407
+ }
408
+ return parsed;
409
+ }
410
+ function parseNullableJson(raw, label) {
411
+ if (raw === null)
412
+ return null;
413
+ try {
414
+ return JSON.parse(raw);
415
+ }
416
+ catch (error) {
417
+ throw new Error(`${label} is not valid JSON.`, { cause: error });
418
+ }
419
+ }
282
420
  function countPendingInbox(home) {
283
421
  return [join(home, "runtime", "inbox"), join(home, "runtime", "inbox-invalid")]
284
422
  .reduce((total, directory) => total + countDirectory(directory), 0);