okengine 0.22.0 → 0.23.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 (262) hide show
  1. package/AGENTS.md +7 -0
  2. package/manifest.v1.schema.json +44 -0
  3. package/package.json +32 -19
  4. package/site/content/docs/ai/mcp.mdx +13 -9
  5. package/site/content/docs/ai/skills.mdx +1 -1
  6. package/site/content/docs/elements/ai/agents.mdx +61 -13
  7. package/site/content/docs/elements/ai/decide.mdx +536 -0
  8. package/site/content/docs/elements/ai/events.mdx +373 -0
  9. package/site/content/docs/elements/ai/index.mdx +9 -3
  10. package/site/content/docs/elements/ai/meta.json +1 -1
  11. package/site/content/docs/elements/ai/prompts.mdx +4 -2
  12. package/site/content/docs/elements/gate/auth.mdx +4 -1
  13. package/site/content/docs/elements/store/index.mdx +1 -1
  14. package/site/content/docs/elements/store/kv.mdx +5 -5
  15. package/site/content/docs/recipes/dragonfly.mdx +5 -5
  16. package/site/content/docs/recipes/index.mdx +6 -6
  17. package/site/content/docs/recipes/postgres.mdx +12 -8
  18. package/site/content/docs/recipes/redis.mdx +4 -4
  19. package/site/content/docs/reference/cli.mdx +2 -1
  20. package/site/content/docs/reference/configuration.mdx +9 -7
  21. package/site/content/docs/reference/fx.mdx +7 -6
  22. package/src/auth/api-key-sql.test.ts +44 -0
  23. package/src/auth/api-key-sql.ts +1 -0
  24. package/src/auth/api-keys.ts +30 -12
  25. package/src/auth/config.ts +6 -2
  26. package/src/auth/gate-auth.test.ts +20 -0
  27. package/src/cli/agents-md.test.ts +25 -0
  28. package/src/cli/ask-vault-gaps.test.ts +38 -1
  29. package/src/cli/ask-vault-gaps.ts +35 -2
  30. package/src/cli/decide.ts +174 -0
  31. package/src/cli/decision-lock-watch.test.ts +45 -0
  32. package/src/cli/decision-lock-watch.ts +51 -0
  33. package/src/cli/dev-app-runner.ts +34 -8
  34. package/src/cli/dev-auth-secret.test.ts +49 -0
  35. package/src/cli/dev-auth-secret.ts +49 -0
  36. package/src/cli/dev-hot.test.ts +41 -0
  37. package/src/cli/dev-hot.ts +48 -0
  38. package/src/cli/dev.test.ts +1 -1
  39. package/src/cli/dev.ts +24 -6
  40. package/src/cli/eval.ts +142 -1
  41. package/src/cli/index.ts +5 -0
  42. package/src/cli/load-config.images.test.ts +2 -2
  43. package/src/cli/load-config.ts +1 -1
  44. package/src/cli/mcp-from-console.ts +48 -0
  45. package/src/cli/registry.ts +35 -1
  46. package/src/client/agent.ts +307 -0
  47. package/src/client/transport.test.ts +20 -0
  48. package/src/client/transport.ts +2 -1
  49. package/src/client/types.ts +1 -1
  50. package/src/client-react/index.ts +7 -1
  51. package/src/client-react/use-agent-run.test.ts +271 -0
  52. package/src/client-react/use-agent-run.ts +193 -0
  53. package/src/compiler/ai-approval.test.ts +36 -0
  54. package/src/compiler/ai-repair.test.ts +22 -0
  55. package/src/compiler/decisions.extract.test.ts +164 -0
  56. package/src/compiler/effects-infer.ts +27 -1
  57. package/src/compiler/extract.ts +268 -12
  58. package/src/compiler/response.ts +35 -2
  59. package/src/config/index.ts +21 -3
  60. package/src/console/server/ai-runs-flows.ts +269 -0
  61. package/src/console/server/ai-runs.test.ts +323 -0
  62. package/src/console/server/ai-runs.ts +562 -0
  63. package/src/console/server/ai.ts +12 -1
  64. package/src/console/server/app.ts +10 -1
  65. package/src/console/server/decisions-flows.ts +157 -0
  66. package/src/console/server/decisions.test.ts +66 -0
  67. package/src/console/server/decisions.ts +229 -0
  68. package/src/console/server/flows.ts +50 -2
  69. package/src/console/server/invoke-user-flow.ts +9 -0
  70. package/src/console/server/runs-ingest.ts +16 -0
  71. package/src/console/server/serve.ts +5 -0
  72. package/src/console/server/state.ts +25 -1
  73. package/src/console/server/store-stats.test.ts +47 -0
  74. package/src/console/server/store-stats.ts +92 -18
  75. package/src/console/ui-next/dist/assets/access-page-DDKkhT9v.js +4 -0
  76. package/src/console/ui-next/dist/assets/{agent-disclosure-Bkohc_8X.js → agent-disclosure-62Xts8Xi.js} +1 -1
  77. package/src/console/ui-next/dist/assets/{cache-glyph-rL0lHxat.js → cache-glyph-DyeoKHKA.js} +1 -1
  78. package/src/console/ui-next/dist/assets/{call-pii-button-Deo4PP18.js → call-pii-button-DvfpLXsU.js} +1 -1
  79. package/src/console/ui-next/dist/assets/{collapsible-CoJ6amHf.js → collapsible-BY03SeCg.js} +1 -1
  80. package/src/console/ui-next/dist/assets/confirm-sheet-DZfuJXP4.js +1 -0
  81. package/src/console/ui-next/dist/assets/copy-inline-button-BTjPVjRT.js +1 -0
  82. package/src/console/ui-next/dist/assets/decisions-page-CgkKlA56.js +1 -0
  83. package/src/console/ui-next/dist/assets/detail-header-Dk5LipF6.js +1 -0
  84. package/src/console/ui-next/dist/assets/dropdown-menu-CRGrj5JU.js +1 -0
  85. package/src/console/ui-next/dist/assets/duration-tone-Y6HLjyJ5.js +9 -0
  86. package/src/console/ui-next/dist/assets/element-icons-C6t8Vlml.js +1 -0
  87. package/src/console/ui-next/dist/assets/explorer-chrome-Dslh2yE8.js +1 -0
  88. package/src/console/ui-next/dist/assets/explorer-empty-CIVg7Jgb.js +1 -0
  89. package/src/console/ui-next/dist/assets/flows-page-DLPfNy-_.js +1 -0
  90. package/src/console/ui-next/dist/assets/{highlighted-json-CPYBzDh0.js → highlighted-json-D7nNzpgB.js} +1 -1
  91. package/src/console/ui-next/dist/assets/{http-method-DTPevKmM.js → http-method-DdL19zzh.js} +1 -1
  92. package/src/console/ui-next/dist/assets/index-BlrN49Hs.css +2 -0
  93. package/src/console/ui-next/dist/assets/index-C0lc_s9d.js +58 -0
  94. package/src/console/ui-next/dist/assets/observability-page-CRlcyLCC.js +8 -0
  95. package/src/console/ui-next/dist/assets/{replica-lag-BM27lXld.js → replica-lag-Bb3FWUu9.js} +7 -7
  96. package/src/console/ui-next/dist/assets/request-meta-DYBMWhX8.js +1 -0
  97. package/src/console/ui-next/dist/assets/shortcut-keys-DTE1sjTC.js +1 -0
  98. package/src/console/ui-next/dist/assets/store-page-CcE-SXC8.js +41 -0
  99. package/src/console/ui-next/dist/assets/trace-detail-sheet-CKMSEQ4s.js +2 -0
  100. package/src/console/ui-next/dist/assets/tree-expand-toggle-QOfk0M0H.js +55 -0
  101. package/src/console/ui-next/dist/assets/units-page-DKC1CDDd.js +1 -0
  102. package/src/console/ui-next/dist/assets/use-vault-list-BAyQG8Re.js +1 -0
  103. package/src/console/ui-next/dist/assets/vault-page-B54v4Yit.js +2 -0
  104. package/src/console/ui-next/dist/index.html +4 -3
  105. package/src/console/ui-next/seed-parked-approval.ts +126 -0
  106. package/src/console/ui-next/src/client.ts +224 -1
  107. package/src/console/ui-next/src/components/explorer/detail-header.tsx +9 -0
  108. package/src/console/ui-next/src/components/explorer/explorer-start-toggle.tsx +5 -1
  109. package/src/console/ui-next/src/components/ui/sheet-form.tsx +21 -12
  110. package/src/console/ui-next/src/features/flows/decisions/decisions-page.tsx +216 -0
  111. package/src/console/ui-next/src/features/flows/decisions/resolve.test.ts +48 -0
  112. package/src/console/ui-next/src/features/flows/decisions/resolve.ts +111 -0
  113. package/src/console/ui-next/src/features/flows/flows-page.tsx +10 -0
  114. package/src/console/ui-next/src/features/flows/graph/element-map.ts +1 -0
  115. package/src/console/ui-next/src/features/flows/traces/effect-kind.ts +6 -0
  116. package/src/console/ui-next/src/features/flows/traces/effect-summary.test.ts +24 -0
  117. package/src/console/ui-next/src/features/flows/traces/effect-summary.ts +24 -0
  118. package/src/console/ui-next/src/features/flows/traces/http-status.ts +58 -0
  119. package/src/console/ui-next/src/features/flows/traces/request-client-card.tsx +355 -0
  120. package/src/console/ui-next/src/features/flows/traces/request-client.test.ts +114 -0
  121. package/src/console/ui-next/src/features/flows/traces/request-client.ts +423 -0
  122. package/src/console/ui-next/src/features/flows/traces/request-input-view.test.ts +52 -6
  123. package/src/console/ui-next/src/features/flows/traces/request-input-view.ts +85 -19
  124. package/src/console/ui-next/src/features/flows/traces/trace-detail-sheet.tsx +505 -80
  125. package/src/console/ui-next/src/features/flows/traces/trace-lanes.test.ts +61 -0
  126. package/src/console/ui-next/src/features/flows/traces/trace-lanes.ts +78 -0
  127. package/src/console/ui-next/src/features/flows/traces/trace-nav.test.ts +48 -0
  128. package/src/console/ui-next/src/features/flows/traces/trace-nav.ts +57 -0
  129. package/src/console/ui-next/src/features/flows/traces/trace-request-section.tsx +275 -96
  130. package/src/console/ui-next/src/features/flows/traces/traces-pane.tsx +16 -2
  131. package/src/console/ui-next/src/features/observability/detail/ai-runs.tsx +369 -0
  132. package/src/console/ui-next/src/features/observability/observability-page.tsx +31 -1
  133. package/src/console/ui-next/src/features/observability/state/observability-selection.ts +18 -3
  134. package/src/console/ui-next/src/features/store/performance/kv-performance-panel.tsx +12 -1
  135. package/src/console/ui-next/src/features/store/performance/performance-panel.tsx +14 -1
  136. package/src/console/ui-next/src/features/units/call/call-api-panel.tsx +1 -1
  137. package/src/console/ui-next/src/features/units/detail/flow-contract-panel.tsx +6 -2
  138. package/src/console/ui-next/src/features/units/explorer/units-tree.tsx +73 -18
  139. package/src/console/ui-next/src/features/units/lib/unit-tree.test.ts +21 -0
  140. package/src/console/ui-next/src/features/units/lib/unit-tree.ts +82 -0
  141. package/src/console/ui-next/src/features/units/units-page.tsx +25 -7
  142. package/src/console/ui-next/src/router.tsx +17 -0
  143. package/src/console/ui-next/ui-next-seed-runs.ts +23 -0
  144. package/src/console/ui-next/vite-console-kernel-plugin.ts +43 -3
  145. package/src/console/ui-next/vite.config.ts +9 -0
  146. package/src/docker/recipes/dragonfly.ts +3 -2
  147. package/src/docker/recipes/redis.ts +1 -1
  148. package/src/drivers/ai-anthropic.ts +212 -21
  149. package/src/drivers/ai-mock.ts +34 -1
  150. package/src/drivers/ai-openai-compatible.ts +67 -26
  151. package/src/drivers/ai-preconnect.ts +18 -0
  152. package/src/drivers/ai-stream.test.ts +62 -0
  153. package/src/drivers/ai-types.ts +20 -0
  154. package/src/drivers/journal-postgres.ts +289 -2
  155. package/src/drivers/memory-ddl.test.ts +18 -0
  156. package/src/drivers/memory.ts +21 -1
  157. package/src/drivers/pg-rls.test.ts +31 -0
  158. package/src/drivers/pg-rls.ts +15 -0
  159. package/src/drivers/postgres.test.ts +9 -0
  160. package/src/drivers/postgres.ts +49 -11
  161. package/src/elements/ai/agent-event-slot.ts +27 -0
  162. package/src/elements/ai/approval-http.ts +207 -0
  163. package/src/elements/ai/approval.test.ts +741 -0
  164. package/src/elements/ai/approval.ts +228 -0
  165. package/src/elements/ai/decisions/bind.ts +163 -0
  166. package/src/elements/ai/decisions/certificate.test.ts +291 -0
  167. package/src/elements/ai/decisions/certificate.ts +377 -0
  168. package/src/elements/ai/decisions/certify.ts +314 -0
  169. package/src/elements/ai/decisions/decide.test.ts +953 -0
  170. package/src/elements/ai/decisions/e2e.test.ts +399 -0
  171. package/src/elements/ai/decisions/export.test.ts +120 -0
  172. package/src/elements/ai/decisions/export.ts +146 -0
  173. package/src/elements/ai/decisions/fixtures/openrouter-request.json +24 -0
  174. package/src/elements/ai/decisions/fixtures/openrouter-response.json +22 -0
  175. package/src/elements/ai/decisions/fixtures/typesafe-request.json +19 -0
  176. package/src/elements/ai/decisions/fixtures/typesafe-response.json +13 -0
  177. package/src/elements/ai/decisions/http.test.ts +154 -0
  178. package/src/elements/ai/decisions/http.ts +240 -0
  179. package/src/elements/ai/decisions/labels.test.ts +282 -0
  180. package/src/elements/ai/decisions/labels.ts +257 -0
  181. package/src/elements/ai/decisions/openrouter.live.test.ts +136 -0
  182. package/src/elements/ai/decisions/openrouter.ts +36 -0
  183. package/src/elements/ai/decisions/provider.ts +102 -0
  184. package/src/elements/ai/decisions/typesafe.ts +36 -0
  185. package/src/elements/ai/declare.ts +252 -1
  186. package/src/elements/ai/events.test.ts +379 -0
  187. package/src/elements/ai/events.ts +138 -0
  188. package/src/elements/ai/run-events.test.ts +461 -0
  189. package/src/elements/ai/run-events.ts +522 -0
  190. package/src/elements/ai/runtime.ts +1186 -105
  191. package/src/elements/ai/stream-turn.ts +214 -0
  192. package/src/elements/ai/stream.live.test.ts +76 -0
  193. package/src/elements/ai/subagent.test.ts +136 -0
  194. package/src/elements/ai.test.ts +497 -3
  195. package/src/elements/ai.ts +16 -1
  196. package/src/elements/clock/durable.ts +27 -1
  197. package/src/elements/store/sql-session.test.ts +51 -0
  198. package/src/elements/store/sql-session.ts +111 -1
  199. package/src/i18n/catalogs/ar.ts +5 -0
  200. package/src/i18n/catalogs/en.ts +5 -0
  201. package/src/index.ts +2 -0
  202. package/src/kernel/agent-event-store.ts +448 -0
  203. package/src/kernel/app.ts +103 -1
  204. package/src/kernel/boot-bind/ai.ts +3 -0
  205. package/src/kernel/boot-bind/store.test.ts +26 -0
  206. package/src/kernel/boot-bind/store.ts +82 -6
  207. package/src/kernel/boot.ts +15 -4
  208. package/src/kernel/builtin-errors.ts +2 -0
  209. package/src/kernel/capability.ts +3 -0
  210. package/src/kernel/decision-label-store.ts +441 -0
  211. package/src/kernel/effects.test.ts +2 -1
  212. package/src/kernel/effects.ts +13 -1
  213. package/src/kernel/element-registries.ts +3 -0
  214. package/src/kernel/errors-text.ts +4 -0
  215. package/src/kernel/errors.ts +2 -0
  216. package/src/kernel/flow.test.ts +2 -3
  217. package/src/kernel/fx-decide.ts +761 -0
  218. package/src/kernel/fx.test.ts +12 -5
  219. package/src/kernel/fx.ts +312 -29
  220. package/src/kernel/http-frame.test.ts +94 -0
  221. package/src/kernel/http-frame.ts +203 -0
  222. package/src/kernel/journal.ts +104 -0
  223. package/src/kernel/json-result.ts +10 -0
  224. package/src/kernel/pipeline.test.ts +45 -0
  225. package/src/kernel/sse-id.ts +32 -0
  226. package/src/manifest/diff.ts +1 -0
  227. package/src/manifest/types.ts +29 -2
  228. package/src/mcp/ai-tools.test.ts +202 -0
  229. package/src/mcp/authorization.ts +66 -0
  230. package/src/mcp/session.ts +3 -0
  231. package/src/mcp/tools.ts +168 -0
  232. package/src/release/http-graph.test.ts +2 -0
  233. package/src/release/http-graph.ts +5 -0
  234. package/src/release/measure.ts +39 -14
  235. package/src/runs/collect.ts +4 -1
  236. package/src/runs/parquet.test.ts +27 -1
  237. package/src/runs/parquet.ts +19 -0
  238. package/src/runs/types.ts +31 -0
  239. package/src/runtime/json-code-block.test.ts +106 -47
  240. package/src/runtime/json-code-block.ts +1209 -46
  241. package/src/term.test.ts +13 -0
  242. package/src/term.ts +81 -49
  243. package/src/console/ui-next/dist/assets/PlusSignIcon-CwG3nxfu.js +0 -1
  244. package/src/console/ui-next/dist/assets/access-page-Ze6ckjdd.js +0 -4
  245. package/src/console/ui-next/dist/assets/copy-inline-button-CWG5_ZOh.js +0 -1
  246. package/src/console/ui-next/dist/assets/detail-header-b_ZzIsmU.js +0 -1
  247. package/src/console/ui-next/dist/assets/dropdown-menu-nwXEO1Ac.js +0 -1
  248. package/src/console/ui-next/dist/assets/duration-tone-BbQ_8z50.js +0 -9
  249. package/src/console/ui-next/dist/assets/element-icons-CwyLpVXz.js +0 -1
  250. package/src/console/ui-next/dist/assets/explorer-empty-C8sSCqYT.js +0 -1
  251. package/src/console/ui-next/dist/assets/flows-page-D2E7BtKK.js +0 -1
  252. package/src/console/ui-next/dist/assets/index-VxoEz295.css +0 -2
  253. package/src/console/ui-next/dist/assets/index-vTuwmeQz.js +0 -58
  254. package/src/console/ui-next/dist/assets/observability-page-SJTSzHFJ.js +0 -4
  255. package/src/console/ui-next/dist/assets/request-meta-BjFT7DNl.js +0 -1
  256. package/src/console/ui-next/dist/assets/shortcut-keys-CUNegEV4.js +0 -1
  257. package/src/console/ui-next/dist/assets/store-page-Qj9ThRE-.js +0 -41
  258. package/src/console/ui-next/dist/assets/trace-detail-sheet-Cmitq3da.js +0 -2
  259. package/src/console/ui-next/dist/assets/tree-expand-toggle-CbDIB-7x.js +0 -55
  260. package/src/console/ui-next/dist/assets/units-page-_PuYqFty.js +0 -1
  261. package/src/console/ui-next/dist/assets/use-vault-list-Cs45Czkt.js +0 -1
  262. package/src/console/ui-next/dist/assets/vault-page-BOMr0go9.js +0 -2
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Decisions list and review queue. A Flows route, not a sidebar module.
3
+ */
4
+
5
+ import { Link, useSearch } from "@tanstack/react-router";
6
+ import { useCallback, useEffect, useState, type JSX } from "react";
7
+ import {
8
+ decisionList,
9
+ decisionQueue,
10
+ decisionResolve,
11
+ type DecisionLabelFailure,
12
+ type DecisionListRow,
13
+ type DecisionQueueRow,
14
+ } from "@/client.ts";
15
+ import {
16
+ EXPLORER_COUNT_CLASS,
17
+ EXPLORER_PAGE_CLASS,
18
+ EXPLORER_ROW_CLASS,
19
+ EXPLORER_STRIP_CLASS,
20
+ SECTION_HEAD_CLASS,
21
+ } from "@/components/explorer/explorer-chrome.ts";
22
+ import { decisionStateLabel, decisionValuesValid, resolveDecisionWithRetry } from "./resolve.ts";
23
+
24
+ /**
25
+ * Format a pending age in seconds or minutes.
26
+ *
27
+ * @param ageMs - Milliseconds since the review was requested
28
+ */
29
+ function formatAge(ageMs: number): string {
30
+ const seconds = Math.max(0, Math.round(ageMs / 1000));
31
+ if (seconds < 60) return `${seconds}s`;
32
+ return `${Math.floor(seconds / 60)}m`;
33
+ }
34
+
35
+ /**
36
+ * Decisions catalogue and the review queue.
37
+ */
38
+ export function DecisionsPage(): JSX.Element {
39
+ const [decisions, setDecisions] = useState<readonly DecisionListRow[]>([]);
40
+ const [failures, setFailures] = useState<readonly DecisionLabelFailure[]>([]);
41
+ const [rows, setRows] = useState<readonly DecisionQueueRow[]>([]);
42
+ const [notice, setNotice] = useState<string | null>(null);
43
+ const selected = (useSearch({ strict: false }) as { decision?: string }).decision;
44
+ const [values, setValues] = useState<Record<string, Record<string, string>>>({});
45
+
46
+ const reload = useCallback(async () => {
47
+ const list = await decisionList();
48
+ if (list.data) {
49
+ setDecisions(list.data.decisions);
50
+ setFailures(list.data.failures ?? []);
51
+ }
52
+ const queue = await decisionQueue();
53
+ if (queue.data) setRows(queue.data.rows);
54
+ }, []);
55
+
56
+ useEffect(() => {
57
+ void reload();
58
+ }, [reload]);
59
+
60
+ async function approve(row: DecisionQueueRow): Promise<void> {
61
+ const draft = values[row.id] ?? {};
62
+ const submitted: Record<string, unknown> = {};
63
+ for (const question of row.questions) {
64
+ const raw = draft[question.id];
65
+ if (question.kind === "boolean") {
66
+ if (raw !== "true" && raw !== "false") continue;
67
+ submitted[question.id] = raw === "true";
68
+ } else if (raw !== undefined) submitted[question.id] = raw;
69
+ }
70
+ if (!decisionValuesValid(row.questions, submitted)) {
71
+ setNotice("ValidationError");
72
+ return;
73
+ }
74
+ const result = await resolveDecisionWithRetry(decisionResolve, {
75
+ id: row.id,
76
+ values: submitted,
77
+ ...(row.labelOnly ? { labelOnly: true } : {}),
78
+ });
79
+ if (result.error?.code === "Conflict") setNotice("Conflict");
80
+ else if (result.error?.code === "JournalLeaseBusy") setNotice("JournalLeaseBusy");
81
+ else if (result.error) setNotice(result.error.code);
82
+ else setNotice(null);
83
+ await reload();
84
+ }
85
+
86
+ return (
87
+ <div className={EXPLORER_PAGE_CLASS} data-slot="decisions-page">
88
+ <div className={EXPLORER_STRIP_CLASS}>
89
+ <Link to="/flows" className={`${SECTION_HEAD_CLASS} px-2.5`}>
90
+ Flows
91
+ </Link>
92
+ <span className={`${SECTION_HEAD_CLASS} px-2.5`}>Decisions</span>
93
+ </div>
94
+ {failures.length > 0 ? (
95
+ <ul>
96
+ {failures.map((failure) => (
97
+ <li
98
+ key={`${failure.at}:${failure.decision}:${failure.question}`}
99
+ className={EXPLORER_ROW_CLASS}
100
+ data-slot="decision-label-error"
101
+ >
102
+ <span className="min-w-0 flex-1 truncate">
103
+ {failure.decision} · {failure.question}
104
+ </span>
105
+ <span className={EXPLORER_COUNT_CLASS}>{failure.message}</span>
106
+ </li>
107
+ ))}
108
+ </ul>
109
+ ) : null}
110
+ <ul>
111
+ {decisions.map((decision) => (
112
+ <li
113
+ key={decision.name}
114
+ className={EXPLORER_ROW_CLASS}
115
+ data-state={decisionStateLabel(decision.state)}
116
+ data-decision={decision.name}
117
+ data-selected={selected === decision.name ? "true" : "false"}
118
+ >
119
+ <span className="min-w-0 flex-1 truncate">{decision.name}</span>
120
+ <span className={EXPLORER_COUNT_CLASS}>{decisionStateLabel(decision.state)}</span>
121
+ {decision.metrics ? (
122
+ <span className={EXPLORER_COUNT_CLASS} data-slot="decision-metrics">
123
+ {Object.entries(decision.metrics)
124
+ .map(([key, value]) => `${key} ${value}`)
125
+ .join(" · ")}
126
+ </span>
127
+ ) : null}
128
+ {decision.promote ? (
129
+ <code className="text-xs" data-slot="decision-promote">
130
+ {decision.promote}
131
+ </code>
132
+ ) : null}
133
+ </li>
134
+ ))}
135
+ </ul>
136
+ <div className={EXPLORER_STRIP_CLASS}>
137
+ <span className={`${SECTION_HEAD_CLASS} px-2.5`}>Review queue</span>
138
+ </div>
139
+ {notice ? (
140
+ <p className="px-2.5 py-1 text-xs" data-slot="decision-notice">
141
+ {notice}
142
+ </p>
143
+ ) : null}
144
+ <ul>
145
+ {rows.map((row) => (
146
+ <li
147
+ key={`${row.labelOnly ? "label" : "park"}:${row.id}`}
148
+ className={EXPLORER_ROW_CLASS}
149
+ data-label-only={row.labelOnly ? "true" : "false"}
150
+ >
151
+ <span className="min-w-0 flex-1 truncate">
152
+ {row.decision}
153
+ {row.labelOnly ? " · label" : ""}
154
+ </span>
155
+ <span className={EXPLORER_COUNT_CLASS} data-slot="decision-age">
156
+ {formatAge(row.ageMs)}
157
+ </span>
158
+ <form
159
+ className="flex items-center gap-2"
160
+ onSubmit={(event) => {
161
+ event.preventDefault();
162
+ void approve(row);
163
+ }}
164
+ >
165
+ {row.questions.map((question) =>
166
+ question.kind === "boolean" ? (
167
+ <select
168
+ key={question.id}
169
+ aria-label={question.id}
170
+ value={values[row.id]?.[question.id] ?? ""}
171
+ onChange={(event) => {
172
+ const next = event.target.value;
173
+ setValues((current) => ({
174
+ ...current,
175
+ [row.id]: { ...current[row.id], [question.id]: next },
176
+ }));
177
+ }}
178
+ >
179
+ <option value="">Select</option>
180
+ <option value="true">true</option>
181
+ <option value="false">false</option>
182
+ </select>
183
+ ) : (
184
+ <select
185
+ key={question.id}
186
+ aria-label={question.id}
187
+ value={values[row.id]?.[question.id] ?? ""}
188
+ onChange={(event) => {
189
+ const next = event.target.value;
190
+ setValues((current) => ({
191
+ ...current,
192
+ [row.id]: { ...current[row.id], [question.id]: next },
193
+ }));
194
+ }}
195
+ >
196
+ <option value="">Select</option>
197
+ {(question.kind === "choice" ? question.options : question.levels)?.map(
198
+ (option) => (
199
+ <option key={option} value={option}>
200
+ {option}
201
+ </option>
202
+ ),
203
+ )}
204
+ </select>
205
+ ),
206
+ )}
207
+ <button type="submit" className="text-xs" disabled={row.status !== "pending"}>
208
+ Approve
209
+ </button>
210
+ </form>
211
+ </li>
212
+ ))}
213
+ </ul>
214
+ </div>
215
+ );
216
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Decisions page resolve checks.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { decisionStateLabel, decisionValuesValid, resolveDecisionWithRetry } from "./resolve.ts";
7
+
8
+ describe("decision resolve form", () => {
9
+ test("choice and score values must be one of the declared options or levels", () => {
10
+ const questions = [
11
+ { id: "team", kind: "choice" as const, options: ["billing", "none_of_these"] },
12
+ { id: "rank", kind: "score" as const, levels: ["low", "high"] },
13
+ ];
14
+ expect(decisionValuesValid(questions, { team: "billing", rank: "low" })).toBe(true);
15
+ expect(decisionValuesValid(questions, { team: "other", rank: "low" })).toBe(false);
16
+ expect(decisionValuesValid(questions, { team: "billing", rank: "mid" })).toBe(false);
17
+ });
18
+
19
+ test("candidate ready is the label for a fitted candidate", () => {
20
+ expect(decisionStateLabel("candidate")).toBe("candidate ready");
21
+ expect(decisionStateLabel("suspended")).toBe("suspended");
22
+ });
23
+
24
+ test("a lease collision retries and a conflict does not", async () => {
25
+ let calls = 0;
26
+ const leased = await resolveDecisionWithRetry(
27
+ async () => {
28
+ calls += 1;
29
+ if (calls < 3) return { error: { code: "JournalLeaseBusy" } };
30
+ return { data: { ok: true as const } };
31
+ },
32
+ { id: "1", values: { team: "billing" } },
33
+ );
34
+ expect(leased.data?.ok).toBe(true);
35
+ expect(calls).toBe(3);
36
+
37
+ let conflictCalls = 0;
38
+ const conflict = await resolveDecisionWithRetry(
39
+ async () => {
40
+ conflictCalls += 1;
41
+ return { error: { code: "Conflict" } };
42
+ },
43
+ { id: "1", values: { team: "billing" } },
44
+ );
45
+ expect(conflict.error?.code).toBe("Conflict");
46
+ expect(conflictCalls).toBe(1);
47
+ });
48
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Resolve-form checks and lease retries for the decisions page.
3
+ */
4
+
5
+ /** One question the form can answer. */
6
+ export interface DecisionFormQuestion {
7
+ readonly id: string;
8
+ readonly kind: "boolean" | "choice" | "score";
9
+ readonly options?: readonly string[];
10
+ readonly levels?: readonly string[];
11
+ }
12
+
13
+ /** Result of one resolve POST. */
14
+ export interface DecisionResolveResult {
15
+ readonly error?: {
16
+ readonly code: string;
17
+ readonly data?: unknown;
18
+ } | null;
19
+ readonly data?: { readonly ok: true } | null;
20
+ }
21
+
22
+ /**
23
+ * True when every open question has a value inside its options or levels.
24
+ *
25
+ * @param questions - Open questions from the queue row
26
+ * @param values - Form values
27
+ */
28
+ export function decisionValuesValid(
29
+ questions: readonly DecisionFormQuestion[],
30
+ values: Readonly<Record<string, unknown>>,
31
+ ): boolean {
32
+ if (Object.keys(values).length !== questions.length) return false;
33
+ for (const question of questions) {
34
+ const value = values[question.id];
35
+ if (question.kind === "boolean") {
36
+ if (typeof value !== "boolean") return false;
37
+ continue;
38
+ }
39
+ if (typeof value !== "string") return false;
40
+ if (question.kind === "choice") {
41
+ if (!question.options?.includes(value)) return false;
42
+ continue;
43
+ }
44
+ if (!question.levels?.includes(value)) return false;
45
+ }
46
+ return true;
47
+ }
48
+
49
+ /**
50
+ * POST a resolve, retrying `JournalLeaseBusy`. `Conflict` returns immediately.
51
+ *
52
+ * @param post - Resolve client
53
+ * @param body - Review id and values
54
+ * @param attempts - How many times to try
55
+ */
56
+ export async function resolveDecisionWithRetry(
57
+ post: (body: {
58
+ readonly id: string;
59
+ readonly values: Readonly<Record<string, unknown>>;
60
+ readonly labelOnly?: boolean;
61
+ }) => Promise<DecisionResolveResult>,
62
+ body: {
63
+ readonly id: string;
64
+ readonly values: Readonly<Record<string, unknown>>;
65
+ readonly labelOnly?: boolean;
66
+ },
67
+ attempts = 5,
68
+ ): Promise<DecisionResolveResult> {
69
+ let last: DecisionResolveResult = {};
70
+ for (let i = 0; i < attempts; i++) {
71
+ last = await post(body);
72
+ if (last.error?.code !== "JournalLeaseBusy") return last;
73
+ const waitMs = retryAfterToMs(retryAfterValue(last.error?.data));
74
+ if (waitMs > 0) await new Promise((resolve) => setTimeout(resolve, waitMs));
75
+ }
76
+ return last;
77
+ }
78
+
79
+ /** Label shown for a list state. */
80
+ export function decisionStateLabel(
81
+ state: "learning" | "candidate" | "certified" | "suspended",
82
+ ): string {
83
+ if (state === "candidate") return "candidate ready";
84
+ return state;
85
+ }
86
+
87
+ /**
88
+ * Read `retryAfter` from a resolve error body.
89
+ *
90
+ * @param data - Error payload
91
+ */
92
+ function retryAfterValue(data: unknown): number | string | undefined {
93
+ if (!data || typeof data !== "object" || !("retryAfter" in data)) return undefined;
94
+ const retryAfter = data.retryAfter;
95
+ return typeof retryAfter === "number" || typeof retryAfter === "string" ? retryAfter : undefined;
96
+ }
97
+
98
+ /**
99
+ * `Retry-After` as delta-seconds or an HTTP-date.
100
+ *
101
+ * @param value - Seconds, or an HTTP-date string
102
+ */
103
+ function retryAfterToMs(value: number | string | undefined): number {
104
+ if (typeof value === "number" && Number.isFinite(value)) return Math.max(0, value) * 1000;
105
+ if (typeof value !== "string" || value.length === 0) return 0;
106
+ const seconds = Number(value);
107
+ if (Number.isFinite(seconds)) return Math.max(0, seconds) * 1000;
108
+ const at = Date.parse(value);
109
+ if (Number.isNaN(at)) return 0;
110
+ return Math.max(0, at - Date.now());
111
+ }
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { Link } from "@tanstack/react-router";
6
7
  import { ExplorerStartToggle } from "@/components/explorer/explorer-start-toggle.tsx";
7
8
  import { EXPLORER_PAGE_CLASS, EXPLORER_SPLIT } from "@/components/explorer/explorer-chrome.ts";
8
9
  import { useExplorerStartPanel } from "@/components/explorer/use-explorer-start-panel.ts";
@@ -42,6 +43,10 @@ export function FlowsPage() {
42
43
 
43
44
  const [graphFilter, setGraphFilter] = useState<GraphFilter | null>(null);
44
45
  const [focusEffectIndex, setFocusEffectIndex] = useState<number | null>(null);
46
+
47
+ useEffect(() => {
48
+ setFocusEffectIndex(null);
49
+ }, [selectedRunId]);
45
50
  const [playbackKey, setPlaybackKey] = useState(0);
46
51
  const [activeNodeId, setActiveNodeId] = useState<string | null>(null);
47
52
  const playbackTimer = useRef<ReturnType<typeof setInterval> | null>(null);
@@ -167,6 +172,11 @@ export function FlowsPage() {
167
172
 
168
173
  return (
169
174
  <div className={EXPLORER_PAGE_CLASS} data-slot="flows-page">
175
+ <div className="flex justify-end px-3 py-1">
176
+ <Link to="/flows/decisions" className="text-sm underline">
177
+ Decisions
178
+ </Link>
179
+ </div>
170
180
  <ResizablePanelGroup orientation="horizontal" className="min-h-0 flex-1">
171
181
  <ResizablePanel
172
182
  panelRef={start.panelRef}
@@ -230,6 +230,7 @@ export function elementsOfRun(run: RunRow): readonly OkeElement[] {
230
230
  break;
231
231
  case "ask":
232
232
  case "embed":
233
+ case "decide":
233
234
  out.add("ai");
234
235
  break;
235
236
  case "secret":
@@ -27,6 +27,7 @@ export const EFFECT_KIND_LABEL: Readonly<Record<RunEffectKind, string>> = {
27
27
  secret: "Secret",
28
28
  call: "Call",
29
29
  fetch: "Fetch",
30
+ decide: "Decide",
30
31
  };
31
32
 
32
33
  /**
@@ -46,6 +47,7 @@ export function effectKindSummaryLabel(kind: RunEffectKind, count: number): stri
46
47
  secret: "secret",
47
48
  call: "call",
48
49
  fetch: "fetch",
50
+ decide: "decide",
49
51
  };
50
52
  const plural: Record<RunEffectKind, string> = {
51
53
  read: "reads",
@@ -57,6 +59,7 @@ export function effectKindSummaryLabel(kind: RunEffectKind, count: number): stri
57
59
  secret: "secrets",
58
60
  call: "calls",
59
61
  fetch: "fetches",
62
+ decide: "decides",
60
63
  };
61
64
  return `${count} ${count === 1 ? singular[kind] : plural[kind]}`;
62
65
  }
@@ -84,6 +87,8 @@ export function effectBarColor(kind: RunEffectKind): string {
84
87
  case "send":
85
88
  case "fetch":
86
89
  return EDGE_STROKE.sends;
90
+ case "decide":
91
+ return EDGE_STROKE.asks;
87
92
  case "secret":
88
93
  return EDGE_STROKE.secrets;
89
94
  }
@@ -108,6 +113,7 @@ export function effectKindIcon(kind: RunEffectKind, resource?: string): ElementH
108
113
  return ELEMENT_ICONS.flow.icon;
109
114
  case "ask":
110
115
  case "embed":
116
+ case "decide":
111
117
  return ELEMENT_ICONS.ai.icon;
112
118
  case "send":
113
119
  case "fetch":
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { effectAiHref } from "./effect-summary.ts";
3
+
4
+ describe("effectAiHref", () => {
5
+ const agents = new Set(["planner"]);
6
+
7
+ test("an agent run id opens that run", () => {
8
+ expect(effectAiHref({ kind: "ask", resource: "planner", agentRunId: "run-9" }, agents)).toBe(
9
+ "/observability?view=ai&agentRun=run-9",
10
+ );
11
+ expect(effectAiHref({ kind: "call", resource: "planner", agentRunId: "run-9" }, agents)).toBe(
12
+ "/observability?view=ai&agentRun=run-9",
13
+ );
14
+ });
15
+
16
+ test("without a run id the link searches by name", () => {
17
+ expect(effectAiHref({ kind: "ask", resource: "weekly-summary@1" }, agents)).toBe(
18
+ "/observability?view=ai&q=weekly-summary",
19
+ );
20
+ expect(effectAiHref({ kind: "call", resource: "planner" }, agents)).toBe(
21
+ "/observability?view=ai&q=planner",
22
+ );
23
+ });
24
+ });
@@ -46,6 +46,7 @@ const KIND_ORDER: readonly RunEffectKind[] = [
46
46
  "embed",
47
47
  "send",
48
48
  "fetch",
49
+ "decide",
49
50
  "secret",
50
51
  ];
51
52
 
@@ -187,6 +188,28 @@ export function isMcpResource(resource: string): boolean {
187
188
  return parseMcpToolRef(resource) !== null;
188
189
  }
189
190
 
191
+ /**
192
+ * Where an ask, decide, or agent call row opens.
193
+ *
194
+ * @param effect - Effect kind and resource
195
+ * @param agents - Declared agent names. A `call` links only when it names one.
196
+ */
197
+ export function effectAiHref(
198
+ effect: Pick<RunEffect, "kind" | "resource"> & { readonly agentRunId?: string },
199
+ agents: ReadonlySet<string>,
200
+ ): string | null {
201
+ const name = effect.resource.split("@")[0] ?? effect.resource;
202
+ if (effect.kind === "decide") {
203
+ return `/flows/decisions?decision=${encodeURIComponent(name)}`;
204
+ }
205
+ const agentCall = effect.kind === "ask" || (effect.kind === "call" && agents.has(name));
206
+ if (!agentCall) return null;
207
+ if (effect.agentRunId) {
208
+ return `/observability?view=ai&agentRun=${encodeURIComponent(effect.agentRunId)}`;
209
+ }
210
+ return `/observability?view=ai&q=${encodeURIComponent(name)}`;
211
+ }
212
+
190
213
  export function effectEventLabel(effect: Pick<RunEffect, "kind" | "resource">): string {
191
214
  if (isComputedCacheKey(effect.resource)) {
192
215
  return "Cache read";
@@ -207,6 +230,7 @@ export function effectEventLabel(effect: Pick<RunEffect, "kind" | "resource">):
207
230
  secret: "Secret",
208
231
  call: "Call",
209
232
  fetch: "Fetch",
233
+ decide: "Decide",
210
234
  };
211
235
  return labels[effect.kind];
212
236
  }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Status accent for the trace Response frame.
3
+ */
4
+
5
+ const STATUS_REASON: Record<number, string> = {
6
+ 200: "OK",
7
+ 201: "Created",
8
+ 202: "Accepted",
9
+ 204: "No Content",
10
+ 301: "Moved",
11
+ 302: "Found",
12
+ 304: "Not Modified",
13
+ 400: "Bad Request",
14
+ 401: "Unauthorized",
15
+ 403: "Forbidden",
16
+ 404: "Not Found",
17
+ 405: "Method Not Allowed",
18
+ 409: "Conflict",
19
+ 415: "Unsupported Media Type",
20
+ 422: "Unprocessable Content",
21
+ 429: "Too Many Requests",
22
+ 500: "Internal Server Error",
23
+ 502: "Bad Gateway",
24
+ 503: "Service Unavailable",
25
+ };
26
+
27
+ /**
28
+ * Short reason phrase for a status code, when we know one.
29
+ *
30
+ * @param status - HTTP status
31
+ */
32
+ export function httpStatusReason(status: number): string | null {
33
+ return STATUS_REASON[status] ?? null;
34
+ }
35
+
36
+ /**
37
+ * Text color for a status code.
38
+ *
39
+ * @param status - HTTP status
40
+ */
41
+ export function httpStatusTextClass(status: number): string {
42
+ if (status >= 500) return "text-destructive";
43
+ if (status >= 400) return "text-amber-700 dark:text-amber-400";
44
+ if (status >= 300) return "text-sky-700 dark:text-sky-400";
45
+ return "text-emerald-700 dark:text-emerald-400";
46
+ }
47
+
48
+ /**
49
+ * Rail fill for a status code.
50
+ *
51
+ * @param status - HTTP status
52
+ */
53
+ export function httpStatusRailClass(status: number): string {
54
+ if (status >= 500) return "bg-destructive";
55
+ if (status >= 400) return "bg-amber-500";
56
+ if (status >= 300) return "bg-sky-500";
57
+ return "bg-emerald-500";
58
+ }