okengine 0.6.1 → 0.7.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 (130) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +73 -181
  3. package/package.json +2 -2
  4. package/site/content/docs/console/clock.mdx +25 -5
  5. package/site/content/docs/console/signals.mdx +13 -4
  6. package/site/content/docs/elements/ai.mdx +29 -2
  7. package/site/content/docs/elements/channel.mdx +19 -13
  8. package/site/content/docs/elements/clock.mdx +56 -24
  9. package/site/content/docs/elements/flow.mdx +2 -0
  10. package/site/content/docs/elements/gate.mdx +8 -1
  11. package/site/content/docs/elements/signal.mdx +173 -29
  12. package/site/content/docs/elements/store.mdx +464 -73
  13. package/site/content/docs/elements/vault.mdx +15 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +13 -6
  15. package/site/content/docs/get-started/installation.mdx +49 -13
  16. package/site/content/docs/get-started/introduction.mdx +1 -1
  17. package/site/content/docs/reference/cli.md +30 -2
  18. package/site/content/docs/reference/configuration.mdx +13 -13
  19. package/site/content/docs/reference/environment-variables.mdx +12 -10
  20. package/site/content/docs/reference/errors.mdx +12 -11
  21. package/site/content/docs/reference/fx.mdx +15 -13
  22. package/site/content/docs/reference/security.md +3 -1
  23. package/src/cli/ai-setup/ai-setup.test.ts +144 -0
  24. package/src/cli/ai-setup/apply.ts +214 -0
  25. package/src/cli/ai-setup/catalog.ts +263 -0
  26. package/src/cli/ai-setup/detect-ollama.ts +166 -0
  27. package/src/cli/ai-setup/index.ts +228 -0
  28. package/src/cli/ai-setup/prompts.ts +649 -0
  29. package/src/cli/ai-setup/recommend.test.ts +100 -0
  30. package/src/cli/ai-setup/recommend.ts +203 -0
  31. package/src/cli/ai.ts +38 -0
  32. package/src/cli/db-auto-push.test.ts +6 -1
  33. package/src/cli/db-auto-push.ts +8 -3
  34. package/src/cli/dev-db-push.test.ts +59 -0
  35. package/src/cli/dev.test.ts +40 -0
  36. package/src/cli/dev.ts +55 -1
  37. package/src/cli/docker-clean.test.ts +179 -0
  38. package/src/cli/docker-clean.ts +406 -0
  39. package/src/cli/docker.ts +16 -4
  40. package/src/cli/ensure-drizzle-config.ts +1 -1
  41. package/src/cli/hero-meta.test.ts +6 -5
  42. package/src/cli/hero-meta.ts +7 -1
  43. package/src/cli/index.ts +5 -0
  44. package/src/cli/load-config.ts +4 -4
  45. package/src/cli/openbao-bootstrap.test.ts +1 -0
  46. package/src/cli/openbao-bootstrap.ts +9 -2
  47. package/src/cli/registry.ts +79 -1
  48. package/src/client/live-gap.test.ts +35 -0
  49. package/src/client/transport.test.ts +21 -0
  50. package/src/client/transport.ts +27 -5
  51. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  52. package/src/config/define-config.test.ts +6 -6
  53. package/src/config/index.ts +1 -1
  54. package/src/console/server/console.test.ts +45 -0
  55. package/src/console/server/flows.ts +39 -9
  56. package/src/console/server/i18n.ts +41 -0
  57. package/src/console/server/vault.ts +12 -38
  58. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  59. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  60. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  61. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  62. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  63. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  64. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  65. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  66. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  67. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  74. package/src/console/ui/dist/index.html +1 -1
  75. package/src/console/ui/shell/components/ui.tsx +5 -1
  76. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  77. package/src/docker/cleanup.test.ts +193 -0
  78. package/src/docker/cleanup.ts +355 -0
  79. package/src/docker/docker.test.ts +4 -0
  80. package/src/docker/index.ts +19 -0
  81. package/src/docker/recipes/ollama.ts +10 -5
  82. package/src/drivers/ai-ollama.ts +45 -2
  83. package/src/drivers/ai-providers.test.ts +31 -0
  84. package/src/drivers/index.ts +3 -0
  85. package/src/drivers/signal-engine.ts +98 -25
  86. package/src/drivers/signal-nats.ts +4 -4
  87. package/src/drivers/signal-postgres.ts +158 -64
  88. package/src/drivers/signal-redis.ts +4 -4
  89. package/src/drivers/signal-types.ts +61 -2
  90. package/src/drivers/vault-driver-removal.test.ts +5 -1
  91. package/src/elements/clock/chaos-child.ts +150 -0
  92. package/src/elements/clock/chaos.test.ts +204 -0
  93. package/src/elements/clock/reconcile.ts +110 -0
  94. package/src/elements/clock/runtime.ts +11 -0
  95. package/src/elements/clock.test.ts +164 -2
  96. package/src/elements/clock.ts +1 -0
  97. package/src/elements/gate/runtime.ts +6 -0
  98. package/src/elements/signal/chaos-child.ts +41 -4
  99. package/src/elements/signal/declare.ts +4 -1
  100. package/src/elements/signal/delivery-modes.test.ts +179 -0
  101. package/src/elements/signal/key-ordering.test.ts +306 -0
  102. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  103. package/src/elements/signal/optional-emit.test.ts +76 -0
  104. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  105. package/src/elements/signal/orphan-messages.test.ts +152 -0
  106. package/src/elements/signal/runtime.ts +11 -4
  107. package/src/elements/signal/schema-emit.test.ts +107 -0
  108. package/src/elements/signal.test.ts +89 -5
  109. package/src/elements/store/files-fx.ts +104 -0
  110. package/src/elements/store/files-image.test.ts +251 -0
  111. package/src/elements/store/files-image.ts +494 -0
  112. package/src/elements/store/runtime.ts +60 -0
  113. package/src/elements/store.ts +24 -0
  114. package/src/elements/vault/boot-chain.ts +150 -0
  115. package/src/elements/vault/runtime.ts +8 -0
  116. package/src/kernel/app.ts +3 -1
  117. package/src/kernel/boot-bind/clock.ts +52 -7
  118. package/src/kernel/boot-bind/gate.ts +71 -3
  119. package/src/kernel/boot-bind/honor-config.test.ts +247 -0
  120. package/src/kernel/boot-bind/signal.ts +64 -7
  121. package/src/kernel/boot-bind/vault.ts +27 -8
  122. package/src/kernel/boot.ts +10 -2
  123. package/src/kernel/errors.ts +8 -2
  124. package/src/kernel/fx.test.ts +13 -0
  125. package/src/kernel/fx.ts +27 -4
  126. package/src/runtime/dev-request-log.test.ts +20 -1
  127. package/src/runtime/dev-request-log.ts +38 -0
  128. package/src/term.test.ts +15 -0
  129. package/src/term.ts +8 -3
  130. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
@@ -2,29 +2,86 @@
2
2
  * Lazy signal binder — loaded only when Signal is declared.
3
3
  */
4
4
 
5
+ import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
5
6
  import { memorySignalDriver } from "../../drivers/signal-memory.ts";
7
+ import { createBunSignalRedisClient, redisSignalDriver } from "../../drivers/signal-redis.ts";
8
+ import type { SignalRedisClientLike } from "../../drivers/signal-types.ts";
6
9
  import { createSignalRuntime, type SignalRuntime } from "../../elements/signal.ts";
7
- import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
8
10
  import type { BootOptions } from "../boot.ts";
9
11
 
12
+ /**
13
+ * Resolve `drivers.signal` for the active env (default `memory`).
14
+ *
15
+ * @param options - Boot options
16
+ * @param env - Active environment
17
+ */
18
+ export function resolveSignalDriverId(options: BootOptions, env: ConfigEnv): string {
19
+ return resolveDriverId(options.config?.drivers?.signal, env) ?? "memory";
20
+ }
21
+
22
+ function redisUrlFor(docker: boolean): string | undefined {
23
+ const url = process.env.REDIS_URL ?? process.env.OKE_STORE_KV_URL ?? undefined;
24
+ if (!url && docker) {
25
+ throw new Error(
26
+ "oke boot: signal redis driver needs REDIS_URL (did `oke dev -d` write docker/.env.docker?)",
27
+ );
28
+ }
29
+ return url;
30
+ }
31
+
10
32
  /**
11
33
  * Construct a Signal runtime, register decls / binding names, start the bus.
12
34
  *
35
+ * Supported ids: `memory` · `redis`. `postgres` / `nats` fail loud until a real
36
+ * native client can be constructed (never silently bind memory).
37
+ *
13
38
  * @param options - Boot options
14
39
  * @param env - Active environment
15
40
  * @param now - Clock
41
+ * @param docker - Docker mode
16
42
  */
17
43
  export async function bindSignal(
18
44
  options: BootOptions,
19
45
  env: ConfigEnv,
20
46
  now: () => number,
47
+ docker = false,
21
48
  ): Promise<SignalRuntime> {
22
- const signalId = resolveDriverId(options.config?.drivers?.signal, env) ?? "memory";
23
- void signalId;
24
- const signal = createSignalRuntime({
25
- driver: memorySignalDriver,
26
- now,
27
- });
49
+ const signalId = resolveSignalDriverId(options, env);
50
+ const injectedRedis = options.clients?.signalRedis as SignalRedisClientLike | undefined;
51
+
52
+ let signal: SignalRuntime;
53
+ switch (signalId) {
54
+ case "memory":
55
+ signal = createSignalRuntime({
56
+ driver: memorySignalDriver,
57
+ now,
58
+ });
59
+ break;
60
+ case "redis": {
61
+ const redis = injectedRedis ?? createBunSignalRedisClient(redisUrlFor(docker));
62
+ signal = createSignalRuntime({
63
+ driver: redisSignalDriver,
64
+ now,
65
+ redis,
66
+ });
67
+ break;
68
+ }
69
+ case "postgres":
70
+ throw new Error(
71
+ 'oke boot: signal driver "postgres" needs a LISTEN/NOTIFY-capable SQL client — ' +
72
+ 'not available via Bun.SQL yet. Use "redis" or "memory", or inject elements.signal.',
73
+ );
74
+ case "nats":
75
+ throw new Error(
76
+ 'oke boot: signal driver "nats" has no production client bind yet — ' +
77
+ 'use "redis" or "memory", or inject elements.signal.',
78
+ );
79
+ default:
80
+ throw new Error(
81
+ `oke boot: unknown signal driver "${signalId}" (expected memory · redis · postgres · nats)`,
82
+ );
83
+ }
84
+
28
85
  for (const decl of options.signals ?? []) {
29
86
  signal.register(decl);
30
87
  }
@@ -2,28 +2,47 @@
2
2
  * Lazy vault binder — loaded only when secrets are declared.
3
3
  */
4
4
 
5
- import { memoryVaultDriver } from "../../drivers/vault-memory.ts";
5
+ import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
6
6
  import { createVaultRuntime, type VaultRuntime } from "../../elements/vault.ts";
7
- import type { ConfigEnv } from "../../config/index.ts";
7
+ import { buildVaultBootChain, normalizeVaultDriverId } from "../../elements/vault/boot-chain.ts";
8
8
  import type { BootOptions } from "../boot.ts";
9
9
 
10
+ /**
11
+ * Resolve `drivers.vault` for the active env (default `env` locally, `memory` in test).
12
+ *
13
+ * @param options - Boot options
14
+ * @param env - Active environment
15
+ */
16
+ export function resolveVaultDriverId(options: BootOptions, env: ConfigEnv): string {
17
+ const resolved = resolveDriverId(options.config?.drivers?.vault, env);
18
+ if (resolved) return resolved;
19
+ return env === "test" ? "memory" : "env";
20
+ }
21
+
10
22
  /**
11
23
  * Construct and boot a Vault runtime from BootOptions.
12
24
  *
25
+ * Uses `drivers.vault` via the shared {@link buildVaultBootChain} helper
26
+ * (same chain Console uses). Injected `options.vault.chain` still wins for tests.
27
+ *
13
28
  * @param options - Boot options
14
29
  * @param env - Active environment
15
30
  */
16
31
  export async function bindVault(options: BootOptions, env: ConfigEnv): Promise<VaultRuntime> {
17
32
  const vaultSecrets = options.vault?.secrets ?? options.secrets ?? [];
33
+ const chain =
34
+ options.vault?.chain ??
35
+ buildVaultBootChain({
36
+ driverId: normalizeVaultDriverId(resolveVaultDriverId(options, env)),
37
+ env,
38
+ cwd: process.cwd(),
39
+ seed: {},
40
+ });
18
41
  const vault = createVaultRuntime({
19
42
  secrets: vaultSecrets,
20
- chain: options.vault?.chain ?? [
21
- {
22
- driver: memoryVaultDriver,
23
- options: { secrets: {} },
24
- },
25
- ],
43
+ chain,
26
44
  allowDevFallbacks: options.vault?.allowDevFallbacks ?? env !== "prod",
45
+ now: options.vault?.now,
27
46
  });
28
47
  await vault.boot();
29
48
  return vault;
@@ -114,6 +114,14 @@ export interface BootOptions {
114
114
  readonly onSignal?: (signal: string, payload: unknown) => void | Promise<void>;
115
115
  /** Injectable clock for test / frozen harnesses. */
116
116
  readonly now?: () => number;
117
+ /**
118
+ * Test-only client injection for binders that open redis / signal redis.
119
+ * Production apps leave this unset.
120
+ */
121
+ readonly clients?: {
122
+ readonly kv?: import("../drivers/types.ts").KvClientLike;
123
+ readonly signalRedis?: import("../drivers/signal-types.ts").SignalRedisClientLike;
124
+ };
117
125
  /** Instance id for leader election. */
118
126
  readonly instanceId?: string;
119
127
  /**
@@ -344,7 +352,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
344
352
  let signal = pre.signal;
345
353
  if (needs.signal) {
346
354
  if (!signal) {
347
- signal = await signalBind!.bindSignal(options, env, now);
355
+ signal = await signalBind!.bindSignal(options, env, now, docker);
348
356
  } else {
349
357
  // Pre-built: still register decls / start if needed.
350
358
  for (const decl of options.signals ?? []) {
@@ -376,7 +384,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
376
384
  // Gate (before AI)
377
385
  let gate = pre.gate;
378
386
  if (needs.gate && !gate) {
379
- gate = await gateBind!.bindGate(options, now);
387
+ gate = await gateBind!.bindGate(options, now, env, docker);
380
388
  }
381
389
 
382
390
  // 5. Channel
@@ -143,14 +143,20 @@ export const OKE_ERRORS = {
143
143
  fix: 'Add "{resource}" to this flow\'s effects.calls.',
144
144
  },
145
145
  /**
146
- * Emit target has no subscriber (unified-theory §21 example).
147
- * Reserved now so the number stays permanent.
146
+ * Emit target has no subscriber (unified-theory §21).
147
+ * Thrown at emit when `optional` is false and nobody is subscribed.
148
148
  */
149
149
  ORPHAN_EMIT: {
150
150
  code: 1042,
151
151
  cause: 'Flow "{flow}" emits signal "{resource}" with no subscriber.',
152
152
  fix: "Add `on({resource}, …)` or mark the signal `{ optional: true }`.",
153
153
  },
154
+ /** Emit payload failed the signal's declared Standard Schema. */
155
+ SIGNAL_SCHEMA: {
156
+ code: 1043,
157
+ cause: '"{resource}": {detail}',
158
+ fix: "Fix schema payload.",
159
+ },
154
160
  /**
155
161
  * Domain table/column missing under docker/prod (migrations not applied).
156
162
  * Store/DDL band starts at 1100.
@@ -258,6 +258,19 @@ describe("errors — registry", () => {
258
258
  expect(err.message).toContain("→");
259
259
  });
260
260
 
261
+ test("OKE1043 is reserved for signal schema emit failures", () => {
262
+ const def = OKE_ERRORS.SIGNAL_SCHEMA;
263
+ expect(def.code).toBe(1043);
264
+ expect(lookupOkeError(1043)).toEqual(def);
265
+ const err = new OkeError(def, {
266
+ resource: "order-placed",
267
+ detail: "total: Expected number, received string",
268
+ });
269
+ expect(err.message).toContain("OKE1043");
270
+ expect(err.message).toContain("order-placed");
271
+ expect(err.message).toContain("https://oke.omqkhafi.dev/e/1043");
272
+ });
273
+
261
274
  test("fx.fail returns a value, not an exception", () => {
262
275
  const fx = createFx({ flow: "x", effects: {} });
263
276
  const result = fx.fail(
package/src/kernel/fx.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  } from "../elements/store.ts";
20
20
  import type { SqlRow } from "../drivers/types.ts";
21
21
  import type { SignalRuntime } from "../elements/signal.ts";
22
+ import type { SignalEmitOptions } from "../drivers/signal-types.ts";
22
23
  import type { VaultRuntime } from "../elements/vault.ts";
23
24
  import type { ChannelRuntime } from "../elements/channel.ts";
24
25
  import type { AiRuntime } from "../elements/ai.ts";
@@ -295,8 +296,9 @@ export interface Fx {
295
296
  *
296
297
  * @param signal - Signal name or handle
297
298
  * @param payload - Payload
299
+ * @param options - Optional emit options (`key` for per-key once ordering)
298
300
  */
299
- emit(signal: NamedRef, payload?: unknown): Promise<void>;
301
+ emit(signal: NamedRef, payload?: unknown, options?: SignalEmitOptions): Promise<void>;
300
302
  /**
301
303
  * Call another flow (records `call`). Stub returns `undefined`.
302
304
  *
@@ -892,7 +894,28 @@ export function createFxContext(options: CreateFxOptions): FxContext {
892
894
  });
893
895
  }
894
896
 
895
- // KV / files / index — thin gated wrappers preserving driver methods.
897
+ if (decl.facet === "files") {
898
+ const ref = decl.ref;
899
+ return runtime.openFilesFx(
900
+ decl as Extract<StoreDecl, { facet: "files" }>,
901
+ {
902
+ effects: options.effects ?? {},
903
+ revealPii: options.revealPii,
904
+ },
905
+ {
906
+ gate: gated,
907
+ refuseDryRunWrite: () => {
908
+ if (isDryRun()) {
909
+ throw new DryRunWriteIsolationError(
910
+ `Driver-backed store "${ref}" cannot isolate writes during dry-run; dry-run refused rather than risk a double-write.`,
911
+ );
912
+ }
913
+ },
914
+ },
915
+ );
916
+ }
917
+
918
+ // KV / index — thin gated wrappers preserving driver methods.
896
919
  const baseRef = decl.ref;
897
920
  return new Proxy({} as StoreHandle, {
898
921
  get(_t, prop) {
@@ -1010,11 +1033,11 @@ export function createFxContext(options: CreateFxOptions): FxContext {
1010
1033
  store(ref) {
1011
1034
  return storeHandle(ref);
1012
1035
  },
1013
- emit(signal, payload) {
1036
+ emit(signal, payload, emitOptions) {
1014
1037
  const name = resolveName(signal);
1015
1038
  return gated("emit", name, async () => {
1016
1039
  if (options.signalRuntime) {
1017
- await options.signalRuntime.emit(name, payload);
1040
+ await options.signalRuntime.emit(name, payload, emitOptions);
1018
1041
  }
1019
1042
  });
1020
1043
  },
@@ -3,7 +3,11 @@
3
3
  */
4
4
 
5
5
  import { afterEach, describe, expect, test } from "bun:test";
6
- import { isSilentDevRequest, shouldLogDevRequests } from "./dev-request-log.ts";
6
+ import {
7
+ failureDetailFromResponse,
8
+ isSilentDevRequest,
9
+ shouldLogDevRequests,
10
+ } from "./dev-request-log.ts";
7
11
 
8
12
  describe("dev-request-log", () => {
9
13
  const prev = process.env.OKE_DEV_REQUEST_LOG;
@@ -27,4 +31,19 @@ describe("dev-request-log", () => {
27
31
  expect(isSilentDevRequest("GET", "/_oke/client.json")).toBe(true);
28
32
  expect(isSilentDevRequest("POST", "/console/flows")).toBe(false);
29
33
  });
34
+
35
+ test("failureDetailFromResponse reads error.message from envelope", async () => {
36
+ const res = Response.json(
37
+ {
38
+ data: null,
39
+ error: {
40
+ code: "ClaimFailed",
41
+ data: { reason: "password_policy" },
42
+ message: "Password needs at least 12 characters, including a letter and a number.",
43
+ },
44
+ },
45
+ { status: 400 },
46
+ );
47
+ expect(await failureDetailFromResponse(res)).toMatch(/12 characters/);
48
+ });
30
49
  });
@@ -52,6 +52,8 @@ export type DevRequestLogInput = {
52
52
  readonly flow?: string;
53
53
  readonly status: number;
54
54
  readonly ms: number;
55
+ /** Human failure detail (error.message / code) for 4xx/5xx. */
56
+ readonly detail?: string;
55
57
  };
56
58
 
57
59
  /**
@@ -70,6 +72,39 @@ export function isSilentDevRequest(method: string, path: string): boolean {
70
72
  return /\.(?:js|css|map|svg|png|ico|woff2?|ttf|webp)$/i.test(path);
71
73
  }
72
74
 
75
+ /**
76
+ * Extract a short failure detail from an OKE JSON envelope body.
77
+ *
78
+ * @param response - HTTP response (cloned; original body stays readable)
79
+ */
80
+ export async function failureDetailFromResponse(response: Response): Promise<string | undefined> {
81
+ if (response.status < 400) return undefined;
82
+ try {
83
+ const body: unknown = await response.clone().json();
84
+ if (body === null || typeof body !== "object" || !("error" in body)) return undefined;
85
+ const error = (body as { error: unknown }).error;
86
+ if (error === null || typeof error !== "object") return undefined;
87
+ const rec = error as { message?: unknown; code?: unknown; data?: unknown };
88
+ if (typeof rec.message === "string" && rec.message.trim().length > 0) {
89
+ return rec.message.trim();
90
+ }
91
+ if (
92
+ rec.data !== null &&
93
+ typeof rec.data === "object" &&
94
+ "reason" in rec.data &&
95
+ typeof (rec.data as { reason: unknown }).reason === "string"
96
+ ) {
97
+ const reason = (rec.data as { reason: string }).reason;
98
+ const code = typeof rec.code === "string" ? rec.code : "Error";
99
+ return `${code}: ${reason}`;
100
+ }
101
+ if (typeof rec.code === "string") return rec.code;
102
+ } catch {
103
+ // non-JSON error bodies stay status-only
104
+ }
105
+ return undefined;
106
+ }
107
+
73
108
  /**
74
109
  * Print one request line when {@link shouldLogDevRequests} is on.
75
110
  *
@@ -86,6 +121,7 @@ export function logDevRequest(input: DevRequestLogInput): void {
86
121
  flow: input.flow,
87
122
  status: input.status,
88
123
  ms: input.ms,
124
+ detail: input.detail,
89
125
  }),
90
126
  );
91
127
  }
@@ -115,6 +151,7 @@ export async function timedDevFetch(
115
151
  const url = new URL(request.url);
116
152
  const method = request.method.toUpperCase();
117
153
  const response = await handle(request);
154
+ const detail = await failureDetailFromResponse(response);
118
155
  logDevRequest({
119
156
  surface: options.surface ?? currentDevSurface(),
120
157
  method,
@@ -122,6 +159,7 @@ export async function timedDevFetch(
122
159
  flow: options.resolveFlow?.(request, response),
123
160
  status: response.status,
124
161
  ms: Math.round(performance.now() - started),
162
+ detail,
125
163
  });
126
164
  return response;
127
165
  }
package/src/term.test.ts CHANGED
@@ -125,6 +125,21 @@ describe("term", () => {
125
125
  expect(out).not.toMatch(/\u001b\[/);
126
126
  });
127
127
 
128
+ test("formatRequestLine prints failure detail under 4xx/5xx", () => {
129
+ const out = formatRequestLine({
130
+ surface: "Console",
131
+ method: "POST",
132
+ path: "/console/setup/claim",
133
+ flow: "console.setup.claim",
134
+ status: 400,
135
+ ms: 1,
136
+ detail: "Password needs at least 12 characters, including a letter and a number.",
137
+ color: false,
138
+ });
139
+ expect(out).toContain("400");
140
+ expect(out).toContain("↳ Password needs at least 12 characters");
141
+ });
142
+
128
143
  test("formatStackSummary is scannable", () => {
129
144
  const out = formatStackSummary({
130
145
  project: "oke-dev-a3f791",
package/src/term.ts CHANGED
@@ -300,6 +300,8 @@ export function formatRequestLine(options: {
300
300
  /** Instant for date/time columns (default now). */
301
301
  readonly at?: Date;
302
302
  readonly color?: boolean;
303
+ /** Failure detail printed on a follow-up line (4xx/5xx). */
304
+ readonly detail?: string;
303
305
  }): string {
304
306
  const s = termStyle(options.color ?? termColorEnabled());
305
307
  const at = options.at ?? new Date();
@@ -323,7 +325,7 @@ export function formatRequestLine(options: {
323
325
  const path = options.path.length > 28 ? `${options.path.slice(0, 27)}…` : options.path.padEnd(28);
324
326
  const flow = (options.flow ?? "—").padEnd(22);
325
327
  const ms = `${options.ms}ms`.padStart(6);
326
- return (
328
+ const main =
327
329
  `${surfaceColor}●${s.reset} ` +
328
330
  `${surfaceColor}${options.surface.padEnd(7)}${s.reset} ` +
329
331
  `${methodColor}${method}${s.reset} ` +
@@ -332,8 +334,11 @@ export function formatRequestLine(options: {
332
334
  `${s.dim}${ms}${s.reset} ` +
333
335
  `${statusColor}${options.status}${s.reset} ` +
334
336
  `${s.dim}${date}${s.reset} ` +
335
- `${s.dim}${time}${s.reset}\n`
336
- );
337
+ `${s.dim}${time}${s.reset}\n`;
338
+ const detail = options.detail?.trim();
339
+ if (!detail || options.status < 400) return main;
340
+ const clipped = detail.length > 120 ? `${detail.slice(0, 119)}…` : detail;
341
+ return `${main}${s.dim} ↳ ${clipped}${s.reset}\n`;
337
342
  }
338
343
 
339
344
  /**