Skip to content

Commit 01167a0

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-23853-form-help-texts-plain
2 parents 4be9dbe + 0af559e commit 01167a0

19 files changed

Lines changed: 1379 additions & 83 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/cli': patch
4+
---
5+
6+
fix(runtime,cli): `os start` and `os dev` refuse a SQLite database whose directory cannot be made, at once, instead of hanging
7+
8+
Clause-②: no
9+
10+
**What was wrong.** The standalone host (`createStandaloneStack`, which `os start`, `os dev`'s standalone boot and every embedder run) prepared the directory of a file-backed SQLite database with Node's recursive `mkdirSync`. Node's recursive mkdir never returns when a level answers `ENOENT` although its parent exists: for `OS_DATABASE_URL=file:/proc/os-x/ctl.db` it looped `mkdir("/proc/os-x")` and `mkdir("/proc")` without end, holding the event loop. `os start` printed `→ Starting server...` and hung with no timeout and no message. The `sqlite-wasm` arm (`wasm-sqlite://…`, or `OS_DATABASE_DRIVER=sqlite-wasm`) did the same. `os dev` hung the same way in its own process when its default database lived under a state directory that cannot be made (for example `OS_HOME=/proc/os-x`).
11+
12+
**What an operator sees now.** The boot exits non-zero within its normal start-up time, naming the database file, the directory and the operating system's error:
13+
14+
```text
15+
✗ [StandaloneStack] SQLite database file '/proc/os-x/ctl.db' cannot be created: its directory '/proc/os-x' could not be made: ENOENT: no such file or directory, mkdir '/proc/os-x'
16+
```
17+
18+
`os dev` with an unusable state directory prints `✗ The default database '…/data/objectstack.db' cannot be created: its directory '…/data' could not be made: …`, followed by the remedy: point `OS_HOME` at a directory that can be created, or choose the database with `--database`.
19+
20+
Nothing else changes. A creatable path is still made, every missing level of it. The read-only boot (`os migrate plan` over an absent database file) still creates no directory. The refusal names the path, never the database URL.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/skills': patch
3+
---
4+
5+
docs(skills): the `objectstack-api` skill stops teaching an app to mount routes on `http.server` — an app declares `apis:`, computes in a flow, and registers code as a stack function or a connector (#23787)
6+
7+
Clause-②: no
8+
9+
`skills/objectstack-api/SKILL.md`'s decision table sent an endpoint that needs code to an imperative `http.server` mount on `kernel:ready`, and its "Code routes" section taught that mount. Ruling C′ item 2 on #23128 retired the mount as an app-author surface: the `http.server` service is the platform plugins' own door-building primitive. The table now points code at a `type: 'flow'` endpoint whose `script` node runs a stack function (`defineStack({ functions })`) or whose `connector_action` node calls a declared connector, names the inbound `type: 'api'` flow hook for a webhook signed with ObjectStack's own `x-objectstack-signature`, and states the three cases that have no app surface today (a third-party callback verified by its own signature over the raw body, a streaming response, a protocol the platform does not speak) instead of inventing one. The "Code routes" section is deleted, and eval 3 expects that answer. Skill text only; nothing an author writes changes.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
Clause-②: no
6+
7+
`os init` (every template: `app`, `plugin`, `empty`) and `os create plugin` now emit
8+
`"overrides": { "vitest": "~4.0.0" }` in the scaffolded `package.json`, so the project installs on
9+
npm 10, the npm Node 22 bundles. Since vitest 4.1.12 shipped a peer set that contradicts itself,
10+
`npm install` of a fresh `os init` project crashed on npm 10.9.x with
11+
`Cannot read properties of null (reading 'edgesOut')`. The override routes every `vitest` edge in
12+
the npm graph to the range the scaffold declares; npm 11 installs the same tree, and pnpm ignores
13+
the field.
14+
15+
The override equals `devDependencies.vitest` wherever the manifest declares vitest directly,
16+
because npm refuses an override that differs from the direct dependency (`EOVERRIDE`). In an `app`
17+
or `empty` project, add vitest at the same range (`npm install -D vitest@~4.0.0`); a bare
18+
`npm install -D vitest` is refused with `EOVERRIDE` while the override is in place. Existing
19+
projects can add the same `overrides` block by hand. `os create plugin --in-repo` is unchanged.

‎.claude/agents/os-dev.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ model: opus
139139
- 仅 diff 改该项目测试或派生清单点名它时本地跑,否则 `deviations` 写明;席位 ACCEPT 读其 CI。
140140
- ③ 读到改动文件的门禁族 = `--commands` 派生清单(按 diff 收窄);派发词清单与全农场归 CI。
141141
- 新 fake engine ⇒ `check:engine-double-contract`;新错误码 ⇒ `check:error-code-casing`。
142+
- 改名或删除导出符号 ⇒ push 前跑 `node scripts/check-platform-checklist.mjs --changed-from origin/main`。
143+
- 判定行原样记 `gates`;它非派生族,不在 `--commands` 清单,`--ran` 只把它列为派生外、不报错。
142144
- ④ 报告 `gates`:CI 跑的每族引 PR check-runs(名+结论),⛔ 不本地重跑;`--ran` 只对账本地子集。
143145
- 推导用 `node scripts/pm/dispatch-gates.mjs --commands` 不传路径,脚本自己从 merge-base 取变更集。
144146
- 变更集含未提交与未跟踪;跑完把已跑清单交给 `--ran <file>` 对账,未跑的本地族它会点名。

‎docs/adr/0088-metadata-kind-admission-and-retirement.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0088: Metadata-kind admission test; retire `trigger`, `router`, `function`, `service`
22

3-
**Status**: Accepted (2026-07-05)
3+
**Status**: Accepted (2026-07-05) · **Amended** (2026-10-16, #23787 — #23128 ruling C′ item 2, record 6142279866: the `router` row's "imperative `http.server` mounts" are the platform plugins' own door-building primitive, not an app's route surface; an app declares `apis:`, computes in a flow, and registers code as a stack function or a connector. The `router` KIND's retirement is unchanged)
44
**Deciders**: ObjectStack Protocol Architects
55
**Builds on**: [ADR-0085](./0085-object-semantic-roles-over-surface-hint-blocks.md) (§2 authored-key admission test — this ADR is its kind-level analogue), [ADR-0018](./0018-unified-node-action-registry.md) / [ADR-0020](./0020-state-machine-converge-and-enforce.md) (prior kind retirements: `workflow`, `approval`), [ADR-0062](./0062-external-datasource-runtime.md) (`external_catalog` lifecycle)
66
**Closes**: framework#2613
@@ -37,7 +37,7 @@ Their real, consumed forms already live elsewhere, and all are **code contributi
3737

3838
| Retired kind | Delivered form |
3939
|---|---|
40-
| `router` | imperative `http.server` mounts (resolve the service from the plugin context and mount on `kernel:ready`) + declarative `apis:` since #5040. ⚠️ **Correction (#4936, 2026-08-04):** this row originally also credited app-authored declarative `apis:` as "executed by `handleApiEndpoint`". That was never true — no route was mounted for a declared path and `matchEndpoint` had no implementation anywhere, so the branch was dead code; it has been deleted and a non-empty `apis:` is now rejected at publish/validate. The retirement decision for the `router` KIND is unaffected; the endpoint executor is being built under #5040, after which declarative `apis:` becomes a real delivered form. ⚠️ **Correction (2026-08-22 ruling):** this row also credited plugin manifest `contributes.routes` (HttpDispatcher prefix routing) as a delivered form. That was never true either — the key has zero readers monorepo-wide, so an entry parses cleanly and serves nothing. The key is being removed and every author-facing material that recommended it is corrected to point at the imperative mount; the delivered forms are the two now named above, and the `router` KIND's retirement is unaffected. |
40+
| `router` | imperative `http.server` mounts (resolve the service from the plugin context and mount on `kernel:ready` — the platform plugins' own primitive, not an app's route surface: see the 2026-10-16 **Amended** entry on the Status line) + declarative `apis:` since #5040. ⚠️ **Correction (#4936, 2026-08-04):** this row originally also credited app-authored declarative `apis:` as "executed by `handleApiEndpoint`". That was never true — no route was mounted for a declared path and `matchEndpoint` had no implementation anywhere, so the branch was dead code; it has been deleted and a non-empty `apis:` is now rejected at publish/validate. The retirement decision for the `router` KIND is unaffected; the endpoint executor is being built under #5040, after which declarative `apis:` becomes a real delivered form. ⚠️ **Correction (2026-08-22 ruling):** this row also credited plugin manifest `contributes.routes` (HttpDispatcher prefix routing) as a delivered form. That was never true either — the key has zero readers monorepo-wide, so an entry parses cleanly and serves nothing. The key is being removed and every author-facing material that recommended it is corrected to point at the imperative mount; the delivered forms are the two now named above, and the `router` KIND's retirement is unaffected. |
4141
| `function` | `defineStack({ functions })` code values (hook-binder & flow `script` body runners). ⚠️ **Correction (#10724, 2026-08-25):** this row originally also credited plugin `contributes.functions` (QL query functions) as a delivered form. That was never true — the #10627 census measured zero readers of the key monorepo-wide, so a function declared there was never registered; the key is retired with the other dead `contributes` members (#10724), and `defineStack({ functions })` remains the one way in, as the paragraph below already records. The retirement decision for the `function` KIND is unaffected. |
4242
| `service` | the plugin/service registry itself (`registerService`) |
4343

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
"setup": "pnpm install && pnpm --filter @objectstack/spec build",
1818
"prepare": "node scripts/setup-git-hooks.mjs",
1919
"check:merge-driver": "node scripts/git-env.mjs --self-test && node scripts/git-merge-regen.mjs --self-test && node scripts/check-regen-pending.mjs --self-test",
20-
"version": "changeset version && node scripts/sync-protocol-version.mjs && node scripts/sync-template-versions.mjs && node scripts/sync-docs-image-tags.mjs && node scripts/sync-release-index-currency.mjs && node scripts/check-skill-compatibility-version.mjs --fix",
20+
"version": "node scripts/release-pending-publish.mjs annotate-shipped; changeset version && node scripts/sync-protocol-version.mjs && node scripts/sync-template-versions.mjs && node scripts/sync-docs-image-tags.mjs && node scripts/sync-release-index-currency.mjs && node scripts/check-skill-compatibility-version.mjs --fix",
2121
"release": "pnpm run build && bash scripts/build-console.sh && bash scripts/release-publish.sh",
2222
"docs:dev": "pnpm --filter @objectstack/docs dev",
2323
"docs:build": "pnpm --filter @objectstack/docs build",

‎packages/cli/src/commands/create.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ import {
134134
NPM_PACKAGE_NAME_MAX_LENGTH,
135135
renderPnpmWorkspaceYaml,
136136
renderScaffoldTsconfig,
137+
SCAFFOLD_NPM_OVERRIDES,
137138
SCAFFOLD_PNPM_RANGE,
138139
SCAFFOLD_TSCONFIG_INCLUDE_SRC_ONLY,
139140
SCAFFOLD_TYPES_NODE_RANGE,
@@ -408,6 +409,13 @@ export const templates: Record<string, CreateTemplate> = {
408409
typescript: SCAFFOLD_TYPESCRIPT_RANGE,
409410
vitest: SCAFFOLD_VITEST_RANGE,
410411
},
412+
// The same npm override `os init` emits, from the same constant, so
413+
// it always equals `devDependencies.vitest` above (npm refuses one
414+
// that differs: `EOVERRIDE`) — see SCAFFOLD_NPM_OVERRIDES for the
415+
// npm 10 crash it prevents. Standalone only: npm reads `overrides`
416+
// from the ROOT manifest alone and pnpm never reads it, so inside
417+
// this monorepo's workspace the block would be inert.
418+
...(standalone ? { overrides: { ...SCAFFOLD_NPM_OVERRIDES } } : {}),
411419
}),
412420
'tsconfig.json': (name: string) =>
413421
standalone
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// `os dev` refuses a unified-default database directory it cannot make — at
4+
// once, naming the database file, the directory and the operating system's
5+
// code — instead of looping in Node's recursive mkdir (#23737).
6+
//
7+
// ## The defect, measured on `origin/main` 43567a8fcb
8+
//
9+
// `os dev` prepared `<state dir>/data/` for its unified-default SQLite file with
10+
// `fs.mkdirSync(dir, { recursive: true })`. Under `OS_HOME=/proc/os-23737` that
11+
// call looped `mkdir("/proc/os-23737") = ENOENT`, `mkdir("/proc") = EEXIST`,
12+
// retry, in the `os dev` process itself: 64,440 failing calls in 25 s under
13+
// `strace`, and the command never reached its next line.
14+
//
15+
// ## Why every case runs in ONE bounded child process
16+
//
17+
// The loop was SYNCHRONOUS: it holds the event loop, so no timer fires and a
18+
// vitest timeout cannot end it. A regression to it would hang this worker
19+
// rather than fail, so `prepareDevDatabaseDirectory` (the function `os dev`'s
20+
// boot awaits) runs from source in a `node --import tsx` child bounded by
21+
// `spawnSync`'s timeout with SIGKILL. All cases share that one child because
22+
// importing `dev.ts` costs about 7 s of the child's wall (measured, cold and
23+
// warm tsx cache alike), and this worker never imports it at all.
24+
//
25+
// ## What is asserted
26+
//
27+
// 1. `/proc/<missing>/` (where `/proc/self` exists — Linux, which CI is): the
28+
// child settles inside the bound and the refusal carries `ENOENT`, naming
29+
// the file and the directory — the shape that looped.
30+
// 2. A path through a regular file: refused with `ENOTDIR`, naming the file.
31+
// 3. CONTROL: a creatable unified-default path three levels deep is made.
32+
// 4. CONTROL: any other source is left to the serve child — nothing is made.
33+
34+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
35+
import { spawnSync } from 'node:child_process';
36+
import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
37+
import { tmpdir } from 'node:os';
38+
import { dirname, join, resolve } from 'node:path';
39+
import { fileURLToPath, pathToFileURL } from 'node:url';
40+
41+
const HERE = dirname(fileURLToPath(import.meta.url));
42+
const PKG_ROOT = resolve(HERE, '../..');
43+
const SUBJECT = pathToFileURL(resolve(HERE, 'dev.ts')).href;
44+
45+
/** The child's whole budget; the loop it guards against never ends. */
46+
const CHILD_BOUND_MS = 45_000;
47+
48+
interface Outcome {
49+
label: string;
50+
outcome: 'returned' | 'rejected';
51+
code?: string;
52+
message?: string;
53+
}
54+
55+
const PROC_DIR = `/proc/os-23737-${process.pid}/data`;
56+
const PROC_FILE = `${PROC_DIR}/objectstack.db`;
57+
const RUNS_PROC = existsSync('/proc/self');
58+
59+
let root: string;
60+
let outcomes: Map<string, Outcome>;
61+
62+
beforeAll(() => {
63+
root = mkdtempSync(join(tmpdir(), 'os-23737-dev-'));
64+
writeFileSync(join(root, 'plain'), '');
65+
const cases: Array<{ label: string; url: string; source: string }> = [
66+
...(RUNS_PROC ? [{ label: 'proc', url: `file:${PROC_FILE}`, source: 'unified-default' }] : []),
67+
{ label: 'through-file', url: `file:${join(root, 'plain', 'data', 'objectstack.db')}`, source: 'unified-default' },
68+
{ label: 'creatable', url: `file:${join(root, 'home', 'a', 'data', 'objectstack.db')}`, source: 'unified-default' },
69+
{ label: 'other-source', url: `file:${join(root, 'env', 'a', 'data', 'objectstack.db')}`, source: 'env' },
70+
];
71+
const script = [
72+
'const [subject, json] = process.argv.slice(1);',
73+
'const { prepareDevDatabaseDirectory } = await import(subject);',
74+
'const out = [];',
75+
'for (const { label, url, source } of JSON.parse(json)) {',
76+
' try { await prepareDevDatabaseDirectory({ url, source }); out.push({ label, outcome: "returned" }); }',
77+
' catch (e) { out.push({ label, outcome: "rejected", code: e?.code, message: String(e?.message) }); }',
78+
'}',
79+
'process.stdout.write("\\nDEV-DIR-OUTCOMES " + JSON.stringify(out) + "\\n");',
80+
].join('\n');
81+
const t0 = Date.now();
82+
const child = spawnSync(
83+
process.execPath,
84+
['--import', 'tsx', '--input-type=module', '-e', script, SUBJECT, JSON.stringify(cases)],
85+
{
86+
cwd: PKG_ROOT,
87+
env: {
88+
PATH: process.env.PATH ?? '',
89+
...(process.env.TMPDIR ? { TMPDIR: process.env.TMPDIR } : {}),
90+
},
91+
encoding: 'utf8',
92+
timeout: CHILD_BOUND_MS,
93+
killSignal: 'SIGKILL',
94+
maxBuffer: 16 * 1024 * 1024,
95+
},
96+
);
97+
if (child.signal) {
98+
throw new Error(
99+
`prepareDevDatabaseDirectory had not settled after ${Date.now() - t0} ms (child killed by ${child.signal}) — ` +
100+
`the directory prep is looping again. stderr tail: ${String(child.stderr).slice(-2000)}`,
101+
);
102+
}
103+
const line = String(child.stdout).split('\n').find((l) => l.startsWith('DEV-DIR-OUTCOMES '));
104+
if (!line) {
105+
throw new Error(`child exited ${child.status} without a result. stderr tail: ${String(child.stderr).slice(-2000)}`);
106+
}
107+
const parsed: Outcome[] = JSON.parse(line.slice('DEV-DIR-OUTCOMES '.length));
108+
outcomes = new Map(parsed.map((o) => [o.label, o]));
109+
}, CHILD_BOUND_MS + 15_000);
110+
111+
afterAll(() => {
112+
if (root) rmSync(root, { recursive: true, force: true });
113+
});
114+
115+
function outcomeOf(label: string): Outcome {
116+
const found = outcomes.get(label);
117+
if (!found) throw new Error(`no outcome recorded for case '${label}'`);
118+
return found;
119+
}
120+
121+
describe('prepareDevDatabaseDirectory — os dev\'s unified-default directory (#23737)', () => {
122+
it.runIf(RUNS_PROC)('refuses /proc/<missing>/ at once with ENOENT, naming the file and the directory', () => {
123+
const o = outcomeOf('proc');
124+
expect(o.outcome).toBe('rejected');
125+
expect(o.code).toBe('ENOENT');
126+
expect(o.message).toContain(`'${PROC_FILE}'`);
127+
expect(o.message).toContain(`'${PROC_DIR}'`);
128+
});
129+
130+
it('refuses a path through a regular file with ENOTDIR, naming the file', () => {
131+
const o = outcomeOf('through-file');
132+
expect(o.outcome).toBe('rejected');
133+
expect(o.code).toBe('ENOTDIR');
134+
expect(o.message).toContain(`'${join(root, 'plain', 'data', 'objectstack.db')}'`);
135+
});
136+
137+
it('CONTROL: a creatable unified-default path three levels deep is made', () => {
138+
expect(outcomeOf('creatable').outcome).toBe('returned');
139+
expect(statSync(join(root, 'home', 'a', 'data')).isDirectory()).toBe(true);
140+
});
141+
142+
it('CONTROL: any other source is left to the serve child — nothing is made', () => {
143+
expect(outcomeOf('other-source').outcome).toBe('returned');
144+
expect(existsSync(join(root, 'env'))).toBe(false);
145+
});
146+
});

0 commit comments

Comments
 (0)