okengine 0.11.0 → 0.11.2

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 (68) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/ai.mdx +22 -1
  3. package/site/content/docs/elements/store.mdx +3 -1
  4. package/site/content/docs/elements/vault.mdx +19 -11
  5. package/site/content/docs/get-started/installation.mdx +7 -1
  6. package/site/content/docs/recipes/llama-cpp.mdx +10 -9
  7. package/site/content/docs/reference/cli.md +6 -2
  8. package/site/content/docs/reference/environment-variables.mdx +9 -9
  9. package/src/cli/ai-setup/ai-setup.test.ts +3 -1
  10. package/src/cli/ai-setup/apply.ts +61 -1
  11. package/src/cli/ask-seed.test.ts +4 -3
  12. package/src/cli/ask-seed.ts +5 -6
  13. package/src/cli/client-add.test.ts +2 -1
  14. package/src/cli/dev.test.ts +116 -0
  15. package/src/cli/dev.ts +107 -18
  16. package/src/cli/project-state.test.ts +50 -0
  17. package/src/cli/project-state.ts +123 -0
  18. package/src/cli/vault-cmd.test.ts +47 -18
  19. package/src/cli/vault-cmd.ts +2 -1
  20. package/src/compiler/extract.ts +12 -1
  21. package/src/console/server/console.test.ts +3 -1
  22. package/src/console/server/operator-db.test.ts +48 -17
  23. package/src/console/server/operator-db.ts +5 -1
  24. package/src/docker/derive.ts +24 -3
  25. package/src/docker/docker.test.ts +4 -2
  26. package/src/docker/index.ts +1 -0
  27. package/src/docker/recipes/index.ts +1 -0
  28. package/src/docker/recipes/llama-cpp.ts +20 -4
  29. package/src/drivers/ai-openai-compatible.ts +15 -3
  30. package/src/drivers/vault-builtin.test.ts +50 -42
  31. package/src/elements/ai/declare.ts +73 -3
  32. package/src/elements/ai/errors.test.ts +35 -0
  33. package/src/elements/ai/errors.ts +139 -0
  34. package/src/elements/ai/eval.ts +26 -1
  35. package/src/elements/ai/runtime.ts +140 -80
  36. package/src/elements/ai/tools.test.ts +1 -1
  37. package/src/elements/ai.test.ts +99 -2
  38. package/src/elements/ai.ts +11 -1
  39. package/src/elements/gate/config.ts +13 -3
  40. package/src/elements/gate/declare.ts +1 -1
  41. package/src/elements/gate/strategies.ts +2 -12
  42. package/src/elements/index.ts +2 -0
  43. package/src/elements/store/index-boot.test.ts +23 -6
  44. package/src/elements/store/resource.test.ts +38 -19
  45. package/src/elements/store/sql-session.test.ts +55 -58
  46. package/src/elements/vault/builtin-adapter.test.ts +115 -58
  47. package/src/elements/vault/builtin-adapter.ts +241 -47
  48. package/src/elements/vault/chaos-child.ts +424 -0
  49. package/src/elements/vault/chaos.test.ts +651 -0
  50. package/src/elements/vault/resilience.ts +6 -1
  51. package/src/elements/vault/security-checklist.test.ts +10 -8
  52. package/src/elements/vault/storage.ts +130 -27
  53. package/src/elements/vault/test-helpers.ts +368 -0
  54. package/src/elements/vault.ts +6 -0
  55. package/src/index.ts +2 -0
  56. package/src/kernel/app-auth.ts +98 -0
  57. package/src/kernel/app.ts +120 -78
  58. package/src/kernel/auto-registry.test.ts +52 -1
  59. package/src/kernel/boot.test.ts +4 -18
  60. package/src/kernel/element-registries.ts +19 -4
  61. package/src/kernel/errors.ts +3 -3
  62. package/src/kernel/fx.test.ts +25 -0
  63. package/src/kernel/fx.ts +4 -1
  64. package/src/manifest/types.ts +4 -0
  65. package/src/release/build-lib.ts +3 -0
  66. package/src/shared/lazy-src.ts +79 -0
  67. package/src/test/create-test-app.ts +16 -11
  68. package/src/test/reset-element-registries.ts +17 -9
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "One law. Eight elements. Ten exports. One package. One manifest. Every backend need is derived, never added.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -86,7 +86,28 @@ export const triage = smart.prompt("ticket-triage", {
86
86
  | `in` / `out` | zod / Standard Schema | Input and output contracts — responses validated against `out` |
87
87
  | `version` | number | Artifact version — diffs and regressions tracked per version |
88
88
  | `evals` | string | Path to a `.jsonl` eval set, regression-gated via `oke eval` |
89
- | `budget` | object | `maxCostPerCall` — cost is a first-class dimension |
89
+ | `budget` | object | `maxCostPerCall` / `maxCostPerRun` — cost, not time |
90
+ | `via` | `string[]` | Ordered recovery chain of logical model names for this prompt |
91
+ | `timeout` | `string \| number` | Deadline — clock duration (`"30s"`) or milliseconds |
92
+
93
+ ## Recovery chain + per-command timeout
94
+
95
+ Fallback is **recovery**, not a fixed cloud→local doctrine. Declare the chain and deadline on the prompt; the call site stays thin:
96
+
97
+ ```typescript
98
+ export const summarizeNote = smart.prompt("summarize-note", {
99
+ via: ["smart", "local"],
100
+ timeout: "30s",
101
+ });
102
+
103
+ // in a flow
104
+ const out = await fx.ask(summarizeNote, { title, body });
105
+ // out.via is the logical model that answered
106
+ ```
107
+
108
+ Resolution: `fx.ask(…, { via })` overrides `prompt.via`, else the prompt’s bound model. On each model the runtime retries **once** for retryable failures (timeout / 429 / 5xx / network), then advances. Permanent failures (401 / other 4xx / schema invalid) stop the chain. When every eligible attempt fails, `fx.ask` throws — map that to a typed error in the flow (no silent text excerpt).
109
+
110
+ `timeout` uses the same duration vocabulary as Clock (`"30s"`, `"2m"`, or a millisecond number). Ask-time `fx.ask(…, { timeout: "10s" })` overrides the prompt. Omit = no artificial cap. Cost caps stay on `budget`.
90
111
 
91
112
  ## Declared guardrails
92
113
 
@@ -234,7 +234,9 @@ export const relations = store.schema.relations({ links, daily }, (r) => ({
234
234
 
235
235
  `oke dev` auto-pushes when you save a schema _input_ (`schema.decl.ts`,
236
236
  hand-written `schema.ts`, `app.ts` for plugin tables, or `drizzle.config.ts`) —
237
- not when emit rewrites `schema.drizzle.ts`.
237
+ not when emit rewrites `schema.drizzle.ts`. A definition error in
238
+ `schema.decl.ts` prints `schema.decl.ts has an error — …` (red ●) and keeps the
239
+ session running — it is not framed as a quiet skip.
238
240
 
239
241
  Opt out with `--no-db-push` or `db: { autoPush: false }`. `prod` **never**
240
242
  auto-applies DDL; a missing table fails as **OKE1101** (`oke db migrate`).
@@ -154,22 +154,30 @@ VaultBootError: 1 secret(s) missing:
154
154
 
155
155
  ## Setting and rotating values
156
156
 
157
- | Command | What it does |
158
- | -------------------------------------------------- | -------------------------------------------------------------------- |
159
- | `oke vault set NAME value` | Write a value to the env file (`.env.local` by default) |
160
- | `oke vault list` | List names present in the active store |
161
- | `oke vault import <file>` | Bulk-import names from a dotenv file |
162
- | `oke vault init` | Initialize the built-in encrypted store (prints the master key once) |
163
- | `oke vault status` / `seal` / `unseal` | Built-in seal lifecycle |
164
- | `oke vault rotate <path>` / `rotate-master` | Re-encrypt a secret, or rewrap every DEK under a new master |
165
- | `oke vault audit` / `audit verify` / `audit purge` | Hash-chained audit (no secret values) |
166
- | `oke vault purge-expired [--dry-run] [--before]` | Hard-delete secret rows past `expires_at` (operator cleanup) |
167
- | `oke vault backup` / `restore` | Encrypted backup under a separate backup KEK |
157
+ | Command | What it does |
158
+ | -------------------------------------------------- | ----------------------------------------------------------------------------- |
159
+ | `oke vault set NAME value` | Write a value to the env file (`.env.local` by default) |
160
+ | `oke vault list` | List names present in the active store |
161
+ | `oke vault import <file>` | Bulk-import names from a dotenv file |
162
+ | `oke vault init` | Initialize the built-in encrypted store (prints the master key once) |
163
+ | `oke vault status` / `seal` / `unseal` | Built-in seal lifecycle |
164
+ | `oke vault rotate <path>` / `rotate-master` | Re-encrypt a secret, or rewrap every DEK under a new master (exclusive lease) |
165
+ | `oke vault audit` / `audit verify` / `audit purge` | Hash-chained audit (serialized appends; no secret values) |
166
+ | `oke vault purge-expired [--dry-run] [--before]` | Hard-delete secret rows past `expires_at` (operator cleanup) |
167
+ | `oke vault backup` / `restore` | Encrypted backup (atomic write + end marker; separate backup KEK) |
168
168
 
169
169
  Flows can write too, through the same capability gate as reads: `fx.vault.set(path, value, { ttlMs?, metadata? })`, `fx.vault.rotate(path, value)`, `fx.vault.delete(path)`, plus `fx.vault.list(prefix?)` and `fx.vault.status()`. These need the encrypted-at-rest backend (`drivers.vault = "vault"`) and throw without it; `fx.vault.get` works on every app.
170
170
 
171
171
  The **Console** (`:6533`) can also set and rotate values, but it is **write-only**: it shows a salted fingerprint (`sha256:…`) per secret, never the cleartext. When you rotate a key there, the panel shows the **blast radius** — which in-flight durable runs will wake up with the new value.
172
172
 
173
+ <Callout title="Builtin concurrency (rotate / audit / backup)">
174
+ `rotate-master` uses the same SKIP LOCKED + lease-expiry physics as Clock and Signal: one holder
175
+ at a time; a crashed holder is reclaimed after the lease TTL. Audit rows append under a
176
+ transactional row lock so the hash chain stays verifiable under concurrent writers. Backups are
177
+ written via temp → fsync → rename and carry an end marker + checksum — restore rejects incomplete
178
+ files before decrypt.
179
+ </Callout>
180
+
173
181
  <Callout title="Builtin master keys and expired rows">
174
182
  Resolve the master key with `--key -` (stdin), `OKE_VAULT_MASTER_KEY`, or a hidden TTY prompt —
175
183
  never put keys on argv in shared shells (history risk). Expired rows stay until `oke vault
@@ -67,7 +67,8 @@ Both are Docker-first (`dev`/`prod` share production protocols; `test` uses PGLi
67
67
  memory). Both ship a minimal GitHub Actions workflow (typecheck + `bun test`).
68
68
 
69
69
  On a TTY: pick a template, then **recommended defaults**, **customize**, or
70
- **reuse** matching saved settings (`~/.oke/create-defaults.json`).
70
+ **reuse** matching saved settings (`~/.oke/create-defaults.json`). Extra locales
71
+ and PgDog answers are saved into that file on every TTY run (not only customize).
71
72
 
72
73
  Customize walks Docker-first facets (including `store.index` with `none`), then
73
74
  **AI setup** (Recommended / Customize / Off). Project name rejects a non-empty
@@ -107,6 +108,11 @@ configured.**
107
108
  stays in sync. Opt out with `oke dev --no-db-push` or
108
109
  `db: { autoPush: false }` in `oke.config.ts`.
109
110
 
111
+ A broken `schema.decl.ts` (bad table / relation definition) prints a red ●
112
+ `schema.decl.ts has an error — …` line and keeps the session up — it is never
113
+ framed as a quiet `skipped`. Environmental gaps (no `oke.config.ts` / no
114
+ images yet) still soft-skip.
115
+
110
116
  For production, generate and apply migrations deliberately — never automatic on
111
117
  boot:
112
118
 
@@ -45,8 +45,9 @@ OKE_AI_MODEL=granite3.3:2b
45
45
  ```
46
46
 
47
47
  `OKE_AI_MODEL` is a Docker Hub `ai/<id>` model. The recipe writes
48
- `docker/llama-entrypoint.py`, which Hub-pulls into the cache volume, then starts
49
- `llama-server -m <gguf> --alias <id>` (single-model).
48
+ `.oke/llama-entrypoint.py` (generated, gitignored not app source), which
49
+ Hub-pulls into the cache volume, then starts `llama-server -m <gguf> --alias <id>`
50
+ (single-model).
50
51
 
51
52
  Never set `LLAMA_ARG_MODELS_PRESET` / `LLAMA_ARG_DOCKER_REPO` — those hang on
52
53
  b10290+. `oke dev` shows the model id and polls `/v1/models` until `ready`
@@ -93,13 +94,13 @@ Postgres/Redis, stricter on the published interface because of GGUF CVE risk.
93
94
 
94
95
  ## What the recipe configures
95
96
 
96
- | Field | Value |
97
- | -------------- | --------------------------------------------------------------- |
98
- | Container port | `8080` |
99
- | Host publish | `127.0.0.1:8080:8080` |
100
- | Healthcheck | `GET /health`, every 5s, 24 retries, 900s start |
101
- | Connection URL | `http://host:8080/v1` |
102
- | Model load | `llama-entrypoint.py` → Hub pull (incl. CNCF weights) then `-m` |
97
+ | Field | Value |
98
+ | -------------- | ------------------------------------------------------------ |
99
+ | Container port | `8080` |
100
+ | Host publish | `127.0.0.1:8080:8080` |
101
+ | Healthcheck | `GET /health`, every 5s, 24 retries, 900s start |
102
+ | Connection URL | `http://host:8080/v1` |
103
+ | Model load | `.oke/llama-entrypoint.py` → Hub pull (incl. CNCF) then `-m` |
103
104
 
104
105
  ## When to choose something else
105
106
 
@@ -85,7 +85,10 @@ prints immediately (wordmark + Starting + profile), then streams background work
85
85
  yellow pending/loading · red error · dim idle. Compose health keeps polling
86
86
  (`docker compose ps -a`); AI ● tracks model phase while the AI container is up.
87
87
  Boot does not wait for the model to become ready. A successful session writes
88
- `.oke/dev.json` (pid · ports · startedAt) and clears it on stop.
88
+ `.oke/dev.json` (pid · ports · startedAt) and clears it on stop. Durable local
89
+ markers live in `.oke/state.json` (e.g. `seededAt` after the one-shot seed
90
+ prompt) — not in the session lock. Console session signing uses
91
+ `.oke/console.secret` (or `OKE_CONSOLE_SECRET`); that is not a Vault secret.
89
92
 
90
93
  On a TTY, Ink keyboard controls stay active after boot (`useInput` — same
91
94
  shortcuts as before). Press `r` to clear the log pane and reprint the latest
@@ -122,7 +125,8 @@ On a TTY: pick **standard** or **advanced**, then recommended defaults or
122
125
  customize. Customize walks **Docker-first** facets once (including `store.index`
123
126
  with a `none` opt-out), then **AI setup**: Recommended (llama.cpp) · Customize ·
124
127
  Off. Writes user-global `~/.oke/create-defaults.json` (reuse only when `template`
125
- matches). Non-TTY / `--yes` / explicit `--template` never prompt.
128
+ matches). Extra locales and PgDog answers are persisted on every TTY run — not
129
+ only after customize. Non-TTY / `--yes` / explicit `--template` never prompt.
126
130
 
127
131
  ### Additional commands
128
132
 
@@ -128,15 +128,15 @@ provider's real credentials — credentials alone never send.
128
128
 
129
129
  ## Framework behavior
130
130
 
131
- | Variable | Used for |
132
- | --------------------- | ------------------------------------------------------------------------------------------ |
133
- | `OKE_DOCKER` | `"1"` marks Compose / `oke dev` posture (set by the CLI) |
134
- | `OKE_DB_AUTO_PUSH` | Overrides `db.autoPush` at boot |
135
- | `OKE_DRIZZLE_DIALECT` | `"postgresql"` for drizzle-kit overlays (templates hardcode it) |
136
- | `OKE_DEV_REQUEST_LOG` | `"1"` logs requests during `oke dev` (set by the CLI) |
137
- | `OKE_CONSOLE_SECRET` | Console session signing secret — set explicitly in production (else `.oke/console.secret`) |
138
- | `PORT` | App port in production containers (default `6530`) |
139
- | `NODE_ENV` | `"production"` switches the Console to its production posture |
131
+ | Variable | Used for |
132
+ | --------------------- | --------------------------------------------------------------------------------------------------------------------- |
133
+ | `OKE_DOCKER` | `"1"` marks Compose / `oke dev` posture (set by the CLI) |
134
+ | `OKE_DB_AUTO_PUSH` | Overrides `db.autoPush` at boot |
135
+ | `OKE_DRIZZLE_DIALECT` | `"postgresql"` for drizzle-kit overlays (templates hardcode it) |
136
+ | `OKE_DEV_REQUEST_LOG` | `"1"` logs requests during `oke dev` (set by the CLI) |
137
+ | `OKE_CONSOLE_SECRET` | Console operator-session signing secret (HMAC) — set in production; else `.oke/console.secret`. Not a Vault contract. |
138
+ | `PORT` | App port in production containers (default `6530`) |
139
+ | `NODE_ENV` | `"production"` switches the Console to its production posture |
140
140
 
141
141
  Console operator rows are **not** stored in `.oke/console.sqlite`. With `DATABASE_URL` (or
142
142
  `OKE_STORE_SQL_URL`) they live in Postgres schema `oke_console`. Without a Postgres URL,
@@ -90,7 +90,7 @@ describe("apply", () => {
90
90
  expect(upsertEnv(env, "OPENAI_API_KEY", "sk-test")).toContain("OPENAI_API_KEY=sk-test");
91
91
  });
92
92
 
93
- test("renderAiTs includes vision + embed", () => {
93
+ test("renderAiTs includes vision + embed + summarize-note + local", () => {
94
94
  const ts = renderAiTs({
95
95
  driver: "ollama",
96
96
  chatModel: "gemma4:e4b",
@@ -98,8 +98,10 @@ describe("apply", () => {
98
98
  embedModel: "nomic-embed-text",
99
99
  });
100
100
  expect(ts).toContain('ai.model("smart"');
101
+ expect(ts).toContain('ai.model("local"');
101
102
  expect(ts).toContain('ai.model("vision"');
102
103
  expect(ts).toContain("docsEmbed");
104
+ expect(ts).toContain('smart.prompt("summarize-note"');
103
105
  });
104
106
 
105
107
  test("applyAiSetup writes config, env, AI models in core.ts", () => {
@@ -208,9 +208,27 @@ export function renderAiTs(input: AiSetupApplyInput): string {
208
208
  const lines = [
209
209
  `import { ai } from "okengine";`,
210
210
  ``,
211
+ `/** Cloud OpenAI-compatible binding (OpenAI / Groq / OpenRouter / …). */`,
211
212
  `export const smart = ai.model("smart", {`,
212
213
  ` provider: "${provider}",`,
213
- ` model: process.env.OKE_AI_MODEL ?? "${chat}",`,
214
+ ` model: process.env.OKE_AI_CLOUD_MODEL ?? process.env.OKE_AI_MODEL ?? "${chat}",`,
215
+ ` baseUrl: process.env.OPENAI_BASE_URL?.trim() || "https://api.openai.com/v1",`,
216
+ ` ...(process.env.OPENAI_API_KEY?.trim()`,
217
+ ` ? { apiKey: process.env.OPENAI_API_KEY.trim() }`,
218
+ ` : {}),`,
219
+ `});`,
220
+ ``,
221
+ `/** Local inference binding (docker llama.cpp / Ollama via \`OKE_AI_URL\`). */`,
222
+ `export const local = ai.model("local", {`,
223
+ ` provider: "${provider === "ollama" ? "ollama" : "openai-compatible"}",`,
224
+ ` model: process.env.OKE_AI_LOCAL_MODEL ?? "${chat}",`,
225
+ ` ...(process.env.OKE_AI_URL?.trim() ? { baseUrl: process.env.OKE_AI_URL.trim() } : {}),`,
226
+ `});`,
227
+ ``,
228
+ `/** Advanced Notes summarize — used by \`notes.summarize\` via \`fx.ask\`. */`,
229
+ `export const summarizeNote = smart.prompt("summarize-note", {`,
230
+ ` via: ["smart", "local"],`,
231
+ ` timeout: "30s",`,
214
232
  `});`,
215
233
  ];
216
234
 
@@ -265,6 +283,10 @@ function writeAiModels(cwd: string, input: AiSetupApplyInput): string {
265
283
  if (existsSync(coreTsPath)) {
266
284
  const existing = readFileSync(coreTsPath, "utf8");
267
285
  if (hasAiModels(existing)) {
286
+ const withPrompt = ensureSummarizeNotePrompt(existing);
287
+ if (withPrompt !== existing) {
288
+ writeFileSync(coreTsPath, withPrompt, "utf8");
289
+ }
268
290
  return coreTsPath;
269
291
  }
270
292
  writeFileSync(coreTsPath, mergeAiIntoCore(existing, rendered), "utf8");
@@ -286,6 +308,44 @@ function hasAiModels(source: string): boolean {
286
308
  );
287
309
  }
288
310
 
311
+ /**
312
+ * Append the advanced Notes `summarize-note` prompt when a `smart` model
313
+ * exists but the prompt was never declared (common after older `--ai` runs).
314
+ *
315
+ * @param source - Existing `src/core.ts` (or AI sidecar) source
316
+ */
317
+ export function ensureSummarizeNotePrompt(source: string): string {
318
+ let next = source;
319
+ if (
320
+ !/\bai\.model\s*\(\s*["']local["']/.test(next) &&
321
+ /\bai\.model\s*\(\s*["']smart["']/.test(next)
322
+ ) {
323
+ next = `${next.trimEnd()}
324
+
325
+ /** Local inference binding (docker llama.cpp / Ollama via \`OKE_AI_URL\`). */
326
+ export const local = ai.model("local", {
327
+ provider: "openai-compatible",
328
+ model: process.env.OKE_AI_LOCAL_MODEL ?? "granite3.3:2b",
329
+ ...(process.env.OKE_AI_URL?.trim() ? { baseUrl: process.env.OKE_AI_URL.trim() } : {}),
330
+ });
331
+ `;
332
+ }
333
+ if (/summarize-note/.test(next) || /summarizeNote/.test(next)) {
334
+ return next;
335
+ }
336
+ if (!/\bai\.model\s*\(\s*["']smart["']/.test(next)) {
337
+ return next;
338
+ }
339
+ const prompt = `
340
+ /** Advanced Notes summarize — used by \`notes.summarize\` via \`fx.ask\`. */
341
+ export const summarizeNote = smart.prompt("summarize-note", {
342
+ via: ["smart", "local"],
343
+ timeout: "30s",
344
+ });
345
+ `;
346
+ return `${next.trimEnd()}\n${prompt}\n`;
347
+ }
348
+
289
349
  /**
290
350
  * Merge rendered AI module into an existing `src/core.ts`.
291
351
  *
@@ -6,7 +6,8 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync } from "node:
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { describe, expect, test } from "bun:test";
9
- import { maybeAskSeed, SEEDED_MARKER } from "./ask-seed.ts";
9
+ import { maybeAskSeed } from "./ask-seed.ts";
10
+ import { PROJECT_STATE_REL } from "./project-state.ts";
10
11
 
11
12
  describe("maybeAskSeed", () => {
12
13
  test("skips when non-TTY", async () => {
@@ -31,7 +32,7 @@ describe("maybeAskSeed", () => {
31
32
  }
32
33
  });
33
34
 
34
- test("asks once then writes marker", async () => {
35
+ test("asks once then writes .oke/state.json seededAt", async () => {
35
36
  const dir = mkdtempSync(join(tmpdir(), "ask-seed-"));
36
37
  try {
37
38
  mkdirSync(join(dir, "src", "db", "seed"), { recursive: true });
@@ -48,7 +49,7 @@ describe("maybeAskSeed", () => {
48
49
  },
49
50
  });
50
51
  expect(calls).toBe(1);
51
- expect(existsSync(join(dir, SEEDED_MARKER))).toBe(true);
52
+ expect(existsSync(join(dir, PROJECT_STATE_REL))).toBe(true);
52
53
 
53
54
  await maybeAskSeed({
54
55
  cwd: dir,
@@ -3,13 +3,13 @@
3
3
  */
4
4
 
5
5
  import { confirm, isCancel } from "@clack/prompts";
6
- import { resolve } from "node:path";
7
6
  import type { ConfigEnv } from "../config/index.ts";
8
7
  import { formatCliChrome } from "../term.ts";
9
8
  import { resolveSeedModulePath, runSeed } from "./db-seed.ts";
9
+ import { isProjectSeeded, markProjectSeeded } from "./project-state.ts";
10
10
 
11
- /** Marker written after a successful prompted seed (skip re-ask). */
12
- export const SEEDED_MARKER = ".oke/seeded";
11
+ /** @deprecated Use `.oke/state.json` `seededAt` via {@link isProjectSeeded}. */
12
+ export { LEGACY_SEEDED_MARKER as SEEDED_MARKER } from "./project-state.ts";
13
13
 
14
14
  /** Options for {@link maybeAskSeed}. */
15
15
  export interface AskSeedOptions {
@@ -37,8 +37,7 @@ export async function maybeAskSeed(options: AskSeedOptions): Promise<void> {
37
37
  if (!tty) return;
38
38
 
39
39
  const cwd = options.cwd;
40
- const marker = resolve(cwd, SEEDED_MARKER);
41
- if (await Bun.file(marker).exists()) return;
40
+ if (await isProjectSeeded(cwd)) return;
42
41
 
43
42
  const seedPath = await resolveSeedModulePath(cwd);
44
43
  if (!(await Bun.file(seedPath).exists())) return;
@@ -74,6 +73,6 @@ export async function maybeAskSeed(options: AskSeedOptions): Promise<void> {
74
73
  }));
75
74
  const code = await seed(cwd, options.env);
76
75
  if (code === 0) {
77
- await Bun.write(marker, `${new Date().toISOString()}\n`);
76
+ await markProjectSeeded(cwd);
78
77
  }
79
78
  }
@@ -96,6 +96,7 @@ void seats;
96
96
  }),
97
97
  );
98
98
 
99
+ // `bunx tsc` under full-suite contention can exceed the default 5s budget.
99
100
  const proc = Bun.spawn(["bunx", "tsc", "--project", tsconfig], {
100
101
  cwd: dir,
101
102
  stdout: "pipe",
@@ -110,7 +111,7 @@ void seats;
110
111
  if (code !== 0) {
111
112
  throw new Error(`tsc failed (${code})\n${stdout}\n${stderr}\n--- d.ts ---\n${result.source}`);
112
113
  }
113
- });
114
+ }, 15_000);
114
115
 
115
116
  test("fetches /_oke/client.json from url", async () => {
116
117
  const dir = await mkdtemp(join(tmpdir(), "oke-client-add-"));
@@ -872,3 +872,119 @@ describe("oke dev Docker-first", () => {
872
872
  }
873
873
  });
874
874
  });
875
+
876
+ describe("oke dev schema.decl sync framing", () => {
877
+ let session: DevSession | undefined;
878
+
879
+ afterEach(() => {
880
+ session?.stop();
881
+ session = undefined;
882
+ });
883
+
884
+ test("schema-definition error is a loud failure line, not a benign skip", async () => {
885
+ const dir = await mkdtemp(join(tmpdir(), "oke-dev-schema-decl-err-"));
886
+ await mkdir(join(dir, "src/db"), { recursive: true });
887
+ await Bun.write(
888
+ join(dir, "oke.config.ts"),
889
+ `export default {
890
+ name: "schema-decl-err",
891
+ images: {
892
+ "store.sql": "postgres:16-alpine",
893
+ "store.kv": "redis:7-alpine",
894
+ },
895
+ drivers: {
896
+ store: { sql: { dev: "postgres", test: "pglite", prod: "postgres" } },
897
+ },
898
+ };
899
+ `,
900
+ );
901
+ await Bun.write(join(dir, "src/app.ts"), "export {}\n");
902
+ // Genuine definition error: `.references()` target is undefined → emit throws.
903
+ await Bun.write(
904
+ join(dir, "src/db/schema.decl.ts"),
905
+ `import { store, field } from ${JSON.stringify(OKE_INDEX)};
906
+
907
+ export const authors = store.schema.table("authors", {
908
+ id: field.text().primaryKey(),
909
+ });
910
+
911
+ export const posts = store.schema.table("posts", {
912
+ id: field.text().primaryKey(),
913
+ authorId: field.text().notNull().references(() => (authors as { missingColumn: never }).missingColumn),
914
+ });
915
+ `,
916
+ );
917
+
918
+ const writes: string[] = [];
919
+ const result = await runDev({
920
+ stdinIsTTY: false,
921
+ cwd: dir,
922
+ silentClaim: true,
923
+ keepAlive: false,
924
+ appPort: 0,
925
+ consolePort: 0,
926
+ mcpPort: 0,
927
+ docsMcpPort: 0,
928
+ ...stubCompose({ noDbPush: false }),
929
+ startApp: async () => ({ stop() {} }),
930
+ regenClient: async () => {},
931
+ write: (t) => {
932
+ writes.push(t);
933
+ },
934
+ serveConsole: async () => ({ stop() {} }),
935
+ serveMcp: async () => ({ stop() {} }),
936
+ serveDocsMcp: async () => ({
937
+ stop() {},
938
+ port: 1,
939
+ url: new URL("http://127.0.0.1:1"),
940
+ }),
941
+ });
942
+
943
+ expect(result.code).toBe(0);
944
+ session = result.session;
945
+
946
+ const plain = writes.join("").replace(/\x1b\[[0-9;]*m/g, "");
947
+ expect(plain).toContain("schema.decl.ts has an error");
948
+ expect(plain).toMatch(/●\s*schema\.decl\.ts has an error/);
949
+ expect(plain).not.toContain("oke db push (dev) skipped");
950
+ }, 60_000);
951
+
952
+ test("benign environmental sync gap still uses skip framing", async () => {
953
+ const dir = await mkdtemp(join(tmpdir(), "oke-dev-schema-skip-"));
954
+ await mkdir(join(dir, "src"), { recursive: true });
955
+ // No oke.config.ts — syncDevSchema(env=dev) throws the known docker-mode skip.
956
+ await Bun.write(join(dir, "src/app.ts"), "export {}\n");
957
+
958
+ const writes: string[] = [];
959
+ const result = await runDev({
960
+ stdinIsTTY: false,
961
+ cwd: dir,
962
+ silentClaim: true,
963
+ keepAlive: false,
964
+ appPort: 0,
965
+ consolePort: 0,
966
+ mcpPort: 0,
967
+ docsMcpPort: 0,
968
+ ...stubCompose({ noDbPush: false }),
969
+ startApp: async () => ({ stop() {} }),
970
+ regenClient: async () => {},
971
+ write: (t) => {
972
+ writes.push(t);
973
+ },
974
+ serveConsole: async () => ({ stop() {} }),
975
+ serveMcp: async () => ({ stop() {} }),
976
+ serveDocsMcp: async () => ({
977
+ stop() {},
978
+ port: 1,
979
+ url: new URL("http://127.0.0.1:1"),
980
+ }),
981
+ });
982
+
983
+ expect(result.code).toBe(0);
984
+ session = result.session;
985
+
986
+ const plain = writes.join("").replace(/\x1b\[[0-9;]*m/g, "");
987
+ expect(plain).toContain("oke db push (dev) skipped — docker mode: oke.config.ts not found");
988
+ expect(plain).not.toContain("schema.decl.ts has an error");
989
+ }, 60_000);
990
+ });