Skip to content

Commit ab52182

Browse files
fix(cloud-connection,plugin-security): a hot install fires the package record-change flows and projects its permission sets without a restart (#21488)
Part of #21322. This PR covers the flows and permission-set half. The jobs half is left open for a decision (see "Jobs" below), so merging this must not close the card. Clause-②: no After `os package install ./dist/objectstack.json` into a running `os start`, the installed package's record-change flow now fires, and its permission set has its `sys_permission_set` row right away. Before this, both needed a restart. The restart path and the `--artifact` boot path are unchanged, and each reads the same as before. ## What was measured first (the card's premise holds on `main` 4c8363f, after PR #21401) This was measured at the public door: a new CLI integration suite spawns `os start`, runs `os package install` against it, and probes the result over REST. The runtime boots a host artifact that declares `requires: ['automation', 'triggers']`. An empty `os start` composes neither capability, so on an empty kernel no flow fires at all, whether hot-installed or restarted. The package reaches the runtime only through the install. | phase | `sys_permission_set?name=tasks_app_task_user` | flow note after `PATCH status=done` | `sys_job?name=tasks_app_tick` | |---|---|---|---| | hot install, before | **0 rows** | **0 rows** | 0 rows | | restart on the same home, before | 1 row (`managed_by: package`) | 1 row | 0 rows | | `os start --artifact` control, before | 1 row | 1 row | 1 row (active) | | hot install, **after** | **1 row** (`managed_by: package`, `package_id: com.example.tasksapp`) | **1 row** | 0 rows | | restart, after | 1 row | 1 row | 0 rows | | control, after | 1 row | 1 row | 1 row | ## Where the boot does this work (measured from the symbols, not the card's line numbers) - **Flows.** Binding is done by `service-automation`'s `AutomationServicePlugin`: `syncFlowsFromProtocol` on `kernel:ready`, and `resyncFlowsFromProtocol` on `metadata:reloaded`. `AppPlugin.start` has no flow step. - **Permission-set projection.** This is done by `plugin-security`'s `SecurityPlugin.runBootstrap` on `kernel:ready`, through `seedCatalogPermissions` and then `bootstrapDeclaredPermissions(ql, metadata, …)` (ADR-0086 D5). That pass reads `ql.registry.listItems('permission')`. Nothing re-ran it after the boot. - **Why a restart worked.** The install-local rehydrate runs inside `kernel:ready` and is registered before both sweeps, so they read the rehydrated package. A hot install registers the package after both sweeps have already run. ## What changed - **`@objectstack/cloud-connection`, the install route.** As its last step, after register, schema sync, the #21321 handler binder, the ledger write and the seed, the route announces `metadata:reloaded` with `changed: ['app/MANIFEST_ID']`. This is the platform's one post-boot re-sync signal. A Studio package publish (`publish-drafts`), a per-item publish and an artifact reload already announce it. It runs after the seed because that is where the boot runs these sweeps: a record-change flow bound before the seed would fire on every seeded row. A subscriber failure is logged at `warn` with the restart that repairs it, and never fails the install. The rehydrate does not announce, so the restart path is unchanged. - **`@objectstack/plugin-security`.** A `metadata:reloaded` subscriber re-runs the same declared-permission seeding the boot runs. It uses the same function, the same organization passes (`catalogSeedPasses`) and the same provenance rules. It runs only once the boot's own pass has finished (`bootstrapRanOnce`), so the platform defaults keep their insert-once shape. It never throws, because `trigger` dispatch propagates. The seeder is idempotent and writes nothing when no set changed. As a side effect, the artifact-reload door gets the same projection. - Nothing changed in `packages/runtime` (`app-artifact-handlers.ts` and `app-plugin.ts` are untouched), in `packages/spec`, `service-automation` or `objectql`. The install response and the CLI output keep their fields and text. **The landing point differs from the claim's file surface, and why.** The claim expected `packages/runtime/src/app-artifact-handlers.ts`, and triage said flows and permission-set projection would "extend that one binder". The measurement shows that at boot, neither flows nor permission-set projection is an `AppPlugin.start` step that the binder could share. Both are `kernel:ready` sweeps owned by the consumer plugins. `bindAppArtifactHandlers` is a synchronous `ql`-only function, and `AppPlugin.start` calls it before `kernel:ready`. Putting flow binding or projection into the binder would have been exactly the second path the ruling forbids. So the hot install re-runs the consumers' own sweeps, and the one edit outside this lane is the producer side in `packages/plugins/plugin-security`. That edit is a cross-lane path, named here for the seat to declare. ## Jobs: measured, not folded in (needs a decision) An installed package's `defineStack({ jobs })` are never scheduled by install-local, on a hot install or after a restart (table above). The control schedules them. That is not a missing registration step. A job's `handler` names a `functions` entry, a compiled artifact carries only the lowered string ref, and the callable rides in the sibling `objectstack-runtime.HASH.mjs` that only `os start --artifact` imports (`mergeRuntimeModule`). An inline install sends the JSON alone, so no step can resolve a handler. The ruling's exception arm (the install response and the CLI name what did not bind) would widen the public response and CLI surface. The hazard note says to stop before writing that, so it is not in this PR. The options are in the report on the card. ## Tests - `packages/cli/test/package-install-local-boot-steps.integration.test.ts` (integration tier, new). It has three phases: hot install, restart on the same home, and the `--artifact` control. Each phase pins the `sys_permission_set` row and the flow's note. Result at `ed91d99506`: 7 of 7 green. The #21321 sibling `package-install-local-handlers.integration.test.ts` ran in the same run, 17 of 17 green. The new announce does not double-bind the installed package's hooks or actions. - `packages/cloud-connection/src/marketplace-install-local-hot-resync.test.ts` (new, 5 tests). The install announces once, naming the app, after register and persist. A reinstall announces again. The rehydrate announces nothing. A throwing subscriber leaves the install at 200 with one `warn` that names the restart. A context without `trigger` says so. - `packages/plugins/plugin-security/src/declared-permission-reload-projection.test.ts` (new, 3 tests). The tests drive the real `SecurityPlugin` hooks. A set registered after `kernel:ready` gets its row, with package provenance, on the reload. A second reload adds no row. A reload before the boot pass writes nothing. Its engine double is recorded in `scripts/engine-double-contract.pinned.json`, as the gate asks. - Full suites: `@objectstack/cloud-connection` 32 files, 406 tests green. `@objectstack/plugin-security` 163 files, 3522 tests green (45 skipped). `@objectstack/cli --project unit` 248 files green. Two published-subpath pins first stopped on PREREQUISITE NOT MET (no CLI `dist`) and were green after `pnpm --filter @objectstack/cli build`. Typecheck is green for all three packages. ## Ablations (one-shot; each leg mutated through `scripts/ablation-replace.mjs`, proven in `dist/` with `ablation-dist-preflight.mjs`, restored and rebuilt) - **Leg A: the install-route announce replaced by a marker.** The pin read: install phase, permission-set row red and flow red; restart and control green (2 failed, 5 passed). The leg's DTS step failed on TS6133 for the now-unused private method, but the JS bundles carried the marker, and preflight proved it in `dist/`. The unit file read 4 red, with the rehydrate case green. - **Leg B: the security subscriber renamed to a non-event.** The pin read: only the install-phase permission-set row red; the install-phase flow stayed green (1 failed, 6 passed). The two halves are independent. The unit file read 2 red, with the before-boot control green. - Both restore legs: rebuilt, `--absent` preflight green, tree clean against HEAD. ## Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` was re-derived with no paths after the last code commit. `--ran` reconciliation: **76 derived, 76 run, 0 NOT-MEASURED**, each with a recorded exit 0. `check:dual-build-cjs-loads` first exited 3 (PREREQUISITE NOT MET) and was green after building the 9 unbuilt packages. `check:engine-double-contract` first exited 1 until the new test's double was recorded. The last commit (`f1fefdf6e9`) only adds an ADR-0086 D5 anchor to one comment. The comment-reading gates and `check:adr-anchors` were re-run on it, all green. `pnpm lint` (CI-owned) as a proven narrowing at `ed91d99506`. ① ESLint's own `isPathIgnored` reports all 5 changed TS files as linted. The changeset and the JSON ledger are not in any config object. ② `eslint --no-inline-config --format json` over them gives 5 files, 0 errors and 0 warnings, and 1 file, 0 and 0 on `f1fefdf6e9`. ③ `eslint.config.mjs` enables no type-aware linting: no `parserOptions.project` and no typed rules, as its own header states. It has no cross-file import rules either, so this diff cannot move a verdict on any untouched file. ## Acceptance notes - **Uninstall symmetry**, measured because this change makes it reachable without a restart. After a hot install, `DELETE /api/v1/marketplace/install-local/com.example.tasksapp` answers 200. The flow still fires and the set's row stays, which matches the route's documented "remains loaded until the next restart". After a restart the package's object answers 404, but the `sys_permission_set` row stays. The pre-existing path (install, restart, DELETE, restart) leaves the same row. Install-local's DELETE runs no `registerUninstallCleanup` (`security.package-permissions`). That is reported as a finding on the card, not fixed here. `DELETE /api/v1/packages/com.example.tasksapp` answers 422 `WRITABLE_PACKAGE_REQUIRED`, which is a different door. - **Same family, not measured.** A hot-installed package's declared `positions` and `capabilities`, and the ADR-0090 audience-binding suggestion for an `isDefault` set, are also seeded only by the `kernel:ready` bootstrap. This PR re-runs only the permission-set seeding the card names. - **Composition.** `os package install` cannot add capabilities to a running runtime. A package whose flows need `automation` and `triggers` installs green into a runtime booted without them, and its flows never fire, before or after a restart. - **Docs drift.** The `metadata:reloaded` description in `packages/spec/src/contracts/plugin-lifecycle-events.ts` still names only the artifact watcher as its emitter, but it has four now. Carrier: none (spec-seat file). - `main` moved 3 commits past the base (4c8363f) during the run. None touches `packages/cloud-connection`, `plugin-security`, `runtime`, `service-automation`, `objectql` or `packages/cli`, so the branch was not merged forward. --- _Generated by [Claude Code](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 100c394 commit ab52182

7 files changed

Lines changed: 827 additions & 0 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
"@objectstack/cloud-connection": patch
3+
"@objectstack/plugin-security": patch
4+
---
5+
6+
fix(cloud-connection,plugin-security): a package installed into a running runtime fires its record-change flows and has its permission sets in `sys_permission_set` right away, not after a restart
7+
8+
Clause-②: no
9+
10+
**Before**, `os package install ./dist/objectstack.json` into a running `os start` (the install-local route) registered the package, bound its script actions and body hooks, and stopped there. Two things the boot does for a package happen at `kernel:ready`, and that moment had already passed. The automation engine binds flows at `kernel:ready`, so the package's record-change flows never fired: a task updated to `done` wrote no note. The security plugin seeds declared permission sets at `kernel:ready`, so the package's set had no `sys_permission_set` row. `/meta/permission` listed the set, but an admin could not grant it. A restart fixed both, because the restart re-registers the package before those two steps run. Nothing in the CLI output or the install response said a restart was needed.
11+
12+
**Now** the install route announces `metadata:reloaded` once the package is registered, bound, persisted and seeded. That is the same event a Studio package publish, a per-item publish and an artifact reload already announce. The automation engine already re-syncs its flows on it. The security plugin now re-runs its declared-permission seeding on it: the same function and organization passes as the boot, with the same provenance rules (`managed_by: 'package'`, `package_id`). Right after the install, the flow fires and the set's row exists, with the same state a restart gives. The seeding is idempotent and writes nothing when no permission set changed. It runs only after the boot's own pass has finished. A failed re-sync does not fail the install. It is logged at `warn` with the restart that repairs it.
13+
14+
**Unchanged.** The restart path (the ledger rehydrate) announces nothing and behaves as before. The install response and the CLI output keep their fields and text. A package's `defineStack({ jobs })` are still not scheduled by install-local, on install or after a restart, because a job's handler is code from the artifact's runtime module and an inline install carries only the JSON.
Lines changed: 368 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,368 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #21322 — a hot install (`os package install <artifact>` into a RUNNING
5+
* `os start`) leaves the runtime in the state a restart would: the installed
6+
* package's record-change flow fires and its permission set is projected into
7+
* `sys_permission_set`, both right after the install, with no restart.
8+
*
9+
* ## The defect, measured on the published train and again on `main`
10+
*
11+
* The install registered the package's metadata (`GET /meta/permission` named
12+
* the set, `GET /meta/flow` named the flow) and nothing that the boot does
13+
* AFTER registration at `kernel:ready`:
14+
*
15+
* - the record-change flow `task_completed_note` never fired — a task updated
16+
* to `done` wrote no note;
17+
* - `sys_permission_set` had no `tasks_app_task_user` row, so an admin could
18+
* not grant the installed app's set to anyone;
19+
*
20+
* and both appeared after a restart on the same home, because the boot's own
21+
* `kernel:ready` sweeps (the automation plugin's flow sync, the security
22+
* plugin's declared-permission seeding) read the rehydrated package. Those
23+
* sweeps run once per boot; nothing re-ran them for a package that arrived
24+
* after it.
25+
*
26+
* ## What each `it` reads
27+
*
28+
* One fixture, three phases — after the hot INSTALL, after a RESTART on the
29+
* same home (the ledger rehydrate), and the `--artifact` CONTROL on a fresh
30+
* home — each probed through the doors a user uses: the data route for the
31+
* permission-set row and for the flow's effect (a task updated to `done`, then
32+
* the note it should have written). The restart and the control are the
33+
* unchanged paths; they must read exactly what the install now reads.
34+
*
35+
* ## Spawn shape
36+
*
37+
* Shared with `package-install-local-handlers.integration.test.ts` (#21321):
38+
* the tsx source entry, one process group per `os start`, every workspace
39+
* package — `@objectstack/runtime` and `@objectstack/cloud-connection`
40+
* included — resolved through its `exports` to `dist/`, so an ablation of
41+
* either package's source reaches this file only after that package is rebuilt.
42+
*/
43+
44+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
45+
import { spawn, type ChildProcess } from 'node:child_process';
46+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
47+
import { tmpdir } from 'node:os';
48+
import { join } from 'node:path';
49+
import {
50+
CLI,
51+
childEnv,
52+
E2E_SECRET_KEY,
53+
portContentionError,
54+
portDriftError,
55+
probeThroughChild,
56+
randomPort,
57+
TSX,
58+
} from './helpers/serve-process.js';
59+
60+
/** The banner's tail — every row above it has printed. */
61+
const READY = /Press Ctrl\+C to stop/;
62+
const BOOT_TIMEOUT_MS = 180_000;
63+
64+
const APP_ID = 'com.example.tasksapp';
65+
const TASK = 'tasks_app_task';
66+
const NOTE = 'tasks_app_note';
67+
const PERMISSION_SET = 'tasks_app_task_user';
68+
/** The development dev-admin seed — see the #21321 sibling for why it is the operator on every boot. */
69+
const EMAIL = 'admin@objectos.ai';
70+
const PASSWORD = 'admin123';
71+
72+
/** `dist/objectstack.json` as `os build` writes it for this app (schema defaults trimmed). */
73+
const ARTIFACT = {
74+
manifest: { id: APP_ID, namespace: 'tasks_app', version: '0.1.0', type: 'app', name: 'Tasks App' },
75+
requires: ['automation', 'triggers'],
76+
objects: [
77+
{
78+
name: TASK,
79+
label: 'Task',
80+
sharingModel: 'public_read_write',
81+
fields: {
82+
name: { type: 'text', label: 'Name' },
83+
status: { type: 'text', label: 'Status' },
84+
},
85+
},
86+
{
87+
name: NOTE,
88+
label: 'Note',
89+
sharingModel: 'public_read_write',
90+
fields: { name: { type: 'text', label: 'Name' } },
91+
},
92+
],
93+
flows: [{
94+
name: 'task_completed_note',
95+
label: 'Task Completed Note',
96+
type: 'record_change',
97+
status: 'active',
98+
nodes: [
99+
{
100+
id: 'start',
101+
type: 'start',
102+
label: 'On Task Update',
103+
config: { objectName: TASK, triggerType: 'record-after-update', condition: "record.status == 'done'" },
104+
},
105+
{
106+
id: 'note',
107+
type: 'create_record',
108+
label: 'Write Note',
109+
config: { objectName: NOTE, fields: { name: 'Completed: {record.name}' } },
110+
},
111+
{ id: 'end', type: 'end', label: 'End' },
112+
],
113+
edges: [
114+
{ id: 'e1', source: 'start', target: 'note' },
115+
{ id: 'e2', source: 'note', target: 'end' },
116+
],
117+
}],
118+
permissions: [{
119+
name: PERMISSION_SET,
120+
label: 'Tasks App Task User',
121+
objects: {
122+
[TASK]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true },
123+
[NOTE]: { allowRead: true, allowCreate: false, allowEdit: false, allowDelete: false },
124+
},
125+
}],
126+
};
127+
128+
/**
129+
* The RUNTIME the package is installed into. `os start` composes its services
130+
* from the boot stack's `requires`, never from a package installed later, and
131+
* the always-on slate carries neither the automation engine nor the triggers —
132+
* so an EMPTY kernel runs no flow at all, before or after a restart. This host
133+
* declares the two capabilities a flow needs and nothing else.
134+
*/
135+
const HOST_ARTIFACT = {
136+
manifest: { id: 'com.example.host', namespace: 'host', version: '0.1.0', type: 'app', name: 'Host' },
137+
requires: ['automation', 'triggers'],
138+
};
139+
140+
const groups: ChildProcess[] = [];
141+
const dirs: string[] = [];
142+
143+
interface LiveStart {
144+
child: ChildProcess;
145+
base: string;
146+
output: () => string;
147+
}
148+
149+
function bootStart(cwd: string, home: string, port: string, extra: string[] = []): Promise<LiveStart> {
150+
return new Promise((resolveBoot, rejectBoot) => {
151+
const child = spawn(TSX, [CLI, 'start', '-p', port, '--home', home, '--auth-secret', E2E_SECRET_KEY, '--no-ui', ...extra], {
152+
cwd,
153+
// `childEnv`, never a bare `...process.env` — see its header.
154+
env: childEnv({ NO_COLOR: '1', OS_CLOUD_URL: 'off', OS_LOG_LEVEL: 'warn', OS_SECRET_KEY: E2E_SECRET_KEY }),
155+
stdio: ['ignore', 'pipe', 'pipe'],
156+
// Own process group: `os start` supervises a `serve` grandchild.
157+
detached: true,
158+
});
159+
groups.push(child);
160+
let out = '';
161+
let settled = false;
162+
const settle = (err: Error | null) => {
163+
if (settled) return;
164+
settled = true;
165+
clearTimeout(timer);
166+
if (err) rejectBoot(err);
167+
else resolveBoot({ child, base: `http://localhost:${port}`, output: () => out });
168+
};
169+
const timer = setTimeout(
170+
() => settle(new Error(`os start never printed ${READY}\n--- output ---\n${out.slice(-4000)}`)),
171+
BOOT_TIMEOUT_MS,
172+
);
173+
const onData = (d: unknown) => {
174+
out += String(d);
175+
// The child is the authority on the port it bound.
176+
if (READY.test(out)) settle(portDriftError(out, 'os start', port));
177+
};
178+
child.stdout?.on('data', onData);
179+
child.stderr?.on('data', onData);
180+
child.on('exit', (code) =>
181+
settle(portContentionError(out, 'os start', port)
182+
?? new Error(`os start exited ${String(code)} before ${READY}\n--- output ---\n${out.slice(-4000)}`)),
183+
);
184+
});
185+
}
186+
187+
async function stopGroup(child: ChildProcess): Promise<void> {
188+
if (child.pid === undefined || child.exitCode !== null || child.signalCode !== null) return;
189+
await new Promise<void>((done) => {
190+
const give = setTimeout(() => {
191+
try { process.kill(-child.pid!, 'SIGKILL'); } catch { /* group already gone */ }
192+
done();
193+
}, 15_000);
194+
child.once('exit', () => { clearTimeout(give); done(); });
195+
try { process.kill(-child.pid!, 'SIGTERM'); } catch { clearTimeout(give); done(); }
196+
});
197+
}
198+
199+
interface Answer { status: number; body: any }
200+
201+
/** One exchange against the running `os start`, attributed to the child if the transport fails. ⛔ No assertion inside it. */
202+
function http(live: LiveStart, method: string, path: string, token: string, body?: unknown): Promise<Answer> {
203+
return probeThroughChild(
204+
{
205+
child: live.child,
206+
transcript: () => `\n--- child output ---\n${live.output().slice(-4000)}`,
207+
label: 'package-install-local-boot-steps',
208+
what: `${method} ${path}`,
209+
},
210+
async () => {
211+
const r = await fetch(`${live.base}${path}`, {
212+
method,
213+
headers: {
214+
origin: live.base,
215+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
216+
...(token ? { authorization: `Bearer ${token}` } : {}),
217+
},
218+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
219+
});
220+
const text = await r.text();
221+
let parsed: any = text;
222+
try { parsed = JSON.parse(text); } catch { /* keep the text */ }
223+
return { status: r.status, body: parsed };
224+
},
225+
);
226+
}
227+
228+
async function authenticate(live: LiveStart): Promise<string> {
229+
const res = await http(live, 'POST', '/api/v1/auth/sign-in/email', '', { email: EMAIL, password: PASSWORD });
230+
const token = res.body?.token;
231+
if (res.status !== 200 || typeof token !== 'string') {
232+
throw new Error(`auth answered ${res.status}: ${JSON.stringify(res.body)}\n--- output ---\n${live.output().slice(-3000)}`);
233+
}
234+
return token;
235+
}
236+
237+
/**
238+
* `os package install ./dist/objectstack.json` against the running runtime.
239+
* ⛔ Asynchronous on purpose — see the #21321 sibling: a `spawnSync` stops this
240+
* process draining the server's pipes for the whole install.
241+
*/
242+
function packageInstall(appDir: string, live: LiveStart): Promise<{ exit: number | null; output: string }> {
243+
return new Promise((done) => {
244+
const child = spawn(TSX, [CLI, 'package', 'install', './dist/objectstack.json', '--runtime', live.base, '--email', EMAIL, '--password', PASSWORD], {
245+
cwd: appDir,
246+
env: childEnv({ NO_COLOR: '1' }),
247+
stdio: ['ignore', 'pipe', 'pipe'],
248+
});
249+
let output = '';
250+
child.stdout?.on('data', (d) => { output += String(d); });
251+
child.stderr?.on('data', (d) => { output += String(d); });
252+
const timer = setTimeout(() => child.kill('SIGKILL'), 120_000);
253+
child.on('close', (code) => { clearTimeout(timer); done({ exit: code, output }); });
254+
});
255+
}
256+
257+
/** The rows of a `GET /api/v1/data/:object` list answer, whichever envelope it came in. */
258+
function rowsOf(answer: Answer): any[] {
259+
const b = answer.body?.data ?? answer.body;
260+
if (Array.isArray(b)) return b;
261+
if (Array.isArray(b?.records)) return b.records;
262+
if (Array.isArray(b?.items)) return b.items;
263+
return [];
264+
}
265+
266+
const recordOf = (a: Answer) => a.body?.data ?? a.body?.record ?? a.body;
267+
268+
interface Phase {
269+
/** `GET /data/sys_permission_set?name=…` — the projection the admin surface grants from. */
270+
permissionSet: Answer;
271+
/** The task the flow is driven through: created, then updated to `done`. */
272+
created: Answer;
273+
updated: Answer;
274+
/** `GET /data/tasks_app_note?name=Completed: …` — what the flow should have written. */
275+
notes: Answer;
276+
}
277+
278+
let seq = 0;
279+
async function probe(live: LiveStart, token: string): Promise<Phase> {
280+
const permissionSet = await http(live, 'GET', `/api/v1/data/sys_permission_set?name=${PERMISSION_SET}`, token);
281+
282+
const taskName = `flow-probe-${++seq}`;
283+
const created = await http(live, 'POST', `/api/v1/data/${TASK}`, token, { name: taskName, status: 'open' });
284+
const id = recordOf(created)?.id;
285+
const updated = await http(live, 'PATCH', `/api/v1/data/${TASK}/${id}`, token, { status: 'done' });
286+
const noteName = encodeURIComponent(`Completed: ${taskName}`);
287+
// The record-change trigger dispatches after the update commits; read the
288+
// note back for a bounded while rather than once, so a flow that fires a
289+
// beat after the 200 is not misread as one that never fires.
290+
let notes = await http(live, 'GET', `/api/v1/data/${NOTE}?name=${noteName}`, token);
291+
for (let i = 0; i < 20 && rowsOf(notes).length === 0; i++) {
292+
await new Promise((r) => setTimeout(r, 250));
293+
notes = await http(live, 'GET', `/api/v1/data/${NOTE}?name=${noteName}`, token);
294+
}
295+
return { permissionSet, created, updated, notes };
296+
}
297+
298+
const phases: Record<'install' | 'restart' | 'control', Phase | undefined> = {
299+
install: undefined, restart: undefined, control: undefined,
300+
};
301+
const installs: Array<{ exit: number | null; output: string }> = [];
302+
303+
beforeAll(async () => {
304+
const root = mkdtempSync(join(tmpdir(), 'install-local-boot-steps-'));
305+
dirs.push(root);
306+
const appDir = join(root, 'app');
307+
mkdirSync(join(appDir, 'dist'), { recursive: true });
308+
writeFileSync(join(appDir, 'dist', 'objectstack.json'), JSON.stringify(ARTIFACT, null, 2), 'utf8');
309+
// The runtime boots the HOST artifact — never the package — so the package
310+
// reaches it only through the install.
311+
const runtimeDir = join(root, 'runtime');
312+
mkdirSync(runtimeDir, { recursive: true });
313+
const hostArtifact = join(runtimeDir, 'host.json');
314+
writeFileSync(hostArtifact, JSON.stringify(HOST_ARTIFACT, null, 2), 'utf8');
315+
const home = join(runtimeDir, 'home');
316+
const port = randomPort();
317+
318+
// ── boot 1: the host, hot install, probe ───────────────────────────────
319+
const first = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]);
320+
const token = await authenticate(first);
321+
installs.push(await packageInstall(appDir, first));
322+
phases.install = await probe(first, token);
323+
await stopGroup(first.child);
324+
325+
// ── boot 2: same host, home and cwd — the ledger rehydrates on kernel:ready ──
326+
const second = await bootStart(runtimeDir, home, port, ['--artifact', hostArtifact]);
327+
phases.restart = await probe(second, await authenticate(second));
328+
await stopGroup(second.child);
329+
330+
// ── boot 3: the CONTROL — the same file as the boot artifact ───────────
331+
const controlDir = join(root, 'control');
332+
mkdirSync(controlDir, { recursive: true });
333+
const third = await bootStart(controlDir, join(controlDir, 'home'), port, ['--artifact', join(appDir, 'dist', 'objectstack.json')]);
334+
phases.control = await probe(third, await authenticate(third));
335+
await stopGroup(third.child);
336+
}, 4 * BOOT_TIMEOUT_MS);
337+
338+
afterAll(async () => {
339+
for (const child of groups) await stopGroup(child);
340+
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
341+
}, 60_000);
342+
343+
describe('#21322: a hot install binds what the boot binds', () => {
344+
it('`os package install` succeeds (harness health)', () => {
345+
for (const run of installs) {
346+
expect(run.exit, run.output).toBe(0);
347+
expect(run.output).toMatch(/Package installed into the running kernel/);
348+
}
349+
});
350+
351+
for (const name of ['install', 'restart', 'control'] as const) {
352+
describe(`after ${name}`, () => {
353+
it('the package permission set is projected into sys_permission_set', () => {
354+
const p = phases[name]!;
355+
expect(p.permissionSet.status, JSON.stringify(p.permissionSet.body)).toBe(200);
356+
expect(rowsOf(p.permissionSet).map((r) => r?.name)).toEqual([PERMISSION_SET]);
357+
});
358+
359+
it('the record-change flow fires: a task updated to done writes its note', () => {
360+
const p = phases[name]!;
361+
expect(p.created.status, JSON.stringify(p.created.body)).toBe(201);
362+
expect(p.updated.status, JSON.stringify(p.updated.body)).toBe(200);
363+
expect(p.notes.status, JSON.stringify(p.notes.body)).toBe(200);
364+
expect(rowsOf(p.notes), 'no note — the flow never fired').toHaveLength(1);
365+
});
366+
});
367+
}
368+
});

0 commit comments

Comments
 (0)