@zq-silk/yui 0.11.3 → 0.12.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 (108) hide show
  1. package/ARCHITECTURE.md +23 -4
  2. package/README.md +44 -22
  3. package/dist/cli/commandCatalog.js +13 -10
  4. package/dist/cli/interactionPolicy.js +5 -6
  5. package/dist/cli/updateCommand.js +3 -1
  6. package/dist/cli/updateOrchestrator.js +173 -28
  7. package/dist/cli/updatePorts.js +137 -8
  8. package/dist/cli/upgradeCommand.js +19 -9
  9. package/dist/cli.js +156 -57
  10. package/dist/commands/configCommands.js +13 -46
  11. package/dist/commands/executionAuditCommands.js +3 -2
  12. package/dist/commands/jobCommands.js +4 -10
  13. package/dist/commands/taskCommands.js +234 -200
  14. package/dist/commands/taskCompletionGate.js +29 -54
  15. package/dist/commands/taskContextCommand.js +61 -13
  16. package/dist/commands/taskInputCommands.js +8 -5
  17. package/dist/commands/taskNextActionCommand.js +56 -6
  18. package/dist/commands/taskOverviewCommand.js +17 -31
  19. package/dist/commands/taskRoleRuntimeStatus.js +55 -7
  20. package/dist/commands/taskWorkspaceCommands.js +5 -2
  21. package/dist/config/configCatalog.js +3 -3
  22. package/dist/config/yuiConfig.js +3 -7
  23. package/dist/context/wakeNotification.js +20 -5
  24. package/dist/controller/agentRuntimeObserver.js +247 -51
  25. package/dist/controller/clientRuntime.js +39 -3
  26. package/dist/controller/controller.js +31 -27
  27. package/dist/controller/fileSchedulerStoreAdapter.js +390 -332
  28. package/dist/controller/runtime.js +58 -3
  29. package/dist/controller/runtimeEventInbox.js +17 -7
  30. package/dist/controller/runtimeEventProcessor.js +12 -19
  31. package/dist/controller/runtimeHookRunFence.js +19 -4
  32. package/dist/controller/runtimeObservationHook.js +45 -0
  33. package/dist/controller/structuredProviderObservation.js +20 -3
  34. package/dist/core/controllerClient.js +20 -2
  35. package/dist/core/controllerServer.js +1 -0
  36. package/dist/execution/resourceBroker.js +5 -4
  37. package/dist/executor/agentExecutor.js +52 -50
  38. package/dist/executor/effectiveLaunch.js +8 -48
  39. package/dist/executor/fileRoleLaunchPlanner.js +129 -45
  40. package/dist/executor/workspacePreflightClassification.js +23 -2
  41. package/dist/interaction/operatorPresentation.js +33 -89
  42. package/dist/lifecycle/exactRunTerminalization.js +69 -5
  43. package/dist/observability/executionAudit.js +5 -0
  44. package/dist/observability/orchestrationMetrics.js +8 -3
  45. package/dist/profile/agentProfile.js +1 -1
  46. package/dist/release/cliHomeReleaseFence.js +123 -0
  47. package/dist/release/runtimeRelease.js +20 -0
  48. package/dist/repository/taskWorkspacePreparer.js +176 -60
  49. package/dist/resources/sqliteResourceRegistry.js +1 -1
  50. package/dist/review/deltaRecheck.js +12 -51
  51. package/dist/review/reviewAcceptance.js +26 -0
  52. package/dist/review/reviewConfig.js +0 -31
  53. package/dist/review/reviewDecision.js +113 -0
  54. package/dist/review/reviewOutcomeClassifier.js +1 -1
  55. package/dist/review/reviewRound.js +1 -1
  56. package/dist/review/reviewerAvailability.js +69 -0
  57. package/dist/run/recoveryProjection.js +45 -6
  58. package/dist/runtime/agentDriverObservation.js +24 -10
  59. package/dist/runtime/agentHost.js +159 -85
  60. package/dist/runtime/builtinAgentDrivers.js +4 -3
  61. package/dist/runtime/builtinTranscriptObserver.js +301 -64
  62. package/dist/runtime/builtinTranscriptUsage.js +9 -7
  63. package/dist/runtime/conversationSwitch.js +277 -0
  64. package/dist/runtime/index.js +2 -1
  65. package/dist/runtime/launchBroker.js +12 -0
  66. package/dist/runtime/processExitOutbox.js +88 -0
  67. package/dist/runtime/providerRuntimeIdentity.js +29 -1
  68. package/dist/runtime/runtimeHealthPolicy.js +5 -5
  69. package/dist/runtime/runtimeObservation.js +28 -0
  70. package/dist/runtime/runtimeProjection.js +22 -32
  71. package/dist/runtime/sessionTokenMetrics.js +181 -0
  72. package/dist/runtime/structuredProviderHost.js +7 -1
  73. package/dist/runtime/tmuxAdapters.js +4 -1
  74. package/dist/scheduler/activeRoleRunDelivery.js +34 -199
  75. package/dist/scheduler/activeTaskProgress.js +7 -6
  76. package/dist/scheduler/leaderWakeupProcessor.js +42 -138
  77. package/dist/scheduler/operatorEvent.js +34 -0
  78. package/dist/scheduler/operatorInputNotificationProcessor.js +54 -94
  79. package/dist/scheduler/roleRunStall.js +53 -31
  80. package/dist/scheduler/taskExecutionProjection.js +97 -76
  81. package/dist/scheduler/taskObservabilityProjection.js +10 -11
  82. package/dist/storage/migration/productionRegistry.js +379 -0
  83. package/dist/storage/sqliteSchema.js +66 -23
  84. package/dist/storage/sqliteStore.js +46 -23
  85. package/dist/storage/storeRpc.js +0 -1
  86. package/dist/storage/taskStore.js +15 -30
  87. package/dist/storage/upgrade/homeClassification.js +52 -0
  88. package/dist/storage/upgrade/offlineUpgradeInventory.js +145 -7
  89. package/dist/storage/upgrade/recordVersions.js +3 -2
  90. package/dist/storage/upgrade/sqliteMigrationTarget.js +30 -8
  91. package/dist/storage/upgrade/sqliteRecordMigrationTarget.js +23 -11
  92. package/dist/storage/upgrade/sqliteStateMigration.js +66 -8
  93. package/dist/storage/upgrade/upgradeOrchestrator.js +333 -12
  94. package/dist/task/completionReadiness.js +10 -6
  95. package/dist/task/nextAction.js +82 -58
  96. package/dist/telemetry/sqliteTelemetryStore.js +1 -1
  97. package/dist/web/assets/client/components.js +13 -3
  98. package/dist/web/assets/client/i18n.js +6 -2
  99. package/dist/web/webSnapshot.js +7 -1
  100. package/i18n/README.zh-CN.md +2 -2
  101. package/package.json +1 -1
  102. package/skills/yui-leader/SKILL.md +60 -20
  103. package/skills/yui-operator/SKILL.md +13 -4
  104. package/skills/yui-reviewer/SKILL.md +35 -11
  105. package/dist/context/sessionContextBudget.js +0 -71
  106. package/dist/lifecycle/contextBudgetRollover.js +0 -81
  107. package/dist/lifecycle/taskRoleSessionReset.js +0 -126
  108. package/dist/scheduler/operatorNotification.js +0 -59
@@ -79,6 +79,8 @@ const STORED_TASK_JOB_CALLER_KEY_HASHES_FROM_VERSION = 15;
79
79
  const STORED_TASK_JOB_CALLER_KEY_HASHES_TO_VERSION = 16;
80
80
  const STORED_TASK_WAKES_FROM_VERSION = 16;
81
81
  const STORED_TASK_WAKES_TO_VERSION = 17;
82
+ const STORED_TASK_EVENT_NOTICES_FROM_VERSION = 17;
83
+ const STORED_TASK_EVENT_NOTICES_TO_VERSION = 18;
82
84
  const TASK_WAKE_FROM_VERSION = 0;
83
85
  const TASK_WAKE_TO_VERSION = 1;
84
86
  const STORED_TASK_PUBLICATION_REFERENCES_FROM_VERSION = 16;
@@ -97,6 +99,8 @@ const PUBLICATION_REFERENCE_FROM_VERSION = 0;
97
99
  const PUBLICATION_REFERENCE_TO_VERSION = 1;
98
100
  const CONFIG_FROM_VERSION = 1;
99
101
  const CONFIG_TO_VERSION = 2;
102
+ const CONFIG_REVIEW_CONTROLS_FROM_VERSION = 2;
103
+ const CONFIG_REVIEW_CONTROLS_TO_VERSION = 3;
100
104
  /**
101
105
  * Build the authoritative production graph. Transition intent and executable
102
106
  * transforms are registered together here; compatible loading and offline
@@ -146,6 +150,7 @@ export function createProductionStorageRegistry() {
146
150
  })
147
151
  .registerOfflineMigration(projectOwnershipStep())
148
152
  .registerOfflineMigration(configV2Step())
153
+ .registerOfflineMigration(configReviewControlsRemovalStep())
149
154
  .registerCompatible(projectKnowledgeProposalsStep())
150
155
  .registerCompatible(projectLifecycleStep())
151
156
  .registerOfflineMigration(taskWorkspaceIdentityStep())
@@ -180,6 +185,7 @@ export function createProductionStorageRegistry() {
180
185
  .registerCompatible(storedTaskDurableJobsStep())
181
186
  .registerCompatible(storedTaskJobCallerKeyHashesStep())
182
187
  .registerCompatible(storedTaskWakesAndPublicationReferencesStep())
188
+ .registerOfflineMigration(storedTaskEventNoticesStep())
183
189
  .registerCompatible(taskWakeIntroductionStep())
184
190
  .registerCompatible(durableJobIntroductionStep())
185
191
  .registerOfflineMigration(capabilityGrantIntroductionStep())
@@ -246,6 +252,61 @@ function migrateConfigV1ToV2(snapshot) {
246
252
  }
247
253
  };
248
254
  }
255
+ /** Removes retired Core controls for the Leader-owned Delta decision. */
256
+ function configReviewControlsRemovalStep() {
257
+ return {
258
+ axis: "record",
259
+ recordKind: "config",
260
+ fromVersion: CONFIG_REVIEW_CONTROLS_FROM_VERSION,
261
+ toVersion: CONFIG_REVIEW_CONTROLS_TO_VERSION,
262
+ preconditions: requireConfigV2,
263
+ transform: migrateConfigV2ToV3,
264
+ declaredEffects: []
265
+ };
266
+ }
267
+ function requireConfigV2(snapshot) {
268
+ const versions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
269
+ if (versions.config !== CONFIG_REVIEW_CONTROLS_FROM_VERSION) {
270
+ throw new Error(`Record config migration requires manifest version ${CONFIG_REVIEW_CONTROLS_FROM_VERSION}.`);
271
+ }
272
+ if (snapshot.state === null)
273
+ return;
274
+ const config = asObject(snapshot.state.config, "Yui config");
275
+ if (config.schemaVersion !== CONFIG_REVIEW_CONTROLS_FROM_VERSION) {
276
+ throw new Error(`Yui config must use schemaVersion ${CONFIG_REVIEW_CONTROLS_FROM_VERSION} before migration.`);
277
+ }
278
+ }
279
+ function migrateConfigV2ToV3(snapshot) {
280
+ requireConfigV2(snapshot);
281
+ const versions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
282
+ const schemaManifest = {
283
+ ...snapshot.schemaManifest,
284
+ recordVersions: { ...versions, config: CONFIG_REVIEW_CONTROLS_TO_VERSION }
285
+ };
286
+ if (snapshot.state === null)
287
+ return { schemaManifest, state: null };
288
+ const config = asObject(snapshot.state.config, "Yui config");
289
+ const review = config.review === undefined
290
+ ? undefined
291
+ : asObject(config.review, "Yui review config");
292
+ const migratedReview = review === undefined
293
+ ? undefined
294
+ : (() => {
295
+ const { deltaRecheck: _retiredSwitch, deltaRecheckMaxChangedLines: _retiredLineThreshold, deltaRecheckMaxChangedFiles: _retiredFileThreshold, ...retained } = review;
296
+ return retained;
297
+ })();
298
+ return {
299
+ schemaManifest,
300
+ state: {
301
+ ...snapshot.state,
302
+ config: {
303
+ ...config,
304
+ schemaVersion: CONFIG_REVIEW_CONTROLS_TO_VERSION,
305
+ ...(migratedReview === undefined ? {} : { review: migratedReview })
306
+ }
307
+ }
308
+ };
309
+ }
249
310
  function workItemExecutionGroupHistoryStep() {
250
311
  return {
251
312
  axis: "record",
@@ -1241,6 +1302,324 @@ function normalizeStoredTaskV16ToV17(snapshot) {
1241
1302
  state: { ...snapshot.state, tasks: nextTasks }
1242
1303
  };
1243
1304
  }
1305
+ /** StoredTask v18 retires the mutable OperatorNotification projection. */
1306
+ function storedTaskEventNoticesStep() {
1307
+ return {
1308
+ axis: "record",
1309
+ recordKind: "storedTask",
1310
+ fromVersion: STORED_TASK_EVENT_NOTICES_FROM_VERSION,
1311
+ toVersion: STORED_TASK_EVENT_NOTICES_TO_VERSION,
1312
+ preconditions: requireStoredTaskV17OperatorNoticeShape,
1313
+ transform: normalizeStoredTaskV17ToV18,
1314
+ declaredEffects: ["operator-mailbox-event-references"]
1315
+ };
1316
+ }
1317
+ const STORED_TASK_V17_FIELDS = [
1318
+ "schemaVersion",
1319
+ "task",
1320
+ "idHighWaterMarks",
1321
+ "brief",
1322
+ "changeSets",
1323
+ "integrationAttempts",
1324
+ "integrationQueue",
1325
+ "durableJobs",
1326
+ "roles",
1327
+ "managedWorkspaces",
1328
+ "roleSessionSets",
1329
+ "jobCallerKeyHashes",
1330
+ "workItems",
1331
+ "contextSnapshots",
1332
+ "agentRuns",
1333
+ "reviewRounds",
1334
+ "activeRuns",
1335
+ "messages",
1336
+ "inputRequests",
1337
+ "decisions",
1338
+ "milestones",
1339
+ "events",
1340
+ "wakes",
1341
+ "capabilityGrants",
1342
+ "releaseWorkflows",
1343
+ "publicationReferences",
1344
+ "leaderFailure",
1345
+ "operatorNotification"
1346
+ ];
1347
+ function requireStoredTaskV17OperatorNoticeShape(snapshot) {
1348
+ const versions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
1349
+ if (versions.storedTask !== STORED_TASK_EVENT_NOTICES_FROM_VERSION) {
1350
+ throw new Error(`Record storedTask compatible step requires manifest version ${STORED_TASK_EVENT_NOTICES_FROM_VERSION}.`);
1351
+ }
1352
+ if (snapshot.state === null)
1353
+ return;
1354
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
1355
+ for (const [taskId, rawTask] of Object.entries(tasks)) {
1356
+ const task = asObject(rawTask, `Task aggregate ${taskId}`);
1357
+ if (task.schemaVersion !== STORED_TASK_EVENT_NOTICES_FROM_VERSION) {
1358
+ throw new Error(`Task aggregate ${taskId} must use schemaVersion ${STORED_TASK_EVENT_NOTICES_FROM_VERSION}.`);
1359
+ }
1360
+ const allowed = new Set(STORED_TASK_V17_FIELDS);
1361
+ const unknown = Object.keys(task).find((key) => !allowed.has(key));
1362
+ if (unknown !== undefined) {
1363
+ throw new Error(`Task aggregate ${taskId} has an unknown v17 field: ${unknown}.`);
1364
+ }
1365
+ const missing = STORED_TASK_V17_FIELDS.find((key) => !Object.hasOwn(task, key));
1366
+ if (missing !== undefined) {
1367
+ throw new Error(`Task aggregate ${taskId} is missing v17 field: ${missing}.`);
1368
+ }
1369
+ if (task.operatorNotification !== null) {
1370
+ requireLegacyOperatorNotice(task.operatorNotification, taskId);
1371
+ }
1372
+ }
1373
+ }
1374
+ function requireLegacyOperatorNotice(value, taskId) {
1375
+ const notice = asObject(value, `Operator notification ${taskId}`);
1376
+ if (notice.schemaVersion !== 1 || notice.taskId !== taskId) {
1377
+ throw new Error(`Operator notification identity is invalid: ${taskId}.`);
1378
+ }
1379
+ if (notice.type === "leader-recovery-failed") {
1380
+ requireLegacyNoticeText(notice.message, `Operator notification message ${taskId}`);
1381
+ }
1382
+ else if (notice.type === "leader-stalled") {
1383
+ requireLegacyNoticeText(notice.message, `Operator notification message ${taskId}`);
1384
+ requireLegacyNoticeText(notice.runId, `Operator notification Run ${taskId}`);
1385
+ requireLegacyNoticeTimestamp(notice.progressAt, `Operator notification progress ${taskId}`);
1386
+ requireLegacyNoticeText(notice.evidenceKey, `Operator notification evidence ${taskId}`);
1387
+ if (notice.classification !== "truly-stalled") {
1388
+ throw new Error(`Operator notification classification is invalid: ${taskId}.`);
1389
+ }
1390
+ }
1391
+ else if (notice.type === "task-terminal") {
1392
+ if (notice.status !== "completed" && notice.status !== "retired") {
1393
+ throw new Error(`Operator notification Task status is invalid: ${taskId}.`);
1394
+ }
1395
+ if (!["user", "operator", "leader"].includes(String(notice.by))) {
1396
+ throw new Error(`Operator notification Task actor is invalid: ${taskId}.`);
1397
+ }
1398
+ requireLegacyNoticeText(notice.summary, `Operator notification summary ${taskId}`);
1399
+ }
1400
+ else {
1401
+ throw new Error(`Operator notification type is invalid: ${taskId}.`);
1402
+ }
1403
+ requireLegacyNoticeTimestamp(notice.createdAt, `Operator notification createdAt ${taskId}`);
1404
+ requireLegacyNoticeTimestamp(notice.updatedAt, `Operator notification updatedAt ${taskId}`);
1405
+ }
1406
+ function requireLegacyNoticeText(value, label) {
1407
+ if (typeof value !== "string" || value.trim().length === 0 || value.includes("\0")) {
1408
+ throw new Error(`${label} is invalid.`);
1409
+ }
1410
+ return value;
1411
+ }
1412
+ function requireLegacyNoticeTimestamp(value, label) {
1413
+ const timestamp = requireLegacyNoticeText(value, label);
1414
+ if (!Number.isFinite(Date.parse(timestamp)))
1415
+ throw new Error(`${label} is invalid.`);
1416
+ return timestamp;
1417
+ }
1418
+ function normalizeStoredTaskV17ToV18(snapshot) {
1419
+ requireStoredTaskV17OperatorNoticeShape(snapshot);
1420
+ const versions = asObject(snapshot.schemaManifest.recordVersions, "schema manifest recordVersions");
1421
+ const schemaManifest = {
1422
+ ...snapshot.schemaManifest,
1423
+ recordVersions: {
1424
+ ...versions,
1425
+ storedTask: STORED_TASK_EVENT_NOTICES_TO_VERSION
1426
+ }
1427
+ };
1428
+ if (snapshot.state === null)
1429
+ return { schemaManifest, state: null };
1430
+ const tasks = asObject(snapshot.state.tasks, "state tasks");
1431
+ const nextTasks = {};
1432
+ const legacyNotices = new Map();
1433
+ for (const [taskId, rawTask] of Object.entries(tasks)) {
1434
+ const task = asObject(rawTask, `Task aggregate ${taskId}`);
1435
+ legacyNotices.set(taskId, task.operatorNotification === null
1436
+ ? null
1437
+ : asObject(task.operatorNotification, `Operator notification ${taskId}`));
1438
+ const nextTask = {
1439
+ ...task,
1440
+ schemaVersion: STORED_TASK_EVENT_NOTICES_TO_VERSION,
1441
+ events: { ...asObject(task.events, `Task events ${taskId}`) },
1442
+ idHighWaterMarks: {
1443
+ ...asObject(task.idHighWaterMarks, `Task id high-water marks ${taskId}`)
1444
+ }
1445
+ };
1446
+ delete nextTask.operatorNotification;
1447
+ nextTasks[taskId] = nextTask;
1448
+ }
1449
+ const eventByTask = new Map();
1450
+ const eventRefForTask = (taskId) => {
1451
+ const existing = eventByTask.get(taskId);
1452
+ if (existing !== undefined)
1453
+ return { type: "event", taskId, id: existing };
1454
+ const task = asObject(nextTasks[taskId], `Task aggregate ${taskId}`);
1455
+ const events = asObject(task.events, `Task events ${taskId}`);
1456
+ const marks = asObject(task.idHighWaterMarks, `Task id high-water marks ${taskId}`);
1457
+ let sequence = Number(marks.event ?? 0) + 1;
1458
+ if (!Number.isSafeInteger(sequence) || sequence < 1) {
1459
+ throw new Error(`Task event high-water mark is invalid: ${taskId}.`);
1460
+ }
1461
+ while (events[`event-${sequence}`] !== undefined)
1462
+ sequence += 1;
1463
+ const eventId = `event-${sequence}`;
1464
+ const notice = legacyNotices.get(taskId) ?? null;
1465
+ const migrated = migratedOperatorNoticeEvent(taskId, eventId, notice, task);
1466
+ events[eventId] = migrated;
1467
+ marks.event = sequence;
1468
+ eventByTask.set(taskId, eventId);
1469
+ return { type: "event", taskId, id: eventId };
1470
+ };
1471
+ const mailboxes = asObject(snapshot.state.mailboxes, "state mailboxes");
1472
+ const nextMailboxes = {};
1473
+ for (const [key, rawMailbox] of Object.entries(mailboxes)) {
1474
+ const mailbox = asObject(rawMailbox, `WorkMailbox ${key}`);
1475
+ const target = asObject(mailbox.target, `WorkMailbox target ${key}`);
1476
+ nextMailboxes[key] = target.kind === "operator"
1477
+ ? rewriteLegacyOperatorMailbox(mailbox, key, eventRefForTask)
1478
+ : { ...mailbox };
1479
+ }
1480
+ for (const [taskId, notice] of legacyNotices) {
1481
+ if (notice !== null)
1482
+ eventRefForTask(taskId);
1483
+ }
1484
+ return {
1485
+ schemaManifest,
1486
+ state: { ...snapshot.state, tasks: nextTasks, mailboxes: nextMailboxes }
1487
+ };
1488
+ }
1489
+ function migratedOperatorNoticeEvent(taskId, eventId, notice, taskAggregate) {
1490
+ const task = asObject(taskAggregate.task, `Task ${taskId}`);
1491
+ const timestamp = String(notice?.updatedAt ?? task.updatedAt ?? task.createdAt ?? "");
1492
+ if (!Number.isFinite(Date.parse(timestamp))) {
1493
+ throw new Error(`Legacy Operator notice timestamp is invalid: ${taskId}.`);
1494
+ }
1495
+ const type = notice?.type;
1496
+ if (notice !== null && type === "task-terminal") {
1497
+ const status = String(notice.status ?? "");
1498
+ if (status !== "completed" && status !== "retired") {
1499
+ throw new Error(`Legacy terminal Operator notice status is invalid: ${taskId}.`);
1500
+ }
1501
+ return {
1502
+ schemaVersion: 2,
1503
+ id: eventId,
1504
+ taskId,
1505
+ type: `task.${status}`,
1506
+ payload: {
1507
+ status,
1508
+ by: String(notice.by ?? "operator"),
1509
+ summary: String(notice.summary ?? "Task reached a terminal state."),
1510
+ migratedFrom: "operator-notification"
1511
+ },
1512
+ createdAt: timestamp
1513
+ };
1514
+ }
1515
+ if (notice !== null && type === "leader-stalled") {
1516
+ return {
1517
+ schemaVersion: 2,
1518
+ id: eventId,
1519
+ taskId,
1520
+ type: "run.stalled",
1521
+ payload: {
1522
+ runId: String(notice.runId ?? "unknown"),
1523
+ roleName: "leader",
1524
+ progressAt: String(notice.progressAt ?? timestamp),
1525
+ classification: "truly-stalled",
1526
+ evidenceKey: String(notice.evidenceKey ?? "legacy-operator-notification"),
1527
+ migratedFrom: "operator-notification"
1528
+ },
1529
+ createdAt: timestamp
1530
+ };
1531
+ }
1532
+ return {
1533
+ schemaVersion: 2,
1534
+ id: eventId,
1535
+ taskId,
1536
+ type: "leader.attention-required",
1537
+ payload: {
1538
+ reason: type === "leader-recovery-failed"
1539
+ ? "leader-recovery-failed"
1540
+ : "legacy-operator-mailbox",
1541
+ message: String(notice?.message ?? "Inspect the migrated Operator notice."),
1542
+ migratedFrom: notice === null ? "operator-mailbox" : "operator-notification"
1543
+ },
1544
+ createdAt: timestamp
1545
+ };
1546
+ }
1547
+ function rewriteLegacyOperatorMailbox(mailbox, key, eventRefForTask) {
1548
+ const rewriteRef = (rawRef) => {
1549
+ const ref = asObject(rawRef, `WorkMailbox ${key} entity ref`);
1550
+ if (ref.type === "input" || ref.type === "event")
1551
+ return { ...ref };
1552
+ const taskId = ref.type === "task" ? ref.id : ref.taskId;
1553
+ if (typeof taskId !== "string" || taskId.trim().length === 0) {
1554
+ throw new Error(`Legacy Operator mailbox ref has no Task identity: ${key}.`);
1555
+ }
1556
+ return eventRefForTask(taskId);
1557
+ };
1558
+ const rewriteBatch = (rawBatch, label) => {
1559
+ const batch = asObject(rawBatch, label);
1560
+ if (!Array.isArray(batch.refs))
1561
+ throw new Error(`${label} refs must be an array.`);
1562
+ const sourceRefs = batch.refs.map((rawRef) => asObject(rawRef, `${label} entity ref`));
1563
+ const semanticTaskIds = new Set(sourceRefs.flatMap((ref) => ((ref.type === "input" || ref.type === "event") && typeof ref.taskId === "string"
1564
+ ? [ref.taskId]
1565
+ : [])));
1566
+ const refs = [];
1567
+ const seen = new Set();
1568
+ for (const sourceRef of sourceRefs) {
1569
+ const referencedTaskId = sourceRef.type === "task" ? sourceRef.id : sourceRef.taskId;
1570
+ if (sourceRef.type !== "input"
1571
+ && sourceRef.type !== "event"
1572
+ && typeof referencedTaskId === "string"
1573
+ && semanticTaskIds.has(referencedTaskId)) {
1574
+ continue;
1575
+ }
1576
+ const ref = rewriteRef(sourceRef);
1577
+ const identity = `${String(ref.type)}\0${String(ref.taskId ?? "")}\0${String(ref.id)}`;
1578
+ if (seen.has(identity))
1579
+ continue;
1580
+ seen.add(identity);
1581
+ refs.push(ref);
1582
+ }
1583
+ return { ...batch, refs };
1584
+ };
1585
+ const rewriteProcessing = (raw) => {
1586
+ if (raw === null)
1587
+ return null;
1588
+ const processing = asObject(raw, `WorkMailbox ${key} processing`);
1589
+ return {
1590
+ ...processing,
1591
+ batch: rewriteBatch(processing.batch, `WorkMailbox ${key} processing batch`),
1592
+ ...(processing.executionRef === undefined
1593
+ ? {}
1594
+ : { executionRef: rewriteRef(processing.executionRef) })
1595
+ };
1596
+ };
1597
+ const rewriteDelivery = (raw) => {
1598
+ if (raw === null)
1599
+ return null;
1600
+ const delivery = asObject(raw, `WorkMailbox ${key} input delivery`);
1601
+ return {
1602
+ ...delivery,
1603
+ batch: rewriteBatch(delivery.batch, `WorkMailbox ${key} input delivery batch`),
1604
+ executionRef: rewriteRef(delivery.executionRef)
1605
+ };
1606
+ };
1607
+ const pending = asObject(mailbox.pending, `WorkMailbox ${key} pending lanes`);
1608
+ return {
1609
+ ...mailbox,
1610
+ processing: rewriteProcessing(mailbox.processing),
1611
+ pending: {
1612
+ ...pending,
1613
+ normal: pending.normal === null
1614
+ ? null
1615
+ : rewriteBatch(pending.normal, `WorkMailbox ${key} normal batch`),
1616
+ userCorrection: pending.userCorrection === null
1617
+ ? null
1618
+ : rewriteBatch(pending.userCorrection, `WorkMailbox ${key} correction batch`)
1619
+ },
1620
+ inputDelivery: rewriteDelivery(mailbox.inputDelivery)
1621
+ };
1622
+ }
1244
1623
  /**
1245
1624
  * The taskWake record family is introduced alongside StoredTask v17. The
1246
1625
  * compatible step supplies the empty map and high-water mark; this step only
@@ -327,7 +327,8 @@ CREATE TABLE IF NOT EXISTS events (
327
327
  );
328
328
  CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(task_id, type, occurred_at);
329
329
 
330
- -- Per-task scheduler projections (leaderFailure, operatorNotification).
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.
331
332
  CREATE TABLE IF NOT EXISTS task_projections (
332
333
  task_id TEXT NOT NULL,
333
334
  kind TEXT NOT NULL CHECK (kind IN ('leader-failure','operator-notification')),
@@ -930,6 +931,12 @@ CREATE TABLE IF NOT EXISTS context_snapshots (
930
931
  CREATE UNIQUE INDEX IF NOT EXISTS idx_context_snapshots_scope_sequence
931
932
  ON context_snapshots(task_id, scope, COALESCE(scope_ref, ''), sequence);
932
933
  `;
934
+ /**
935
+ * The single forward migration history for current SQLite Homes. New durable
936
+ * layout, aggregate, and record changes belong here and must be expressible as
937
+ * one atomic in-place transaction. The separate logical migration registry is
938
+ * only the bridge from valid pre-SQLite Homes and is allowed to rebuild them.
939
+ */
933
940
  const MIGRATIONS = [
934
941
  { version: 1, axis: "layout", sql: MIGRATION_1_SQL },
935
942
  { version: 2, axis: "record", recordKind: "durableJob+capability-grant+release-workflow", sql: MIGRATION_2_SQL },
@@ -986,10 +993,13 @@ const SCHEMA_MIGRATIONS_SQL = `
986
993
  * layout migrations. A database with any sqlite_master object is therefore
987
994
  * diagnosed as corrupt/partially initialized and left untouched.
988
995
  */
989
- function ensureMigrationLedger(db) {
996
+ function ensureMigrationLedger(db, mode) {
990
997
  const objects = db.prepare("SELECT type, name FROM sqlite_master WHERE name IS NOT NULL").all();
991
998
  const ledger = objects.find(({ name }) => name === "schema_migrations");
992
999
  if (ledger === undefined) {
1000
+ if (mode === "validate") {
1001
+ throw new SqliteSchemaMigrationError("schema_migrations ledger is missing from an existing database");
1002
+ }
993
1003
  if (objects.length !== 0) {
994
1004
  throw new SqliteSchemaMigrationError("schema_migrations ledger is missing from a non-empty database");
995
1005
  }
@@ -1134,34 +1144,67 @@ function validatePendingProjectionBackfillSources(db) {
1134
1144
  }
1135
1145
  }
1136
1146
  /**
1137
- * Apply pending migrations idempotently inside transactions.
1138
- *
1139
- * Each migration runs in its own transaction: the DDL and the
1140
- * `schema_migrations` bookkeeping commit atomically, so a crash mid-migration
1141
- * rolls back and the next open re-applies cleanly. Re-running on a current
1142
- * database performs no work (every version is already recorded).
1147
+ * Inspect a database's checksummed migration prefix without changing it.
1148
+ * Pending versions are a supported upgrade state; malformed, gapped, changed,
1149
+ * or future ledger entries remain explicit integrity failures.
1143
1150
  */
1144
- export function migrateSqliteSchema(db) {
1145
- const ledgerWasCreated = ensureMigrationLedger(db);
1146
- // Validate the complete ledger before touching any pending migration. This
1147
- // prevents a manually altered or partially recorded ledger from silently
1148
- // skipping the partial-index/projection migrations added after a valid Home.
1151
+ export function inspectSqliteSchemaMigrations(db) {
1152
+ const ledgerWasCreated = ensureMigrationLedger(db, "validate");
1149
1153
  const applied = validateAppliedMigrations(db, ledgerWasCreated);
1150
- const newlyApplied = [];
1151
- for (const migration of MIGRATIONS) {
1152
- if (applied.has(migration.version))
1153
- continue;
1154
- const apply = db.transaction(() => {
1154
+ const pendingVersions = MIGRATIONS
1155
+ .filter((migration) => !applied.has(migration.version))
1156
+ .map((migration) => migration.version);
1157
+ if (pendingVersions.length === 0)
1158
+ validateSchemaObjects(db);
1159
+ const current = MIGRATIONS[applied.size - 1];
1160
+ const target = MIGRATIONS.at(-1);
1161
+ if (current === undefined) {
1162
+ throw new SqliteSchemaMigrationError("schema_migrations ledger has no current head");
1163
+ }
1164
+ return {
1165
+ currentVersion: applied.size,
1166
+ currentChecksum: checksum(current.sql),
1167
+ targetVersion: SQLITE_SCHEMA_VERSION,
1168
+ targetChecksum: checksum(target.sql),
1169
+ pendingVersions
1170
+ };
1171
+ }
1172
+ /**
1173
+ * Apply or validate schema migrations without letting an ordinary open mutate
1174
+ * an existing authoritative database.
1175
+ *
1176
+ * In `apply` mode every pending DDL/data step and every ledger row runs in one
1177
+ * outer transaction. The database therefore advances to the release version
1178
+ * as one commit or remains entirely at its previous version. `validate` mode
1179
+ * rejects a pending version before executing any migration.
1180
+ */
1181
+ export function migrateSqliteSchema(db, options) {
1182
+ const migrate = () => {
1183
+ const ledgerWasCreated = ensureMigrationLedger(db, options.mode);
1184
+ // Validate the complete ledger before touching any pending migration. This
1185
+ // prevents a manually altered or partially recorded ledger from silently
1186
+ // skipping a later schema/data step.
1187
+ const applied = validateAppliedMigrations(db, ledgerWasCreated);
1188
+ const pending = MIGRATIONS.filter((migration) => !applied.has(migration.version));
1189
+ if (options.mode === "validate" && pending.length > 0) {
1190
+ throw new SqliteSchemaMigrationError(`pending SQLite schema migration ${pending[0].version}; `
1191
+ + "run the explicit storage upgrade before opening this database", "admission");
1192
+ }
1193
+ const newlyApplied = [];
1194
+ for (const migration of pending) {
1155
1195
  if (migration.version === 13)
1156
1196
  validatePendingProjectionBackfillSources(db);
1157
1197
  db.exec(migration.sql);
1158
1198
  db.prepare(`INSERT INTO schema_migrations (version, axis, record_kind, applied_at, checksum)
1159
1199
  VALUES (?, ?, ?, ?, ?)`).run(migration.version, migration.axis, migration.recordKind ?? null, new Date().toISOString(), checksum(migration.sql));
1160
- });
1161
- apply();
1162
- newlyApplied.push(migration.version);
1163
- }
1164
- validateSchemaObjects(db);
1200
+ newlyApplied.push(migration.version);
1201
+ }
1202
+ validateSchemaObjects(db);
1203
+ return newlyApplied;
1204
+ };
1205
+ const newlyApplied = options.mode === "apply" && !db.inTransaction
1206
+ ? db.transaction(migrate)()
1207
+ : migrate();
1165
1208
  return { applied: newlyApplied, version: SQLITE_SCHEMA_VERSION };
1166
1209
  }
1167
1210
  /** 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, 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 = {}) {
@@ -143,16 +144,34 @@ export class SqliteTaskStore {
143
144
  this.#migration = _options.migration ?? false;
144
145
  mkdirSync(rootDir, { recursive: true, mode: 0o700 });
145
146
  const filename = _options.databaseFilename ?? "yui.db";
146
- this.#db = new Database(join(rootDir, filename));
147
- // §4.1 / §9: WAL, no fsync weakening, FKs on, busy timeout for CLI contention.
148
- this.#db.pragma("journal_mode = WAL");
149
- this.#db.pragma("synchronous = FULL");
150
- this.#db.pragma("foreign_keys = ON");
151
- this.#db.pragma("busy_timeout = 5000");
152
- this.#db.pragma("wal_autocheckpoint = 1000");
153
- migrateSqliteSchema(this.#db);
154
- this.#seedHomeMeta();
155
- this.#seedConfig();
147
+ const databasePath = join(rootDir, filename);
148
+ const databaseExisted = existsSync(databasePath);
149
+ if (this.#migration && filename === "yui.db") {
150
+ throw new SqliteSchemaMigrationError("offline migration mode cannot open the authoritative yui.db", "admission");
151
+ }
152
+ this.#db = new Database(databasePath);
153
+ try {
154
+ // §4.1 / §9: WAL, no fsync weakening, FKs on, busy timeout for CLI contention.
155
+ this.#db.pragma("journal_mode = WAL");
156
+ this.#db.pragma("synchronous = FULL");
157
+ this.#db.pragma("foreign_keys = ON");
158
+ this.#db.pragma("busy_timeout = 5000");
159
+ this.#db.pragma("wal_autocheckpoint = 1000");
160
+ migrateSqliteSchema(this.#db, {
161
+ mode: this.#migration || !databaseExisted ? "apply" : "validate"
162
+ });
163
+ const schema = inspectSqliteSchemaMigrations(this.#db);
164
+ this.#openedSchemaHead = {
165
+ version: schema.currentVersion,
166
+ checksum: schema.currentChecksum
167
+ };
168
+ this.#seedHomeMeta();
169
+ this.#seedConfig();
170
+ }
171
+ catch (error) {
172
+ this.#db.close();
173
+ throw error;
174
+ }
156
175
  }
157
176
  rootDirectory() { return this.#rootDir; }
158
177
  /** Close the underlying database connection. */
@@ -202,6 +221,13 @@ export class SqliteTaskStore {
202
221
  if (this.#migration)
203
222
  return;
204
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
+ }
205
231
  }
206
232
  #bumpRevision() {
207
233
  this.#db.prepare("UPDATE home_meta SET revision = revision + 1, updated_at = ? WHERE id = 1").run(this.#now());
@@ -217,9 +243,12 @@ export class SqliteTaskStore {
217
243
  this.#dirty = true;
218
244
  return result;
219
245
  }
220
- this.#prepareWrite();
221
246
  this.#begin();
222
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();
223
252
  const result = fn();
224
253
  if (!this.#migration) {
225
254
  this.#bumpRevision();
@@ -238,9 +267,12 @@ export class SqliteTaskStore {
238
267
  return run(this);
239
268
  this.#begin();
240
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();
241
274
  const result = run(this);
242
275
  if (this.#dirty) {
243
- this.#prepareWrite();
244
276
  if (options?.requestId !== undefined) {
245
277
  this.#insertOutbox(options.requestId, options.outboxCommand ?? null);
246
278
  }
@@ -265,9 +297,9 @@ export class SqliteTaskStore {
265
297
  async transactionAsync(execute) {
266
298
  if (this.#inTransaction)
267
299
  return execute(this);
268
- this.#prepareWrite();
269
300
  this.#begin();
270
301
  try {
302
+ this.#prepareWrite();
271
303
  const result = await execute(this);
272
304
  if (this.#dirty)
273
305
  this.#bumpRevision();
@@ -2048,15 +2080,6 @@ export class SqliteTaskStore {
2048
2080
  clearLeaderFailure(taskId) {
2049
2081
  this.#clearProjection(taskId, "leader-failure");
2050
2082
  }
2051
- getOperatorNotification(taskId) {
2052
- return this.#getProjection(taskId, "operator-notification");
2053
- }
2054
- saveOperatorNotification(notification) {
2055
- this.#saveProjection(notification.taskId, "operator-notification", notification);
2056
- }
2057
- clearOperatorNotification(taskId) {
2058
- this.#clearProjection(taskId, "operator-notification");
2059
- }
2060
2083
  // -- pending wakeups (leader-role work-mailbox projection, mirrors taskStore.ts) --
2061
2084
  getPendingWakeup(taskId) {
2062
2085
  return pendingWakeupProjection(this.getWorkMailbox({ kind: "role", taskId, roleName: "leader" }));