@zq-silk/yui 0.6.4 → 0.6.6

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.
@@ -27,7 +27,7 @@ export const SQLITE_LAYOUT_VERSION = 7;
27
27
  /** The aggregate version of the normalized SQLite schema. */
28
28
  export const SQLITE_AGGREGATE_VERSION = 1;
29
29
  /** The current schema migration version. */
30
- export const SQLITE_SCHEMA_VERSION = 9;
30
+ export const SQLITE_SCHEMA_VERSION = 13;
31
31
  /** Telemetry retention bounds (§4.4). Open question 3 in §11; defaults from the design. */
32
32
  export const TELEMETRY_KEEP_PER_GENERATION = 200;
33
33
  export const TELEMETRY_RUN_CAP = 50_000;
@@ -608,6 +608,170 @@ CREATE TABLE IF NOT EXISTS gate_artifact_logs (
608
608
  FOREIGN KEY (artifact_key) REFERENCES gate_artifacts(key) ON DELETE CASCADE
609
609
  );
610
610
  `;
611
+ /**
612
+ * Migration 10: bounded ready-mailbox lookup for the Controller hot path.
613
+ *
614
+ * Historical empty mailboxes remain durable, but only mailboxes with a
615
+ * processing or pending batch require scheduling. The partial index contains
616
+ * exactly that unsettled set in target-key order, so the Controller's recovery
617
+ * query is O(log H + ready) instead of scanning every historical mailbox.
618
+ */
619
+ const MIGRATION_10_SQL = `
620
+ CREATE INDEX IF NOT EXISTS idx_mailboxes_ready
621
+ ON mailboxes(target_key)
622
+ WHERE processing IS NOT NULL OR pending IS NOT NULL;
623
+ `;
624
+ /**
625
+ * Migration 11: bounded current-Session projection for runtime cleanup.
626
+ *
627
+ * RoleSessionSet payloads remain authoritative. This table contains only the
628
+ * current active Agent Session while it is non-stopped, so terminal Session
629
+ * history cannot enlarge Controller cleanup discovery. Runtime writes maintain
630
+ * it in the same transaction as the source payload; this migration performs
631
+ * the one-time deterministic backfill for existing layout-7 Homes.
632
+ */
633
+ const MIGRATION_11_SQL = `
634
+ CREATE TABLE IF NOT EXISTS runtime_session_candidates (
635
+ scope TEXT NOT NULL CHECK (scope IN ('task','global')),
636
+ task_id TEXT NOT NULL,
637
+ role_name TEXT NOT NULL,
638
+ agent_id TEXT NOT NULL,
639
+ adapter_id TEXT NOT NULL,
640
+ native_session_id TEXT NOT NULL,
641
+ launch_id TEXT,
642
+ status TEXT NOT NULL CHECK (status IN ('reserved','ready','running','broken')),
643
+ session_updated_at TEXT NOT NULL,
644
+ cleanup_required INTEGER NOT NULL CHECK (cleanup_required IN (0,1)),
645
+ PRIMARY KEY (scope, task_id, role_name),
646
+ CHECK (
647
+ (scope = 'task' AND length(task_id) > 0)
648
+ OR (scope = 'global' AND task_id = '')
649
+ ),
650
+ CHECK (
651
+ cleanup_required = CASE
652
+ WHEN status NOT IN ('stopped','broken')
653
+ AND (status = 'running' OR launch_id IS NOT NULL)
654
+ THEN 1 ELSE 0
655
+ END
656
+ )
657
+ );
658
+
659
+ CREATE INDEX IF NOT EXISTS idx_runtime_session_cleanup_required
660
+ ON runtime_session_candidates(scope, task_id, role_name)
661
+ WHERE cleanup_required = 1;
662
+
663
+ INSERT INTO runtime_session_candidates (
664
+ scope, task_id, role_name, agent_id, adapter_id, native_session_id,
665
+ launch_id, status, session_updated_at, cleanup_required
666
+ )
667
+ SELECT
668
+ 'task', source.task_id, source.role_name,
669
+ json_extract(active.value, '$.agentId'),
670
+ json_extract(active.value, '$.adapterId'),
671
+ json_extract(active.value, '$.nativeSessionId'),
672
+ json_extract(active.value, '$.launchId'),
673
+ json_extract(active.value, '$.status'),
674
+ json_extract(active.value, '$.updatedAt'),
675
+ CASE
676
+ WHEN json_extract(active.value, '$.status') NOT IN ('stopped','broken')
677
+ AND (
678
+ json_extract(active.value, '$.status') = 'running'
679
+ OR json_type(active.value, '$.launchId') = 'text'
680
+ )
681
+ THEN 1 ELSE 0
682
+ END
683
+ FROM role_session_sets AS source
684
+ JOIN json_each(source.payload, '$.sessions') AS active
685
+ ON active.key = json_extract(source.payload, '$.activeAgentId')
686
+ WHERE json_extract(active.value, '$.status') <> 'stopped';
687
+
688
+ INSERT INTO runtime_session_candidates (
689
+ scope, task_id, role_name, agent_id, adapter_id, native_session_id,
690
+ launch_id, status, session_updated_at, cleanup_required
691
+ )
692
+ SELECT
693
+ 'global', '', source.name,
694
+ json_extract(active.value, '$.agentId'),
695
+ json_extract(active.value, '$.adapterId'),
696
+ json_extract(active.value, '$.nativeSessionId'),
697
+ json_extract(active.value, '$.launchId'),
698
+ json_extract(active.value, '$.status'),
699
+ json_extract(active.value, '$.updatedAt'),
700
+ CASE
701
+ WHEN json_extract(active.value, '$.status') NOT IN ('stopped','broken')
702
+ AND (
703
+ json_extract(active.value, '$.status') = 'running'
704
+ OR json_type(active.value, '$.launchId') = 'text'
705
+ )
706
+ THEN 1 ELSE 0
707
+ END
708
+ FROM global_role_session_sets AS source
709
+ JOIN json_each(source.payload, '$.sessions') AS active
710
+ ON active.key = json_extract(source.payload, '$.activeAgentId')
711
+ WHERE json_extract(active.value, '$.status') <> 'stopped';
712
+ `;
713
+ /**
714
+ * Migration 12: bounded open-InputRequest lookup for Controller deadlines.
715
+ *
716
+ * The baseline index's historical predicate predates the current
717
+ * open/answered/cancelled status contract and retains terminal rows. This
718
+ * partial index contains only the live InputRequest set, so deadline arming and
719
+ * targeted auto-resolution never scan terminal request history.
720
+ */
721
+ const MIGRATION_12_SQL = `
722
+ CREATE INDEX IF NOT EXISTS idx_input_requests_open_hot
723
+ ON input_requests(task_id, input_id)
724
+ WHERE status = 'open';
725
+ `;
726
+ /**
727
+ * Migration 13: durable pending native Turn-completion hot projection.
728
+ *
729
+ * A Task Role may have an unsettled Turn completion after the native Session
730
+ * has ended. That completion is independent lifecycle state, so it cannot be
731
+ * discovered through the current-Session projection (which intentionally
732
+ * removes stopped Sessions). Keep one typed row per Task Role and maintain it
733
+ * in the same transaction as `role_session_sets`; deadline discovery can then
734
+ * read this table without parsing a RoleSessionSet or its historical Sessions.
735
+ */
736
+ const MIGRATION_13_SQL = `
737
+ CREATE TABLE IF NOT EXISTS pending_runtime_turn_completions (
738
+ task_id TEXT NOT NULL,
739
+ role_name TEXT NOT NULL,
740
+ schema_version INTEGER NOT NULL CHECK (schema_version = 1),
741
+ agent_id TEXT NOT NULL,
742
+ native_session_id TEXT NOT NULL,
743
+ turn_id TEXT NOT NULL,
744
+ run_id TEXT NOT NULL,
745
+ summary TEXT NOT NULL,
746
+ observed_at TEXT NOT NULL,
747
+ due_at TEXT NOT NULL,
748
+ PRIMARY KEY (task_id, role_name),
749
+ CHECK (due_at >= observed_at),
750
+ FOREIGN KEY (task_id, role_name)
751
+ REFERENCES role_session_sets(task_id, role_name) ON DELETE CASCADE
752
+ );
753
+
754
+ CREATE INDEX IF NOT EXISTS idx_pending_runtime_turn_completions_due
755
+ ON pending_runtime_turn_completions(task_id, due_at, role_name);
756
+
757
+ INSERT INTO pending_runtime_turn_completions (
758
+ task_id, role_name, schema_version, agent_id, native_session_id,
759
+ turn_id, run_id, summary, observed_at, due_at
760
+ )
761
+ SELECT
762
+ source.task_id,
763
+ source.role_name,
764
+ json_extract(source.payload, '$.pendingTurnCompletion.schemaVersion'),
765
+ json_extract(source.payload, '$.pendingTurnCompletion.agentId'),
766
+ json_extract(source.payload, '$.pendingTurnCompletion.nativeSessionId'),
767
+ json_extract(source.payload, '$.pendingTurnCompletion.turnId'),
768
+ json_extract(source.payload, '$.pendingTurnCompletion.runId'),
769
+ json_extract(source.payload, '$.pendingTurnCompletion.summary'),
770
+ json_extract(source.payload, '$.pendingTurnCompletion.observedAt'),
771
+ json_extract(source.payload, '$.pendingTurnCompletion.dueAt')
772
+ FROM role_session_sets AS source
773
+ WHERE json_type(source.payload, '$.pendingTurnCompletion') = 'object';
774
+ `;
611
775
  const MIGRATIONS = [
612
776
  { version: 1, axis: "layout", sql: MIGRATION_1_SQL },
613
777
  { version: 2, axis: "record", recordKind: "durableJob+capability-grant+release-workflow", sql: MIGRATION_2_SQL },
@@ -617,11 +781,197 @@ const MIGRATIONS = [
617
781
  { version: 6, axis: "record", recordKind: "reviewFinding", sql: MIGRATION_6_SQL },
618
782
  { version: 7, axis: "record", recordKind: "sessionOwner", sql: MIGRATION_7_SQL },
619
783
  { version: 8, axis: "record", recordKind: "resource-registry", sql: MIGRATION_8_SQL },
620
- { version: 9, axis: "record", recordKind: "gateArtifact", sql: MIGRATION_9_SQL }
784
+ { version: 9, axis: "record", recordKind: "gateArtifact", sql: MIGRATION_9_SQL },
785
+ { version: 10, axis: "layout", sql: MIGRATION_10_SQL },
786
+ { version: 11, axis: "layout", sql: MIGRATION_11_SQL },
787
+ { version: 12, axis: "layout", sql: MIGRATION_12_SQL },
788
+ { version: 13, axis: "layout", sql: MIGRATION_13_SQL }
789
+ ];
790
+ /** Current hot-path indexes whose absence would invalidate a current Home. */
791
+ const REQUIRED_SCHEMA_INDEXES = [
792
+ "idx_mailboxes_ready",
793
+ "idx_runtime_session_cleanup_required",
794
+ "idx_input_requests_open_hot",
795
+ "idx_pending_runtime_turn_completions_due"
621
796
  ];
622
797
  function checksum(sql) {
623
798
  return createHash("sha256").update(sql).digest("hex");
624
799
  }
800
+ /**
801
+ * A SQLite Home is only safe to open when its migration ledger proves exactly
802
+ * which schema definition was applied. The ledger is durable metadata, not a
803
+ * best-effort cache: a missing row, a changed checksum, or an unknown version
804
+ * must stop startup before any pending migration is run.
805
+ */
806
+ export class SqliteSchemaMigrationError extends Error {
807
+ constructor(detail, subject = "metadata") {
808
+ super(`SQLite schema migration ${subject} is invalid: ${detail}`);
809
+ this.name = "SqliteSchemaMigrationError";
810
+ }
811
+ }
812
+ const SCHEMA_MIGRATIONS_SQL = `
813
+ CREATE TABLE schema_migrations (
814
+ version INTEGER PRIMARY KEY,
815
+ axis TEXT NOT NULL CHECK (axis IN ('layout','aggregate','record')),
816
+ record_kind TEXT,
817
+ applied_at TEXT NOT NULL,
818
+ checksum TEXT NOT NULL
819
+ )
820
+ `;
821
+ /**
822
+ * Admit the migration ledger only for a genuinely empty SQLite database.
823
+ * Recreating an absent ledger on top of existing Yui tables would make the
824
+ * migration runner mistake a live Home for a fresh one and replay destructive
825
+ * layout migrations. A database with any sqlite_master object is therefore
826
+ * diagnosed as corrupt/partially initialized and left untouched.
827
+ */
828
+ function ensureMigrationLedger(db) {
829
+ const objects = db.prepare("SELECT type, name FROM sqlite_master WHERE name IS NOT NULL").all();
830
+ const ledger = objects.find(({ name }) => name === "schema_migrations");
831
+ if (ledger === undefined) {
832
+ if (objects.length !== 0) {
833
+ throw new SqliteSchemaMigrationError("schema_migrations ledger is missing from a non-empty database");
834
+ }
835
+ db.exec(SCHEMA_MIGRATIONS_SQL);
836
+ return true;
837
+ }
838
+ if (ledger.type !== "table") {
839
+ throw new SqliteSchemaMigrationError(`schema_migrations has type ${String(ledger.type)} instead of table`);
840
+ }
841
+ return false;
842
+ }
843
+ /** Validate the applied prefix and return its versions for the migration loop. */
844
+ function validateAppliedMigrations(db, ledgerWasCreated) {
845
+ const expected = new Map(MIGRATIONS.map((migration) => [migration.version, migration]));
846
+ const rows = db.prepare("SELECT version, axis, record_kind, checksum FROM schema_migrations ORDER BY version").all();
847
+ if (rows.length === 0 && !ledgerWasCreated) {
848
+ throw new SqliteSchemaMigrationError("schema_migrations ledger is empty in an existing database");
849
+ }
850
+ const applied = new Set();
851
+ for (const row of rows) {
852
+ if (!Number.isInteger(row.version) || row.version < 1) {
853
+ throw new SqliteSchemaMigrationError(`invalid migration version ${String(row.version)}`);
854
+ }
855
+ const version = row.version;
856
+ const migration = expected.get(version);
857
+ if (migration === undefined) {
858
+ throw new SqliteSchemaMigrationError(`unknown migration version ${version}`);
859
+ }
860
+ if (applied.has(version)) {
861
+ throw new SqliteSchemaMigrationError(`duplicate migration version ${version}`);
862
+ }
863
+ applied.add(version);
864
+ const expectedRecordKind = migration.recordKind ?? null;
865
+ if (row.axis !== migration.axis) {
866
+ throw new SqliteSchemaMigrationError(`migration ${version} axis ${String(row.axis)} does not match ${migration.axis}`);
867
+ }
868
+ if (row.record_kind !== expectedRecordKind) {
869
+ throw new SqliteSchemaMigrationError(`migration ${version} record_kind ${String(row.record_kind)} does not match ${String(expectedRecordKind)}`);
870
+ }
871
+ const expectedChecksum = checksum(migration.sql);
872
+ if (row.checksum !== expectedChecksum) {
873
+ throw new SqliteSchemaMigrationError(`migration ${version} checksum ${String(row.checksum)} does not match current definition`);
874
+ }
875
+ }
876
+ const versions = [...applied].sort((left, right) => left - right);
877
+ for (let index = 0; index < versions.length; index += 1) {
878
+ const expectedVersion = index + 1;
879
+ if (versions[index] !== expectedVersion) {
880
+ throw new SqliteSchemaMigrationError(`migration ledger has a gap before version ${versions[index]}`);
881
+ }
882
+ }
883
+ return applied;
884
+ }
885
+ /**
886
+ * Validate the physical objects promised by the migration ledger. Ledger
887
+ * rows can be forged independently of SQLite's schema, so a complete and
888
+ * checksummed ledger is not enough to authorize startup when an object was
889
+ * manually removed or replaced.
890
+ */
891
+ function validateSchemaObjects(db) {
892
+ const objects = new Map(db.prepare("SELECT type, name FROM sqlite_master WHERE name IS NOT NULL").all().flatMap(({ type, name }) => (typeof type === "string" && typeof name === "string" ? [[name, type]] : [])));
893
+ for (const table of SQLITE_SCHEMA_TABLES) {
894
+ if (objects.get(table) !== "table") {
895
+ throw new SqliteSchemaMigrationError(`required table '${table}' is missing or has the wrong type`, "schema object");
896
+ }
897
+ }
898
+ for (const index of REQUIRED_SCHEMA_INDEXES) {
899
+ if (objects.get(index) !== "index") {
900
+ throw new SqliteSchemaMigrationError(`required index '${index}' is missing or has the wrong type`, "schema object");
901
+ }
902
+ }
903
+ }
904
+ /** Validate migration 13's source invariant before creating its hot table. */
905
+ function validatePendingProjectionBackfillSources(db) {
906
+ const existing = db.prepare(`SELECT name
907
+ FROM sqlite_master
908
+ WHERE name IN (
909
+ 'pending_runtime_turn_completions',
910
+ 'idx_pending_runtime_turn_completions_due'
911
+ )
912
+ LIMIT 1`).get();
913
+ if (existing !== undefined) {
914
+ throw new SqliteSchemaMigrationError(`schema object '${existing.name}' exists before migration 13 is recorded`, "backfill");
915
+ }
916
+ const mismatch = db.prepare(`SELECT task_id, role_name
917
+ FROM role_session_sets
918
+ WHERE COALESCE(json_type(payload, '$.pendingTurnCompletion'), 'missing')
919
+ NOT IN ('null', 'object')
920
+ OR (
921
+ json_type(payload, '$.pendingTurnCompletion') = 'object'
922
+ AND (
923
+ json_extract(payload, '$.owner.scope') <> 'task'
924
+ OR json_extract(payload, '$.owner.taskId') IS NULL
925
+ OR json_extract(payload, '$.owner.taskId') <> task_id
926
+ OR json_extract(payload, '$.owner.roleName') IS NULL
927
+ OR json_extract(payload, '$.owner.roleName') <> role_name
928
+ OR json_extract(payload, '$.pendingTurnCompletion.taskId') IS NULL
929
+ OR json_extract(payload, '$.pendingTurnCompletion.taskId') <> task_id
930
+ OR json_extract(payload, '$.pendingTurnCompletion.roleName') IS NULL
931
+ OR json_extract(payload, '$.pendingTurnCompletion.roleName') <> role_name
932
+ OR json_extract(payload, '$.pendingTurnCompletion.schemaVersion') IS NOT 1
933
+ OR COALESCE(json_type(payload, '$.pendingTurnCompletion.agentId'), 'missing') <> 'text'
934
+ OR COALESCE(json_type(payload, '$.pendingTurnCompletion.nativeSessionId'), 'missing') <> 'text'
935
+ OR COALESCE(json_type(payload, '$.pendingTurnCompletion.turnId'), 'missing') <> 'text'
936
+ OR COALESCE(json_type(payload, '$.pendingTurnCompletion.runId'), 'missing') <> 'text'
937
+ OR COALESCE(json_type(payload, '$.pendingTurnCompletion.summary'), 'missing') <> 'text'
938
+ OR COALESCE(json_type(payload, '$.pendingTurnCompletion.observedAt'), 'missing') <> 'text'
939
+ OR COALESCE(json_type(payload, '$.pendingTurnCompletion.dueAt'), 'missing') <> 'text'
940
+ OR COALESCE(json_type(payload, '$.inFlight'), 'missing') <> 'object'
941
+ OR COALESCE(json_type(payload, '$.activeAgentId'), 'missing') <> 'text'
942
+ OR COALESCE(json_type(payload, '$.inFlight.agentId'), 'missing') <> 'text'
943
+ OR COALESCE(json_type(payload, '$.inFlight.runId'), 'missing') <> 'text'
944
+ OR json_extract(payload, '$.activeAgentId')
945
+ <> json_extract(payload, '$.pendingTurnCompletion.agentId')
946
+ OR json_extract(payload, '$.inFlight.agentId')
947
+ <> json_extract(payload, '$.pendingTurnCompletion.agentId')
948
+ OR json_extract(payload, '$.inFlight.runId')
949
+ <> json_extract(payload, '$.pendingTurnCompletion.runId')
950
+ OR COALESCE(json_type(payload, '$.inFlight.pushedAt'), 'missing') <> 'text'
951
+ OR COALESCE(
952
+ json_type(
953
+ payload,
954
+ '$.sessions.' || json_quote(
955
+ json_extract(payload, '$.pendingTurnCompletion.agentId')
956
+ ) || '.nativeSessionId'
957
+ ),
958
+ 'missing'
959
+ ) <> 'text'
960
+ OR json_extract(
961
+ payload,
962
+ '$.sessions.' || json_quote(
963
+ json_extract(payload, '$.pendingTurnCompletion.agentId')
964
+ ) || '.nativeSessionId'
965
+ ) <> json_extract(payload, '$.pendingTurnCompletion.nativeSessionId')
966
+ OR json_extract(payload, '$.pendingTurnCompletion.dueAt')
967
+ < json_extract(payload, '$.pendingTurnCompletion.observedAt')
968
+ )
969
+ )
970
+ LIMIT 1`).get();
971
+ if (mismatch !== undefined) {
972
+ throw new SqliteSchemaMigrationError(`pending Turn completion source is invalid for ${mismatch.task_id}/${mismatch.role_name}`, "backfill");
973
+ }
974
+ }
625
975
  /**
626
976
  * Apply pending migrations idempotently inside transactions.
627
977
  *
@@ -631,21 +981,18 @@ function checksum(sql) {
631
981
  * database performs no work (every version is already recorded).
632
982
  */
633
983
  export function migrateSqliteSchema(db) {
634
- db.exec(`
635
- CREATE TABLE IF NOT EXISTS schema_migrations (
636
- version INTEGER PRIMARY KEY,
637
- axis TEXT NOT NULL CHECK (axis IN ('layout','aggregate','record')),
638
- record_kind TEXT,
639
- applied_at TEXT NOT NULL,
640
- checksum TEXT NOT NULL
641
- )
642
- `);
643
- const applied = new Set(db.prepare("SELECT version FROM schema_migrations").all().map((row) => row.version));
984
+ const ledgerWasCreated = ensureMigrationLedger(db);
985
+ // Validate the complete ledger before touching any pending migration. This
986
+ // prevents a manually altered or partially recorded ledger from silently
987
+ // skipping the partial-index/projection migrations added after a valid Home.
988
+ const applied = validateAppliedMigrations(db, ledgerWasCreated);
644
989
  const newlyApplied = [];
645
990
  for (const migration of MIGRATIONS) {
646
991
  if (applied.has(migration.version))
647
992
  continue;
648
993
  const apply = db.transaction(() => {
994
+ if (migration.version === 13)
995
+ validatePendingProjectionBackfillSources(db);
649
996
  db.exec(migration.sql);
650
997
  db.prepare(`INSERT INTO schema_migrations (version, axis, record_kind, applied_at, checksum)
651
998
  VALUES (?, ?, ?, ?, ?)`).run(migration.version, migration.axis, migration.recordKind ?? null, new Date().toISOString(), checksum(migration.sql));
@@ -653,8 +1000,8 @@ export function migrateSqliteSchema(db) {
653
1000
  apply();
654
1001
  newlyApplied.push(migration.version);
655
1002
  }
656
- const version = MIGRATIONS.reduce((max, m) => Math.max(max, m.version), 0);
657
- return { applied: newlyApplied, version };
1003
+ validateSchemaObjects(db);
1004
+ return { applied: newlyApplied, version: SQLITE_SCHEMA_VERSION };
658
1005
  }
659
1006
  /** The names of every table the schema creates (for tests/introspection). */
660
1007
  export const SQLITE_SCHEMA_TABLES = [
@@ -699,6 +1046,8 @@ export const SQLITE_SCHEMA_TABLES = [
699
1046
  "capability_grants",
700
1047
  "release_workflows",
701
1048
  "session_owners",
1049
+ "runtime_session_candidates",
1050
+ "pending_runtime_turn_completions",
702
1051
  "resource_registry",
703
1052
  "gate_artifacts",
704
1053
  "gate_artifact_logs"