@hyperdrive.bot/fleet-server 0.3.159 → 0.3.160

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 (48) hide show
  1. package/dist/server/server/agent/agent-manager.d.ts +29 -0
  2. package/dist/server/server/agent/agent-manager.js +69 -5
  3. package/dist/server/server/agent/agent-timeline-store.d.ts +10 -0
  4. package/dist/server/server/agent/agent-timeline-store.js +54 -6
  5. package/dist/server/server/agent/blocker-log.d.ts +3 -1
  6. package/dist/server/server/agent/blocker-log.js +17 -5
  7. package/dist/server/server/agent/card-move-log.d.ts +3 -1
  8. package/dist/server/server/agent/card-move-log.js +21 -14
  9. package/dist/server/server/agent/import-sessions.js +1 -1
  10. package/dist/server/server/agent/jsonl-card-index.d.ts +53 -0
  11. package/dist/server/server/agent/jsonl-card-index.js +138 -0
  12. package/dist/server/server/agent/lifecycle-command.d.ts +18 -1
  13. package/dist/server/server/agent/lifecycle-command.js +21 -2
  14. package/dist/server/server/agent/mcp-shared.d.ts +1 -0
  15. package/dist/server/server/agent/provider-snapshot-manager.d.ts +9 -0
  16. package/dist/server/server/agent/provider-snapshot-manager.js +38 -1
  17. package/dist/server/server/agent/structured-generation-providers.d.ts +2 -1
  18. package/dist/server/server/agent/structured-generation-providers.js +21 -8
  19. package/dist/server/server/fleet/decision-service.d.ts +8 -0
  20. package/dist/server/server/fleet/decision-service.js +24 -1
  21. package/dist/server/server/fleet/fleet-controls.js +2 -1
  22. package/dist/server/server/fleet/fleet-reader.d.ts +12 -0
  23. package/dist/server/server/fleet/fleet-reader.js +48 -2
  24. package/dist/server/server/schedule/service.d.ts +4 -0
  25. package/dist/server/server/schedule/service.js +26 -5
  26. package/dist/server/server/schedule/store.d.ts +51 -0
  27. package/dist/server/server/schedule/store.js +184 -16
  28. package/dist/server/server/session/agent-updates/agent-updates-service.d.ts +16 -1
  29. package/dist/server/server/session/agent-updates/agent-updates-service.js +59 -2
  30. package/dist/server/server/session/provider/provider-catalog-session.d.ts +8 -0
  31. package/dist/server/server/session/provider/provider-catalog-session.js +21 -1
  32. package/dist/server/server/session.d.ts +16 -0
  33. package/dist/server/server/session.js +115 -19
  34. package/dist/server/server/websocket-server.d.ts +5 -0
  35. package/dist/server/server/websocket-server.js +10 -0
  36. package/dist/server/server/workspace-directory.d.ts +4 -1
  37. package/dist/server/server/workspace-directory.js +3 -2
  38. package/dist/server/web-ui/_expo/static/js/web/{index-1420ca2d21c343afea2c8e2da752cd54.js → index-29775d90476d32ca65921d262ae06baa.js} +6 -6
  39. package/dist/server/web-ui/_expo/static/js/web/index-29775d90476d32ca65921d262ae06baa.js.br +0 -0
  40. package/dist/server/web-ui/_expo/static/js/web/index-29775d90476d32ca65921d262ae06baa.js.gz +0 -0
  41. package/dist/server/web-ui/_expo/static/js/web/{index-1420ca2d21c343afea2c8e2da752cd54.js.map.br → index-29775d90476d32ca65921d262ae06baa.js.map.br} +0 -0
  42. package/dist/server/web-ui/_expo/static/js/web/{index-1420ca2d21c343afea2c8e2da752cd54.js.map.gz → index-29775d90476d32ca65921d262ae06baa.js.map.gz} +0 -0
  43. package/dist/server/web-ui/index.html +1 -1
  44. package/dist/server/web-ui/index.html.br +0 -0
  45. package/dist/server/web-ui/index.html.gz +0 -0
  46. package/package.json +6 -6
  47. package/dist/server/web-ui/_expo/static/js/web/index-1420ca2d21c343afea2c8e2da752cd54.js.br +0 -0
  48. package/dist/server/web-ui/_expo/static/js/web/index-1420ca2d21c343afea2c8e2da752cd54.js.gz +0 -0
@@ -236,6 +236,8 @@ export declare class AgentManager {
236
236
  private readonly providerSwapMetadata;
237
237
  private readonly agents;
238
238
  private readonly timelineStore;
239
+ /** Deferred archive teardowns in flight, per agent. See `scheduleArchiveTeardown`. */
240
+ private readonly archiveTeardowns;
239
241
  private readonly agentsAwaitingInitialSnapshotPersist;
240
242
  private readonly sessionEventTails;
241
243
  private readonly foregroundRuns;
@@ -345,6 +347,8 @@ export declare class AgentManager {
345
347
  listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]>;
346
348
  getAgent(id: string): ManagedAgent | null;
347
349
  getTimeline(id: string): AgentTimelineItem[];
350
+ /** Timeline row count, without the full copy `getTimeline(id).length` makes. */
351
+ getTimelineSize(id: string): number;
348
352
  /**
349
353
  * Timeline epoch for an agent. Row `seq` restarts at 1 whenever the store is
350
354
  * re-initialized without seeded rows, so a seq is only comparable WITHIN an
@@ -414,6 +418,31 @@ export declare class AgentManager {
414
418
  archiveAgent(agentId: string): Promise<{
415
419
  archivedAt: string;
416
420
  }>;
421
+ /**
422
+ * First half of `archiveAgent`: persist the archived record. After this the
423
+ * archive is durable and can be acknowledged; the process is still running.
424
+ */
425
+ markLiveAgentArchived(agentId: string): Promise<{
426
+ archivedAt: string;
427
+ }>;
428
+ /**
429
+ * Second half of `archiveAgent`: close the process and archive the subagents
430
+ * it spawned. Safe to run after the archive was acknowledged.
431
+ */
432
+ completeLiveAgentArchive(agentId: string): Promise<void>;
433
+ /**
434
+ * Run the deferred half of an archive (close the process if still open, archive
435
+ * subagents) in the background, serialized per agent.
436
+ *
437
+ * Everything that can revive the agent (unarchive, resume, reload) awaits it
438
+ * first via `awaitArchiveTeardown`, so the outcome is the same as the old
439
+ * synchronous archive: the revive always lands after the close, and a close
440
+ * can never land on a revived agent. Resolves to the error when the teardown
441
+ * failed, never rejects.
442
+ */
443
+ scheduleArchiveTeardown(agentId: string): Promise<Error | null>;
444
+ /** Wait for a pending archive teardown of this agent, if any. */
445
+ awaitArchiveTeardown(agentId: string | undefined): Promise<void>;
417
446
  private cascadeArchiveChildren;
418
447
  private markRecordArchived;
419
448
  private fireAgentArchived;
@@ -218,6 +218,8 @@ export class AgentManager {
218
218
  this.providerSwapMetadata = new Map();
219
219
  this.agents = new Map();
220
220
  this.timelineStore = new InMemoryAgentTimelineStore();
221
+ /** Deferred archive teardowns in flight, per agent. See `scheduleArchiveTeardown`. */
222
+ this.archiveTeardowns = new Map();
221
223
  this.agentsAwaitingInitialSnapshotPersist = new Set();
222
224
  this.sessionEventTails = new Map();
223
225
  this.foregroundRuns = new ForegroundRunState();
@@ -350,7 +352,7 @@ export class AgentManager {
350
352
  if (!this.timelineStore.has(agent.id)) {
351
353
  continue;
352
354
  }
353
- const len = this.timelineStore.getItems(agent.id).length;
355
+ const len = this.timelineStore.size(agent.id);
354
356
  totalItems += len;
355
357
  if (len > maxItemsPerAgent) {
356
358
  maxItemsPerAgent = len;
@@ -640,6 +642,11 @@ export class AgentManager {
640
642
  this.requireAgent(id);
641
643
  return this.timelineStore.getItems(id);
642
644
  }
645
+ /** Timeline row count, without the full copy `getTimeline(id).length` makes. */
646
+ getTimelineSize(id) {
647
+ this.requireAgent(id);
648
+ return this.timelineStore.size(id);
649
+ }
643
650
  /**
644
651
  * Timeline epoch for an agent. Row `seq` restarts at 1 whenever the store is
645
652
  * re-initialized without seeded rows, so a seq is only comparable WITHIN an
@@ -690,6 +697,7 @@ export class AgentManager {
690
697
  // Reconstruct an agent from provider persistence. Callers should explicitly
691
698
  // hydrate timeline history after resume.
692
699
  async resumeAgentFromPersistence(handle, overrides, agentId, options) {
700
+ await this.awaitArchiveTeardown(agentId);
693
701
  const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "resumeAgentFromPersistence");
694
702
  const metadata = (handle.metadata ?? {});
695
703
  const mergedConfig = {
@@ -853,6 +861,7 @@ export class AgentManager {
853
861
  // Hot-reload an active agent session with config overrides while preserving
854
862
  // in-memory timeline state.
855
863
  async reloadAgentSession(agentId, overrides, options) {
864
+ await this.awaitArchiveTeardown(agentId);
856
865
  let existing = this.requireSessionAgent(agentId);
857
866
  if (this.hasInFlightRun(agentId)) {
858
867
  await this.cancelAgentRun(agentId);
@@ -983,6 +992,15 @@ export class AgentManager {
983
992
  }, "agent.manager.close.complete");
984
993
  }
985
994
  async archiveAgent(agentId) {
995
+ const { archivedAt } = await this.markLiveAgentArchived(agentId);
996
+ await this.completeLiveAgentArchive(agentId);
997
+ return { archivedAt };
998
+ }
999
+ /**
1000
+ * First half of `archiveAgent`: persist the archived record. After this the
1001
+ * archive is durable and can be acknowledged; the process is still running.
1002
+ */
1003
+ async markLiveAgentArchived(agentId) {
986
1004
  const agent = this.requireAgent(agentId);
987
1005
  if (!this.registry) {
988
1006
  throw new Error("Agent storage is not configured");
@@ -996,10 +1014,54 @@ export class AgentManager {
996
1014
  }
997
1015
  const { archivedAt } = await this.markRecordArchived(stored);
998
1016
  agent.updatedAt = new Date(archivedAt);
999
- await this.closeAgent(agentId);
1000
- await this.cascadeArchiveChildren(agentId);
1001
1017
  return { archivedAt };
1002
1018
  }
1019
+ /**
1020
+ * Second half of `archiveAgent`: close the process and archive the subagents
1021
+ * it spawned. Safe to run after the archive was acknowledged.
1022
+ */
1023
+ async completeLiveAgentArchive(agentId) {
1024
+ if (this.agents.has(agentId)) {
1025
+ await this.closeAgent(agentId);
1026
+ }
1027
+ await this.cascadeArchiveChildren(agentId);
1028
+ }
1029
+ /**
1030
+ * Run the deferred half of an archive (close the process if still open, archive
1031
+ * subagents) in the background, serialized per agent.
1032
+ *
1033
+ * Everything that can revive the agent (unarchive, resume, reload) awaits it
1034
+ * first via `awaitArchiveTeardown`, so the outcome is the same as the old
1035
+ * synchronous archive: the revive always lands after the close, and a close
1036
+ * can never land on a revived agent. Resolves to the error when the teardown
1037
+ * failed, never rejects.
1038
+ */
1039
+ scheduleArchiveTeardown(agentId) {
1040
+ const previous = this.archiveTeardowns.get(agentId) ?? Promise.resolve();
1041
+ const run = previous
1042
+ .then(async () => {
1043
+ if (this.agents.has(agentId)) {
1044
+ await this.closeAgent(agentId);
1045
+ }
1046
+ await this.cascadeArchiveChildren(agentId);
1047
+ return null;
1048
+ })
1049
+ .catch((error) => (error instanceof Error ? error : new Error(String(error))));
1050
+ const tracked = run.then(() => undefined);
1051
+ this.archiveTeardowns.set(agentId, tracked);
1052
+ void tracked.finally(() => {
1053
+ if (this.archiveTeardowns.get(agentId) === tracked) {
1054
+ this.archiveTeardowns.delete(agentId);
1055
+ }
1056
+ });
1057
+ return run;
1058
+ }
1059
+ /** Wait for a pending archive teardown of this agent, if any. */
1060
+ async awaitArchiveTeardown(agentId) {
1061
+ if (!agentId)
1062
+ return;
1063
+ await this.archiveTeardowns.get(agentId);
1064
+ }
1003
1065
  // Children created via the MCP `create_agent` tool carry the parent-agent-id
1004
1066
  // label pointing back at the caller. Archiving the parent cascades to those
1005
1067
  // children so subagent fleets don't outlive their orchestrator. Detached
@@ -1385,6 +1447,9 @@ export class AgentManager {
1385
1447
  return nextRecord;
1386
1448
  }
1387
1449
  async unarchiveSnapshot(agentId) {
1450
+ // Let a deferred archive teardown finish before the record is revived, so
1451
+ // it can never close the revived agent.
1452
+ await this.awaitArchiveTeardown(agentId);
1388
1453
  const registry = this.requireRegistry();
1389
1454
  const record = await registry.get(agentId);
1390
1455
  if (!record || !record.archivedAt) {
@@ -2046,8 +2111,7 @@ export class AgentManager {
2046
2111
  };
2047
2112
  }
2048
2113
  async getLastAssistantMessageFromStores(agentId) {
2049
- const liveTimeline = this.timelineStore.getItems(agentId);
2050
- const liveSegment = this.getLastAssistantMessageSegmentFromTimeline(liveTimeline);
2114
+ const liveSegment = this.timelineStore.getLastAssistantSegment(agentId);
2051
2115
  if (!this.durableTimelineStore) {
2052
2116
  return liveSegment?.text ?? null;
2053
2117
  }
@@ -13,6 +13,8 @@ export declare class InMemoryAgentTimelineStore {
13
13
  initialize(agentId: string, options?: SeedAgentTimelineOptions): void;
14
14
  delete(agentId: string): void;
15
15
  getItems(agentId: string): AgentTimelineItem[];
16
+ /** Row count without copying the timeline. */
17
+ size(agentId: string): number;
16
18
  getRows(agentId: string): AgentTimelineRow[];
17
19
  getEpoch(agentId: string): string;
18
20
  fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult;
@@ -21,6 +23,14 @@ export declare class InMemoryAgentTimelineStore {
21
23
  }): AgentTimelineRow;
22
24
  getLastItem(agentId: string): AgentTimelineItem | null;
23
25
  getLastAssistantMessage(agentId: string): string | null;
26
+ /**
27
+ * The last contiguous run of assistant_message items (Claude streams chunks),
28
+ * walked from the end in place, and whether it starts at the first row.
29
+ */
30
+ getLastAssistantSegment(agentId: string): {
31
+ text: string;
32
+ startsAtBeginning: boolean;
33
+ } | null;
24
34
  private requireState;
25
35
  private buildRowsFromItems;
26
36
  }
@@ -16,13 +16,47 @@ const MAX_TIMELINE_FETCH_BYTES = 2 * 1024 * 1024;
16
16
  function cloneRow(row) {
17
17
  return { ...row };
18
18
  }
19
+ /**
20
+ * Serialized size per stored row, computed once. Rows are append-only and never
21
+ * edited in place, so the size of a row never changes; without this every page
22
+ * fetch re-serialized every row it considered (a tool result can be megabytes).
23
+ */
24
+ const rowByteCache = new WeakMap();
19
25
  function rowBytes(row) {
26
+ const cached = rowByteCache.get(row);
27
+ if (cached !== undefined) {
28
+ return cached;
29
+ }
30
+ let bytes = 0;
20
31
  try {
21
- return JSON.stringify(row.item)?.length ?? 0;
32
+ bytes = JSON.stringify(row.item)?.length ?? 0;
22
33
  }
23
34
  catch {
24
- return 0;
35
+ bytes = 0;
25
36
  }
37
+ rowByteCache.set(row, bytes);
38
+ return bytes;
39
+ }
40
+ /**
41
+ * Index of the first row whose seq is strictly greater than `seq` (or at least
42
+ * `seq` when `inclusive`), or `rows.length` when there is none. Rows are kept in
43
+ * ascending seq order, so this is a binary search instead of a linear scan of a
44
+ * timeline that can hold tens of thousands of rows.
45
+ */
46
+ function firstIndexAfterSeq(rows, seq, inclusive = false) {
47
+ let low = 0;
48
+ let high = rows.length;
49
+ while (low < high) {
50
+ const mid = (low + high) >>> 1;
51
+ const rowSeq = rows[mid].seq;
52
+ if (inclusive ? rowSeq >= seq : rowSeq > seq) {
53
+ high = mid;
54
+ }
55
+ else {
56
+ low = mid + 1;
57
+ }
58
+ }
59
+ return low;
26
60
  }
27
61
  /**
28
62
  * Trim a selected page to {@link MAX_TIMELINE_FETCH_BYTES}, dropping rows from
@@ -72,8 +106,8 @@ function fetchTail(ctx) {
72
106
  function fetchAfter(ctx) {
73
107
  const { state, direction, limit, selectAll, cursor, minSeq, maxSeq, window } = ctx;
74
108
  const baseSeq = cursor?.seq ?? 0;
75
- const startIdx = state.rows.findIndex((row) => row.seq > baseSeq);
76
- if (startIdx < 0) {
109
+ const startIdx = firstIndexAfterSeq(state.rows, baseSeq);
110
+ if (startIdx >= state.rows.length) {
77
111
  return {
78
112
  epoch: state.epoch,
79
113
  direction,
@@ -103,7 +137,8 @@ function fetchAfter(ctx) {
103
137
  function fetchBefore(ctx) {
104
138
  const { state, direction, limit, selectAll, cursor, minSeq, window } = ctx;
105
139
  const beforeSeq = cursor?.seq ?? state.nextSeq;
106
- const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq);
140
+ const firstAtOrAfter = firstIndexAfterSeq(state.rows, beforeSeq, true);
141
+ const endExclusive = firstAtOrAfter >= state.rows.length ? -1 : firstAtOrAfter;
107
142
  const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive);
108
143
  const selected = applyByteBudget(selectAll || limit >= boundedRows.length
109
144
  ? boundedRows
@@ -162,6 +197,10 @@ export class InMemoryAgentTimelineStore {
162
197
  getItems(agentId) {
163
198
  return this.requireState(agentId).rows.map((row) => row.item);
164
199
  }
200
+ /** Row count without copying the timeline. */
201
+ size(agentId) {
202
+ return this.requireState(agentId).rows.length;
203
+ }
165
204
  getRows(agentId) {
166
205
  return this.requireState(agentId).rows.map(cloneRow);
167
206
  }
@@ -237,8 +276,16 @@ export class InMemoryAgentTimelineStore {
237
276
  return state.rows[state.rows.length - 1]?.item ?? null;
238
277
  }
239
278
  getLastAssistantMessage(agentId) {
279
+ return this.getLastAssistantSegment(agentId)?.text ?? null;
280
+ }
281
+ /**
282
+ * The last contiguous run of assistant_message items (Claude streams chunks),
283
+ * walked from the end in place, and whether it starts at the first row.
284
+ */
285
+ getLastAssistantSegment(agentId) {
240
286
  const rows = this.requireState(agentId).rows;
241
287
  const chunks = [];
288
+ let startsAtBeginning = false;
242
289
  for (let i = rows.length - 1; i >= 0; i -= 1) {
243
290
  const item = rows[i].item;
244
291
  if (item.type !== "assistant_message") {
@@ -248,11 +295,12 @@ export class InMemoryAgentTimelineStore {
248
295
  continue;
249
296
  }
250
297
  chunks.push(item.text);
298
+ startsAtBeginning = i === 0;
251
299
  }
252
300
  if (chunks.length === 0) {
253
301
  return null;
254
302
  }
255
- return chunks.toReversed().join("");
303
+ return { text: chunks.toReversed().join(""), startsAtBeginning };
256
304
  }
257
305
  requireState(agentId) {
258
306
  const state = this.states.get(agentId);
@@ -50,6 +50,8 @@ export declare class BlockerLog {
50
50
  private readonly logger;
51
51
  private readonly now;
52
52
  private ensured;
53
+ /** Per-card view of the ledger, kept current incrementally (see JsonlCardIndex). */
54
+ private readonly index;
53
55
  constructor(options: BlockerLogOptions);
54
56
  /**
55
57
  * Record the current blocker set for a card. No-ops when the set is unchanged
@@ -60,7 +62,7 @@ export declare class BlockerLog {
60
62
  record(cardId: string, blockers: readonly string[] | undefined): void;
61
63
  /** All rows, oldest first. Returns [] when the log does not exist yet. */
62
64
  readAll(): BlockerRow[];
63
- /** Rows for one card, oldest first. */
65
+ /** The newest rows for one card (at most DEFAULT_ROWS_PER_CARD), oldest first. */
64
66
  history(cardId: string): BlockerRow[];
65
67
  /** The most recent recording for a card, or null. */
66
68
  latest(cardId: string): BlockerRow | null;
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { JsonlCardIndex } from "./jsonl-card-index.js";
3
4
  /** Change detection only. NOT used for matching or counting. */
4
5
  function fingerprint(blockers) {
5
6
  return blockers
@@ -14,6 +15,18 @@ export class BlockerLog {
14
15
  this.filePath = path.join(options.dir, "blockers.jsonl");
15
16
  this.logger = options.logger;
16
17
  this.now = options.now ?? (() => new Date());
18
+ this.index = new JsonlCardIndex({
19
+ filePath: this.filePath,
20
+ parse: (line) => {
21
+ const parsed = JSON.parse(line);
22
+ return typeof parsed?.cardId === "string" && Array.isArray(parsed?.blockers)
23
+ ? parsed
24
+ : null;
25
+ },
26
+ cardIdOf: (row) => row.cardId,
27
+ count: (row) => ({ blocked: row.blockers.length > 0 ? 1 : 0 }),
28
+ onReadError: (error) => this.logger.error({ err: error }, "Blocker log read failed"),
29
+ });
17
30
  }
18
31
  /**
19
32
  * Record the current blocker set for a card. No-ops when the set is unchanged
@@ -73,14 +86,13 @@ export class BlockerLog {
73
86
  }
74
87
  return rows;
75
88
  }
76
- /** Rows for one card, oldest first. */
89
+ /** The newest rows for one card (at most DEFAULT_ROWS_PER_CARD), oldest first. */
77
90
  history(cardId) {
78
- return this.readAll().filter((row) => row.cardId === cardId);
91
+ return [...(this.index.get(cardId)?.rows ?? [])];
79
92
  }
80
93
  /** The most recent recording for a card, or null. */
81
94
  latest(cardId) {
82
- const rows = this.history(cardId);
83
- return rows.length > 0 ? rows[rows.length - 1] : null;
95
+ return this.index.get(cardId)?.rows.at(-1) ?? null;
84
96
  }
85
97
  /**
86
98
  * How many separate times this card has been recorded as blocked by anything.
@@ -88,7 +100,7 @@ export class BlockerLog {
88
100
  * Marco specifically", because that is a semantic question, see the module note.
89
101
  */
90
102
  blockedRecordings(cardId) {
91
- return this.history(cardId).filter((r) => r.blockers.length > 0).length;
103
+ return this.index.get(cardId)?.counters.blocked ?? 0;
92
104
  }
93
105
  /**
94
106
  * Compact history for a judge briefing: one line per distinct recording,
@@ -36,6 +36,8 @@ export declare class CardMoveLog {
36
36
  private readonly logger;
37
37
  private readonly now;
38
38
  private ensured;
39
+ /** Per-card view of the ledger, kept current incrementally (see JsonlCardIndex). */
40
+ private readonly index;
39
41
  constructor(options: CardMoveLogOptions);
40
42
  /**
41
43
  * Record a column change. No-ops when the column did not actually change, so
@@ -46,7 +48,7 @@ export declare class CardMoveLog {
46
48
  record(cardId: string, fromColumn: string | null, toColumn: string | null): void;
47
49
  /** All rows, oldest first. Returns [] when the log does not exist yet. */
48
50
  readAll(): CardMoveRow[];
49
- /** Rows for one card, oldest first. */
51
+ /** The newest rows for one card (at most DEFAULT_ROWS_PER_CARD), oldest first. */
50
52
  history(cardId: string): CardMoveRow[];
51
53
  /**
52
54
  * How many times this card has been pushed away from active work.
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { JsonlCardIndex } from "./jsonl-card-index.js";
3
4
  /** Columns that mean "pushed away from now", used for the deferral count. */
4
5
  const DEFERRAL_TARGETS = new Set(["later", "future", "awaiting"]);
5
6
  /** Columns that mean "actively committed to". */
@@ -10,6 +11,16 @@ export class CardMoveLog {
10
11
  this.filePath = path.join(options.dir, "card-moves.jsonl");
11
12
  this.logger = options.logger;
12
13
  this.now = options.now ?? (() => new Date());
14
+ this.index = new JsonlCardIndex({
15
+ filePath: this.filePath,
16
+ parse: (line) => {
17
+ const parsed = JSON.parse(line);
18
+ return typeof parsed?.cardId === "string" && typeof parsed?.at === "string" ? parsed : null;
19
+ },
20
+ cardIdOf: (row) => row.cardId,
21
+ count: (row) => ({ deferrals: isDeferral(row) ? 1 : 0 }),
22
+ onReadError: (error) => this.logger.error({ err: error }, "Card move log read failed"),
23
+ });
13
24
  }
14
25
  /**
15
26
  * Record a column change. No-ops when the column did not actually change, so
@@ -71,9 +82,9 @@ export class CardMoveLog {
71
82
  }
72
83
  return rows;
73
84
  }
74
- /** Rows for one card, oldest first. */
85
+ /** The newest rows for one card (at most DEFAULT_ROWS_PER_CARD), oldest first. */
75
86
  history(cardId) {
76
- return this.readAll().filter((row) => row.cardId === cardId);
87
+ return [...(this.index.get(cardId)?.rows ?? [])];
77
88
  }
78
89
  /**
79
90
  * How many times this card has been pushed away from active work.
@@ -85,21 +96,11 @@ export class CardMoveLog {
85
96
  * a lie.
86
97
  */
87
98
  deferralCount(cardId) {
88
- let count = 0;
89
- for (const row of this.history(cardId)) {
90
- if (row.fromColumn &&
91
- ACTIVE_SOURCES.has(row.fromColumn) &&
92
- row.toColumn &&
93
- DEFERRAL_TARGETS.has(row.toColumn)) {
94
- count += 1;
95
- }
96
- }
97
- return count;
99
+ return this.index.get(cardId)?.counters.deferrals ?? 0;
98
100
  }
99
101
  /** When the card last entered its current column, from the log. Null if unknown. */
100
102
  lastMoveAt(cardId) {
101
- const history = this.history(cardId);
102
- return history.length > 0 ? history[history.length - 1].at : null;
103
+ return this.index.get(cardId)?.rows.at(-1)?.at ?? null;
103
104
  }
104
105
  ensureDir() {
105
106
  if (this.ensured) {
@@ -109,5 +110,11 @@ export class CardMoveLog {
109
110
  this.ensured = true;
110
111
  }
111
112
  }
113
+ function isDeferral(row) {
114
+ return Boolean(row.fromColumn &&
115
+ ACTIVE_SOURCES.has(row.fromColumn) &&
116
+ row.toColumn &&
117
+ DEFERRAL_TARGETS.has(row.toColumn));
118
+ }
112
119
  export const KANBAN_LABEL_KEY = "kanban";
113
120
  //# sourceMappingURL=card-move-log.js.map
@@ -88,7 +88,7 @@ export async function importProviderSession(input) {
88
88
  await unarchiveAgentState(input.agentStorage, input.agentManager, snapshot.id);
89
89
  return {
90
90
  snapshot,
91
- timelineSize: input.agentManager.getTimeline(snapshot.id).length,
91
+ timelineSize: input.agentManager.getTimelineSize(snapshot.id),
92
92
  };
93
93
  }
94
94
  async function unarchiveAgentByHandle(agentStorage, agentManager, handle) {
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Per-card index over an append-only `*.jsonl` ledger (blockers.jsonl,
3
+ * card-moves.jsonl), so per-card reads stop re-reading and re-parsing the whole
4
+ * file on every digest write.
5
+ *
6
+ * - Incremental: each access `stat`s the file and parses only the bytes appended
7
+ * since the last read, so rows appended by another process (a loop script) are
8
+ * seen on the next access. A file that shrank (rotated or rewritten) is
9
+ * re-indexed from scratch.
10
+ * - Bounded: a card keeps its newest `rowsPerCard` rows plus running counters
11
+ * supplied by the caller (`count`), and at most `maxCards` cards are held,
12
+ * least recently used evicted first. A read for an evicted card rebuilds that
13
+ * one card with a full scan, which is the old cost, paid only on a miss.
14
+ */
15
+ export interface JsonlCardIndexOptions<Row> {
16
+ filePath: string;
17
+ parse(line: string): Row | null;
18
+ cardIdOf(row: Row): string;
19
+ /** Running counters kept per card across the whole history, e.g. deferrals. */
20
+ count?: (row: Row) => Record<string, number>;
21
+ rowsPerCard?: number;
22
+ maxCards?: number;
23
+ onReadError?: (error: unknown) => void;
24
+ }
25
+ export interface CardEntry<Row> {
26
+ /** Newest rows for the card, oldest first, at most `rowsPerCard`. */
27
+ rows: Row[];
28
+ /** Every row ever indexed for the card, including trimmed ones. */
29
+ total: number;
30
+ counters: Record<string, number>;
31
+ }
32
+ export declare const DEFAULT_ROWS_PER_CARD = 64;
33
+ export declare const DEFAULT_MAX_CARDS = 4096;
34
+ export declare class JsonlCardIndex<Row> {
35
+ private readonly options;
36
+ private readonly cards;
37
+ private offset;
38
+ private mtimeMs;
39
+ private partial;
40
+ private evicted;
41
+ private readonly rowsPerCard;
42
+ private readonly maxCards;
43
+ constructor(options: JsonlCardIndexOptions<Row>);
44
+ /** The card's entry, current with the file. */
45
+ get(cardId: string): CardEntry<Row> | null;
46
+ /** Read whatever was appended since the last call. */
47
+ sync(): void;
48
+ private parseLine;
49
+ private add;
50
+ private rebuildCard;
51
+ private reset;
52
+ }
53
+ //# sourceMappingURL=jsonl-card-index.d.ts.map