@stigmer/server 3.25.0 → 3.26.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 (142) hide show
  1. package/dist/boot/compose.d.ts.map +1 -1
  2. package/dist/boot/compose.js +11 -8
  3. package/dist/boot/compose.js.map +1 -1
  4. package/dist/boot/list-indexes.d.ts +3 -0
  5. package/dist/boot/list-indexes.d.ts.map +1 -0
  6. package/dist/boot/list-indexes.js +24 -0
  7. package/dist/boot/list-indexes.js.map +1 -0
  8. package/dist/domain/agentexecution/controller.d.ts.map +1 -1
  9. package/dist/domain/agentexecution/controller.js +6 -8
  10. package/dist/domain/agentexecution/controller.js.map +1 -1
  11. package/dist/domain/agentexecution/list-index.d.ts +2 -0
  12. package/dist/domain/agentexecution/list-index.d.ts.map +1 -0
  13. package/dist/domain/agentexecution/list-index.js +23 -0
  14. package/dist/domain/agentexecution/list-index.js.map +1 -0
  15. package/dist/domain/agentexecution/steps.d.ts +17 -33
  16. package/dist/domain/agentexecution/steps.d.ts.map +1 -1
  17. package/dist/domain/agentexecution/steps.js +94 -95
  18. package/dist/domain/agentexecution/steps.js.map +1 -1
  19. package/dist/domain/agentexecution/usage.d.ts.map +1 -1
  20. package/dist/domain/agentexecution/usage.js +37 -14
  21. package/dist/domain/agentexecution/usage.js.map +1 -1
  22. package/dist/domain/artifact/controller.d.ts.map +1 -1
  23. package/dist/domain/artifact/controller.js +27 -15
  24. package/dist/domain/artifact/controller.js.map +1 -1
  25. package/dist/domain/artifact/list-index.d.ts +2 -0
  26. package/dist/domain/artifact/list-index.d.ts.map +1 -0
  27. package/dist/domain/artifact/list-index.js +21 -0
  28. package/dist/domain/artifact/list-index.js.map +1 -0
  29. package/dist/domain/session/list-index.d.ts +4 -0
  30. package/dist/domain/session/list-index.d.ts.map +1 -0
  31. package/dist/domain/session/list-index.js +26 -0
  32. package/dist/domain/session/list-index.js.map +1 -0
  33. package/dist/domain/session/steps.d.ts +7 -8
  34. package/dist/domain/session/steps.d.ts.map +1 -1
  35. package/dist/domain/session/steps.js +76 -78
  36. package/dist/domain/session/steps.js.map +1 -1
  37. package/dist/domain/workflowexecution/controller.d.ts.map +1 -1
  38. package/dist/domain/workflowexecution/controller.js +53 -55
  39. package/dist/domain/workflowexecution/controller.js.map +1 -1
  40. package/dist/domain/workflowexecution/execution-filter.d.ts +4 -0
  41. package/dist/domain/workflowexecution/execution-filter.d.ts.map +1 -1
  42. package/dist/domain/workflowexecution/execution-filter.js +11 -5
  43. package/dist/domain/workflowexecution/execution-filter.js.map +1 -1
  44. package/dist/domain/workflowexecution/get-execution-summary.d.ts.map +1 -1
  45. package/dist/domain/workflowexecution/get-execution-summary.js +23 -17
  46. package/dist/domain/workflowexecution/get-execution-summary.js.map +1 -1
  47. package/dist/domain/workflowexecution/list-index.d.ts +2 -0
  48. package/dist/domain/workflowexecution/list-index.d.ts.map +1 -0
  49. package/dist/domain/workflowexecution/list-index.js +21 -0
  50. package/dist/domain/workflowexecution/list-index.js.map +1 -0
  51. package/dist/domain/workflowexecution/list-pending-approvals.d.ts.map +1 -1
  52. package/dist/domain/workflowexecution/list-pending-approvals.js +69 -32
  53. package/dist/domain/workflowexecution/list-pending-approvals.js.map +1 -1
  54. package/dist/domain/workflowexecution/queries.d.ts +30 -4
  55. package/dist/domain/workflowexecution/queries.d.ts.map +1 -1
  56. package/dist/domain/workflowexecution/queries.js +76 -22
  57. package/dist/domain/workflowexecution/queries.js.map +1 -1
  58. package/dist/extensions/list-read-scope.d.ts +9 -12
  59. package/dist/extensions/list-read-scope.d.ts.map +1 -1
  60. package/dist/extensions/list-read-scope.js.map +1 -1
  61. package/dist/index.d.ts +2 -1
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js.map +1 -1
  64. package/dist/pipeline/steps/list-page.d.ts +63 -0
  65. package/dist/pipeline/steps/list-page.d.ts.map +1 -0
  66. package/dist/pipeline/steps/list-page.js +187 -0
  67. package/dist/pipeline/steps/list-page.js.map +1 -0
  68. package/dist/query/activity/handler.d.ts +6 -7
  69. package/dist/query/activity/handler.d.ts.map +1 -1
  70. package/dist/query/activity/handler.js +42 -59
  71. package/dist/query/activity/handler.js.map +1 -1
  72. package/dist/store/interface.d.ts +29 -0
  73. package/dist/store/interface.d.ts.map +1 -1
  74. package/dist/store/interface.js.map +1 -1
  75. package/dist/store/list-index.d.ts +123 -0
  76. package/dist/store/list-index.d.ts.map +1 -0
  77. package/dist/store/list-index.js +277 -0
  78. package/dist/store/list-index.js.map +1 -0
  79. package/dist/store/logger.d.ts +3 -0
  80. package/dist/store/logger.d.ts.map +1 -1
  81. package/dist/store/logger.js +5 -1
  82. package/dist/store/logger.js.map +1 -1
  83. package/dist/store/postgres/migrations.d.ts +10 -6
  84. package/dist/store/postgres/migrations.d.ts.map +1 -1
  85. package/dist/store/postgres/migrations.js +64 -6
  86. package/dist/store/postgres/migrations.js.map +1 -1
  87. package/dist/store/postgres/store.d.ts +26 -5
  88. package/dist/store/postgres/store.d.ts.map +1 -1
  89. package/dist/store/postgres/store.js +377 -22
  90. package/dist/store/postgres/store.js.map +1 -1
  91. package/dist/store/sqlite/migrations.d.ts +11 -5
  92. package/dist/store/sqlite/migrations.d.ts.map +1 -1
  93. package/dist/store/sqlite/migrations.js +50 -4
  94. package/dist/store/sqlite/migrations.js.map +1 -1
  95. package/dist/store/sqlite/store.d.ts +18 -3
  96. package/dist/store/sqlite/store.d.ts.map +1 -1
  97. package/dist/store/sqlite/store.js +321 -15
  98. package/dist/store/sqlite/store.js.map +1 -1
  99. package/package.json +6 -6
  100. package/src/authorization/__tests__/drivers.ts +6 -1
  101. package/src/boot/__tests__/list-indexes.test.ts +65 -0
  102. package/src/boot/compose.ts +23 -13
  103. package/src/boot/list-indexes.ts +25 -0
  104. package/src/domain/agentexecution/__tests__/agentexecution.test.ts +85 -3
  105. package/src/domain/agentexecution/controller.ts +8 -10
  106. package/src/domain/agentexecution/list-index.ts +24 -0
  107. package/src/domain/agentexecution/steps.ts +118 -115
  108. package/src/domain/agentexecution/usage.ts +38 -17
  109. package/src/domain/artifact/controller.ts +28 -18
  110. package/src/domain/artifact/list-index.ts +22 -0
  111. package/src/domain/session/list-index.ts +28 -0
  112. package/src/domain/session/steps.ts +108 -123
  113. package/src/domain/workflowexecution/__tests__/list-read-scope-summaries.test.ts +155 -99
  114. package/src/domain/workflowexecution/__tests__/workflowexecution.test.ts +193 -31
  115. package/src/domain/workflowexecution/controller.ts +54 -82
  116. package/src/domain/workflowexecution/execution-filter.ts +20 -7
  117. package/src/domain/workflowexecution/get-execution-summary.ts +31 -24
  118. package/src/domain/workflowexecution/list-index.ts +22 -0
  119. package/src/domain/workflowexecution/list-pending-approvals.ts +82 -25
  120. package/src/domain/workflowexecution/queries.ts +134 -23
  121. package/src/extensions/list-read-scope.ts +9 -12
  122. package/src/index.ts +10 -1
  123. package/src/pipeline/steps/__tests__/list-page.test.ts +186 -0
  124. package/src/pipeline/steps/list-page.ts +284 -0
  125. package/src/query/activity/handler.ts +57 -78
  126. package/src/store/README.md +51 -32
  127. package/src/store/__tests__/list-index.measure.test.ts +293 -0
  128. package/src/store/__tests__/list-index.test.ts +328 -0
  129. package/src/store/__tests__/store-contract.ts +346 -3
  130. package/src/store/interface.ts +39 -0
  131. package/src/store/list-index.ts +465 -0
  132. package/src/store/logger.ts +8 -1
  133. package/src/store/postgres/__tests__/list-index-repair.test.ts +133 -0
  134. package/src/store/postgres/__tests__/migrations.test.ts +27 -19
  135. package/src/store/postgres/__tests__/store-contract.test.ts +50 -3
  136. package/src/store/postgres/migrations.ts +69 -7
  137. package/src/store/postgres/store.ts +485 -29
  138. package/src/store/sqlite/__tests__/migrations.test.ts +51 -7
  139. package/src/store/sqlite/__tests__/store-contract.test.ts +70 -16
  140. package/src/store/sqlite/__tests__/support.ts +12 -3
  141. package/src/store/sqlite/migrations.ts +57 -6
  142. package/src/store/sqlite/store.ts +462 -21
@@ -22,8 +22,6 @@ import type { ConnectRouter, HandlerContext } from "@connectrpc/connect";
22
22
  import { WorkflowExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
23
23
  import type { WorkflowExecution } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
24
24
  import { WorkflowExecutionCommandController } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/command_pb";
25
- import { ExecutionSortField } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/io_pb";
26
- import { WorkflowExecutionListSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/io_pb";
27
25
  import type {
28
26
  ListWorkflowExecutionsByWorkflowRequest,
29
27
  ListWorkflowExecutionsRequest,
@@ -40,7 +38,6 @@ import type { Authorizer } from "../../extensions/authorizer.js";
40
38
  import type { ResolvedGateSteps } from "../../extensions/gate-slots.js";
41
39
  import { stepsForSlot } from "../../extensions/gate-slots.js";
42
40
  import type { ListReadScope } from "../../extensions/list-read-scope.js";
43
- import { restrictListByReadScope } from "../../extensions/list-read-scope.js";
44
41
  import type { CallerIdentity } from "../../extensions/identity.js";
45
42
  import type { ResourceAuthorizationLifecycle } from "../../extensions/resource-authorization.js";
46
43
  import { internalError, invalidArgumentError } from "../../pipeline/errors.js";
@@ -102,9 +99,8 @@ import { newCreateExecutionContextStep } from "./create-execution-context-step.j
102
99
  import type { WorkflowExecutionEngineStateProvider } from "./engine.js";
103
100
  import { newEnsureEngineAvailableStep } from "./engine.js";
104
101
  import {
105
- applyFilterCriteria,
106
- applyLegacyPhaseFilter,
107
- applySortField,
102
+ matchesFilterCriteria,
103
+ matchesLegacyPhase,
108
104
  } from "./execution-filter.js";
109
105
  import {
110
106
  cancelExecution,
@@ -119,7 +115,7 @@ import { sendSignal } from "./send-signal.js";
119
115
  import { getEventLog } from "./get-event-log.js";
120
116
  import { getExecutionSummary } from "./get-execution-summary.js";
121
117
  import { listPendingApprovals } from "./list-pending-approvals.js";
122
- import { loadAllWorkflowExecutions } from "./queries.js";
118
+ import { readWorkflowExecutionList } from "./queries.js";
123
119
  import { workflowExecutionRunTarget } from "./run-target.js";
124
120
  import { workflowExecutionSearchExtractor } from "./search-extractor.js";
125
121
  import type { SandboxLane } from "../../sandbox/lane.js";
@@ -484,60 +480,48 @@ async function get(
484
480
  }
485
481
 
486
482
  /**
487
- * List — list.go: full scan, the legacy top-level phase filter (only when
488
- * filter.phases is absent), structured filter criteria (T13), and the
489
- * sort default started_at descending. No pagination (total_pages
490
- * placeholder 1). With a composed ListReadScope (20260830.01, census
491
- * lane 6) the list narrows to the caller's authorized executions and the
492
- * request `org` narrows when non-blank (the Java
493
- * WorkflowExecutionListHandler baseline: blank = permission-bounded
494
- * across orgs); no scope = the full scan with `org` a deliberate no-op
495
- * on this single-tenant edition — byte-identical. The scope runs AFTER
496
- * the phase and criteria filters (stigmer-cloud 20260913.04 T02: the
497
- * scope is the last per-row predicate), so a composed driver is asked
498
- * about the rows the request keeps, not the kind.
483
+ * List — list.go: the request's org through the index (every org when
484
+ * blank), the legacy top-level phase filter (only when filter.phases is
485
+ * absent), structured filter criteria (T13), the read scope last (census
486
+ * lane 6; no scope = every matching execution). Newest created first and
487
+ * paged; another sort field sorts the whole set (queries.ts).
499
488
  */
500
489
  async function list(
501
490
  deps: WorkflowExecutionControllerDeps,
502
491
  req: ListWorkflowExecutionsRequest,
503
492
  identity: CallerIdentity,
504
493
  ): Promise<WorkflowExecutionList> {
505
- let executions = await loadAllWorkflowExecutions(
506
- deps.store,
507
- deps.logger,
508
- "failed to list workflow executions",
509
- );
510
-
511
- if (req.filter === undefined || req.filter.phases.length === 0) {
512
- executions = applyLegacyPhaseFilter(executions, req.phase);
513
- }
514
- executions = applyFilterCriteria(executions, req.filter);
515
- executions = await restrictListByReadScope(
516
- deps.listReadScope,
494
+ const legacyPhase =
495
+ req.filter === undefined || req.filter.phases.length === 0;
496
+ return readWorkflowExecutionList({
497
+ store: deps.store,
498
+ logger: deps.logger,
499
+ listReadScope: deps.listReadScope,
517
500
  identity,
518
- ApiResourceKind.workflow_execution,
519
- executions,
520
- req.org,
521
- );
522
-
523
- let sortField = req.sortField;
524
- let ascending = req.sortAscending;
525
- if (sortField === ExecutionSortField.UNSPECIFIED) {
526
- sortField = ExecutionSortField.STARTED_AT;
527
- ascending = false;
528
- }
529
- applySortField(executions, sortField, ascending);
530
-
531
- return create(WorkflowExecutionListSchema, {
532
- entries: executions,
533
- totalPages: 1,
501
+ query: { org: req.org },
502
+ request: req,
503
+ sortField: req.sortField,
504
+ sortAscending: req.sortAscending,
505
+ keep: (execution) =>
506
+ (!legacyPhase || matchesLegacyPhase(execution, req.phase)) &&
507
+ matchesFilterCriteria(execution, req.filter),
508
+ fingerprint: {
509
+ lane: "workflowExecution.list",
510
+ org: req.org,
511
+ phase: req.phase,
512
+ tags: req.tags,
513
+ filter: req.filter,
514
+ sortField: req.sortField,
515
+ sortAscending: req.sortAscending,
516
+ },
534
517
  });
535
518
  }
536
519
 
537
520
  /**
538
521
  * ListByWorkflow — list_by_workflow.go: the request field accepts either
539
- * a Workflow ID or a WorkflowInstance ID, so both spec fields are
540
- * matched. Same filter/sort tail as list; no pagination.
522
+ * a Workflow ID or a WorkflowInstance ID, so both of the index's keys are
523
+ * read. Same filter, scope and order as list (census lane 7; bounded by
524
+ * the workflow id, org never consulted).
541
525
  */
542
526
  async function listByWorkflow(
543
527
  deps: WorkflowExecutionControllerDeps,
@@ -547,39 +531,27 @@ async function listByWorkflow(
547
531
  if (req.workflowId === "") {
548
532
  throw invalidArgumentError("workflow_id is required");
549
533
  }
550
-
551
- // Census lane 7: workflow filter ∩ criteria ∩ authorized ids, the scope
552
- // last (bounded by the workflow id; the Java handler never consults org
553
- // on this lane).
554
- const all = await loadAllWorkflowExecutions(
555
- deps.store,
556
- deps.logger,
557
- "failed to list workflow executions",
558
- );
559
- let executions = all.filter(
560
- (execution) =>
561
- execution.spec?.workflowInstanceId === req.workflowId ||
562
- execution.spec?.workflowId === req.workflowId,
563
- );
564
- executions = applyFilterCriteria(executions, req.filter);
565
- executions = await restrictListByReadScope(
566
- deps.listReadScope,
534
+ return readWorkflowExecutionList({
535
+ store: deps.store,
536
+ logger: deps.logger,
537
+ listReadScope: deps.listReadScope,
567
538
  identity,
568
- ApiResourceKind.workflow_execution,
569
- executions,
570
- "",
571
- );
572
-
573
- let sortField = req.sortField;
574
- let ascending = req.sortAscending;
575
- if (sortField === ExecutionSortField.UNSPECIFIED) {
576
- sortField = ExecutionSortField.STARTED_AT;
577
- ascending = false;
578
- }
579
- applySortField(executions, sortField, ascending);
580
-
581
- return create(WorkflowExecutionListSchema, {
582
- entries: executions,
583
- totalPages: 1,
539
+ query: {
540
+ anyKey: [
541
+ { name: "workflow", value: req.workflowId },
542
+ { name: "workflow_instance", value: req.workflowId },
543
+ ],
544
+ },
545
+ request: req,
546
+ sortField: req.sortField,
547
+ sortAscending: req.sortAscending,
548
+ keep: (execution) => matchesFilterCriteria(execution, req.filter),
549
+ fingerprint: {
550
+ lane: "workflowExecution.listByWorkflow",
551
+ workflowId: req.workflowId,
552
+ filter: req.filter,
553
+ sortField: req.sortField,
554
+ sortAscending: req.sortAscending,
555
+ },
584
556
  });
585
557
  }
@@ -63,6 +63,14 @@ export function applyFilterCriteria(
63
63
  return executions.filter((execution) => matchesFilter(execution, filter));
64
64
  }
65
65
 
66
+ /** The same criteria for one execution, for a page read's per-row filter. */
67
+ export function matchesFilterCriteria(
68
+ execution: WorkflowExecution,
69
+ filter: ExecutionFilterCriteria | undefined,
70
+ ): boolean {
71
+ return filter === undefined || matchesFilter(execution, filter);
72
+ }
73
+
66
74
  function matchesFilter(
67
75
  execution: WorkflowExecution,
68
76
  f: ExecutionFilterCriteria,
@@ -244,12 +252,17 @@ export function applyLegacyPhaseFilter(
244
252
  executions: WorkflowExecution[],
245
253
  phase: ExecutionPhase,
246
254
  ): WorkflowExecution[] {
247
- if (phase === ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED) {
248
- return executions;
249
- }
250
- return executions.filter(
251
- (execution) =>
252
- (execution.status?.phase ??
253
- ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED) === phase,
255
+ return executions.filter((execution) => matchesLegacyPhase(execution, phase));
256
+ }
257
+
258
+ /** The legacy phase filter for one execution. */
259
+ export function matchesLegacyPhase(
260
+ execution: WorkflowExecution,
261
+ phase: ExecutionPhase,
262
+ ): boolean {
263
+ return (
264
+ phase === ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED ||
265
+ (execution.status?.phase ?? ExecutionPhase.EXECUTION_PHASE_UNSPECIFIED) ===
266
+ phase
254
267
  );
255
268
  }
@@ -10,16 +10,15 @@
10
10
  * deliberately absent when nothing completed (an average over nothing is
11
11
  * not 0).
12
12
  *
13
- * With a composed ListReadScope (C2 Stage 4's ExecutionReadScope,
14
- * absorbed by 20260830.01 — the multi-tenant
15
- * edition), the scan narrows to the caller's authorized ids intersected
16
- * with the requested org (the Java GetExecutionSummary baseline: without
17
- * the org filter a member of several organizations sees every org's
18
- * numbers on every dashboard), and an EMPTY authorized set answers the
19
- * proto default instance — the conformance-pinned multi-tenant zero shape
20
- * (success_rate 0, NO cost summary) falls out of the scoping, never a
21
- * special case. No scope composed = the full scan above, byte-identical
22
- * (the request's org deliberately not consulted — single-user semantics).
13
+ * The rows are the requested org's, created within the time window, read
14
+ * through the list index (queries.ts) — every edition honours the org, so
15
+ * a member of several organizations never sees every org's numbers on
16
+ * every dashboard (the Java GetExecutionSummary baseline). With a composed
17
+ * ListReadScope those rows are offered to its restrict verb — exact, one
18
+ * read of the kind — and when the caller may see NONE of them the answer
19
+ * is the proto default instance: the conformance-pinned multi-tenant zero
20
+ * shape (success_rate 0, NO cost summary) falls out of the scoping, never
21
+ * a special case. No scope composed = every row of the org and window.
23
22
  *
24
23
  * Tie order in the two ranked lists is not wire-stable in Go (map
25
24
  * iteration feeds a stable sort), so ties here — deterministic first-seen
@@ -50,11 +49,13 @@ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/
50
49
 
51
50
  import type { Logger } from "../../boot/logger.js";
52
51
  import type { ListReadScope } from "../../extensions/list-read-scope.js";
52
+ import { restrictListByReadScope } from "../../extensions/list-read-scope.js";
53
53
  import type { CallerIdentity } from "../../extensions/identity.js";
54
54
  import type { Store } from "../../store/interface.js";
55
+ import { listIndexInstantOfMillis } from "../../store/list-index.js";
55
56
 
56
57
  import { parseRfc3339Ms } from "./execution-filter.js";
57
- import { loadAllWorkflowExecutions } from "./queries.js";
58
+ import { loadWorkflowExecutions } from "./queries.js";
58
59
 
59
60
  const RANK_LIMIT = 10;
60
61
 
@@ -70,30 +71,36 @@ export async function getExecutionSummary(
70
71
  req: GetExecutionSummaryRequest,
71
72
  identity: CallerIdentity,
72
73
  ): Promise<ExecutionSummary> {
73
- let executions = await loadAllWorkflowExecutions(
74
+ // The store's window is one millisecond wider than the loop's check
75
+ // below, which stays the authority: it only ever narrows the read.
76
+ const cutoffMs = resolveTimeCutoffMs(req.timeWindow);
77
+ let executions = await loadWorkflowExecutions(
74
78
  deps.store,
75
79
  deps.logger,
80
+ {
81
+ org: req.org,
82
+ ...(cutoffMs === undefined
83
+ ? {}
84
+ : { createdAtOrAfter: listIndexInstantOfMillis(cutoffMs - 1) }),
85
+ },
76
86
  "failed to list workflow executions for summary",
77
87
  );
78
88
 
79
89
  if (deps.listReadScope !== undefined) {
80
- const authorizedIds = await deps.listReadScope.authorizedResourceIds(
90
+ executions = await restrictListByReadScope(
91
+ deps.listReadScope,
81
92
  identity,
82
93
  ApiResourceKind.workflow_execution,
94
+ executions,
95
+ "",
83
96
  );
84
- if (authorizedIds.size === 0) {
97
+ if (executions.length === 0) {
85
98
  // The Java baseline's empty arm: the default instance, before any
86
99
  // aggregation — success_rate 0 and NO cost summary by construction.
87
100
  return create(ExecutionSummarySchema);
88
101
  }
89
- executions = executions.filter(
90
- (execution) =>
91
- authorizedIds.has(execution.metadata?.id ?? "") &&
92
- execution.metadata?.org === req.org,
93
- );
94
102
  }
95
103
 
96
- const cutoffMs = resolveTimeCutoffMs(req.timeWindow);
97
104
  const workflowFilter = req.workflowId;
98
105
 
99
106
  const phaseCounts: Record<number, number> = {};
@@ -203,9 +210,7 @@ export async function getExecutionSummary(
203
210
  for (const durationMs of completedDurationsMs) {
204
211
  totalMs += durationMs;
205
212
  }
206
- summary.avgDuration = durationFromMs(
207
- totalMs / completedDurationsMs.length,
208
- );
213
+ summary.avgDuration = durationFromMs(totalMs / completedDurationsMs.length);
209
214
  }
210
215
 
211
216
  summary.topFailingWorkflows = buildFailureRanks(
@@ -255,7 +260,9 @@ function isActivePhase(phase: ExecutionPhase): boolean {
255
260
  }
256
261
 
257
262
  /** Go auditCreatedAt: nil timestamp → zero time (undefined here). */
258
- function auditCreatedAtMs(createdAt: Timestamp | undefined): number | undefined {
263
+ function auditCreatedAtMs(
264
+ createdAt: Timestamp | undefined,
265
+ ): number | undefined {
259
266
  if (createdAt === undefined) {
260
267
  return undefined;
261
268
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The workflow-execution list index (store/list-index.ts): a run names
3
+ * its workflow and, when it ran through one, its instance;
4
+ * `listByWorkflow` accepts either id, so it reads both keys at once.
5
+ *
6
+ * A change to `keys` bumps `revision` (boot/__tests__/list-indexes.test.ts
7
+ * pins the pair).
8
+ */
9
+ import { WorkflowExecutionSchema } from "@stigmer/protos/ai/stigmer/agentic/workflowexecution/v1/api_pb";
10
+ import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
11
+
12
+ import { declareListIndex, field } from "../../store/list-index.js";
13
+
14
+ export const workflowExecutionListIndex = declareListIndex({
15
+ kind: ApiResourceKind.workflow_execution,
16
+ schema: WorkflowExecutionSchema,
17
+ revision: 1,
18
+ keys: {
19
+ workflow: field("spec.workflow_id"),
20
+ workflow_instance: field("spec.workflow_instance_id"),
21
+ },
22
+ });
@@ -1,8 +1,12 @@
1
1
  /**
2
2
  * ListPendingApprovals — ports list_pending_approvals.go (T14 dashboard):
3
- * scans IN_PROGRESS executions for tasks in WORKFLOW_TASK_WAITING_APPROVAL
4
- * and projects them into PendingApproval entries. total_count is the
5
- * pre-truncation total; only the entries list is trimmed to the page.
3
+ * reads the org's IN_PROGRESS executions for tasks in
4
+ * WORKFLOW_TASK_WAITING_APPROVAL and projects them into PendingApproval
5
+ * entries, newest execution first. total_count is the whole set's; the
6
+ * entries are one page (default 20, at most 100), continued by a token
7
+ * whose position is an execution's list-index cursor plus the approval's
8
+ * place among its waiting tasks — stable while approvals land and resolve
9
+ * between pages, where an offset would skip or repeat them.
6
10
  */
7
11
  import { create } from "@bufbuild/protobuf";
8
12
  import type { Timestamp } from "@bufbuild/protobuf/wkt";
@@ -29,14 +33,25 @@ import type { Logger } from "../../boot/logger.js";
29
33
  import type { CallerIdentity } from "../../extensions/identity.js";
30
34
  import type { ListReadScope } from "../../extensions/list-read-scope.js";
31
35
  import { restrictListByReadScope } from "../../extensions/list-read-scope.js";
36
+ import { invalidArgumentError } from "../../pipeline/errors.js";
37
+ import {
38
+ decodeListPageToken,
39
+ encodeListPageToken,
40
+ listPageFingerprint,
41
+ } from "../../pipeline/steps/list-page.js";
42
+ import type { ListPageTokenCursor } from "../../pipeline/steps/list-page.js";
32
43
  import type { Store } from "../../store/interface.js";
44
+ import {
45
+ compareListIndexOrder,
46
+ listIndexInstant,
47
+ } from "../../store/list-index.js";
33
48
 
34
49
  import {
35
50
  DEFAULT_PENDING_APPROVALS_PAGE_SIZE,
36
51
  MAX_PENDING_APPROVALS_PAGE_SIZE,
37
52
  } from "./constants.js";
38
53
  import { parseRfc3339Ms } from "./execution-filter.js";
39
- import { loadAllWorkflowExecutions } from "./queries.js";
54
+ import { loadWorkflowExecutions } from "./queries.js";
40
55
 
41
56
  export interface PendingApprovalsDeps {
42
57
  readonly store: Store;
@@ -50,44 +65,68 @@ export async function listPendingApprovals(
50
65
  req: ListPendingApprovalsRequest,
51
66
  identity: CallerIdentity,
52
67
  ): Promise<PendingApprovalsList> {
53
- // Census lane 8 (20260830.01): the scope narrows the EXECUTIONS — before
54
- // the approvals projection and its pagination, the Java
55
- // WorkflowExecutionListPendingApprovalsHandler order — and the request
56
- // org narrows when non-blank (blank = permission-bounded across orgs).
57
- // It runs over the executions that CAN carry a pending approval (in
58
- // progress, a task waiting) rather than the org's whole history: the
59
- // predicate is per-row, so the scope is last (stigmer-cloud 20260913.04
60
- // T02) and a composed driver is asked about a handful of rows.
68
+ // Census lane 8: the org's executions that CAN carry a
69
+ // pending approval (in progress, a task waiting) through the list index,
70
+ // then the scope — the last per-row predicate, so a composed driver is
71
+ // asked about a handful of rows — before the approvals projection and
72
+ // its pagination, the Java WorkflowExecutionListPendingApprovalsHandler
73
+ // order.
74
+ if (req.pageSize < 0) {
75
+ throw invalidArgumentError("page_size must not be negative");
76
+ }
77
+ const fingerprint = listPageFingerprint({
78
+ lane: "workflowExecution.listPendingApprovals",
79
+ org: req.org,
80
+ });
81
+ const after =
82
+ req.pageToken === ""
83
+ ? undefined
84
+ : decodeListPageToken(req.pageToken, fingerprint);
85
+
61
86
  const executions = await restrictListByReadScope(
62
87
  deps.listReadScope,
63
88
  identity,
64
89
  ApiResourceKind.workflow_execution,
65
90
  (
66
- await loadAllWorkflowExecutions(
91
+ await loadWorkflowExecutions(
67
92
  deps.store,
68
93
  deps.logger,
94
+ { org: req.org },
69
95
  "failed to list workflow executions for pending approvals",
70
96
  )
71
97
  ).filter(mayHavePendingApproval),
72
- req.org,
98
+ "",
73
99
  );
74
100
 
75
101
  let pageSize = req.pageSize;
76
- if (pageSize <= 0) {
102
+ if (pageSize === 0) {
77
103
  pageSize = DEFAULT_PENDING_APPROVALS_PAGE_SIZE;
78
104
  }
79
105
  if (pageSize > MAX_PENDING_APPROVALS_PAGE_SIZE) {
80
106
  pageSize = MAX_PENDING_APPROVALS_PAGE_SIZE;
81
107
  }
82
108
 
83
- let approvals: PendingApproval[] = [];
109
+ // Newest execution first (the index's order), an execution's waiting
110
+ // tasks in their order; each approval's position is its execution's
111
+ // cursor and its place among that execution's waiting tasks.
112
+ const approvals: Array<{
113
+ readonly approval: PendingApproval;
114
+ readonly at: ListPageTokenCursor & { readonly position: number };
115
+ }> = [];
84
116
  for (const execution of executions) {
117
+ const cursor = {
118
+ createdAt: listIndexInstant(
119
+ execution.status?.audit?.specAudit?.createdAt,
120
+ ),
121
+ id: execution.metadata?.id ?? "",
122
+ };
123
+ let position = 0;
85
124
  for (const task of execution.status?.tasks ?? []) {
86
125
  if (task.status !== WorkflowTaskStatus.WORKFLOW_TASK_WAITING_APPROVAL) {
87
126
  continue;
88
127
  }
89
- approvals.push(
90
- create(PendingApprovalSchema, {
128
+ approvals.push({
129
+ approval: create(PendingApprovalSchema, {
91
130
  executionId: execution.metadata?.id ?? "",
92
131
  workflowName: execution.metadata?.name ?? "",
93
132
  // task_name, not task_id: the composite task_id ("gate:2")
@@ -98,21 +137,39 @@ export async function listPendingApprovals(
98
137
  requestedAt: parseTimestampString(task.startedAt),
99
138
  uiHint: task.uiHint,
100
139
  }),
101
- );
140
+ at: { cursor, position },
141
+ });
142
+ position += 1;
102
143
  }
103
144
  }
104
145
 
105
- const totalCount = approvals.length;
106
- if (approvals.length > pageSize) {
107
- approvals = approvals.slice(0, pageSize);
108
- }
146
+ const start =
147
+ after === undefined
148
+ ? 0
149
+ : approvals.findIndex((a) => comesAfter(a.at, after));
150
+ const remaining = start < 0 ? [] : approvals.slice(start);
151
+ const page = remaining.slice(0, pageSize);
152
+ const last = page[page.length - 1];
109
153
 
110
154
  return create(PendingApprovalsListSchema, {
111
- entries: approvals,
112
- totalCount,
155
+ entries: page.map((a) => a.approval),
156
+ totalCount: approvals.length,
157
+ nextPageToken:
158
+ remaining.length > pageSize && last !== undefined
159
+ ? encodeListPageToken(last.at.cursor, fingerprint, last.at.position)
160
+ : "",
113
161
  });
114
162
  }
115
163
 
164
+ /** Whether an approval comes strictly after a token's position. */
165
+ function comesAfter(
166
+ at: ListPageTokenCursor & { readonly position: number },
167
+ after: ListPageTokenCursor,
168
+ ): boolean {
169
+ const order = compareListIndexOrder(at.cursor, after.cursor);
170
+ return order > 0 || (order === 0 && at.position > (after.position ?? -1));
171
+ }
172
+
116
173
  /** Go parseTimestampString: RFC3339 or nothing (nil on parse failure). */
117
174
  function parseTimestampString(value: string): Timestamp | undefined {
118
175
  const ms = parseRfc3339Ms(value);