okengine 0.20.0 → 0.21.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 (86) hide show
  1. package/package.json +2 -1
  2. package/site/content/docs/client/calling.mdx +117 -29
  3. package/site/content/docs/client/index.mdx +3 -3
  4. package/site/content/docs/elements/flow/http.mdx +18 -15
  5. package/site/content/docs/elements/flow/index.mdx +26 -18
  6. package/site/content/docs/elements/gate/tenancy.mdx +1 -1
  7. package/site/content/docs/elements/store/files.mdx +11 -5
  8. package/site/content/docs/elements/store/index.mdx +2 -3
  9. package/site/content/docs/elements/store/kv.mdx +8 -2
  10. package/site/content/docs/elements/store/sql.mdx +9 -6
  11. package/site/content/docs/elements/vault/index.mdx +11 -8
  12. package/site/content/docs/elements/vault/secrets.mdx +6 -3
  13. package/site/content/docs/index.mdx +1 -1
  14. package/site/content/docs/recipes/rustfs.mdx +1 -1
  15. package/site/content/docs/reference/configuration.mdx +1 -1
  16. package/site/content/docs/reference/errors.mdx +199 -25
  17. package/site/content/docs/reference/fx.mdx +6 -1
  18. package/site/content/docs/understand/the-architecture.mdx +2 -2
  19. package/site/content/docs/understand/try-it.mdx +758 -25
  20. package/src/cli/dev-app-runner.ts +2 -1
  21. package/src/cli/start.ts +2 -1
  22. package/src/client/create.ts +3 -3
  23. package/src/client/explain.test.ts +252 -0
  24. package/src/client/explain.ts +272 -0
  25. package/src/client/live.test.ts +44 -0
  26. package/src/client/notes-contract.test.ts +10 -0
  27. package/src/client/sse.ts +6 -2
  28. package/src/client/transport.test.ts +67 -0
  29. package/src/client/transport.ts +24 -40
  30. package/src/client/types.ts +17 -6
  31. package/src/client-react/live-resource.ts +6 -2
  32. package/src/compiler/response.ts +17 -27
  33. package/src/console/server/invoke-user-flow.test.ts +8 -2
  34. package/src/console/server/invoke-user-flow.ts +12 -18
  35. package/src/console/server/security.gate.test.ts +1 -1
  36. package/src/console/ui-next/dist/assets/{access-page-DFLu0wTA.js → access-page-tIbsiphz.js} +1 -1
  37. package/src/console/ui-next/dist/assets/{agent-disclosure-DGscxaF5.js → agent-disclosure-CSKumwS2.js} +1 -1
  38. package/src/console/ui-next/dist/assets/{cache-glyph-BGmRZk7d.js → cache-glyph-BCC-DxKT.js} +1 -1
  39. package/src/console/ui-next/dist/assets/{call-pii-button--feUYxvG.js → call-pii-button-jOmYISdc.js} +1 -1
  40. package/src/console/ui-next/dist/assets/{collapsible-JWvpaiGY.js → collapsible-DC2xNaAb.js} +1 -1
  41. package/src/console/ui-next/dist/assets/{duration-tone-D9yCJG4n.js → duration-tone-CwoV56jn.js} +1 -1
  42. package/src/console/ui-next/dist/assets/{flows-page-Bs6MD9GB.js → flows-page-gT1lsWQK.js} +1 -1
  43. package/src/console/ui-next/dist/assets/{highlighted-json-xH8MrEnv.js → highlighted-json-C2GZEJNI.js} +1 -1
  44. package/src/console/ui-next/dist/assets/{http-method-C4vB6ZIw.js → http-method-BdYjcIrD.js} +1 -1
  45. package/src/console/ui-next/dist/assets/{index-yTCY4AcS.js → index-Cul17AcV.js} +3 -3
  46. package/src/console/ui-next/dist/assets/{observability-page-BxJ3R6dU.js → observability-page-oY9vdYBk.js} +1 -1
  47. package/src/console/ui-next/dist/assets/{replica-lag-QRKB_IE8.js → replica-lag-C4QdAF7J.js} +1 -1
  48. package/src/console/ui-next/dist/assets/{request-meta-DqZ-fMu5.js → request-meta-C43DHyld.js} +1 -1
  49. package/src/console/ui-next/dist/assets/{store-page-Dixb6L7a.js → store-page-CL0D9dOq.js} +1 -1
  50. package/src/console/ui-next/dist/assets/{trace-detail-sheet-CazhjtiU.js → trace-detail-sheet-Dcpo4Us_.js} +1 -1
  51. package/src/console/ui-next/dist/assets/{tree-expand-toggle-DlnqYKfr.js → tree-expand-toggle-CKJTuv43.js} +1 -1
  52. package/src/console/ui-next/dist/assets/{units-page-BXTLjU2-.js → units-page-1PlT19ft.js} +1 -1
  53. package/src/console/ui-next/dist/assets/{vault-page-39KR__bc.js → vault-page-BwZ9YjTW.js} +1 -1
  54. package/src/console/ui-next/dist/index.html +1 -1
  55. package/src/docker/docker.test.ts +3 -3
  56. package/src/docker/images-config.test.ts +4 -4
  57. package/src/docker/stack-id.test.ts +1 -1
  58. package/src/elements/store/files-errors.test.ts +149 -0
  59. package/src/elements/store/files-errors.ts +189 -0
  60. package/src/elements/store/kv-errors.test.ts +98 -0
  61. package/src/elements/store/kv-errors.ts +139 -0
  62. package/src/elements/store/resource.ts +11 -7
  63. package/src/elements/store/runtime.ts +18 -13
  64. package/src/elements/store/sql-errors.test.ts +197 -0
  65. package/src/elements/store/sql-errors.ts +294 -0
  66. package/src/elements/store/sql-session.test.ts +52 -0
  67. package/src/elements/store/sql-session.ts +6 -0
  68. package/src/elements/store/store-errors.ts +47 -0
  69. package/src/http.ts +9 -1
  70. package/src/i18n/catalogs/ar.ts +18 -0
  71. package/src/i18n/catalogs/en.ts +18 -0
  72. package/src/index.ts +9 -1
  73. package/src/kernel/app.ts +14 -2
  74. package/src/kernel/builtin-errors.test.ts +117 -0
  75. package/src/kernel/builtin-errors.ts +129 -0
  76. package/src/kernel/call.test.ts +182 -0
  77. package/src/kernel/errors-vault.ts +16 -0
  78. package/src/kernel/errors.registry.test.ts +7 -0
  79. package/src/kernel/errors.ts +94 -21
  80. package/src/kernel/fail-helpers.ts +34 -0
  81. package/src/kernel/fx.test.ts +8 -0
  82. package/src/kernel/fx.ts +12 -6
  83. package/src/kernel/index.ts +12 -1
  84. package/src/runtime/dev-request-log.ts +29 -11
  85. package/src/term.test.ts +76 -0
  86. package/src/term.ts +166 -3
package/src/kernel/fx.ts CHANGED
@@ -58,7 +58,7 @@ import {
58
58
  recordWouldHaveFired,
59
59
  touchDryRunStore,
60
60
  } from "./dry-run.ts";
61
- import { fail, type FailOptions, type FlowFailure } from "./errors.ts";
61
+ import type { FailFn } from "./errors.ts";
62
62
  import { currentAbortSignal, linkAbort } from "./abort-scope.ts";
63
63
  import {
64
64
  fxAll,
@@ -82,6 +82,13 @@ import { okid } from "../okid.ts";
82
82
  import { lazyRequire } from "./lazy-require.ts";
83
83
  import type { AppMessageKey, MessageValues } from "../i18n/types.ts";
84
84
 
85
+ function loadFail(): FailFn {
86
+ return lazyRequire<typeof import("./fail-helpers.ts")>(
87
+ import.meta.dir,
88
+ ["fail", "helpers"].join("-"),
89
+ ).fail;
90
+ }
91
+
85
92
  /** Lazy runs/window helpers — kept off the cold `oke` static graph. */
86
93
  async function loadRunsWindow(): Promise<typeof import("../runs/window.ts")> {
87
94
  return import("../runs/window.ts");
@@ -884,11 +891,10 @@ export interface Fx {
884
891
  /**
885
892
  * Flow-boundary failure value (does not throw).
886
893
  *
887
- * @param code - Declared error code (narrowed by clients via `error.code`)
888
- * @param data - Error payload
889
- * @param opts - Optional message
894
+ * Built-in helpers (`fx.fail.notFound`, `fx.fail.forbidden`, …) need no
895
+ * `errors:` declaration. Domain codes still use `fx.fail("OutOfStock", data)`.
890
896
  */
891
- fail<E>(code: string, data: E, opts?: FailOptions): FlowFailure<E>;
897
+ readonly fail: FailFn;
892
898
  /** JSON response helpers (status + Stripe-style envelope). */
893
899
  readonly json: FxJson;
894
900
  /**
@@ -2246,7 +2252,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
2246
2252
  operator,
2247
2253
  principal,
2248
2254
  tenant,
2249
- fail,
2255
+ fail: loadFail(),
2250
2256
  json: {
2251
2257
  ok<T>(value: T, opts?: { readonly meta?: Record<string, unknown> }): JsonResult<T> {
2252
2258
  return {
@@ -106,14 +106,15 @@ export {
106
106
  type RunTelemetry,
107
107
  } from "./run-telemetry.ts";
108
108
 
109
+ export { fail } from "./fail-helpers.ts";
109
110
  export {
110
- fail,
111
111
  formatOkeMessage,
112
112
  lookupOkeError,
113
113
  OKE_ERROR_RANGES,
114
114
  OKE_ERRORS,
115
115
  OkeError,
116
116
  throwOke,
117
+ type FailFn,
117
118
  type FailOptions,
118
119
  type FlowErrorValue,
119
120
  type FlowFailure,
@@ -122,6 +123,16 @@ export {
122
123
  type OkeErrorDomain,
123
124
  type OkeErrorParams,
124
125
  } from "./errors.ts";
126
+ export {
127
+ BUILTIN_ERROR_STATUS,
128
+ httpStatusForFailure,
129
+ statusForBuiltinError,
130
+ type BuiltinErrorBag,
131
+ type BuiltinErrorCode,
132
+ type BuiltinErrorMap,
133
+ type BuiltinValidationIssue,
134
+ type DatabaseErrorReason,
135
+ } from "./builtin-errors.ts";
125
136
 
126
137
  export {
127
138
  flow,
@@ -56,6 +56,8 @@ export type DevRequestLogInput = {
56
56
  readonly ms: number;
57
57
  /** Human failure detail (error.message / code) for 4xx/5xx. */
58
58
  readonly detail?: string;
59
+ /** Failure `error.code` for the title chip. */
60
+ readonly errorCode?: string;
59
61
  };
60
62
 
61
63
  /**
@@ -85,16 +87,19 @@ export function isSilentDevRequest(method: string, path: string, surface?: DevLo
85
87
  *
86
88
  * @param response - HTTP response (cloned; original body stays readable)
87
89
  */
88
- export async function failureDetailFromResponse(response: Response): Promise<string | undefined> {
89
- if (response.status < 400) return undefined;
90
+ export async function failureEnvelopeFromResponse(
91
+ response: Response,
92
+ ): Promise<{ readonly detail?: string; readonly code?: string }> {
93
+ if (response.status < 400) return {};
90
94
  try {
91
95
  const body: unknown = await response.clone().json();
92
- if (body === null || typeof body !== "object" || !("error" in body)) return undefined;
96
+ if (body === null || typeof body !== "object" || !("error" in body)) return {};
93
97
  const error = (body as { error: unknown }).error;
94
- if (error === null || typeof error !== "object") return undefined;
98
+ if (error === null || typeof error !== "object") return {};
95
99
  const rec = error as { message?: unknown; code?: unknown; data?: unknown };
100
+ const code = typeof rec.code === "string" ? rec.code : undefined;
96
101
  if (typeof rec.message === "string" && rec.message.trim().length > 0) {
97
- return rec.message.trim();
102
+ return { detail: rec.message.trim(), ...(code ? { code } : {}) };
98
103
  }
99
104
  if (
100
105
  rec.data !== null &&
@@ -103,14 +108,25 @@ export async function failureDetailFromResponse(response: Response): Promise<str
103
108
  typeof (rec.data as { reason: unknown }).reason === "string"
104
109
  ) {
105
110
  const reason = (rec.data as { reason: string }).reason;
106
- const code = typeof rec.code === "string" ? rec.code : "Error";
107
- return `${code}: ${reason}`;
111
+ return {
112
+ detail: `${code ?? "Error"}: ${reason}`,
113
+ ...(code ? { code } : {}),
114
+ };
108
115
  }
109
- if (typeof rec.code === "string") return rec.code;
116
+ if (code) return { detail: code, code };
110
117
  } catch {
111
118
  // non-JSON error bodies stay status-only
112
119
  }
113
- return undefined;
120
+ return {};
121
+ }
122
+
123
+ /**
124
+ * Extract a short failure detail from an OKE JSON envelope body.
125
+ *
126
+ * @param response - HTTP response (cloned; original body stays readable)
127
+ */
128
+ export async function failureDetailFromResponse(response: Response): Promise<string | undefined> {
129
+ return (await failureEnvelopeFromResponse(response)).detail;
114
130
  }
115
131
 
116
132
  /**
@@ -135,6 +151,7 @@ export function logDevRequest(input: DevRequestLogInput): void {
135
151
  status: input.status,
136
152
  ms: input.ms,
137
153
  detail: input.detail,
154
+ errorCode: input.errorCode,
138
155
  }),
139
156
  );
140
157
  }
@@ -167,7 +184,7 @@ export async function timedDevFetch(
167
184
  const url = new URL(request.url);
168
185
  const method = request.method.toUpperCase();
169
186
  const response = await handle(request);
170
- const detail = await failureDetailFromResponse(response);
187
+ const envelope = await failureEnvelopeFromResponse(response);
171
188
  logDevRequest({
172
189
  surface: options.surface ?? currentDevSurface(),
173
190
  method,
@@ -176,7 +193,8 @@ export async function timedDevFetch(
176
193
  runId: options.resolveRunId?.(request, response),
177
194
  status: response.status,
178
195
  ms: Math.round(performance.now() - started),
179
- detail,
196
+ detail: envelope.detail,
197
+ errorCode: envelope.code,
180
198
  });
181
199
  return response;
182
200
  }
package/src/term.test.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  formatOkeWordmark,
17
17
  formatServiceLine,
18
18
  formatSurfaceBox,
19
+ formatFatalError,
19
20
  formatRequestLine,
20
21
  formatStackSummary,
21
22
  countTermLines,
@@ -296,6 +297,81 @@ describe("term", () => {
296
297
  expect(out).not.toContain("↳");
297
298
  });
298
299
 
300
+ test("formatRequestLine title chip includes error.code", () => {
301
+ const out = formatRequestLine({
302
+ surface: "Backend",
303
+ method: "POST",
304
+ path: "/links",
305
+ flow: "links.create",
306
+ status: 409,
307
+ ms: 3,
308
+ detail: "That value is already in use.",
309
+ errorCode: "Conflict",
310
+ color: false,
311
+ });
312
+ expect(out).toMatch(/◇ {2}409 Conflict\n/);
313
+ });
314
+
315
+ test("formatFatalError prints VaultBootError as OKE1510 with cause and fix", () => {
316
+ const err = Object.assign(new Error("vault boot failed"), {
317
+ name: "VaultBootError",
318
+ gaps: [{ name: "STRIPE_SECRET_KEY" }, { name: "DATABASE_URL" }],
319
+ });
320
+ const out = formatFatalError(err, false);
321
+ expect(out).toContain("OKE1510");
322
+ expect(out).toContain("2 secrets have no value in any resolution layer.");
323
+ expect(out).toContain("- STRIPE_SECRET_KEY");
324
+ expect(out).toContain("oke vault set");
325
+ expect(out).toContain("https://oke.omqkhafi.dev/e/1510");
326
+ expect(out).not.toContain("Error:");
327
+ expect(out).not.toContain(" at ");
328
+ });
329
+
330
+ test("formatFatalError prints GateBootError with posture gaps", () => {
331
+ const err = Object.assign(new Error("gate boot failed"), {
332
+ name: "GateBootError",
333
+ gaps: [{ flowId: "notes.get", method: "GET", path: "/notes/:id" }],
334
+ });
335
+ const out = formatFatalError(err, false);
336
+ expect(out).toContain("Gate");
337
+ expect(out).toContain("1 trigger(s) missing auth posture.");
338
+ expect(out).toContain("- notes.get GET /notes/:id");
339
+ expect(out).toContain("Attach a gate or `.public()`.");
340
+ expect(out).not.toContain(" at ");
341
+ });
342
+
343
+ test("formatFatalError prints PluginNeedsError with unmet needs", () => {
344
+ const err = Object.assign(new Error("plugin needs failed"), {
345
+ name: "PluginNeedsError",
346
+ gaps: [{ plugin: "auth", need: "store.sql" }],
347
+ });
348
+ const out = formatFatalError(err, false);
349
+ expect(out).toContain("Plugin");
350
+ expect(out).toContain("1 unmet .needs() dependency.");
351
+ expect(out).toContain('- auth needs "store.sql"');
352
+ expect(out).toContain("Install the missing plugin");
353
+ expect(out).not.toContain(" at ");
354
+ });
355
+
356
+ test("formatFatalError hides stack unless OKE_DEBUG=1", () => {
357
+ const prev = process.env["OKE_DEBUG"];
358
+ const boom = new Error("plain boom");
359
+ try {
360
+ delete process.env["OKE_DEBUG"];
361
+ const hidden = formatFatalError(boom, false);
362
+ expect(hidden).toContain("Error");
363
+ expect(hidden).toContain("plain boom");
364
+ expect(hidden).not.toContain(" at ");
365
+
366
+ process.env["OKE_DEBUG"] = "1";
367
+ const shown = formatFatalError(boom, false);
368
+ expect(shown).toContain("Error");
369
+ } finally {
370
+ if (prev === undefined) delete process.env["OKE_DEBUG"];
371
+ else process.env["OKE_DEBUG"] = prev;
372
+ }
373
+ });
374
+
299
375
  test("formatStackSummary is scannable", () => {
300
376
  const out = formatStackSummary({
301
377
  project: "oke-dev-a3f791",
package/src/term.ts CHANGED
@@ -5,6 +5,9 @@
5
5
  * Flip to monochrome with `NO_COLOR=1` or non-TTY stdout.
6
6
  */
7
7
 
8
+ import { docsUrl } from "./docs-origin.ts";
9
+ import { lookupOkeError } from "./kernel/errors.ts";
10
+
8
11
  /** Whether ANSI color should be applied. */
9
12
  export function termColorEnabled(stream: { readonly isTTY?: boolean } = process.stdout): boolean {
10
13
  if (process.env["NO_COLOR"] !== undefined) return false;
@@ -701,15 +704,171 @@ export function formatDevNote(options: {
701
704
  const ink = options.tone === "error" ? s.red : s.yellow;
702
705
  const bar = `${s.dim}│${s.reset}`;
703
706
  const width = Math.max(40, Math.min(72, (process.stdout.columns ?? 80) - 6));
704
- const wrapped = wrapWords(options.body.trim(), width);
707
+ const wrapped = wrapBodyLines(options.body.trimEnd(), width);
705
708
  const lines = [`${ink}◇${s.reset} ${s.bold}${ink}${options.title}${s.reset}`, bar];
706
709
  for (const line of wrapped) {
707
- lines.push(`${bar} ${ink}${line}${s.reset}`);
710
+ lines.push(line.length === 0 ? bar : `${bar} ${ink}${line}${s.reset}`);
708
711
  }
709
712
  lines.push(bar);
710
713
  return `${lines.join("\n")}\n`;
711
714
  }
712
715
 
716
+ function wrapBodyLines(body: string, width: number): string[] {
717
+ const out: string[] = [];
718
+ for (const raw of body.split("\n")) {
719
+ const line = raw.trimEnd();
720
+ if (line.length === 0) {
721
+ out.push("");
722
+ continue;
723
+ }
724
+ out.push(...wrapWords(line, width));
725
+ }
726
+ return out;
727
+ }
728
+
729
+ /**
730
+ * Boot / start fatal — cause, optional list, fix, docs URL. No stack unless
731
+ * `OKE_DEBUG=1`.
732
+ *
733
+ * @param err - Thrown boot value
734
+ * @param color - Color on/off
735
+ */
736
+ export function formatFatalError(err: unknown, color: boolean = termColorEnabled()): string {
737
+ const debug = process.env["OKE_DEBUG"] === "1";
738
+ const formatted = describeFatalError(err, debug);
739
+ return formatDevNote({
740
+ title: formatted.title,
741
+ body: formatted.body,
742
+ tone: "error",
743
+ color,
744
+ });
745
+ }
746
+
747
+ function describeFatalError(
748
+ err: unknown,
749
+ debug: boolean,
750
+ ): { readonly title: string; readonly body: string } {
751
+ const vault = vaultBootGaps(err);
752
+ if (vault) {
753
+ const def = lookupOkeError(1510);
754
+ const count = String(vault.length);
755
+ const cause = def
756
+ ? interpolateTemplate(def.cause, { count })
757
+ : `${count} secrets have no value in any resolution layer.`;
758
+ const fix = def
759
+ ? interpolateTemplate(def.fix, { count })
760
+ : "Set each name (`oke vault set <name>`, or `.env.local`).";
761
+ const url = def ? docsUrl(`/e/${def.code}`) : docsUrl("/e/1510");
762
+ const names = vault.map((g) =>
763
+ g.description ? `- ${g.name}: ${g.description}` : `- ${g.name}`,
764
+ );
765
+ return {
766
+ title: "OKE1510",
767
+ body: [cause, ...names, `→ ${fix}`, url].join("\n"),
768
+ };
769
+ }
770
+
771
+ const gate = namedGaps(err, "GateBootError");
772
+ if (gate) {
773
+ const lines = gate.map((g) => {
774
+ const rec = g as Record<string, unknown>;
775
+ const flow = typeof rec.flowId === "string" ? rec.flowId : "flow";
776
+ const method = typeof rec.method === "string" ? rec.method : "";
777
+ const path = typeof rec.path === "string" ? rec.path : "";
778
+ return `- ${flow} ${method} ${path}`.trim();
779
+ });
780
+ return {
781
+ title: "Gate",
782
+ body: [
783
+ `${gate.length} trigger(s) missing auth posture.`,
784
+ ...lines,
785
+ "→ Attach a gate or `.public()`.",
786
+ ].join("\n"),
787
+ };
788
+ }
789
+
790
+ const plugin = namedGaps(err, "PluginNeedsError");
791
+ if (plugin) {
792
+ const lines = plugin.map((g) => {
793
+ const rec = g as Record<string, unknown>;
794
+ const name = typeof rec.plugin === "string" ? rec.plugin : "plugin";
795
+ const need = typeof rec.need === "string" ? rec.need : "";
796
+ return `- ${name} needs "${need}"`;
797
+ });
798
+ return {
799
+ title: "Plugin",
800
+ body: [
801
+ `${plugin.length} unmet .needs() dependenc${plugin.length === 1 ? "y" : "ies"}.`,
802
+ ...lines,
803
+ "→ Install the missing plugin or bind the element / driver.",
804
+ ].join("\n"),
805
+ };
806
+ }
807
+
808
+ if (isOkeErrorShape(err)) {
809
+ return {
810
+ title: `OKE${err.code}`,
811
+ body: [err.causeText, `→ ${err.fix}`, err.docsUrl].join("\n"),
812
+ };
813
+ }
814
+
815
+ const message = err instanceof Error ? err.message : String(err);
816
+ const stack = debug && err instanceof Error && err.stack ? `\n${err.stack}` : "";
817
+ return { title: "Error", body: `${message}${stack}` };
818
+ }
819
+
820
+ function interpolateTemplate(template: string, params: Readonly<Record<string, string>>): string {
821
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, key: string) => params[key] ?? `{${key}}`);
822
+ }
823
+
824
+ function vaultBootGaps(
825
+ err: unknown,
826
+ ): readonly { readonly name: string; readonly description?: string }[] | undefined {
827
+ if (!err || typeof err !== "object") return undefined;
828
+ if ((err as { name?: unknown }).name !== "VaultBootError") return undefined;
829
+ const gaps = (err as { gaps?: unknown }).gaps;
830
+ if (!Array.isArray(gaps)) return undefined;
831
+ const out: { name: string; description?: string }[] = [];
832
+ for (const g of gaps) {
833
+ if (!g || typeof g !== "object") continue;
834
+ const name = (g as { name?: unknown }).name;
835
+ if (typeof name !== "string") continue;
836
+ const description = (g as { description?: unknown }).description;
837
+ out.push({
838
+ name,
839
+ ...(typeof description === "string" ? { description } : {}),
840
+ });
841
+ }
842
+ return out;
843
+ }
844
+
845
+ function namedGaps(err: unknown, name: string): readonly unknown[] | undefined {
846
+ if (!err || typeof err !== "object") return undefined;
847
+ if ((err as { name?: unknown }).name !== name) return undefined;
848
+ const gaps = (err as { gaps?: unknown }).gaps;
849
+ return Array.isArray(gaps) ? gaps : undefined;
850
+ }
851
+
852
+ function isOkeErrorShape(
853
+ err: unknown,
854
+ ): err is { code: number; causeText: string; fix: string; docsUrl: string } {
855
+ if (!err || typeof err !== "object") return false;
856
+ const rec = err as {
857
+ name?: unknown;
858
+ code?: unknown;
859
+ causeText?: unknown;
860
+ fix?: unknown;
861
+ docsUrl?: unknown;
862
+ };
863
+ return (
864
+ rec.name === "OkeError" &&
865
+ typeof rec.code === "number" &&
866
+ typeof rec.causeText === "string" &&
867
+ typeof rec.fix === "string" &&
868
+ typeof rec.docsUrl === "string"
869
+ );
870
+ }
871
+
713
872
  /**
714
873
  * Boot honesty notice — Clack-like column under the `oke dev` hero.
715
874
  *
@@ -808,6 +967,8 @@ export function formatRequestLine(options: {
808
967
  readonly color?: boolean;
809
968
  /** Failure detail printed on a follow-up line (4xx/5xx). */
810
969
  readonly detail?: string;
970
+ /** Failure `error.code` for the title chip (`409 Conflict`). */
971
+ readonly errorCode?: string;
811
972
  /** WideEvent / run id when the request executed a flow. */
812
973
  readonly runId?: string;
813
974
  }): string {
@@ -849,8 +1010,10 @@ export function formatRequestLine(options: {
849
1010
  const detail = options.detail?.trim();
850
1011
  if (!detail || options.status < 400) return main;
851
1012
  const tone: DevNoteTone = options.status >= 500 ? "error" : "warn";
1013
+ const code = options.errorCode?.trim();
1014
+ const title = code && code.length > 0 ? `${options.status} ${code}` : String(options.status);
852
1015
  const note = formatDevNote({
853
- title: String(options.status),
1016
+ title,
854
1017
  body: detail,
855
1018
  tone,
856
1019
  color: options.color ?? termColorEnabled(),