@fungi.computer/shiitake 0.1.0 → 0.3.0

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 (112) hide show
  1. package/README.md +155 -34
  2. package/dist/agent-control.d.ts +78 -11
  3. package/dist/agent-control.js +273 -70
  4. package/dist/client-entry.d.ts +15 -8
  5. package/dist/client-entry.js +7 -5
  6. package/dist/client-wire.d.ts +24 -8
  7. package/dist/client-wire.js +53 -11
  8. package/dist/client.d.ts +56 -11
  9. package/dist/client.js +1 -1
  10. package/dist/cloudflare.d.ts +42 -23
  11. package/dist/cloudflare.js +76 -54
  12. package/dist/cold-sessions.d.ts +28 -0
  13. package/dist/cold-sessions.js +1 -0
  14. package/dist/delegation.d.ts +214 -0
  15. package/dist/delegation.js +319 -0
  16. package/dist/effect.d.ts +1 -1
  17. package/dist/errors.d.ts +2 -2
  18. package/dist/errors.js +2 -2
  19. package/dist/extension-contract.d.ts +10 -2
  20. package/dist/extensions.d.ts +6 -2
  21. package/dist/fleet.d.ts +52 -34
  22. package/dist/fleet.js +101 -63
  23. package/dist/hosted.js +1 -0
  24. package/dist/http-routes.d.ts +106 -22
  25. package/dist/http-routes.js +18 -1
  26. package/dist/index.d.ts +3 -2
  27. package/dist/internal/accepted-run-executor.d.ts +5 -0
  28. package/dist/internal/accepted-run-executor.js +10 -4
  29. package/dist/internal/admission-door.d.ts +3 -0
  30. package/dist/internal/admission-door.js +12 -2
  31. package/dist/internal/client-boundary.js +7 -1
  32. package/dist/internal/compaction/compaction-slot.d.ts +36 -0
  33. package/dist/internal/compaction/compaction-slot.js +41 -0
  34. package/dist/internal/compaction/compaction.d.ts +7 -4
  35. package/dist/internal/compaction/compaction.js +59 -30
  36. package/dist/internal/compaction/session-compaction.d.ts +8 -16
  37. package/dist/internal/compaction/session-compaction.js +358 -119
  38. package/dist/internal/compaction/settings.js +5 -0
  39. package/dist/internal/compaction/utils.d.ts +19 -16
  40. package/dist/internal/compaction/utils.js +30 -84
  41. package/dist/internal/contracts.d.ts +40 -13
  42. package/dist/internal/coordinator.js +28 -10
  43. package/dist/internal/effect-client.d.ts +4 -2
  44. package/dist/internal/effect-client.js +29 -0
  45. package/dist/internal/effect-contracts.d.ts +17 -4
  46. package/dist/internal/extension-runtime.d.ts +5 -0
  47. package/dist/internal/extension-runtime.js +184 -89
  48. package/dist/internal/host-meta.d.ts +7 -0
  49. package/dist/internal/model-acquisition.js +7 -1
  50. package/dist/internal/model-preference.d.ts +2 -0
  51. package/dist/internal/model-preference.js +2 -1
  52. package/dist/internal/promise-facade.js +4 -16
  53. package/dist/internal/run-failure-reason.d.ts +10 -0
  54. package/dist/internal/run-failure-reason.js +19 -0
  55. package/dist/internal/run-scope.d.ts +4 -1
  56. package/dist/internal/run-scope.js +38 -5
  57. package/dist/internal/runtime-coordinator.d.ts +2 -1
  58. package/dist/internal/runtime-coordinator.js +65 -4
  59. package/dist/internal/runtime-core.js +1 -0
  60. package/dist/internal/runtime-reconciliation.js +42 -6
  61. package/dist/internal/session-context.d.ts +1 -2
  62. package/dist/internal/session-context.js +8 -30
  63. package/dist/internal/session-status.d.ts +2 -1
  64. package/dist/internal/session-status.js +4 -1
  65. package/dist/internal/woodstock/client-projection.d.ts +6 -5
  66. package/dist/internal/woodstock/client-projection.js +79 -53
  67. package/dist/internal/woodstock/cold-sessions.d.ts +4 -0
  68. package/dist/internal/woodstock/cold-sessions.js +90 -0
  69. package/dist/internal/woodstock/extension-store.d.ts +2 -0
  70. package/dist/internal/woodstock/extension-store.js +4 -0
  71. package/dist/internal/woodstock/fleet-store.d.ts +3 -3
  72. package/dist/internal/woodstock/fleet-store.js +16 -11
  73. package/dist/internal/woodstock/index.d.ts +11 -3
  74. package/dist/internal/woodstock/index.js +33 -14
  75. package/dist/internal/woodstock/progress.d.ts +14 -1
  76. package/dist/internal/woodstock/progress.js +97 -19
  77. package/dist/internal/woodstock/run-recovery.js +6 -4
  78. package/dist/internal/woodstock/session-attention.d.ts +1 -1
  79. package/dist/internal/woodstock/session-attention.js +2 -2
  80. package/dist/internal/woodstock/session-entry.d.ts +109 -42
  81. package/dist/internal/woodstock/session-entry.js +63 -20
  82. package/dist/internal/woodstock/session-inbox.js +2 -0
  83. package/dist/internal/woodstock/session-store.d.ts +104 -24
  84. package/dist/internal/woodstock/session-store.js +937 -246
  85. package/dist/internal/woodstock/storage-admission.d.ts +8 -8
  86. package/dist/internal/woodstock/storage-admission.js +8 -34
  87. package/dist/internal/woodstock/storage-conformance.d.ts +15 -0
  88. package/dist/internal/woodstock/storage-conformance.js +489 -0
  89. package/dist/model-preference.d.ts +2 -2
  90. package/dist/model-preference.js +2 -2
  91. package/dist/node.js +1 -1
  92. package/dist/remote-client.js +8 -1
  93. package/dist/session-attention.d.ts +8 -1
  94. package/dist/session-attention.js +6 -1
  95. package/dist/testing.js +1 -0
  96. package/docs/api/README.md +18 -16
  97. package/docs/api/agent-control.md +172 -2
  98. package/docs/api/client-entry.md +26 -10
  99. package/docs/api/client.md +153 -37
  100. package/docs/api/cloudflare.md +76 -23
  101. package/docs/api/delegation.md +485 -0
  102. package/docs/api/effect.md +20 -2
  103. package/docs/api/extensions.md +21 -9
  104. package/docs/api/fleet.md +149 -122
  105. package/docs/api/http.md +4 -1
  106. package/docs/api/index.md +116 -20
  107. package/docs/api/internal/woodstock/storage-conformance.md +81 -0
  108. package/docs/api/model-preference.md +17 -0
  109. package/docs/api/testing.md +16 -4
  110. package/package.json +26 -14
  111. package/dist/internal/browser-sessions.d.ts +0 -31
  112. package/dist/internal/browser-sessions.js +0 -173
@@ -56,6 +56,7 @@ const EntryRowSchema = Schema.Struct({
56
56
  data_json: Schema.String,
57
57
  timestamp,
58
58
  completed_at: Schema.NullOr(timestamp),
59
+ damaged: Schema.Literals([0, 1]),
59
60
  });
60
61
  const CommandAdmissionRowSchema = Schema.Struct({
61
62
  id: boundedId,
@@ -100,7 +101,6 @@ const admissionColumns = (alias) => [
100
101
  .join(", ");
101
102
  const NextSequenceRowSchema = Schema.Struct({ next_sequence: sequence });
102
103
  const EntryPresenceRowSchema = Schema.Struct({ present: Schema.Literal(1) });
103
- const decodeSessionRow = Schema.decodeUnknownResult(SessionRowSchema, closed);
104
104
  const decodeSessionCreationReceiptRow = Schema.decodeUnknownResult(SessionCreationReceiptRowSchema, closed);
105
105
  const decodeSessionSummaryRow = Schema.decodeUnknownResult(SessionSummaryRowSchema, closed);
106
106
  const decodeSessionIdRow = Schema.decodeUnknownResult(SessionIdRowSchema, closed);
@@ -116,9 +116,29 @@ const SummaryLineageSchema = Schema.Struct({
116
116
  const decodeSummaryLineage = Schema.decodeUnknownResult(SummaryLineageSchema, closed);
117
117
  const decodeEntryRow = Schema.decodeUnknownResult(EntryRowSchema, closed);
118
118
  const decodeCommandAdmissionRow = Schema.decodeUnknownResult(CommandAdmissionRowSchema, closed);
119
- const decodeModelChoiceRow = Schema.decodeUnknownResult(Schema.Struct({ sequence: Schema.NullOr(sequence) }), closed);
120
119
  const decodeNextSequenceRow = Schema.decodeUnknownResult(NextSequenceRowSchema, closed);
121
120
  const decodeEntryPresenceRow = Schema.decodeUnknownResult(EntryPresenceRowSchema, closed);
121
+ /** The columns of an entry row from `table`, with whether its Session is damaged. */
122
+ function entryColumnsOf(table) {
123
+ const columns = [
124
+ "id",
125
+ "session_id",
126
+ "parent_id",
127
+ "sequence",
128
+ "type",
129
+ "role",
130
+ "state",
131
+ "request_id",
132
+ "work_id",
133
+ "data_json",
134
+ "timestamp",
135
+ "completed_at",
136
+ ].map((column) => `${table}.${column}`);
137
+ return `${columns.join(", ")},
138
+ EXISTS (SELECT 1 FROM session_lineage_damage AS damage
139
+ WHERE damage.session_id = ${table}.session_id) AS damaged`;
140
+ }
141
+ const entryColumns = entryColumnsOf("session_entries");
122
142
  /**
123
143
  * Page stored conversation facts without creating Sessions or opening hosts.
124
144
  *
@@ -167,6 +187,8 @@ export function readStoredSessionFactPage(owner, after) {
167
187
  * title derived from the first entered prompt.
168
188
  */
169
189
  export function readStoredSessionSummary(owner, sessionId) {
190
+ if (readSessionRow(owner, sessionId) === undefined)
191
+ return undefined;
170
192
  const raw = owner.sql
171
193
  .exec(`SELECT id, leaf_id, created_at, updated_at, metadata_json
172
194
  FROM sessions
@@ -328,12 +350,6 @@ function unavailable(message) {
328
350
  function incompatible(message) {
329
351
  return new ShiitakeError("store", "incompatible", message);
330
352
  }
331
- function parseSessionRow(raw) {
332
- const decoded = decodeSessionRow(raw);
333
- if (decoded._tag === "Failure")
334
- throw unavailable("Stored Session row is invalid");
335
- return decoded.success;
336
- }
337
353
  function requestIdFor(entry) {
338
354
  return entry.kind === "command" ||
339
355
  entry.kind === "cancellation" ||
@@ -359,11 +375,18 @@ function sameCausalIdentity(left, right) {
359
375
  left.parent?.kind === right.parent?.kind &&
360
376
  left.parent?.value === right.parent?.value);
361
377
  }
362
- function parseEntryRow(raw) {
378
+ /**
379
+ * Decode one entry row. Every entry read selects its Session's damage with
380
+ * it (`entryColumns`), so a row of a Session the lineage audit refused is
381
+ * refused here, whichever reader asked for it.
382
+ */
383
+ function parseEntryRow(owner, raw) {
363
384
  const decodedRow = decodeEntryRow(raw);
364
385
  if (decodedRow._tag === "Failure")
365
386
  throw unavailable("Stored Session entry row is invalid");
366
387
  const row = decodedRow.success;
388
+ if (row.damaged === 1)
389
+ throw brokenSession(owner, row.session_id);
367
390
  const decodedEntry = decodeStoredSessionEntry(row.data_json);
368
391
  if (decodedEntry._tag === "Failure")
369
392
  throw unavailable("Stored Session entry data is invalid");
@@ -424,20 +447,41 @@ function parseCommandAdmissionRow(raw) {
424
447
  export function hasStoredSession(owner, sessionId) {
425
448
  return readSessionRow(owner, sessionId) !== undefined;
426
449
  }
450
+ const decodeReadableSessionRow = Schema.decodeUnknownResult(Schema.Struct({
451
+ ...SessionRowSchema.fields,
452
+ damaged: Schema.Literals([0, 1]),
453
+ }), closed);
454
+ /**
455
+ * The Session row every reader and writer of one Session starts from. A
456
+ * Session the lineage audit found broken is refused here, so no read of it
457
+ * and no write to it gets further.
458
+ */
427
459
  function readSessionRow(owner, sessionId) {
428
460
  const raw = owner.sql
429
- .exec("SELECT id, leaf_id FROM sessions WHERE id = ?", sessionId)
461
+ .exec(`SELECT id, leaf_id,
462
+ EXISTS (SELECT 1 FROM session_lineage_damage
463
+ WHERE session_id = sessions.id) AS damaged
464
+ FROM sessions WHERE id = ?`, sessionId)
430
465
  .toArray()[0];
431
- return raw === undefined ? undefined : parseSessionRow(raw);
466
+ if (raw === undefined)
467
+ return undefined;
468
+ const decoded = decodeReadableSessionRow(raw);
469
+ if (decoded._tag === "Failure")
470
+ throw unavailable("Stored Session row is invalid");
471
+ if (decoded.success.damaged === 1)
472
+ throw brokenSession(owner, sessionId);
473
+ return Object.freeze({
474
+ id: decoded.success.id,
475
+ leaf_id: decoded.success.leaf_id,
476
+ });
432
477
  }
433
478
  function readEntryRow(owner, sessionId, id) {
434
479
  const raw = owner.sql
435
- .exec(`SELECT id, session_id, parent_id, sequence, type, role, state,
436
- request_id, work_id, data_json, timestamp, completed_at
480
+ .exec(`SELECT ${entryColumns}
437
481
  FROM session_entries
438
482
  WHERE session_id = ? AND id = ?`, sessionId, id)
439
483
  .toArray()[0];
440
- return raw === undefined ? undefined : parseEntryRow(raw);
484
+ return raw === undefined ? undefined : parseEntryRow(owner, raw);
441
485
  }
442
486
  function validateCompleteSessionLeaf(owner, session) {
443
487
  if (session.leaf_id === null) {
@@ -462,12 +506,11 @@ function validateCompleteSessionLeaf(owner, session) {
462
506
  /** Read one retained Session entry by its globally unique row identity. */
463
507
  export function readSessionEntryById(owner, id) {
464
508
  const raw = owner.sql
465
- .exec(`SELECT id, session_id, parent_id, sequence, type, role, state,
466
- request_id, work_id, data_json, timestamp, completed_at
509
+ .exec(`SELECT ${entryColumns}
467
510
  FROM session_entries
468
511
  WHERE id = ?`, id)
469
512
  .toArray()[0];
470
- return raw === undefined ? undefined : parseEntryRow(raw);
513
+ return raw === undefined ? undefined : parseEntryRow(owner, raw);
471
514
  }
472
515
  function readCommandAdmission(owner, sessionId, lookup) {
473
516
  const [column, value] = "id" in lookup
@@ -491,6 +534,36 @@ const pendingAdmissions = `FROM session_command_admissions AS a
491
534
  WHERE e.session_id = a.session_id AND e.work_id = a.work_id
492
535
  AND e.type IN ('command', 'cancellation', 'run_terminal', 'work_terminal')
493
536
  )`;
537
+ const decodeIdentity = Schema.decodeUnknownSync(Schema.Struct({ identity: Schema.NullOr(Schema.String) }));
538
+ /** Extract the host-selected principal without giving the engine domain grammar. */
539
+ export function readAdmissionIdentity(owner, path, meta) {
540
+ const { identity } = decodeIdentity(owner.sql
541
+ .exec("SELECT json_extract(?, ?) AS identity", JSON.stringify(meta ?? null), path)
542
+ .one());
543
+ if (identity === null || identity.length === 0)
544
+ throw new ShiitakeError("runtime", "rejected", "Request author is required");
545
+ return { path, value: identity };
546
+ }
547
+ /** Immutable admission author check; used before every exact cancellation, including replay. */
548
+ export function assertAdmissionIdentity(owner, sessionId, commandId, identity) {
549
+ const admission = readSessionCommandAdmission(owner, sessionId, commandId);
550
+ if (admission === undefined)
551
+ return;
552
+ const stored = readAdmissionIdentity(owner, identity.path, admission.entry.meta);
553
+ if (stored.value !== identity.value)
554
+ throw new ShiitakeError("runtime", "rejected", "Message author does not match");
555
+ }
556
+ /** Read just one newest admission; FIFO execution and oldest-first paging are unchanged. */
557
+ export function readNewestPendingSessionCommand(owner, sessionId, identity) {
558
+ const raw = owner.sql
559
+ .exec(`SELECT ${admissionColumns("a.")} ${pendingAdmissions}
560
+ AND json_extract(a.data_json, '$.work') IS NULL
561
+ AND a.origin_kind = 'user'
562
+ AND (? IS NULL OR json_extract(json_extract(a.data_json, '$.meta'), ?) = ?)
563
+ ORDER BY a.sequence DESC LIMIT 1`, sessionId, identity?.path ?? null, identity?.path ?? null, identity?.value ?? null)
564
+ .toArray()[0];
565
+ return raw === undefined ? undefined : parseCommandAdmissionRow(raw);
566
+ }
494
567
  const decodePendingCount = Schema.decodeUnknownSync(Schema.Struct({
495
568
  total: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
496
569
  }));
@@ -575,14 +648,22 @@ export function settleSessionControls(owner, input) {
575
648
  });
576
649
  continue;
577
650
  }
651
+ // Only a delivered control shares its failed parent's safe reason.
652
+ // Late, unmaterialized work was requeued above and owns its own outcome.
653
+ const diagnostic = input.outcome === "failed" || input.outcome === "interrupted"
654
+ ? input.diagnostic
655
+ : undefined;
656
+ let entry = {
657
+ kind: "work_terminal",
658
+ workId: row.entry.workId,
659
+ outcome: input.outcome,
660
+ };
661
+ if (diagnostic !== undefined)
662
+ entry = { ...entry, diagnostic };
578
663
  appendSessionEntry(owner, {
579
664
  sessionId: input.sessionId,
580
665
  id: sessionIds.fact.generate(),
581
- entry: {
582
- kind: "work_terminal",
583
- workId: row.entry.workId,
584
- outcome: input.outcome,
585
- },
666
+ entry,
586
667
  });
587
668
  }
588
669
  });
@@ -601,20 +682,21 @@ export function readSessionCommandAdmissionByRequest(owner, sessionId, requestId
601
682
  }
602
683
  /** Read entries correlated to one accepted work identity in Session order. */
603
684
  export function readSessionEntriesForWork(owner, sessionId, workId) {
685
+ // Refuse the Session before selecting entries: a damaged work may have no
686
+ // rows left, so decoding the result cannot establish that it is readable.
687
+ readSessionRow(owner, sessionId);
604
688
  return Object.freeze(owner.sql
605
- .exec(`SELECT id, session_id, parent_id, sequence, type, role, state,
606
- request_id, work_id, data_json, timestamp, completed_at
689
+ .exec(`SELECT ${entryColumns}
607
690
  FROM session_entries
608
691
  WHERE session_id = ? AND work_id = ?
609
692
  ORDER BY sequence ASC`, sessionId, workId)
610
693
  .toArray()
611
- .map(parseEntryRow));
694
+ .map((raw) => parseEntryRow(owner, raw)));
612
695
  }
613
696
  /** Read the latest retained live-retry event for one accepted work item. */
614
697
  export function readLatestLiveRetryForWork(owner, sessionId, workId) {
615
698
  const raw = owner.sql
616
- .exec(`SELECT id, session_id, parent_id, sequence, type, role, state,
617
- request_id, work_id, data_json, timestamp, completed_at
699
+ .exec(`SELECT ${entryColumns}
618
700
  FROM session_entries
619
701
  WHERE session_id = ? AND work_id = ? AND type = 'live_retry'
620
702
  ORDER BY sequence DESC
@@ -622,7 +704,7 @@ export function readLatestLiveRetryForWork(owner, sessionId, workId) {
622
704
  .toArray()[0];
623
705
  if (raw === undefined)
624
706
  return undefined;
625
- const row = parseEntryRow(raw);
707
+ const row = parseEntryRow(owner, raw);
626
708
  if (row.entry.kind !== "live_retry")
627
709
  throw unavailable("Stored live retry entry is invalid");
628
710
  return { event: row.entry.event, timestamp: row.timestamp };
@@ -639,10 +721,7 @@ export function hasSuccessfulToolResult(owner, sessionId, workId, toolCallId) {
639
721
  /** Most recent tool intent without a matching outcome for the running work. */
640
722
  export function readActiveToolForWork(owner, sessionId, workId) {
641
723
  const raw = owner.sql
642
- .exec(`SELECT intent.id, intent.session_id, intent.parent_id,
643
- intent.sequence, intent.type, intent.role, intent.state,
644
- intent.request_id, intent.work_id, intent.data_json,
645
- intent.timestamp, intent.completed_at
724
+ .exec(`SELECT ${entryColumnsOf("intent")}
646
725
  FROM session_entries AS intent
647
726
  WHERE intent.session_id = ? AND intent.work_id = ?
648
727
  AND intent.type = 'tool_intent'
@@ -659,7 +738,7 @@ export function readActiveToolForWork(owner, sessionId, workId) {
659
738
  .toArray()[0];
660
739
  if (raw === undefined)
661
740
  return undefined;
662
- const row = parseEntryRow(raw);
741
+ const row = parseEntryRow(owner, raw);
663
742
  if (row.entry.kind !== "tool_intent")
664
743
  throw unavailable("Stored tool intent is invalid");
665
744
  return row.entry.call.name;
@@ -737,12 +816,15 @@ admitNew) {
737
816
  */
738
817
  export function deleteStoredSession(owner, input) {
739
818
  return owner.transactionSync(() => {
740
- if (readSessionRow(owner, input.sessionId) === undefined)
819
+ // A Session the lineage audit refused can still be deleted.
820
+ const exists = owner.sql
821
+ .exec("SELECT id FROM sessions WHERE id = ?", input.sessionId)
822
+ .toArray().length > 0;
823
+ if (!exists)
741
824
  return false;
742
825
  input.assertIdleSession(input.sessionId);
743
826
  input.recordInboxDeletion(input.sessionId);
744
827
  for (const statement of [
745
- "DELETE FROM session_entries WHERE session_id = ?",
746
828
  "DELETE FROM session_command_admissions WHERE session_id = ?",
747
829
  // The inbox row references the Session; it goes in the same transaction.
748
830
  "DELETE FROM session_attention WHERE session_id = ?",
@@ -750,13 +832,20 @@ export function deleteStoredSession(owner, input) {
750
832
  "DELETE FROM session_inbox WHERE session_id = ?",
751
833
  "DELETE FROM session_creation_receipts WHERE session_id = ?",
752
834
  "DELETE FROM sessions WHERE id = ?",
835
+ // Entries go last: only a removed Session's lineage may lose parents.
836
+ "DELETE FROM session_entries WHERE session_id = ?",
837
+ "DELETE FROM session_lineage_damage WHERE session_id = ?",
753
838
  ])
754
839
  owner.sql.exec(statement, input.sessionId);
755
840
  return true;
756
841
  });
757
842
  }
758
- /** The exact Woodstock SQL shape this build reads and writes. */
759
- const currentWoodstockSchemaVersion = 1;
843
+ /**
844
+ * Version 3 keeps application tool data outside model messages/results.
845
+ * Older preview stores require explicit host custody; no compatibility reader
846
+ * or automatic rewrite runs when the store opens.
847
+ */
848
+ const currentWoodstockSchemaVersion = 3;
760
849
  const currentWoodstockProtocol = "shiitake.extension/v0";
761
850
  function tableExists(owner, name) {
762
851
  const rows = owner.sql
@@ -786,10 +875,12 @@ function readWoodstockStoreProtocol(owner) {
786
875
  if (rows.length !== 1)
787
876
  return "unmarked";
788
877
  const decoded = decodeWoodstockSchemaVersionRow(rows[0]);
789
- return decoded._tag === "Success" &&
790
- decoded.success.version === currentWoodstockSchemaVersion
791
- ? "v0"
792
- : "unmarked";
878
+ if (decoded._tag !== "Success")
879
+ return "unmarked";
880
+ // The decoder already requires the current protocol literal.
881
+ if (decoded.success.version === currentWoodstockSchemaVersion)
882
+ return "v0";
883
+ return "unmarked";
793
884
  }
794
885
  /** Stamp fresh Woodstock stores; incompatible files require host-side custody. */
795
886
  function ensureWoodstockSchemaVersion(owner) {
@@ -797,7 +888,7 @@ function ensureWoodstockSchemaVersion(owner) {
797
888
  if (protocol === "v0")
798
889
  return;
799
890
  if (protocol === "unmarked")
800
- throw incompatible("Woodstock store has no v0 protocol marker; a hosted store never resets itself — reset the owning Durable Object storage, or remove the old local store file before opening a fresh one");
891
+ throw incompatible("Woodstock store has no current v0 schema marker; incompatible storage requires explicit host custody. To reset, stop the owning host before resetting a local store, then remove its database file and the file's .owner.sqlite sidecar. Reset discards that store's data. For the Hub, a Hub Agent is erased only with its Team; Team erasure is destructive and separately authorized, never a routine single-Agent repair. No migration runs.");
801
892
  owner.sql.exec("CREATE TABLE woodstock_schema (version INTEGER NOT NULL, protocol TEXT NOT NULL)");
802
893
  owner.sql.exec("INSERT INTO woodstock_schema (version, protocol) VALUES (?, ?)", currentWoodstockSchemaVersion, currentWoodstockProtocol);
803
894
  }
@@ -820,6 +911,201 @@ function createSessionEntryTable(owner) {
820
911
  )
821
912
  `);
822
913
  }
914
+ const lineageGuards = [
915
+ "session_entries_lineage_insert",
916
+ "session_entries_lineage_update",
917
+ "session_entries_lineage_delete",
918
+ "session_entries_damaged_session",
919
+ "session_entries_lineage_replace",
920
+ "session_entries_lineage_replace_position",
921
+ ];
922
+ const decodeInstalledGuards = Schema.decodeUnknownResult(Schema.Struct({
923
+ installed: Schema.Int.pipe(Schema.check(Schema.isGreaterThanOrEqualTo(0))),
924
+ }), closed);
925
+ const decodeDamagedSession = Schema.decodeUnknownResult(Schema.Struct({ session_id: Schema.String }), closed);
926
+ /** What a writer to a Session the lineage audit found broken is told. */
927
+ const brokenSessionMessage = "Woodstock Session holds a broken lineage; a store never repairs itself — delete this Session (deleteSession), or reset the owning store";
928
+ const decodeDamageCount = Schema.decodeUnknownResult(Schema.Struct({
929
+ damaged: Schema.Int.pipe(Schema.check(Schema.isGreaterThan(0))),
930
+ first: Schema.String,
931
+ }), closed);
932
+ /**
933
+ * The refusal of every read of, and write to, a Session the lineage audit
934
+ * found broken. Work settled for it carries `diagnostic`, a bounded form of
935
+ * the message that still names the Session and its reset.
936
+ */
937
+ export class BrokenSessionError extends ShiitakeError {
938
+ store;
939
+ sessionId;
940
+ constructor(store, sessionId, message) {
941
+ super("store", "unavailable", message);
942
+ this.store = store;
943
+ this.sessionId = sessionId;
944
+ }
945
+ get diagnostic() {
946
+ return `${this.store}: Woodstock Session ${this.sessionId} holds a broken lineage; delete it with deleteSession("${this.sessionId}"), or reset the owning store`;
947
+ }
948
+ }
949
+ /** The refusal of one Session the lineage audit found broken, naming its store. */
950
+ function brokenSession(owner, sessionId) {
951
+ const store = owner.storeName ?? "Woodstock store";
952
+ return new BrokenSessionError(store, sessionId, `${store}: ${brokenSessionRefusal(owner, sessionId)}`);
953
+ }
954
+ /**
955
+ * Whether the lineage audit refused this Session. Settlement asks before it
956
+ * records anything in the Session, which it can no longer write.
957
+ */
958
+ export function sessionLineageRefused(owner, sessionId) {
959
+ return (owner.sql
960
+ .exec("SELECT session_id FROM session_lineage_damage WHERE session_id = ?", sessionId)
961
+ .toArray().length > 0);
962
+ }
963
+ /**
964
+ * What a reader of a broken Session is told: which Session, how many of this
965
+ * store's Sessions the audit found broken and the first of them, and the
966
+ * steps that clear it.
967
+ */
968
+ function brokenSessionRefusal(owner, sessionId) {
969
+ const decoded = decodeDamageCount(owner.sql
970
+ .exec("SELECT COUNT(*) AS damaged, MIN(session_id) AS first FROM session_lineage_damage")
971
+ .one());
972
+ if (decoded._tag === "Failure")
973
+ throw unavailable("Stored Session lineage damage is invalid");
974
+ const { damaged, first } = decoded.success;
975
+ return `Woodstock Session ${sessionId} holds a broken lineage (this store has ${String(damaged)} damaged Session${damaged === 1 ? "" : "s"}, first ${first}); a store never repairs itself — delete this Session with deleteSession("${sessionId}"), or reset the owning store: for a Durable Object, erase all of its storage (DurableObjectStorage.deleteAll(); a Hub Agent is erased only with its Team, POST /operator/teams/:teamId/erasure-durable-objects); for a local store, remove its file and the file's .owner.sqlite sidecar`;
976
+ }
977
+ /**
978
+ * Every lineage stays whole by construction, enforced where it is written
979
+ * rather than re-walked where it is read. Each entry's parent is an earlier
980
+ * complete entry of its own Session, each Session has one complete root, the
981
+ * lineage columns never change, and a parent is never removed from under a
982
+ * child while its Session exists. So every walk from a complete leaf reaches
983
+ * that one root over complete rows of falling sequence, and readers walk only
984
+ * the rows they return.
985
+ *
986
+ * A store written while any guard was missing is audited as they are
987
+ * installed. Each Session that breaks them is recorded in
988
+ * `session_lineage_damage`, in the same pass: its inbox row turns
989
+ * unavailable, every read of it is refused and no entry is written to it,
990
+ * while every other Session opens and works as before. The guards only judge
991
+ * later writes, and each judges one Session, so a damaged Session never
992
+ * blocks installing them or writing elsewhere. Deleting the Session clears it.
993
+ */
994
+ function guardSessionLineage(owner) {
995
+ owner.sql.exec("CREATE TABLE IF NOT EXISTS session_lineage_damage (session_id TEXT PRIMARY KEY)");
996
+ const installed = decodeInstalledGuards(owner.sql
997
+ .exec(`SELECT COUNT(*) AS installed FROM sqlite_master
998
+ WHERE type = 'trigger'
999
+ AND name IN (SELECT value FROM json_each(?))`, JSON.stringify(lineageGuards))
1000
+ .one());
1001
+ if (installed._tag === "Failure")
1002
+ throw unavailable("Stored Session lineage guards are invalid");
1003
+ if (installed.success.installed !== lineageGuards.length) {
1004
+ const damaged = owner.sql
1005
+ .exec(`SELECT child.session_id AS session_id
1006
+ FROM session_entries AS child
1007
+ LEFT JOIN session_entries AS parent ON parent.id = child.parent_id
1008
+ WHERE child.parent_id IS NOT NULL
1009
+ AND (parent.id IS NULL
1010
+ OR parent.session_id != child.session_id
1011
+ OR parent.state != 'complete'
1012
+ OR parent.sequence >= child.sequence)
1013
+ UNION
1014
+ SELECT session_id FROM session_entries
1015
+ WHERE parent_id IS NULL AND state = 'complete'
1016
+ GROUP BY session_id HAVING COUNT(*) > 1`)
1017
+ .toArray()
1018
+ .map((raw) => {
1019
+ const decoded = decodeDamagedSession(raw);
1020
+ if (decoded._tag === "Failure")
1021
+ throw unavailable("Stored Session lineage audit is invalid");
1022
+ return decoded.success.session_id;
1023
+ });
1024
+ for (const sessionId of damaged) {
1025
+ owner.sql.exec("INSERT OR IGNORE INTO session_lineage_damage (session_id) VALUES (?)", sessionId);
1026
+ touchSessionInbox(owner, { sessionId, title: null });
1027
+ }
1028
+ }
1029
+ // SQLite fires DELETE triggers for the row an INSERT OR REPLACE removes
1030
+ // only under PRAGMA recursive_triggers, which is off by default and which
1031
+ // Woodstock never turns on. So the delete guard cannot see a REPLACE; this
1032
+ // guard refuses a replacing row that would leave the replaced row's
1033
+ // children under a streaming, later or foreign parent.
1034
+ owner.sql.exec(`
1035
+ CREATE TRIGGER IF NOT EXISTS session_entries_lineage_replace
1036
+ BEFORE INSERT ON session_entries
1037
+ WHEN EXISTS (
1038
+ SELECT 1 FROM session_entries AS child
1039
+ WHERE child.parent_id = NEW.id
1040
+ AND (child.session_id != NEW.session_id
1041
+ OR child.sequence <= NEW.sequence
1042
+ OR NEW.state != 'complete'))
1043
+ BEGIN
1044
+ SELECT RAISE(ABORT, 'Session entry would strand its children');
1045
+ END
1046
+ `);
1047
+ // REPLACE also resolves the UNIQUE (session_id, sequence) conflict by
1048
+ // deleting whichever row holds that position, under any id. Refuse a
1049
+ // replacing row whose position belongs to another row with children.
1050
+ owner.sql.exec(`
1051
+ CREATE TRIGGER IF NOT EXISTS session_entries_lineage_replace_position
1052
+ BEFORE INSERT ON session_entries
1053
+ WHEN EXISTS (
1054
+ SELECT 1 FROM session_entries AS held
1055
+ JOIN session_entries AS child ON child.parent_id = held.id
1056
+ WHERE held.session_id = NEW.session_id
1057
+ AND held.sequence = NEW.sequence
1058
+ AND held.id != NEW.id)
1059
+ BEGIN
1060
+ SELECT RAISE(ABORT, 'Session entry would strand its children');
1061
+ END
1062
+ `);
1063
+ owner.sql.exec(`
1064
+ CREATE TRIGGER IF NOT EXISTS session_entries_damaged_session
1065
+ BEFORE INSERT ON session_entries
1066
+ WHEN EXISTS (
1067
+ SELECT 1 FROM session_lineage_damage WHERE session_id = NEW.session_id)
1068
+ BEGIN
1069
+ SELECT RAISE(ABORT, '${brokenSessionMessage}');
1070
+ END
1071
+ `);
1072
+ owner.sql.exec(`
1073
+ CREATE TRIGGER IF NOT EXISTS session_entries_lineage_insert
1074
+ BEFORE INSERT ON session_entries
1075
+ BEGIN
1076
+ SELECT RAISE(ABORT, 'Session entry parent is not an earlier complete entry of its Session')
1077
+ WHERE NEW.parent_id IS NOT NULL AND NOT EXISTS (
1078
+ SELECT 1 FROM session_entries AS parent
1079
+ WHERE parent.id = NEW.parent_id
1080
+ AND parent.session_id = NEW.session_id
1081
+ AND parent.state = 'complete'
1082
+ AND parent.sequence < NEW.sequence);
1083
+ SELECT RAISE(ABORT, 'Session already has a root entry')
1084
+ WHERE NEW.parent_id IS NULL AND EXISTS (
1085
+ SELECT 1 FROM session_entries AS other
1086
+ WHERE other.session_id = NEW.session_id
1087
+ AND other.state = 'complete'
1088
+ AND other.id != NEW.id);
1089
+ END
1090
+ `);
1091
+ owner.sql.exec(`
1092
+ CREATE TRIGGER IF NOT EXISTS session_entries_lineage_update
1093
+ BEFORE UPDATE OF id, session_id, parent_id, sequence, state
1094
+ ON session_entries
1095
+ BEGIN
1096
+ SELECT RAISE(ABORT, 'Session entry lineage columns are immutable');
1097
+ END
1098
+ `);
1099
+ owner.sql.exec(`
1100
+ CREATE TRIGGER IF NOT EXISTS session_entries_lineage_delete
1101
+ BEFORE DELETE ON session_entries
1102
+ WHEN EXISTS (SELECT 1 FROM sessions WHERE id = OLD.session_id)
1103
+ AND EXISTS (SELECT 1 FROM session_entries WHERE parent_id = OLD.id)
1104
+ BEGIN
1105
+ SELECT RAISE(ABORT, 'Session entry still has children');
1106
+ END
1107
+ `);
1108
+ }
823
1109
  /** Create the retained Session schema and private creation/admission receipts. */
824
1110
  export function prepareSessionStore(owner) {
825
1111
  owner.transactionSync(() => {
@@ -853,10 +1139,26 @@ export function prepareSessionStore(owner) {
853
1139
  ON session_entries(session_id, request_id)
854
1140
  WHERE request_id IS NOT NULL`);
855
1141
  owner.sql.exec("CREATE INDEX IF NOT EXISTS idx_session_entries_work ON session_entries(work_id, sequence)");
1142
+ // Context and preference reads look up these rare older facts directly
1143
+ // rather than walking the history behind them.
1144
+ owner.sql.exec(`CREATE INDEX IF NOT EXISTS idx_session_entries_model_preference
1145
+ ON session_entries(session_id, sequence)
1146
+ WHERE type = 'model_preference'`);
1147
+ owner.sql.exec(`CREATE INDEX IF NOT EXISTS idx_session_entries_cancellation_target
1148
+ ON session_entries(session_id, json_extract(data_json, '$.targetWorkId'))
1149
+ WHERE type = 'cancellation'`);
856
1150
  owner.sql
857
1151
  .exec(`CREATE INDEX IF NOT EXISTS idx_session_entries_successful_tool
858
1152
  ON session_entries(session_id, work_id, json_extract(data_json, '$.call.id'))
859
1153
  WHERE type = 'tool_outcome' AND json_extract(data_json, '$.result.isError') = 0`);
1154
+ guardSessionLineage(owner);
1155
+ owner.sql.exec(`
1156
+ CREATE TABLE IF NOT EXISTS session_request_withdrawals (
1157
+ session_id TEXT NOT NULL,
1158
+ request_id TEXT NOT NULL,
1159
+ PRIMARY KEY (session_id, request_id)
1160
+ )
1161
+ `);
860
1162
  owner.sql.exec(`
861
1163
  CREATE TABLE IF NOT EXISTS session_command_admissions (
862
1164
  id TEXT PRIMARY KEY,
@@ -882,7 +1184,10 @@ export function prepareSessionStore(owner) {
882
1184
  }
883
1185
  /** Read the nullable preference at exactly this selected lineage. */
884
1186
  export function readSessionModelPreference(owner, sessionId) {
885
- return lineageModelPreference(owner, readSessionLineage(owner, { sessionId }));
1187
+ const leaf = readSessionRow(owner, sessionId)?.leaf_id;
1188
+ return leaf === undefined || leaf === null
1189
+ ? null
1190
+ : lineageModelPreference(owner, sessionId, leaf);
886
1191
  }
887
1192
  /**
888
1193
  * Woodstock order of the newest model choice on the selected lineage, or null.
@@ -892,26 +1197,7 @@ export function readLastModelChoiceSequence(owner, sessionId) {
892
1197
  const leaf = readSessionRow(owner, sessionId)?.leaf_id;
893
1198
  if (leaf === undefined || leaf === null)
894
1199
  return null;
895
- const raw = owner.sql
896
- .exec(`WITH RECURSIVE branch (id, parent_id, sequence, type, state) AS (
897
- SELECT id, parent_id, sequence, type, state
898
- FROM session_entries
899
- WHERE session_id = ? AND id = ?
900
- UNION
901
- SELECT parent.id, parent.parent_id, parent.sequence, parent.type,
902
- parent.state
903
- FROM session_entries AS parent
904
- JOIN branch AS child ON parent.id = child.parent_id
905
- WHERE parent.session_id = ?
906
- )
907
- SELECT MAX(sequence) AS sequence
908
- FROM branch
909
- WHERE type = 'model_preference' AND state != 'streaming'`, sessionId, leaf, sessionId)
910
- .one();
911
- const decoded = decodeModelChoiceRow(raw);
912
- if (decoded._tag === "Failure")
913
- throw unavailable("Stored model choice order is invalid");
914
- return decoded.success.sequence;
1200
+ return newestLineagePreference(owner, sessionId, leaf)?.sequence ?? null;
915
1201
  }
916
1202
  /** Append a decoded preference request; no command or scheduler work is minted. */
917
1203
  export function setSessionModelPreference(owner, sessionId, preference) {
@@ -929,7 +1215,12 @@ export function setSessionModelPreference(owner, sessionId, preference) {
929
1215
  export function setSessionTitle(owner, sessionId, title) {
930
1216
  owner.transactionSync(() => {
931
1217
  ensureSession(owner, sessionId);
932
- owner.sql.exec("UPDATE sessions SET metadata_json = ? WHERE id = ?", title === null ? null : encodeSessionMetadata({ title }), sessionId);
1218
+ const metadata = title === null ? null : encodeSessionMetadata({ title });
1219
+ if (owner.sql
1220
+ .exec("SELECT 1 FROM sessions WHERE id = ? AND metadata_json IS ?", sessionId, metadata)
1221
+ .toArray().length > 0)
1222
+ return;
1223
+ owner.sql.exec("UPDATE sessions SET metadata_json = ? WHERE id = ?", metadata, sessionId);
933
1224
  touchSessionInbox(owner, {
934
1225
  sessionId,
935
1226
  title: inboxTitle(owner, sessionId),
@@ -1022,16 +1313,8 @@ export function completeSessionLeaf(owner, input) {
1022
1313
  throw unavailable("Completed Session leaf is unavailable");
1023
1314
  return completed;
1024
1315
  }
1025
- const entryColumns = `id, session_id, parent_id, sequence, type, role, state,
1026
- request_id, work_id, data_json, timestamp, completed_at`;
1027
1316
  /** Rows one bounded lineage read decodes at once. */
1028
1317
  export const lineageRowLimit = 200;
1029
- /**
1030
- * Stored bytes one model context read may decode: the retained storage
1031
- * plane's `LLM_CONTEXT_BYTES`, now a typed refusal rather than a silent
1032
- * truncation, because a truncated context would change the model's input.
1033
- */
1034
- export const contextByteLimit = 12 * 1_048_576;
1035
1318
  /**
1036
1319
  * Rows one fork copy window decodes. A fork copies the whole lineage, so a
1037
1320
  * wide window keeps the walks few; the byte bound still caps each window.
@@ -1047,17 +1330,22 @@ const lineageByteLimit = 4 * 1_048_576;
1047
1330
  * law); entry data is read afterwards for exactly the rows a reader needs.
1048
1331
  * The selected walk drops streaming placeholders after walking; the fork walk
1049
1332
  * stops at them, as a fork copies only complete history.
1333
+ * Each walk descends from the tip only while it is above a floor sequence,
1334
+ * so a reader of recent rows never visits the history behind them. The
1335
+ * selected walk steps only to an older parent, so it ends without
1336
+ * deduplicating; the fork walk keeps every link for its whole-lineage check.
1050
1337
  */
1051
1338
  const lineageWalks = {
1052
1339
  selected: `WITH RECURSIVE branch (id, parent_id, sequence, state) AS (
1053
1340
  SELECT id, parent_id, sequence, state
1054
1341
  FROM session_entries
1055
1342
  WHERE session_id = ? AND id = ?
1056
- UNION
1343
+ UNION ALL
1057
1344
  SELECT parent.id, parent.parent_id, parent.sequence, parent.state
1058
1345
  FROM session_entries AS parent
1059
1346
  JOIN branch AS child ON parent.id = child.parent_id
1060
- WHERE parent.session_id = ?
1347
+ WHERE parent.session_id = ? AND child.sequence > ?
1348
+ AND +parent.sequence < child.sequence
1061
1349
  )`,
1062
1350
  fork: `WITH RECURSIVE branch (id, parent_id, sequence, state) AS (
1063
1351
  SELECT id, parent_id, sequence, state
@@ -1067,7 +1355,8 @@ const lineageWalks = {
1067
1355
  SELECT parent.id, parent.parent_id, parent.sequence, parent.state
1068
1356
  FROM session_entries AS parent
1069
1357
  JOIN branch AS child ON parent.id = child.parent_id
1070
- WHERE parent.session_id = ? AND parent.state != 'streaming'
1358
+ WHERE parent.session_id = ? AND child.sequence > ?
1359
+ AND parent.state != 'streaming'
1071
1360
  )`,
1072
1361
  };
1073
1362
  const lineageFailures = {
@@ -1091,18 +1380,23 @@ const decodeLineageCheck = Schema.decodeUnknownResult(Schema.Struct({
1091
1380
  newest_id: Schema.NullOr(boundedId),
1092
1381
  newest_sequence: Schema.NullOr(sequence),
1093
1382
  }), closed);
1094
- const decodeLineageSize = Schema.decodeUnknownResult(Schema.Struct({ sequence, bytes: count }), closed);
1095
- const decodeContextBytes = Schema.decodeUnknownResult(Schema.Struct({ bytes: count }), closed);
1096
1383
  const decodeLineageSequence = Schema.decodeUnknownResult(Schema.Struct({ sequence }), closed);
1097
- const decodeFirstKept = Schema.decodeUnknownResult(Schema.Struct({ first_kept: boundedId }), closed);
1098
1384
  const decodeLineageWorkId = Schema.decodeUnknownResult(Schema.Struct({ work_id: boundedId }), closed);
1099
1385
  const decodeLineageDiagnostic = Schema.decodeUnknownResult(Schema.Struct({ work_id: boundedId, diagnostic: Schema.String }), closed);
1100
- function lineageQuery(owner, lineage, query, ...bindings) {
1386
+ /**
1387
+ * Run one query over the lineage rows above `floor`: the walk descends from
1388
+ * the tip while its rows are above it, so only those rows, and the first at
1389
+ * or below it, are visited.
1390
+ */
1391
+ function lineageQuery(owner, lineage, floor, query, ...bindings) {
1101
1392
  return owner.sql
1102
- .exec(`${lineageWalks[lineage.walk]} ${query}`, lineage.sessionId, lineage.tipId, lineage.sessionId, ...bindings)
1393
+ .exec(`${lineageWalks[lineage.walk]} ${query}`, lineage.sessionId, lineage.tipId, lineage.sessionId, floor, ...bindings)
1103
1394
  .toArray();
1104
1395
  }
1105
- /** Validate one root-to-tip chain in SQL, the same way decoded rows would be. */
1396
+ /**
1397
+ * Walk and check one whole root-to-tip chain in SQL, the same way decoded
1398
+ * rows would be. A fork copies every row of it, so it checks every row.
1399
+ */
1106
1400
  function locateLineage(owner, sessionId, tipId, walk) {
1107
1401
  const failures = lineageFailures[walk];
1108
1402
  const checked = decodeLineageCheck(owner.sql
@@ -1124,7 +1418,7 @@ function locateLineage(owner, sessionId, tipId, walk) {
1124
1418
  WHERE previous IS NOT NULL AND parent_id IS NOT previous) AS broken,
1125
1419
  (SELECT id FROM kept ORDER BY sequence DESC LIMIT 1) AS newest_id,
1126
1420
  (SELECT sequence FROM kept ORDER BY sequence DESC LIMIT 1)
1127
- AS newest_sequence`, sessionId, tipId, sessionId)
1421
+ AS newest_sequence`, sessionId, tipId, sessionId, 0)
1128
1422
  .one());
1129
1423
  if (checked._tag === "Failure")
1130
1424
  throw unavailable("Stored Session lineage check is invalid");
@@ -1147,38 +1441,147 @@ function locateLineage(owner, sessionId, tipId, walk) {
1147
1441
  newestSequence: lineage.newest_sequence,
1148
1442
  });
1149
1443
  }
1150
- /** Locate the selected lineage; undefined when the Session has no leaf. */
1444
+ const decodeLineageEnds = Schema.decodeUnknownResult(Schema.Struct({
1445
+ state: Schema.Literals(["complete", "streaming"]),
1446
+ newest_sequence: sequence,
1447
+ root_id: Schema.NullOr(boundedId),
1448
+ root_parent_id: Schema.NullOr(Schema.String),
1449
+ }), closed);
1450
+ /**
1451
+ * Locate a selected lineage from its two ends; undefined when the Session
1452
+ * has no leaf. The guards make a Session's one complete root the root of
1453
+ * every lineage in it, so neither end needs a walk.
1454
+ */
1151
1455
  export function readSessionLineage(owner, input) {
1152
- const tip = input.leafId ?? readSessionRow(owner, input.sessionId)?.leaf_id;
1456
+ // Read the Session row even for an explicit tip: it refuses a broken one.
1457
+ const session = readSessionRow(owner, input.sessionId);
1458
+ const tip = input.leafId ?? session?.leaf_id;
1153
1459
  if (tip === undefined || tip === null)
1154
1460
  return undefined;
1155
- return locateLineage(owner, input.sessionId, tip, "selected");
1461
+ const failures = lineageFailures.selected;
1462
+ const raw = owner.sql
1463
+ .exec(`SELECT tip.state AS state, tip.sequence AS newest_sequence,
1464
+ root.id AS root_id, root.parent_id AS root_parent_id
1465
+ FROM session_entries AS tip
1466
+ LEFT JOIN (
1467
+ SELECT id, parent_id FROM session_entries
1468
+ WHERE session_id = ? AND state = 'complete'
1469
+ ORDER BY sequence ASC LIMIT 1
1470
+ ) AS root
1471
+ WHERE tip.session_id = ? AND tip.id = ?`, input.sessionId, input.sessionId, tip)
1472
+ .toArray()[0];
1473
+ if (raw === undefined)
1474
+ throw unavailable(failures.empty);
1475
+ const ends = decodeLineageEnds(raw);
1476
+ if (ends._tag === "Failure")
1477
+ throw unavailable("Stored Session lineage ends are invalid");
1478
+ if (ends.success.state !== "complete" || ends.success.root_id === null)
1479
+ throw unavailable(failures.empty);
1480
+ if (ends.success.root_parent_id !== null)
1481
+ throw unavailable(failures.missingParent);
1482
+ return Object.freeze({
1483
+ sessionId: input.sessionId,
1484
+ walk: "selected",
1485
+ tipId: tip,
1486
+ rootId: ends.success.root_id,
1487
+ newestId: tip,
1488
+ newestSequence: ends.success.newest_sequence,
1489
+ });
1156
1490
  }
1157
1491
  function readLineageRange(owner, lineage, oldest, newest) {
1158
- return Object.freeze(lineageQuery(owner, lineage, `SELECT ${entryColumns}
1492
+ const rows = Object.freeze(lineageQuery(owner, lineage, oldest - 1, `SELECT ${entryColumns}
1159
1493
  FROM session_entries
1160
1494
  WHERE session_id = ? AND state != 'streaming'
1161
1495
  AND sequence BETWEEN ? AND ?
1162
1496
  AND id IN (SELECT id FROM branch)
1163
- ORDER BY sequence ASC`, lineage.sessionId, oldest, newest).map(parseEntryRow));
1497
+ ORDER BY sequence ASC`, lineage.sessionId, oldest, newest).map((raw) => parseEntryRow(owner, raw)));
1498
+ // The walk stopped at the range or at the root, never at a missing parent.
1499
+ const failures = lineageFailures.selected;
1500
+ const deepest = rows[0];
1501
+ if (deepest !== undefined &&
1502
+ deepest.parentId !== null &&
1503
+ deepest.sequence !== oldest)
1504
+ throw unavailable(failures.missingParent);
1505
+ let previous;
1506
+ for (const row of rows) {
1507
+ if (previous !== undefined && row.parentId !== previous.id)
1508
+ throw unavailable(failures.invalid);
1509
+ previous = row;
1510
+ }
1511
+ return rows;
1512
+ }
1513
+ const decodeWindowSizes = Schema.decodeUnknownResult(Schema.Struct({
1514
+ sizes: Schema.fromJsonString(Schema.Array(Schema.Tuple([sequence, count]))),
1515
+ deepest_parent: Schema.NullOr(Schema.String),
1516
+ deepest_sequence: Schema.NullOr(sequence),
1517
+ }), closed);
1518
+ /**
1519
+ * Sizes of up to `rows` lineage rows inside the bounds, newest or oldest
1520
+ * first. A newest window's walk counts its rows as it descends and stops once
1521
+ * it holds that many; an oldest window's walk stops at `after`. Either way the
1522
+ * walk visits the rows from the tip down to the window, never the history
1523
+ * below it. The deepest walked row comes back with the sizes, so a walk that
1524
+ * ended at a missing parent above where it meant to stop is refused, never
1525
+ * read as the end of history.
1526
+ */
1527
+ function readWindowSizes(owner, lineage, from, after, before, rows) {
1528
+ const ends = `SELECT
1529
+ (SELECT json_group_array(json_array(sequence, bytes)) FROM (
1530
+ SELECT sequence, LENGTH(CAST(data_json AS BLOB)) AS bytes
1531
+ FROM session_entries
1532
+ WHERE ${from === "newest" ? "" : "session_id = ? AND"}
1533
+ state != 'streaming'
1534
+ AND sequence > ? AND sequence < ?
1535
+ AND id IN (SELECT id FROM branch)
1536
+ ORDER BY sequence ${from === "newest" ? "DESC" : "ASC"}
1537
+ LIMIT ?)) AS sizes,
1538
+ (SELECT parent_id FROM branch ORDER BY sequence ASC LIMIT 1)
1539
+ AS deepest_parent,
1540
+ (SELECT sequence FROM branch ORDER BY sequence ASC LIMIT 1)
1541
+ AS deepest_sequence`;
1542
+ const counts = (row) => `CASE WHEN ${row}.state != 'streaming'
1543
+ AND ${row}.sequence < ? AND ${row}.sequence > ?
1544
+ THEN 1 ELSE 0 END`;
1545
+ const raw = from === "newest"
1546
+ ? owner.sql
1547
+ .exec(`WITH RECURSIVE branch (id, parent_id, sequence, state, taken) AS (
1548
+ SELECT id, parent_id, sequence, state,
1549
+ ${counts("session_entries")}
1550
+ FROM session_entries
1551
+ WHERE session_id = ? AND id = ?
1552
+ UNION ALL
1553
+ SELECT parent.id, parent.parent_id, parent.sequence, parent.state,
1554
+ child.taken + ${counts("parent")}
1555
+ FROM session_entries AS parent
1556
+ JOIN branch AS child ON parent.id = child.parent_id
1557
+ WHERE parent.session_id = ? AND +parent.sequence < child.sequence
1558
+ AND child.taken < ? AND child.sequence > ?
1559
+ )
1560
+ ${ends}`, before, after, lineage.sessionId, lineage.tipId, before, after, lineage.sessionId, rows, after, after, before, rows)
1561
+ .toArray()
1562
+ : lineageQuery(owner, lineage, after, ends, lineage.sessionId, after, before, rows);
1563
+ const decoded = decodeWindowSizes(raw[0]);
1564
+ if (decoded._tag === "Failure")
1565
+ throw unavailable("Stored Session entry size is invalid");
1566
+ const { sizes, deepest_parent, deepest_sequence } = decoded.success;
1567
+ const stopped = deepest_parent === null ||
1568
+ deepest_sequence === null ||
1569
+ deepest_sequence <= after ||
1570
+ (from === "newest" && sizes.length === rows);
1571
+ if (!stopped)
1572
+ throw unavailable(lineageFailures.selected.missingParent);
1573
+ return Object.freeze(sizes
1574
+ .map(([rowSequence, bytes]) => ({ sequence: rowSequence, bytes }))
1575
+ .sort((left, right) => from === "newest"
1576
+ ? right.sequence - left.sequence
1577
+ : left.sequence - right.sequence));
1164
1578
  }
1165
1579
  /**
1166
1580
  * Read one contiguous window of a lineage, bounded by row count and by stored
1167
1581
  * bytes. Row sizes are measured in SQL before any entry is decoded.
1168
1582
  */
1169
1583
  export function readLineageWindow(owner, lineage, input) {
1170
- const sizes = lineageQuery(owner, lineage, `SELECT sequence, LENGTH(CAST(data_json AS BLOB)) AS bytes
1171
- FROM session_entries
1172
- WHERE session_id = ? AND state != 'streaming'
1173
- AND sequence > ? AND sequence < ?
1174
- AND id IN (SELECT id FROM branch)
1175
- ORDER BY sequence ${input.from === "newest" ? "DESC" : "ASC"}
1176
- LIMIT ?`, lineage.sessionId, input.after ?? 0, input.before ?? Number.MAX_SAFE_INTEGER, input.rows + 1).map((raw) => {
1177
- const decoded = decodeLineageSize(raw);
1178
- if (decoded._tag === "Failure")
1179
- throw unavailable("Stored Session entry size is invalid");
1180
- return decoded.success;
1181
- });
1584
+ const sizes = readWindowSizes(owner, lineage, input.from, input.after ?? 0, input.before ?? Number.MAX_SAFE_INTEGER, input.rows + 1);
1182
1585
  let taken = 0;
1183
1586
  let bytes = 0;
1184
1587
  for (const size of sizes) {
@@ -1200,45 +1603,59 @@ export function readLineageWindow(owner, lineage, input) {
1200
1603
  }
1201
1604
  /** Whether a complete row with this sequence lies on the lineage. */
1202
1605
  export function lineageHasSequence(owner, lineage, rowSequence) {
1203
- return (lineageQuery(owner, lineage, `SELECT 1 AS present
1606
+ return (lineageQuery(owner, lineage, rowSequence - 1, `SELECT 1 AS present
1204
1607
  FROM session_entries
1205
1608
  WHERE session_id = ? AND state != 'streaming' AND sequence = ?
1206
1609
  AND id IN (SELECT id FROM branch)`, lineage.sessionId, rowSequence).length > 0);
1207
1610
  }
1208
1611
  /** The sequence of one complete row on the lineage, if it is there. */
1209
1612
  export function lineageEntrySequence(owner, lineage, id) {
1210
- const raw = lineageQuery(owner, lineage, `SELECT sequence
1211
- FROM session_entries
1212
- WHERE session_id = ? AND state != 'streaming' AND id = ?
1213
- AND id IN (SELECT id FROM branch)`, lineage.sessionId, id)[0];
1613
+ const raw = owner.sql
1614
+ .exec(`SELECT sequence FROM session_entries
1615
+ WHERE session_id = ? AND id = ? AND state != 'streaming'`, lineage.sessionId, id)
1616
+ .toArray()[0];
1214
1617
  if (raw === undefined)
1215
1618
  return undefined;
1216
1619
  const decoded = decodeLineageSequence(raw);
1217
1620
  if (decoded._tag === "Failure")
1218
1621
  throw unavailable("Stored Session entry sequence is invalid");
1219
- return decoded.success.sequence;
1220
- }
1221
- /** The newest lineage row of one entry kind, decoded. */
1222
- function readNewestLineageEntry(owner, lineage, kind) {
1223
- const raw = lineageQuery(owner, lineage, `SELECT ${entryColumns}
1224
- FROM session_entries
1225
- WHERE session_id = ? AND state != 'streaming' AND type = ?
1226
- AND id IN (SELECT id FROM branch)
1227
- ORDER BY sequence DESC
1228
- LIMIT 1`, lineage.sessionId, kind)[0];
1229
- return raw === undefined ? undefined : parseEntryRow(raw);
1622
+ return lineageHasSequence(owner, lineage, decoded.success.sequence)
1623
+ ? decoded.success.sequence
1624
+ : undefined;
1230
1625
  }
1231
- function lineageModelPreference(owner, lineage) {
1232
- const entry = lineage === undefined
1626
+ /**
1627
+ * The newest preference on the lineage ending at `tipId`. The Session's
1628
+ * preference rows come from their index; the walk from the tip stops at the
1629
+ * first of them it meets, so it never passes the newest preference on this
1630
+ * lineage, and a Session without one walks nothing. One set at the root
1631
+ * still walks the whole lineage.
1632
+ */
1633
+ function newestLineagePreference(owner, sessionId, tipId) {
1634
+ const continues = (row) => `${row}.id NOT IN (SELECT id FROM targets)
1635
+ AND ${row}.sequence > (SELECT MIN(sequence) FROM targets)`;
1636
+ const [newest] = walkedTargets(owner.sql
1637
+ .exec(`WITH RECURSIVE targets AS MATERIALIZED (
1638
+ SELECT id, sequence FROM session_entries
1639
+ WHERE session_id = ? AND type = 'model_preference'
1640
+ AND state != 'streaming'
1641
+ ),
1642
+ ${targetWalk(continues("child"))}
1643
+ ${walkSteps(continues("walk"))}`, sessionId, sessionId, tipId, sessionId)
1644
+ .toArray());
1645
+ return newest === undefined
1233
1646
  ? undefined
1234
- : readNewestLineageEntry(owner, lineage, "model_preference")?.entry;
1647
+ : readEntryRow(owner, sessionId, newest);
1648
+ }
1649
+ function lineageModelPreference(owner, sessionId, tipId) {
1650
+ const entry = newestLineagePreference(owner, sessionId, tipId)?.entry;
1235
1651
  return entry?.kind === "model_preference" ? entry.preference : null;
1236
1652
  }
1237
1653
  /** Latest document value on this exact lineage, not a global last write. */
1238
1654
  function readLineageDocument(owner, lineage, extensionId, name) {
1239
1655
  if (lineage === undefined)
1240
1656
  return undefined;
1241
- const raw = lineageQuery(owner, lineage, `SELECT ${entryColumns}
1657
+ // No index finds documents yet, so this still walks the whole lineage.
1658
+ const raw = lineageQuery(owner, lineage, 0, `SELECT ${entryColumns}
1242
1659
  FROM session_entries
1243
1660
  WHERE session_id = ? AND state != 'streaming' AND type = 'document'
1244
1661
  AND json_extract(data_json, '$.extensionId') = ?
@@ -1246,7 +1663,7 @@ function readLineageDocument(owner, lineage, extensionId, name) {
1246
1663
  AND id IN (SELECT id FROM branch)
1247
1664
  ORDER BY sequence DESC
1248
1665
  LIMIT 1`, lineage.sessionId, extensionId, name)[0];
1249
- const entry = raw === undefined ? undefined : parseEntryRow(raw).entry;
1666
+ const entry = raw === undefined ? undefined : parseEntryRow(owner, raw).entry;
1250
1667
  if (entry === undefined)
1251
1668
  return undefined;
1252
1669
  if (entry.kind !== "document" ||
@@ -1255,10 +1672,12 @@ function readLineageDocument(owner, lineage, extensionId, name) {
1255
1672
  throw unavailable("Stored Session document disagrees with its row");
1256
1673
  return entry.valueJson;
1257
1674
  }
1675
+ const decodeOldestWorkRow = Schema.decodeUnknownResult(Schema.Struct({ sequence: Schema.NullOr(sequence) }), closed);
1258
1676
  /**
1259
- * Look up, across the whole lineage, only the facts of the given works. A
1260
- * diagnostic is the newest of an assistant error, a final retry error or a
1261
- * work terminal's diagnostic; only that text is extracted, never the message.
1677
+ * Look up, on the lineage, only the facts of the given works. Diagnostics come
1678
+ * only from owned terminal fields, never provider message or retry text. The works'
1679
+ * indexes give their oldest row, and the walks stop just below it, so a page
1680
+ * of recent works never visits older history.
1262
1681
  */
1263
1682
  export function readLineageWorkFacts(owner, lineage, workIds) {
1264
1683
  if (workIds.size === 0)
@@ -1268,49 +1687,64 @@ export function readLineageWorkFacts(owner, lineage, workIds) {
1268
1687
  cancellations: new Map(),
1269
1688
  });
1270
1689
  const works = JSON.stringify([...workIds]);
1271
- const commands = new Set(lineageQuery(owner, lineage, `SELECT DISTINCT work_id
1690
+ const oldest = decodeOldestWorkRow(owner.sql
1691
+ .exec(`SELECT MIN(sequence) AS sequence FROM (
1692
+ SELECT MIN(sequence) AS sequence
1693
+ FROM session_entries INDEXED BY idx_session_entries_work
1694
+ WHERE work_id IN (SELECT value FROM json_each(?))
1695
+ AND session_id = ?
1696
+ UNION ALL
1697
+ SELECT MIN(sequence) FROM session_entries
1698
+ INDEXED BY idx_session_entries_cancellation_target
1699
+ WHERE session_id = ? AND type = 'cancellation'
1700
+ AND json_extract(data_json, '$.targetWorkId')
1701
+ IN (SELECT value FROM json_each(?))
1702
+ )`, works, lineage.sessionId, lineage.sessionId, works)
1703
+ .one());
1704
+ if (oldest._tag === "Failure")
1705
+ throw unavailable("Stored Session work order is invalid");
1706
+ if (oldest.success.sequence === null)
1707
+ return Object.freeze({
1708
+ commands: new Set(),
1709
+ diagnostics: new Map(),
1710
+ cancellations: new Map(),
1711
+ });
1712
+ const floor = oldest.success.sequence - 1;
1713
+ const commands = new Set(lineageQuery(owner, lineage, floor, `SELECT DISTINCT work_id
1272
1714
  FROM session_entries
1273
- WHERE session_id = ? AND state != 'streaming' AND type = 'command'
1715
+ WHERE session_id = ? AND sequence > ? AND state != 'streaming' AND type = 'command'
1274
1716
  AND work_id IN (SELECT value FROM json_each(?))
1275
- AND id IN (SELECT id FROM branch)`, lineage.sessionId, works).map((raw) => {
1717
+ AND id IN (SELECT id FROM branch)`, lineage.sessionId, floor, works).map((raw) => {
1276
1718
  const decoded = decodeLineageWorkId(raw);
1277
1719
  if (decoded._tag === "Failure")
1278
1720
  throw unavailable("Stored Session command work is invalid");
1279
1721
  return decoded.success.work_id;
1280
1722
  }));
1281
1723
  const diagnostics = new Map();
1282
- for (const raw of lineageQuery(owner, lineage, `SELECT work_id, diagnostic FROM (
1724
+ for (const raw of lineageQuery(owner, lineage, floor, `SELECT work_id, diagnostic FROM (
1283
1725
  SELECT work_id, sequence,
1284
- CASE
1285
- WHEN type = 'work_terminal'
1286
- THEN json_extract(data_json, '$.diagnostic')
1287
- WHEN type = 'message'
1288
- THEN json_extract(data_json, '$.value.errorMessage')
1289
- WHEN json_extract(data_json, '$.event.type') = 'auto_retry_end'
1290
- THEN json_extract(data_json, '$.event.finalError')
1291
- END AS diagnostic
1726
+ json_extract(data_json, '$.diagnostic') AS diagnostic
1292
1727
  FROM session_entries
1293
- WHERE session_id = ? AND state != 'streaming'
1728
+ WHERE session_id = ? AND sequence > ? AND state != 'streaming'
1294
1729
  AND work_id IN (SELECT value FROM json_each(?))
1295
- AND (type IN ('work_terminal', 'live_retry')
1296
- OR (type = 'message' AND role = 'assistant'))
1730
+ AND type IN ('work_terminal', 'run_terminal')
1297
1731
  AND id IN (SELECT id FROM branch)
1298
1732
  )
1299
1733
  WHERE diagnostic IS NOT NULL
1300
- ORDER BY sequence ASC`, lineage.sessionId, works)) {
1734
+ ORDER BY sequence ASC`, lineage.sessionId, floor, works)) {
1301
1735
  const decoded = decodeLineageDiagnostic(raw);
1302
1736
  if (decoded._tag === "Failure")
1303
1737
  throw unavailable("Stored Session diagnostic is invalid");
1304
1738
  diagnostics.set(decoded.success.work_id, decoded.success.diagnostic);
1305
1739
  }
1306
1740
  const cancellations = new Map();
1307
- for (const { entry } of lineageQuery(owner, lineage, `SELECT ${entryColumns}
1741
+ for (const { entry } of lineageQuery(owner, lineage, floor, `SELECT ${entryColumns}
1308
1742
  FROM session_entries
1309
- WHERE session_id = ? AND state != 'streaming' AND type = 'cancellation'
1743
+ WHERE session_id = ? AND sequence > ? AND state != 'streaming' AND type = 'cancellation'
1310
1744
  AND json_extract(data_json, '$.targetWorkId')
1311
1745
  IN (SELECT value FROM json_each(?))
1312
1746
  AND id IN (SELECT id FROM branch)
1313
- ORDER BY sequence ASC`, lineage.sessionId, works).map(parseEntryRow))
1747
+ ORDER BY sequence ASC`, lineage.sessionId, floor, works).map((raw) => parseEntryRow(owner, raw)))
1314
1748
  if (entry.kind === "cancellation" && entry.meta !== undefined)
1315
1749
  cancellations.set(entry.targetWorkId, entry.meta);
1316
1750
  return Object.freeze({ commands, diagnostics, cancellations });
@@ -1327,81 +1761,245 @@ export function readSelectedPath(owner, input) {
1327
1761
  ? Object.freeze([])
1328
1762
  : readLineageRange(owner, lineage, 1, lineage.newestSequence);
1329
1763
  }
1330
- function segmentQuery(owner, segment, columns, clause, ...bindings) {
1331
- return lineageQuery(owner, segment.lineage, `SELECT ${columns}
1332
- FROM session_entries
1333
- WHERE session_id = ? AND state != 'streaming'
1334
- AND id IN (SELECT id FROM branch)
1335
- AND sequence > ?
1336
- AND (? IS NULL OR work_id = ?
1337
- OR (type = 'command'
1338
- AND json_extract(data_json, '$.targetWorkId') = ?))
1339
- ${clause}`, segment.lineage.sessionId, segment.after, segment.work, segment.work, segment.work, ...bindings);
1764
+ /**
1765
+ * A walk from one row toward the root for the `targets` the statement
1766
+ * selects first by index. `continues` decides, per row, whether the walk
1767
+ * steps past it; a statement without targets reads only its starting row,
1768
+ * so a missing one is still refused.
1769
+ */
1770
+ function targetWalk(continues) {
1771
+ return `walk (id, parent_id, sequence, state) AS (
1772
+ SELECT id, parent_id, sequence, state
1773
+ FROM session_entries
1774
+ WHERE session_id = ? AND id = ?
1775
+ UNION ALL
1776
+ SELECT parent.id, parent.parent_id, parent.sequence, parent.state
1777
+ FROM session_entries AS parent
1778
+ JOIN walk AS child ON parent.id = child.parent_id
1779
+ WHERE parent.session_id = ? AND +parent.sequence < child.sequence
1780
+ AND ${continues}
1781
+ )`;
1340
1782
  }
1341
1783
  /**
1342
- * First sequence the model context reads: the latest compaction's kept
1343
- * entry. Zero when nothing compacted, or when that entry is not on these
1344
- * segments, where `buildSessionContext` also reads the whole path.
1784
+ * The walked targets, newest first, then the deepest walked row, each with
1785
+ * whether the walk meant to pass it. Only these rows reach JavaScript.
1345
1786
  */
1346
- function contextStart(owner, segments) {
1347
- for (const segment of [...segments].reverse()) {
1348
- const raw = segmentQuery(owner, segment, "json_extract(data_json, '$.firstKeptEntryId') AS first_kept", "AND type = 'compaction' ORDER BY sequence DESC LIMIT 1")[0];
1349
- if (raw === undefined)
1350
- continue;
1351
- const latest = decodeFirstKept(raw);
1352
- if (latest._tag === "Failure")
1353
- throw unavailable("Stored Session compaction boundary is invalid");
1354
- for (const candidate of segments) {
1355
- const kept = segmentQuery(owner, candidate, "sequence", "AND id = ?", latest.success.first_kept)[0];
1356
- if (kept === undefined)
1357
- continue;
1358
- const decoded = decodeLineageSequence(kept);
1359
- if (decoded._tag === "Failure")
1360
- throw unavailable("Stored Session entry sequence is invalid");
1361
- return decoded.success.sequence;
1362
- }
1363
- return 0;
1787
+ function walkSteps(continues) {
1788
+ return `SELECT id, parent_id, target, continues FROM (
1789
+ SELECT id, parent_id, sequence,
1790
+ id IN (SELECT id FROM targets) AS target,
1791
+ COALESCE((${continues}), 0) AS continues
1792
+ FROM walk
1793
+ )
1794
+ WHERE target = 1 OR sequence = (SELECT MIN(sequence) FROM walk)
1795
+ ORDER BY sequence DESC`;
1796
+ }
1797
+ const flag = Schema.Literals([0, 1]);
1798
+ const decodeWalkStep = Schema.decodeUnknownResult(Schema.Struct({
1799
+ id: boundedId,
1800
+ parent_id: Schema.NullOr(Schema.String),
1801
+ target: flag,
1802
+ continues: flag,
1803
+ }), closed);
1804
+ /**
1805
+ * The targets a bounded walk reached, newest first, once the walk is known to
1806
+ * have ended at the root or where it meant to stop, never at a missing or
1807
+ * out-of-order parent. Walks step only to an older parent, so they end.
1808
+ * A walk that found not even its starting row is refused as an unavailable
1809
+ * leaf.
1810
+ */
1811
+ function walkedTargets(raws) {
1812
+ const failures = lineageFailures.selected;
1813
+ if (raws.length === 0)
1814
+ throw unavailable(failures.empty);
1815
+ const steps = raws.map((raw) => {
1816
+ const decoded = decodeWalkStep(raw);
1817
+ if (decoded._tag === "Failure")
1818
+ throw unavailable("Stored Session walk row is invalid");
1819
+ return decoded.success;
1820
+ });
1821
+ const deepest = steps.at(-1);
1822
+ if (deepest?.continues === 1 && deepest.parent_id !== null)
1823
+ throw unavailable(failures.missingParent);
1824
+ return steps.flatMap((step) => (step.target === 1 ? [step.id] : []));
1825
+ }
1826
+ /** The kept head of a complete compaction row, else null. */
1827
+ function compactionHead(row) {
1828
+ return `CASE WHEN ${row}.type = 'compaction' AND ${row}.state != 'streaming'
1829
+ THEN json_extract(${row}.data_json, '$.head') END`;
1830
+ }
1831
+ /**
1832
+ * The walk a settled context reads: from the tip back to the kept entry of
1833
+ * the newest complete compaction, which `head` carries once passed, or to the
1834
+ * root when nothing compacted or that entry is not on this lineage. It never
1835
+ * visits the history a compaction already cut.
1836
+ */
1837
+ const contextWalk = `WITH RECURSIVE walk (id, parent_id, sequence, state, head) AS (
1838
+ SELECT id, parent_id, sequence, state,
1839
+ ${compactionHead("session_entries")}
1840
+ FROM session_entries
1841
+ WHERE session_id = ? AND id = ?
1842
+ UNION ALL
1843
+ SELECT parent.id, parent.parent_id, parent.sequence, parent.state,
1844
+ COALESCE(child.head, ${compactionHead("parent")})
1845
+ FROM session_entries AS parent
1846
+ JOIN walk AS child ON parent.id = child.parent_id
1847
+ WHERE parent.session_id = ? AND +parent.sequence < child.sequence
1848
+ AND (child.head IS NOT child.id OR child.state = 'streaming')
1849
+ )`;
1850
+ /**
1851
+ * The older facts that still decide a kept window: compactions of the active
1852
+ * work and cancellations of a command inside the window. Their indexes find
1853
+ * them; the walk down from the kept entry stops at the oldest of them, so
1854
+ * only facts on this lineage count and a window without any walks nothing.
1855
+ */
1856
+ function readDecidingFacts(owner, sessionId, kept, window, workId) {
1857
+ const commands = JSON.stringify(window.flatMap(({ entry }) => entry.kind === "command" ? [entry.workId] : []));
1858
+ const continues = (row) => `${row}.sequence > (SELECT MIN(sequence) FROM targets)`;
1859
+ const facts = walkedTargets(owner.sql
1860
+ .exec(`WITH RECURSIVE targets AS MATERIALIZED (
1861
+ SELECT id, sequence FROM session_entries
1862
+ WHERE session_id = ? AND type = 'compaction' AND work_id = ?
1863
+ AND sequence < ? AND state != 'streaming'
1864
+ UNION ALL
1865
+ SELECT id, sequence
1866
+ FROM session_entries
1867
+ INDEXED BY idx_session_entries_cancellation_target
1868
+ WHERE session_id = ? AND type = 'cancellation'
1869
+ AND json_extract(data_json, '$.targetWorkId')
1870
+ IN (SELECT value FROM json_each(?))
1871
+ AND sequence < ? AND state != 'streaming'
1872
+ ),
1873
+ ${targetWalk(continues("child"))}
1874
+ ${walkSteps(continues("walk"))}`, sessionId, workId, kept.sequence, sessionId, commands, kept.sequence, sessionId, kept.id, sessionId)
1875
+ .toArray());
1876
+ if (facts.length === 0)
1877
+ return Object.freeze([]);
1878
+ return Object.freeze(owner.sql
1879
+ .exec(`SELECT ${entryColumns}
1880
+ FROM session_entries
1881
+ WHERE id IN (SELECT value FROM json_each(?))
1882
+ ORDER BY sequence ASC`, JSON.stringify(facts))
1883
+ .toArray()
1884
+ .map((raw) => parseEntryRow(owner, raw)));
1885
+ }
1886
+ /**
1887
+ * One lineage's settled window: the walk from `tipId` to the kept entry of
1888
+ * its newest complete compaction, or to the root. `compacted` says the walk
1889
+ * stopped at a kept entry rather than at the root. Only the walked rows are
1890
+ * read, and they are checked the way `locateLineage` checks a whole lineage.
1891
+ */
1892
+ function readContextWindow(owner, sessionId, tipId) {
1893
+ const failures = lineageFailures.selected;
1894
+ const rows = owner.sql
1895
+ .exec(`${contextWalk}
1896
+ SELECT ${entryColumns}
1897
+ FROM session_entries
1898
+ WHERE id IN (SELECT id FROM walk) AND state != 'streaming'
1899
+ ORDER BY sequence ASC`, sessionId, tipId, sessionId)
1900
+ .toArray()
1901
+ .map((raw) => parseEntryRow(owner, raw));
1902
+ const kept = rows[0];
1903
+ if (kept === undefined)
1904
+ throw unavailable(failures.empty);
1905
+ let head;
1906
+ let previous;
1907
+ for (const row of rows) {
1908
+ if (previous !== undefined && row.parentId !== previous.id)
1909
+ throw unavailable(failures.invalid);
1910
+ if (row.entry.kind === "compaction")
1911
+ head = row.entry.head;
1912
+ previous = row;
1364
1913
  }
1365
- return 0;
1914
+ const compacted = head === kept.id;
1915
+ if (!compacted && kept.parentId !== null)
1916
+ throw unavailable(failures.missingParent);
1917
+ return Object.freeze({ rows: Object.freeze(rows), compacted });
1366
1918
  }
1367
1919
  /**
1368
- * The rows model context and compaction read: the latest compaction's kept
1920
+ * Context rows of the current selected lineage for one accepted work, or,
1921
+ * without a work, the settled context the next run starts from: the kept
1369
1922
  * window through the tip, preceded only by the older facts that still decide
1370
1923
  * it. Those are cancellations that withdrew a command inside the window, and
1371
1924
  * earlier compactions of the active work, whose recovery runs at most once.
1925
+ * The read is bounded by the kept window and its deciding facts, not by the
1926
+ * history a compaction cut.
1372
1927
  */
1373
- function readContextRows(owner, segments, workId) {
1374
- const start = contextStart(owner, segments);
1375
- let stored = 0;
1376
- for (const segment of segments) {
1377
- const measured = decodeContextBytes(segmentQuery(owner, segment, "COALESCE(SUM(LENGTH(CAST(data_json AS BLOB))), 0) AS bytes", "AND sequence >= ?", start)[0]);
1378
- if (measured._tag === "Failure")
1379
- throw unavailable("Stored Session context size is invalid");
1380
- stored += measured.success.bytes;
1381
- }
1382
- if (stored > contextByteLimit)
1383
- throw new ShiitakeError("store", "context_too_large", "This Session's model context is too large to read safely. Branch or fork from an earlier entry to continue.");
1384
- const window = segments.flatMap((segment) => segmentQuery(owner, segment, entryColumns, "AND sequence >= ? ORDER BY sequence ASC", start).map(parseEntryRow));
1385
- if (start === 0)
1386
- return Object.freeze(window);
1387
- const commands = JSON.stringify(window.flatMap(({ entry }) => entry.kind === "command" ? [entry.workId] : []));
1388
- const prefix = segments.flatMap((segment) => segmentQuery(owner, segment, entryColumns, `AND sequence < ?
1389
- AND ((type = 'compaction' AND work_id = ?)
1390
- OR (type = 'cancellation'
1391
- AND json_extract(data_json, '$.targetWorkId')
1392
- IN (SELECT value FROM json_each(?))))
1393
- ORDER BY sequence ASC`, start, workId, commands).map(parseEntryRow));
1394
- return Object.freeze([...prefix, ...window]);
1928
+ export function readSessionContextPath(owner, input) {
1929
+ const leaf = readSessionRow(owner, input.sessionId)?.leaf_id;
1930
+ if (leaf === undefined || leaf === null)
1931
+ return Object.freeze([]);
1932
+ const window = readContextWindow(owner, input.sessionId, leaf);
1933
+ const kept = window.rows[0];
1934
+ if (!window.compacted || kept === undefined)
1935
+ return window.rows;
1936
+ return Object.freeze([
1937
+ ...readDecidingFacts(owner, input.sessionId, kept, window.rows, input.workId ?? null),
1938
+ ...window.rows,
1939
+ ]);
1940
+ }
1941
+ /** Whether a row is an older fact that decides `commands` for `workId`. */
1942
+ function decides(row, workId, commands) {
1943
+ return ((row.entry.kind === "compaction" && row.entry.workId === workId) ||
1944
+ (row.entry.kind === "cancellation" && commands.has(row.entry.targetWorkId)));
1395
1945
  }
1396
1946
  /**
1397
- * Context rows of the current selected lineage for one accepted work, or,
1398
- * without a work, the settled context the next run starts from.
1947
+ * The context a prepared run reads: its command's own lineage through the
1948
+ * command, then only this work's later rows on the current lineage (the
1949
+ * tail). The newest compaction of the tail, else of the command's lineage,
1950
+ * decides where the window starts, with the same deciding facts before it as
1951
+ * a settled context. The command's lineage is walked only from the command
1952
+ * down to that start, and the tail only down to the command.
1399
1953
  */
1400
- export function readSessionContextPath(owner, input) {
1401
- const lineage = readSessionLineage(owner, { sessionId: input.sessionId });
1402
- return lineage === undefined
1403
- ? Object.freeze([])
1404
- : readContextRows(owner, [{ lineage, after: 0, work: null }], input.workId ?? null);
1954
+ function readRunContextRows(owner, sessionId, command, current, workId) {
1955
+ const through = readSessionLineage(owner, { sessionId, leafId: command.id });
1956
+ if (through === undefined)
1957
+ throw unavailable("Accepted Session command lineage is unavailable");
1958
+ const tail = current === undefined
1959
+ ? []
1960
+ : lineageQuery(owner, current, command.sequence, `SELECT ${entryColumns}
1961
+ FROM session_entries
1962
+ WHERE session_id = ? AND sequence > ? AND state != 'streaming'
1963
+ AND id IN (SELECT id FROM branch)
1964
+ AND (work_id = ?
1965
+ OR (type = 'command'
1966
+ AND json_extract(data_json, '$.targetWorkId') = ?))
1967
+ ORDER BY sequence ASC`, sessionId, command.sequence, workId, workId).map((raw) => parseEntryRow(owner, raw));
1968
+ let latest;
1969
+ for (const row of tail)
1970
+ if (row.entry.kind === "compaction")
1971
+ latest = row.entry.head;
1972
+ let start = 0;
1973
+ let lineageRows;
1974
+ if (latest === undefined) {
1975
+ const window = readContextWindow(owner, sessionId, command.id);
1976
+ lineageRows = window.rows;
1977
+ if (window.compacted)
1978
+ start = window.rows[0]?.sequence ?? 0;
1979
+ }
1980
+ else {
1981
+ const head = latest;
1982
+ start =
1983
+ tail.find(({ id }) => id === head)?.sequence ??
1984
+ lineageEntrySequence(owner, through, head) ??
1985
+ 0;
1986
+ lineageRows =
1987
+ start > command.sequence
1988
+ ? []
1989
+ : readLineageRange(owner, through, Math.max(start, 1), command.sequence);
1990
+ }
1991
+ if (start === 0)
1992
+ return Object.freeze([...lineageRows, ...tail]);
1993
+ const window = [
1994
+ ...lineageRows,
1995
+ ...tail.filter(({ sequence }) => sequence >= start),
1996
+ ];
1997
+ const commands = new Set(window.flatMap(({ entry }) => entry.kind === "command" ? [entry.workId] : []));
1998
+ return Object.freeze([
1999
+ ...readDecidingFacts(owner, sessionId, lineageRows[0] ?? command, window, workId),
2000
+ ...tail.filter((row) => row.sequence < start && decides(row, workId, commands)),
2001
+ ...window,
2002
+ ]);
1405
2003
  }
1406
2004
  /** Work cancelled before delivery stays in history but never enters model context. */
1407
2005
  export function withdrawnWorkIds(path) {
@@ -1431,13 +2029,12 @@ export function buildSessionContext(path, activeWorkId) {
1431
2029
  const compactionEntry = compaction?.entry.kind === "compaction" ? compaction.entry : undefined;
1432
2030
  const keptIndex = compactionEntry === undefined
1433
2031
  ? -1
1434
- : path.findIndex(({ id }) => id === compactionEntry.firstKeptEntryId);
2032
+ : path.findIndex(({ id }) => id === compactionEntry.head);
1435
2033
  const contextPath = keptIndex >= 0 ? path.slice(keptIndex) : path;
1436
2034
  const visible = contextMessageIds(contextPath, activeWorkId);
1437
2035
  const messages = [];
1438
- if (compactionEntry !== undefined && compaction !== undefined) {
1439
- messages.push(compactionSummaryMessage(compactionEntry.summary, compaction.timestamp));
1440
- }
2036
+ if (compactionEntry !== undefined)
2037
+ messages.push(...compactionEntry.model);
1441
2038
  for (const row of contextPath) {
1442
2039
  const prompt = promptCommand(row.entry);
1443
2040
  if (prompt !== undefined && !withdrawn.has(prompt.workId))
@@ -1469,9 +2066,6 @@ export function prepareSessionRun(owner, input) {
1469
2066
  encodeStoredSessionEntry(command.entry) !==
1470
2067
  encodeStoredSessionEntry(admission.entry))
1471
2068
  throw unavailable("Accepted Session command disagrees with its admission");
1472
- const through = locateLineage(owner, input.sessionId, command.id, "selected");
1473
- if (through.newestId !== command.id)
1474
- throw unavailable("Accepted Session command lineage is unavailable");
1475
2069
  // The current claim owns the command. Empty placeholders from a lost
1476
2070
  // reader carry no recoverable text and must not survive as live progress.
1477
2071
  owner.sql.exec("DELETE FROM session_entries WHERE session_id = ? AND work_id = ? AND state = 'streaming' AND type = 'message_start'", input.sessionId, command.entry.workId);
@@ -1479,19 +2073,14 @@ export function prepareSessionRun(owner, input) {
1479
2073
  const workId = command.entry.workId;
1480
2074
  const current = readSessionLineage(owner, { sessionId: input.sessionId });
1481
2075
  // The command's own lineage, then only this work's later rows.
1482
- const continuedPath = readContextRows(owner, current === undefined
1483
- ? [{ lineage: through, after: 0, work: null }]
1484
- : [
1485
- { lineage: through, after: 0, work: null },
1486
- { lineage: current, after: command.sequence, work: workId },
1487
- ], workId);
2076
+ const continuedPath = readRunContextRows(owner, input.sessionId, command, current, workId);
1488
2077
  const selectedCommand = Object.freeze({
1489
2078
  ...command,
1490
2079
  entry: command.entry,
1491
2080
  });
1492
2081
  return Object.freeze({
1493
2082
  command: selectedCommand,
1494
- modelPreference: lineageModelPreference(owner, through),
2083
+ modelPreference: lineageModelPreference(owner, input.sessionId, command.id),
1495
2084
  messages: buildSessionContext(continuedPath, command.entry.workId),
1496
2085
  path: continuedPath,
1497
2086
  entryDelivery,
@@ -1500,49 +2089,125 @@ export function prepareSessionRun(owner, input) {
1500
2089
  });
1501
2090
  });
1502
2091
  }
2092
+ /**
2093
+ * Whether a summary whose first kept entry is `head` still fits the selected
2094
+ * lineage (Pi Durable §6). It is stale when `head` is not on the selected
2095
+ * lineage or sits before the current context start, where a later compaction
2096
+ * already cut; placing it would bring cut history back. One bounded read.
2097
+ */
2098
+ export function compactionHeadState(owner, input) {
2099
+ const leaf = readSessionRow(owner, input.sessionId)?.leaf_id;
2100
+ if (leaf === undefined || leaf === null)
2101
+ return "stale";
2102
+ // The head is current exactly when it lies in the settled window.
2103
+ const raw = owner.sql
2104
+ .exec(`${contextWalk}
2105
+ SELECT sequence
2106
+ FROM session_entries
2107
+ WHERE state != 'streaming' AND id = ? AND id IN (SELECT id FROM walk)`, input.sessionId, leaf, input.sessionId, input.head)
2108
+ .toArray()[0];
2109
+ if (raw === undefined)
2110
+ return "stale";
2111
+ const decoded = decodeLineageSequence(raw);
2112
+ if (decoded._tag === "Failure")
2113
+ throw unavailable("Stored Session entry sequence is invalid");
2114
+ return "current";
2115
+ }
1503
2116
  /** Append one compaction notice on the selected lineage. */
1504
2117
  export function appendCompactionNoticeOwned(owner, input) {
1505
2118
  return owner.transactionSync(() => {
1506
- const entry = {
2119
+ const common = {
1507
2120
  kind: "compaction_notice",
1508
2121
  workId: input.workId,
1509
2122
  trigger: input.trigger,
1510
- result: input.result,
1511
2123
  };
2124
+ let entry;
2125
+ if (input.result === "stale" || input.result === "insufficient") {
2126
+ const dropped = { ...common, result: input.result };
2127
+ entry =
2128
+ input.usage === undefined
2129
+ ? dropped
2130
+ : { ...dropped, usage: input.usage };
2131
+ }
2132
+ else if (input.result === "failed") {
2133
+ const failed = {
2134
+ ...common,
2135
+ result: "failed",
2136
+ };
2137
+ const diagnosed = input.diagnostic === undefined
2138
+ ? failed
2139
+ : { ...failed, diagnostic: input.diagnostic.slice(0, 200) };
2140
+ entry =
2141
+ input.usage === undefined
2142
+ ? diagnosed
2143
+ : { ...diagnosed, usage: input.usage };
2144
+ }
2145
+ else {
2146
+ const nothing = {
2147
+ ...common,
2148
+ result: "nothing_to_compact",
2149
+ };
2150
+ entry =
2151
+ input.diagnostic === undefined
2152
+ ? nothing
2153
+ : { ...nothing, diagnostic: input.diagnostic.slice(0, 200) };
2154
+ }
1512
2155
  return appendSessionEntry(owner, {
1513
2156
  sessionId: input.sessionId,
1514
2157
  id: sessionIds.fact.generate(),
1515
- entry: input.diagnostic === undefined
1516
- ? entry
1517
- : { ...entry, diagnostic: input.diagnostic.slice(0, 200) },
2158
+ entry,
1518
2159
  });
1519
2160
  });
1520
2161
  }
1521
2162
  /** Atomically append one compaction transition using the selected leaf CAS. */
1522
2163
  export function appendCompactionTransitionOwned(owner, input) {
1523
- return owner.transactionSync(() => {
1524
- const session = readSessionRow(owner, input.sessionId);
1525
- if (session?.leaf_id !== input.expectedLeafId)
1526
- throw new ShiitakeError("store", "conflict", "Session leaf changed during compaction");
1527
- const entry = {
1528
- kind: "compaction",
2164
+ return owner.transactionSync(() => appendCompactionRow(owner, input));
2165
+ }
2166
+ /**
2167
+ * Place a summary selected before now (a background compaction): the head
2168
+ * check of `compactionHeadState` and the leaf CAS in one transaction, so a
2169
+ * head that moved after any earlier check still makes it `stale` (Pi
2170
+ * Durable §6). A stale summary writes nothing.
2171
+ */
2172
+ export function placeCompactionTransitionOwned(owner, input) {
2173
+ return owner.transactionSync(() => compactionHeadState(owner, {
2174
+ sessionId: input.sessionId,
2175
+ head: input.head,
2176
+ }) === "stale"
2177
+ ? "stale"
2178
+ : appendCompactionRow(owner, input));
2179
+ }
2180
+ /** The leaf CAS and the compaction row; callers own the transaction. */
2181
+ function appendCompactionRow(owner, input) {
2182
+ const session = readSessionRow(owner, input.sessionId);
2183
+ if (session?.leaf_id !== input.expectedLeafId)
2184
+ throw new ShiitakeError("store", "conflict", "Session leaf changed during compaction");
2185
+ const entry = {
2186
+ kind: "compaction",
2187
+ workId: input.workId,
2188
+ head: input.head,
2189
+ model: [compactionSummaryMessage(input.summary, Date.now())],
2190
+ data: {
2191
+ reason: input.trigger,
1529
2192
  resumesRun: input.resumesRun ?? false,
1530
- trigger: input.trigger,
1531
- tokensAfter: input.tokensAfter,
1532
- workId: input.workId,
1533
- firstKeptEntryId: input.firstKeptEntryId,
1534
- summary: input.summary,
1535
2193
  tokensBefore: input.tokensBefore,
2194
+ tokensAfter: input.tokensAfter,
1536
2195
  usage: input.usage,
1537
2196
  contextRevision: input.contextRevision,
1538
- };
1539
- return appendSessionEntry(owner, {
1540
- sessionId: input.sessionId,
1541
- id: sessionIds.fact.generate(),
1542
- entry: input.details === undefined
1543
- ? entry
1544
- : { ...entry, details: input.details },
1545
- });
2197
+ },
2198
+ };
2199
+ const withReceipt = input.receipt === undefined
2200
+ ? entry
2201
+ : { ...entry, data: { ...entry.data, receipt: input.receipt } };
2202
+ return appendSessionEntry(owner, {
2203
+ sessionId: input.sessionId,
2204
+ id: sessionIds.fact.generate(),
2205
+ entry: input.details === undefined
2206
+ ? withReceipt
2207
+ : {
2208
+ ...withReceipt,
2209
+ data: { ...withReceipt.data, details: input.details },
2210
+ },
1546
2211
  });
1547
2212
  }
1548
2213
  function isPromptParts(content) {
@@ -1656,6 +2321,32 @@ export function acceptSessionCommandOwned(owner, sessionId, input, meta, origin,
1656
2321
  });
1657
2322
  return acceptanceFromAdmission(command, "new");
1658
2323
  }
2324
+ /** Called only in the admission door's transaction, shared by admission and withdrawal. */
2325
+ export function assertSessionRequestNotWithdrawn(owner, sessionId, requestId) {
2326
+ if (owner.sql
2327
+ .exec("SELECT 1 FROM session_request_withdrawals WHERE session_id = ? AND request_id = ?", sessionId, requestId)
2328
+ .toArray().length > 0)
2329
+ throw new ShiitakeError("contract", "closed", "Request was withdrawn", requestId);
2330
+ }
2331
+ /** An admitted request wins; otherwise its key becomes a durable admission fence. */
2332
+ export function withdrawSessionRequestOwned(owner, sessionId, requestId) {
2333
+ const admitted = readSessionCommandAdmissionByRequest(owner, sessionId, requestId);
2334
+ if (admitted !== undefined)
2335
+ return { status: "accepted", commandId: admitted.id };
2336
+ // A held website reply records an operator line rather than scheduling work.
2337
+ // It is already durable and must not be restored as though it were unsent.
2338
+ const recorded = owner.sql
2339
+ .exec("SELECT id FROM session_entries WHERE session_id = ? AND request_id = ? AND type = 'operator'", sessionId, requestId)
2340
+ .toArray()[0];
2341
+ if (recorded !== undefined) {
2342
+ const decoded = decodeSessionIdRow(recorded);
2343
+ if (decoded._tag === "Failure")
2344
+ throw unavailable("Stored operator receipt is invalid");
2345
+ return { status: "accepted", commandId: decoded.success.id };
2346
+ }
2347
+ owner.sql.exec("INSERT OR IGNORE INTO session_request_withdrawals(session_id, request_id) VALUES (?, ?)", sessionId, requestId);
2348
+ return { status: "unknown" };
2349
+ }
1659
2350
  function acceptanceFromAdmission(row, delivery) {
1660
2351
  const accepted = Object.freeze({
1661
2352
  sessionId: row.sessionId,
@@ -1811,6 +2502,7 @@ export function selectSessionBranch(owner, input) {
1811
2502
  */
1812
2503
  export function appendOperatorEntry(owner, input) {
1813
2504
  return owner.transactionSync(() => {
2505
+ assertSessionRequestNotWithdrawn(owner, input.sessionId, input.requestId);
1814
2506
  if (readSessionRow(owner, input.sessionId) === undefined)
1815
2507
  throw unavailable("Session is unavailable for an operator entry");
1816
2508
  const existing = owner.sql
@@ -1846,13 +2538,12 @@ export function appendOperatorEntry(owner, input) {
1846
2538
  }
1847
2539
  function readSessionRows(owner, sessionId) {
1848
2540
  return owner.sql
1849
- .exec(`SELECT id, session_id, parent_id, sequence, type, role, state,
1850
- request_id, work_id, data_json, timestamp, completed_at
2541
+ .exec(`SELECT ${entryColumns}
1851
2542
  FROM session_entries
1852
2543
  WHERE session_id = ? AND state != 'streaming'
1853
2544
  ORDER BY sequence ASC`, sessionId)
1854
2545
  .toArray()
1855
- .map(parseEntryRow);
2546
+ .map((raw) => parseEntryRow(owner, raw));
1856
2547
  }
1857
2548
  /** Link every stored row into a forest, retaining rows with no parent. */
1858
2549
  function buildMutableSessionForest(rows) {
@@ -1952,7 +2643,7 @@ export function forkSessionAt(owner, input) {
1952
2643
  const entry = row.entry.kind === "compaction"
1953
2644
  ? {
1954
2645
  ...row.entry,
1955
- firstKeptEntryId: idMap.get(row.entry.firstKeptEntryId) ??
2646
+ head: idMap.get(row.entry.head) ??
1956
2647
  (() => {
1957
2648
  throw unavailable("Fork compaction boundary is unavailable");
1958
2649
  })(),