@nanobpm/urban 0.71.0 → 0.72.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.
- package/dist/context/adversarial/harness.d.ts +24 -0
- package/dist/context/adversarial/harness.js +65 -0
- package/dist/runtime/core/context.d.ts +16 -0
- package/dist/runtime/core/modules/scheduler.d.ts +24 -0
- package/dist/runtime/core/modules/scheduler.js +23 -4
- package/dist/runtime/core/modules/workers.d.ts +2 -1
- package/dist/runtime/core/modules/workers.js +11 -2
- package/dist/runtime/core/runtime.d.ts +8 -6
- package/dist/runtime/core/runtime.js +12 -3
- package/package.json +2 -2
- package/src/context/adversarial/conformance-drift.test.ts +89 -0
- package/src/context/adversarial/governance.test.ts +97 -0
- package/src/context/adversarial/harness.ts +84 -0
- package/src/context/adversarial/pii-guard.test.ts +119 -0
- package/src/context/adversarial/provenance-forgery.test.ts +126 -0
- package/src/context/adversarial/seams.test.ts +104 -0
- package/src/context/adversarial/sharing.test.ts +84 -0
- package/src/runtime/core/context.ts +16 -0
- package/src/runtime/core/extensions.test.ts +2 -0
- package/src/runtime/core/modules/actions.test.ts +2 -0
- package/src/runtime/core/modules/api.test.ts +3 -1
- package/src/runtime/core/modules/cancel.test.ts +2 -0
- package/src/runtime/core/modules/connectors.test.ts +2 -0
- package/src/runtime/core/modules/instance-tracking.test.ts +3 -41
- package/src/runtime/core/modules/scheduler.test-utils.ts +62 -0
- package/src/runtime/core/modules/scheduler.test.ts +44 -0
- package/src/runtime/core/modules/scheduler.ts +39 -4
- package/src/runtime/core/modules/surfaces.test.ts +4 -0
- package/src/runtime/core/modules/triggers.test.ts +4 -45
- package/src/runtime/core/modules/workers.test.ts +95 -0
- package/src/runtime/core/modules/workers.ts +16 -2
- package/src/runtime/core/runtime.ts +19 -9
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { MemoryRecord } from "../schema/index.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Run a git command in `cwd`, returning trimmed stdout. Every invocation is
|
|
4
|
+
* routed through {@link hardenedGitArgs} (the repo's single source of truth for
|
|
5
|
+
* `core.hooksPath=/dev/null`), so `init`/`commit`/`checkout` can never execute a
|
|
6
|
+
* hook from global templates or local config — keeping the harness deterministic
|
|
7
|
+
* and closing the CI code-execution vector.
|
|
8
|
+
*/
|
|
9
|
+
export declare function git(cwd: string, ...args: string[]): Promise<string>;
|
|
10
|
+
/**
|
|
11
|
+
* Create a fresh temp git repo with a single seed commit on `main`, register it
|
|
12
|
+
* for cleanup in `roots`, and return its absolute path. This stands in for the
|
|
13
|
+
* local working copy S1 resolves a binding to — the substrate S3 writes and S4
|
|
14
|
+
* reads.
|
|
15
|
+
*/
|
|
16
|
+
export declare function makeSubstrate(roots: string[]): Promise<string>;
|
|
17
|
+
/**
|
|
18
|
+
* Best-effort recursive cleanup of every registered temp root. Per-root failures
|
|
19
|
+
* (e.g. a transient EBUSY/EPERM) are swallowed so teardown can never fail an
|
|
20
|
+
* otherwise-correct suite.
|
|
21
|
+
*/
|
|
22
|
+
export declare function cleanup(roots: readonly string[]): Promise<void>;
|
|
23
|
+
/** A well-formed measured/authoritative record, with per-test overrides. */
|
|
24
|
+
export declare function rec(overrides?: Partial<MemoryRecord>): MemoryRecord;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Slice S7 (adversarial) — shared test harness.
|
|
2
|
+
//
|
|
3
|
+
// This module is NOT a test (no `*.test.ts` suffix, so `node --test` ignores it)
|
|
4
|
+
// and NOT part of the published surface (the top barrel never re-exports the
|
|
5
|
+
// adversarial slice). It only provides the local temp-git-substrate helpers the
|
|
6
|
+
// adversarial suites drive S3's REAL write path through. Nothing here touches the
|
|
7
|
+
// network or any external service.
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
import { hardenedGitArgs } from "../binding/git-backend.js";
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
15
|
+
/**
|
|
16
|
+
* Run a git command in `cwd`, returning trimmed stdout. Every invocation is
|
|
17
|
+
* routed through {@link hardenedGitArgs} (the repo's single source of truth for
|
|
18
|
+
* `core.hooksPath=/dev/null`), so `init`/`commit`/`checkout` can never execute a
|
|
19
|
+
* hook from global templates or local config — keeping the harness deterministic
|
|
20
|
+
* and closing the CI code-execution vector.
|
|
21
|
+
*/
|
|
22
|
+
export async function git(cwd, ...args) {
|
|
23
|
+
const { stdout } = await execFileAsync("git", hardenedGitArgs(args), { cwd });
|
|
24
|
+
return stdout.trim();
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Create a fresh temp git repo with a single seed commit on `main`, register it
|
|
28
|
+
* for cleanup in `roots`, and return its absolute path. This stands in for the
|
|
29
|
+
* local working copy S1 resolves a binding to — the substrate S3 writes and S4
|
|
30
|
+
* reads.
|
|
31
|
+
*/
|
|
32
|
+
export async function makeSubstrate(roots) {
|
|
33
|
+
const dir = await mkdtemp(join(tmpdir(), "s7-substrate-"));
|
|
34
|
+
roots.push(dir);
|
|
35
|
+
await git(dir, "init");
|
|
36
|
+
await writeFile(join(dir, "README.md"), "# context substrate\n", "utf8");
|
|
37
|
+
await git(dir, "add", "-A");
|
|
38
|
+
await git(dir, "-c", "user.name=seed", "-c", "user.email=seed@nanobpm.local", "commit", "--no-gpg-sign", "-m", "seed");
|
|
39
|
+
// Normalise the default branch name regardless of the host git's default.
|
|
40
|
+
await git(dir, "branch", "-M", "main");
|
|
41
|
+
return dir;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Best-effort recursive cleanup of every registered temp root. Per-root failures
|
|
45
|
+
* (e.g. a transient EBUSY/EPERM) are swallowed so teardown can never fail an
|
|
46
|
+
* otherwise-correct suite.
|
|
47
|
+
*/
|
|
48
|
+
export async function cleanup(roots) {
|
|
49
|
+
await Promise.all(roots.map((dir) => rm(dir, { recursive: true, force: true }).catch(() => undefined)));
|
|
50
|
+
}
|
|
51
|
+
/** A well-formed measured/authoritative record, with per-test overrides. */
|
|
52
|
+
export function rec(overrides = {}) {
|
|
53
|
+
const base = {
|
|
54
|
+
schemaVersion: 1,
|
|
55
|
+
id: "rec-1",
|
|
56
|
+
scope: "epic",
|
|
57
|
+
scopeRef: "issue-303",
|
|
58
|
+
mode: "empirical",
|
|
59
|
+
provenance: "measured",
|
|
60
|
+
authority: "authoritative",
|
|
61
|
+
statement: "the measured throughput was 42 rps",
|
|
62
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
63
|
+
};
|
|
64
|
+
return { ...base, ...overrides };
|
|
65
|
+
}
|
|
@@ -37,6 +37,22 @@ export interface AppApi {
|
|
|
37
37
|
*/
|
|
38
38
|
sdk?: EngineSdkClient;
|
|
39
39
|
env(name: string): string | undefined;
|
|
40
|
+
/**
|
|
41
|
+
* App clock seam — current time in ms since epoch, sourced from the runtime's injectable
|
|
42
|
+
* scheduler (`CreateUrbanAppOptions.scheduler`). The real wall clock in production; the virtual
|
|
43
|
+
* clock under the test kit. A handler doing time-bounded work (poll loops, backoff,
|
|
44
|
+
* budgets) should read `app.now()` instead of `Date.now()` so a whole-app `advanceTime()`
|
|
45
|
+
* bounds it deterministically rather than the loop burning real wall-time. See
|
|
46
|
+
* {@link AppApi.wait}.
|
|
47
|
+
*/
|
|
48
|
+
now(): number;
|
|
49
|
+
/**
|
|
50
|
+
* Sleep `ms` on the app clock, resolving once that much time has elapsed on the runtime
|
|
51
|
+
* scheduler. Real timers in production (no behavior change on the live transport); under
|
|
52
|
+
* the test kit it advances with `advanceTime()`. Prefer over a hand-rolled `setTimeout`
|
|
53
|
+
* sleep in time-bounded work so the delay shares the engine's (virtual) clock.
|
|
54
|
+
*/
|
|
55
|
+
wait(ms: number): Promise<void>;
|
|
40
56
|
/**
|
|
41
57
|
* Structured logger (see {@link Logger}). Callable for back-compat
|
|
42
58
|
* (`log("info", msg, fields)`), but prefer the level methods and bound context:
|
|
@@ -9,3 +9,27 @@ export interface SchedulerDeps {
|
|
|
9
9
|
export declare const MAX_TIMER_DELAY_MS = 2147483647;
|
|
10
10
|
/** Default (live) scheduler seam, backed by the global timer functions. */
|
|
11
11
|
export declare function defaultScheduler(): SchedulerDeps;
|
|
12
|
+
/**
|
|
13
|
+
* The app-facing clock/`wait` seam handed to handlers (see `AppApi.now`/`AppApi.wait`).
|
|
14
|
+
* A narrow read-only projection of a {@link SchedulerDeps}: `now()` reads its clock and
|
|
15
|
+
* `wait(ms)` sleeps on its timer. Real wall clock + real timers in production; the
|
|
16
|
+
* virtual clock under the test kit — so time-bounded worker work advances with
|
|
17
|
+
* `advanceTime()` instead of burning real wall-time.
|
|
18
|
+
*/
|
|
19
|
+
export interface AppClock {
|
|
20
|
+
/** Current time in ms since epoch on the scheduler's clock. */
|
|
21
|
+
now(): number;
|
|
22
|
+
/** Resolve after `ms` have elapsed on the scheduler's clock (armed via its timer seam). */
|
|
23
|
+
wait(ms: number): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Derive the app clock/`wait` seam from a scheduler — the single canonical mapping from the
|
|
27
|
+
* runtime's injectable timer seam to the surface handlers use, so worker/trigger/surface
|
|
28
|
+
* handlers doing time-bounded work share the same clock as the background loops (no drift
|
|
29
|
+
* between a handler's budget and the engine's timers). A non-positive/non-finite `wait`
|
|
30
|
+
* delay clamps to fire at the current instant, and a far-future delay clamps to
|
|
31
|
+
* {@link MAX_TIMER_DELAY_MS} — matching the same clamp the background loops apply to their
|
|
32
|
+
* `setTimer` delays (see `mountTriggers`/`mountInstanceTracking`) — so `app.wait()` can
|
|
33
|
+
* never reintroduce a Node `TimeoutOverflowWarning`.
|
|
34
|
+
*/
|
|
35
|
+
export declare function schedulerClock(sched: SchedulerDeps): AppClock;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// The injectable timer + clock seam shared by every background loop in urban (cron
|
|
2
|
-
// triggers, the instance-tracking reconciler)
|
|
3
|
-
// than a copy per module — means a
|
|
4
|
-
// way, and the "clamp a far-future delay to
|
|
5
|
-
// single home.
|
|
2
|
+
// triggers, the instance-tracking reconciler) and the app clock/`wait` seam handed to
|
|
3
|
+
// worker handlers. Keeping one definition here — rather than a copy per module — means a
|
|
4
|
+
// test's fake scheduler drives them all the same way, and the "clamp a far-future delay to
|
|
5
|
+
// setTimeout's 32-bit range" rule has a single home.
|
|
6
6
|
/** Max delay a single `setTimeout` honours before its 32-bit signed overflow (~24.8 days). */
|
|
7
7
|
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
8
8
|
function isTimerHandle(handle) {
|
|
@@ -19,3 +19,22 @@ export function defaultScheduler() {
|
|
|
19
19
|
now: () => Date.now(),
|
|
20
20
|
};
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Derive the app clock/`wait` seam from a scheduler — the single canonical mapping from the
|
|
24
|
+
* runtime's injectable timer seam to the surface handlers use, so worker/trigger/surface
|
|
25
|
+
* handlers doing time-bounded work share the same clock as the background loops (no drift
|
|
26
|
+
* between a handler's budget and the engine's timers). A non-positive/non-finite `wait`
|
|
27
|
+
* delay clamps to fire at the current instant, and a far-future delay clamps to
|
|
28
|
+
* {@link MAX_TIMER_DELAY_MS} — matching the same clamp the background loops apply to their
|
|
29
|
+
* `setTimer` delays (see `mountTriggers`/`mountInstanceTracking`) — so `app.wait()` can
|
|
30
|
+
* never reintroduce a Node `TimeoutOverflowWarning`.
|
|
31
|
+
*/
|
|
32
|
+
export function schedulerClock(sched) {
|
|
33
|
+
return {
|
|
34
|
+
now: () => sched.now(),
|
|
35
|
+
wait: (ms) => new Promise((resolve) => {
|
|
36
|
+
const delay = Number.isFinite(ms) && ms > 0 ? Math.min(ms, MAX_TIMER_DELAY_MS) : 0;
|
|
37
|
+
sched.setTimer(() => resolve(), delay);
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AppApi, Mounted, RuntimeContext } from "../context.ts";
|
|
2
2
|
import type { EngineJob } from "../host.ts";
|
|
3
3
|
import { type DecisionEvaluator } from "./llm.ts";
|
|
4
|
+
import { type SchedulerDeps } from "./scheduler.ts";
|
|
4
5
|
/** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
|
|
5
6
|
* decision's JSON-string `output` into the value fed back to the LLM job. Validates the
|
|
6
7
|
* SDK shape and adds decision context to a parse failure rather than surfacing a raw
|
|
@@ -34,4 +35,4 @@ export interface WorkersHandle extends Mounted {
|
|
|
34
35
|
readonly jobTypes: string[];
|
|
35
36
|
}
|
|
36
37
|
/** Load handler modules and register a push worker per declared worker. */
|
|
37
|
-
export declare function mountWorkers(ctx: RuntimeContext, app: AppApi): Promise<WorkersHandle>;
|
|
38
|
+
export declare function mountWorkers(ctx: RuntimeContext, app: AppApi, sched?: SchedulerDeps): Promise<WorkersHandle>;
|
|
@@ -7,6 +7,7 @@ import { runLlmJob } from "./llm.js";
|
|
|
7
7
|
import { runInJobContext } from "../execContext.js";
|
|
8
8
|
import { readLineage } from "../lineage.js";
|
|
9
9
|
import { LineageStore } from "./lineage-store.js";
|
|
10
|
+
import { defaultScheduler, schedulerClock } from "./scheduler.js";
|
|
10
11
|
/** Adapt an engine SDK client into the LLM module's `DecisionEvaluator`, parsing the
|
|
11
12
|
* decision's JSON-string `output` into the value fed back to the LLM job. Validates the
|
|
12
13
|
* SDK shape and adds decision context to a parse failure rather than surfacing a raw
|
|
@@ -125,10 +126,18 @@ export function resolveHandler(mod, jobType) {
|
|
|
125
126
|
return undefined;
|
|
126
127
|
}
|
|
127
128
|
/** Load handler modules and register a push worker per declared worker. */
|
|
128
|
-
export async function mountWorkers(ctx, app) {
|
|
129
|
+
export async function mountWorkers(ctx, app, sched = defaultScheduler()) {
|
|
129
130
|
const decls = ctx.manifest.workers ?? [];
|
|
130
131
|
const subs = [];
|
|
131
132
|
const jobTypes = [];
|
|
133
|
+
// Bind the app clock/`wait` seam to the injected scheduler, so a handler's time-bounded
|
|
134
|
+
// work (poll loops, backoff, budgets) sources `now`/`wait` from the same clock that drives
|
|
135
|
+
// the engine's timers and the other background loops. Under the test kit that is the
|
|
136
|
+
// virtual clock, so `advanceTime()` bounds the loop instead of it burning real wall-time;
|
|
137
|
+
// in production it is the live scheduler (real timers) — no behavior change. Mirrors how
|
|
138
|
+
// `mountTriggers`/`mountInstanceTracking` receive the same seam.
|
|
139
|
+
const clock = schedulerClock(sched);
|
|
140
|
+
const clockedApp = { ...app, now: clock.now, wait: clock.wait };
|
|
132
141
|
// The framework lineage projection (issue #254): each activated job records the `_urban.lineage`
|
|
133
142
|
// edge its instance carries, so intent→progress lineage materialises for free. Absent when the
|
|
134
143
|
// app configures no default data source.
|
|
@@ -195,7 +204,7 @@ export async function mountWorkers(ctx, app) {
|
|
|
195
204
|
const wrapped = (job) => {
|
|
196
205
|
if (lineageStore)
|
|
197
206
|
recordJobLineage(lineageStore, job, ctx.host.log);
|
|
198
|
-
return runInJobContext(jobExecContext(job, jobType), () => handler(job, withJobLog(
|
|
207
|
+
return runInJobContext(jobExecContext(job, jobType), () => handler(job, withJobLog(clockedApp, job)));
|
|
199
208
|
};
|
|
200
209
|
const sub = await ctx.engine.registerWorker(jobType, wrapped, {
|
|
201
210
|
workerName: `${ctx.manifest.id}:${jobType}`,
|
|
@@ -2,7 +2,7 @@ import type { EngineClient, HostContext } from "./host.ts";
|
|
|
2
2
|
import { type AppManifest } from "./manifest.ts";
|
|
3
3
|
import { DataLayer } from "./modules/datasource.ts";
|
|
4
4
|
import { type Logger } from "./logger.ts";
|
|
5
|
-
import type
|
|
5
|
+
import { type SchedulerDeps } from "./modules/scheduler.ts";
|
|
6
6
|
import { type SecurityPolicy } from "./modules/security.ts";
|
|
7
7
|
import { type UrbanEvents, type UrbanExtension } from "./extensions.ts";
|
|
8
8
|
/** Resolve the HTTP port: explicit option, else $PORT, else 8090. Throws a clear
|
|
@@ -34,11 +34,13 @@ export interface CreateUrbanAppOptions {
|
|
|
34
34
|
mount?: MountFlags;
|
|
35
35
|
/**
|
|
36
36
|
* Injectable timer + clock seam driving every background loop (the cron trigger loops
|
|
37
|
-
* and the instance-tracking reconciler)
|
|
38
|
-
* wall clock). The e2e test kit
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
41
|
-
*
|
|
37
|
+
* and the instance-tracking reconciler) and the app clock/`wait` seam handed to worker
|
|
38
|
+
* handlers. Defaults to the live scheduler (real timers, wall clock). The e2e test kit
|
|
39
|
+
* injects a manual scheduler so those loops — and any time-bounded worker sourcing
|
|
40
|
+
* `app.now()`/`app.wait()` — advance deterministically over a virtual clock: the single
|
|
41
|
+
* seam that makes a whole-app `settle()` possible. Threaded verbatim into `mountWorkers`,
|
|
42
|
+
* `mountTriggers` and `mountInstanceTracking` so one injected scheduler drives them all
|
|
43
|
+
* (no per-module wiring, no drift).
|
|
42
44
|
*/
|
|
43
45
|
scheduler?: SchedulerDeps;
|
|
44
46
|
/**
|
|
@@ -14,6 +14,7 @@ import { mountConnectors } from "./modules/connectors.js";
|
|
|
14
14
|
import { mountSurfaces } from "./modules/surfaces.js";
|
|
15
15
|
import { mountTriggers } from "./modules/triggers.js";
|
|
16
16
|
import { mountInstanceTracking } from "./modules/instance-tracking.js";
|
|
17
|
+
import { defaultScheduler, schedulerClock } from "./modules/scheduler.js";
|
|
17
18
|
import { mountSecurity } from "./modules/security.js";
|
|
18
19
|
import { EventBus } from "./events.js";
|
|
19
20
|
import { createUrbanEvents, mountExtensions } from "./extensions.js";
|
|
@@ -157,6 +158,12 @@ export async function createUrbanApp(opts) {
|
|
|
157
158
|
if (flags.deploy)
|
|
158
159
|
describe.deploy = await deployModels(ctx);
|
|
159
160
|
data = flags.data ? await provisionData(ctx) : new DataLayer(new Map(), undefined, {});
|
|
161
|
+
// Resolve the injectable scheduler once (default: live real-timer seam) so the app
|
|
162
|
+
// clock/`wait` seam handed to handlers and every background loop share one clock —
|
|
163
|
+
// the single source the test kit swaps for a virtual clock to make `settle()` /
|
|
164
|
+
// `advanceTime()` bound the whole app (workers included).
|
|
165
|
+
const scheduler = opts.scheduler ?? defaultScheduler();
|
|
166
|
+
const clock = schedulerClock(scheduler);
|
|
160
167
|
const api = {
|
|
161
168
|
manifest,
|
|
162
169
|
data,
|
|
@@ -164,9 +171,11 @@ export async function createUrbanApp(opts) {
|
|
|
164
171
|
sdk: engineSdk(engine),
|
|
165
172
|
env: (n) => host.env(n),
|
|
166
173
|
log: appLog,
|
|
174
|
+
now: clock.now,
|
|
175
|
+
wait: clock.wait,
|
|
167
176
|
};
|
|
168
177
|
if (flags.workers) {
|
|
169
|
-
const w = await mountWorkers(ctx, api);
|
|
178
|
+
const w = await mountWorkers(ctx, api, scheduler);
|
|
170
179
|
mounted.push(w);
|
|
171
180
|
describe.workers = w.describe?.();
|
|
172
181
|
// Connector-pack workers share the `workers` flag: same lifecycle, same
|
|
@@ -191,13 +200,13 @@ export async function createUrbanApp(opts) {
|
|
|
191
200
|
describe.surfaces = s.describe();
|
|
192
201
|
}
|
|
193
202
|
if (flags.triggers) {
|
|
194
|
-
const t = mountTriggers(ctx, api,
|
|
203
|
+
const t = mountTriggers(ctx, api, scheduler);
|
|
195
204
|
routes.push(...t.routes);
|
|
196
205
|
mounted.push(t);
|
|
197
206
|
describe.triggers = t.describe?.();
|
|
198
207
|
}
|
|
199
208
|
if (flags.instanceTracking && (manifest.instanceTracking?.length ?? 0) > 0) {
|
|
200
|
-
const it = mountInstanceTracking(ctx, api,
|
|
209
|
+
const it = mountInstanceTracking(ctx, api, scheduler);
|
|
201
210
|
mounted.push(it);
|
|
202
211
|
describe.instanceTracking = it.describe?.();
|
|
203
212
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.72.0",
|
|
4
4
|
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -101,7 +101,7 @@
|
|
|
101
101
|
"dependencies": {
|
|
102
102
|
"@nanobpm/nano-app-schema": "^0.9.0",
|
|
103
103
|
"@nanobpm/nano-sdk": "^1.2.5",
|
|
104
|
-
"@nanobpm/workflow": "^0.
|
|
104
|
+
"@nanobpm/workflow": "^0.13.0",
|
|
105
105
|
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
106
106
|
"create-urban-app": "^0.15.0",
|
|
107
107
|
"yaml": "^2.9.0"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Slice S7 (adversarial) — SCHEMA / vocabulary DRIFT guards + conformance.
|
|
2
|
+
//
|
|
3
|
+
// The shared conformance corpus (S2) is the contract BOTH producer and consumer
|
|
4
|
+
// are held to. Here we (a) hold the producer's own validator to the full corpus,
|
|
5
|
+
// and (b) attack the controlled vocabulary directly — drifted scope/mode/
|
|
6
|
+
// provenance/authority values, and structurally malformed records, must all be
|
|
7
|
+
// rejected with clear, structured errors.
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { test } from "node:test";
|
|
11
|
+
import {
|
|
12
|
+
assertConformance,
|
|
13
|
+
corpus,
|
|
14
|
+
fromResultValidator,
|
|
15
|
+
invalidFixtures,
|
|
16
|
+
runConformance,
|
|
17
|
+
validFixtures,
|
|
18
|
+
} from "../conformance/index.ts";
|
|
19
|
+
import { isMemoryRecord, validateMemoryRecord } from "../schema/index.ts";
|
|
20
|
+
|
|
21
|
+
test("the producer validator passes the FULL conformance corpus", () => {
|
|
22
|
+
// Result-form validator, adapted to the corpus's { ok } contract.
|
|
23
|
+
assert.doesNotThrow(() => assertConformance(fromResultValidator(validateMemoryRecord)));
|
|
24
|
+
|
|
25
|
+
const report = runConformance(fromResultValidator(validateMemoryRecord));
|
|
26
|
+
assert.equal(report.failed, 0, "no corpus case may fail");
|
|
27
|
+
assert.equal(report.total, corpus.length);
|
|
28
|
+
assert.ok(report.total > 0);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("the boolean type-guard validator also conforms", () => {
|
|
32
|
+
const report = runConformance((input) => ({ ok: isMemoryRecord(input) }));
|
|
33
|
+
assert.equal(report.failed, 0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("every valid fixture is accepted and every invalid fixture is rejected", () => {
|
|
37
|
+
for (const c of validFixtures) {
|
|
38
|
+
assert.equal(validateMemoryRecord(c.input).ok, true, `should accept: ${c.name}`);
|
|
39
|
+
}
|
|
40
|
+
for (const c of invalidFixtures) {
|
|
41
|
+
assert.equal(validateMemoryRecord(c.input).ok, false, `should reject: ${c.name}`);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("drifted vocabulary is rejected with the right structured code", () => {
|
|
46
|
+
const drifts: Array<{ field: string; value: string }> = [
|
|
47
|
+
{ field: "scope", value: "galaxy" },
|
|
48
|
+
{ field: "mode", value: "prescriptive" },
|
|
49
|
+
{ field: "provenance", value: "robot" },
|
|
50
|
+
{ field: "authority", value: "supreme" },
|
|
51
|
+
];
|
|
52
|
+
for (const { field, value } of drifts) {
|
|
53
|
+
const input: Record<string, unknown> = {
|
|
54
|
+
schemaVersion: 1,
|
|
55
|
+
id: "x",
|
|
56
|
+
scope: "repo",
|
|
57
|
+
mode: "empirical",
|
|
58
|
+
provenance: "human",
|
|
59
|
+
authority: "authoritative",
|
|
60
|
+
statement: "s",
|
|
61
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
62
|
+
};
|
|
63
|
+
input[field] = value;
|
|
64
|
+
const result = validateMemoryRecord(input);
|
|
65
|
+
assert.equal(result.ok, false, `drifted ${field}=${value} must be rejected`);
|
|
66
|
+
if (!result.ok) {
|
|
67
|
+
assert.equal(
|
|
68
|
+
result.errors.some((e) => e.path === field && e.code === "invalid-vocabulary"),
|
|
69
|
+
true,
|
|
70
|
+
`expected invalid-vocabulary on ${field}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("hostile inputs never crash the validator (throwing getters, exotic shapes)", () => {
|
|
77
|
+
const hostile = {
|
|
78
|
+
get id() {
|
|
79
|
+
throw new Error("boom");
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
// Must return a structured rejection, not propagate the throw.
|
|
83
|
+
assert.doesNotThrow(() => validateMemoryRecord(hostile));
|
|
84
|
+
assert.equal(validateMemoryRecord(hostile).ok, false);
|
|
85
|
+
|
|
86
|
+
for (const value of [42, null, undefined, "string", [], new Date()]) {
|
|
87
|
+
assert.equal(validateMemoryRecord(value).ok, false);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Slice S7 (adversarial) — GOVERNANCE can't be bypassed.
|
|
2
|
+
//
|
|
3
|
+
// A proposed prior is a bot "PR": it lives on its own branch and is NOT on the
|
|
4
|
+
// authoritative line until MERGED (ratified). These tests prove an unmerged
|
|
5
|
+
// proposal never acts as a ratified one, and that the ratify path refuses to be
|
|
6
|
+
// steered onto an arbitrary branch or base.
|
|
7
|
+
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { after, test } from "node:test";
|
|
10
|
+
import { ContextWriter, GovernanceError } from "../git/index.ts";
|
|
11
|
+
import { ContextRetriever } from "../retrieval/index.ts";
|
|
12
|
+
import { cleanup, git, makeSubstrate, rec } from "./harness.ts";
|
|
13
|
+
|
|
14
|
+
const TEMP_ROOTS: string[] = [];
|
|
15
|
+
after(() => cleanup(TEMP_ROOTS));
|
|
16
|
+
|
|
17
|
+
function retro(id: string) {
|
|
18
|
+
return rec({
|
|
19
|
+
id,
|
|
20
|
+
provenance: "agent-retro",
|
|
21
|
+
mode: "normative",
|
|
22
|
+
authority: "hypothesis",
|
|
23
|
+
statement: `proposed prior ${id}`,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test("an UNMERGED proposal is invisible on the authoritative line", async () => {
|
|
28
|
+
const dir = await makeSubstrate(TEMP_ROOTS);
|
|
29
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
30
|
+
|
|
31
|
+
const proposal = await writer.proposePrior(retro("gov-1"));
|
|
32
|
+
assert.equal(proposal.ratified, false);
|
|
33
|
+
|
|
34
|
+
// Before ratifying, a reader on main sees nothing — the hypothesis is parked
|
|
35
|
+
// on its own branch, not on the base line.
|
|
36
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
37
|
+
assert.equal((await retriever.all()).some((s) => s.record.id === "gov-1"), false);
|
|
38
|
+
|
|
39
|
+
// Only the merge ratifies it onto main.
|
|
40
|
+
await writer.ratify(proposal);
|
|
41
|
+
retriever.invalidate();
|
|
42
|
+
assert.equal((await retriever.all()).some((s) => s.record.id === "gov-1"), true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("ratify refuses a branch outside the proposal namespace", async () => {
|
|
46
|
+
const dir = await makeSubstrate(TEMP_ROOTS);
|
|
47
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
48
|
+
|
|
49
|
+
// A real, existing branch that is NOT a proposal branch.
|
|
50
|
+
await git(dir, "branch", "sneaky-branch", "main");
|
|
51
|
+
|
|
52
|
+
await assert.rejects(
|
|
53
|
+
() =>
|
|
54
|
+
writer.ratify({
|
|
55
|
+
path: "whatever",
|
|
56
|
+
commit: "deadbeef",
|
|
57
|
+
branch: "sneaky-branch",
|
|
58
|
+
baseBranch: "main",
|
|
59
|
+
proposalId: "forged",
|
|
60
|
+
ratified: false,
|
|
61
|
+
}),
|
|
62
|
+
GovernanceError,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("ratify refuses a proposal whose base was steered off the writer's own base", async () => {
|
|
67
|
+
const dir = await makeSubstrate(TEMP_ROOTS);
|
|
68
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
69
|
+
|
|
70
|
+
const proposal = await writer.proposePrior(retro("gov-steer"));
|
|
71
|
+
// ProposalResult is a plain object; a hostile caller mutates the target base.
|
|
72
|
+
await assert.rejects(
|
|
73
|
+
() => writer.ratify({ ...proposal, baseBranch: "some-other-branch" }),
|
|
74
|
+
GovernanceError,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// The proposal is still unratified: main must NOT contain it.
|
|
78
|
+
const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
|
|
79
|
+
assert.equal((await retriever.all()).some((s) => s.record.id === "gov-steer"), false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("ratify refuses a non-existent proposal branch", async () => {
|
|
83
|
+
const dir = await makeSubstrate(TEMP_ROOTS);
|
|
84
|
+
const writer = new ContextWriter({ localPath: dir, ref: "main" });
|
|
85
|
+
await assert.rejects(
|
|
86
|
+
() =>
|
|
87
|
+
writer.ratify({
|
|
88
|
+
path: "p",
|
|
89
|
+
commit: "c",
|
|
90
|
+
branch: "context/proposal/does-not-exist",
|
|
91
|
+
baseBranch: "main",
|
|
92
|
+
proposalId: "ghost",
|
|
93
|
+
ratified: false,
|
|
94
|
+
}),
|
|
95
|
+
GovernanceError,
|
|
96
|
+
);
|
|
97
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Slice S7 (adversarial) — shared test harness.
|
|
2
|
+
//
|
|
3
|
+
// This module is NOT a test (no `*.test.ts` suffix, so `node --test` ignores it)
|
|
4
|
+
// and NOT part of the published surface (the top barrel never re-exports the
|
|
5
|
+
// adversarial slice). It only provides the local temp-git-substrate helpers the
|
|
6
|
+
// adversarial suites drive S3's REAL write path through. Nothing here touches the
|
|
7
|
+
// network or any external service.
|
|
8
|
+
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { promisify } from "node:util";
|
|
14
|
+
import { hardenedGitArgs } from "../binding/git-backend.ts";
|
|
15
|
+
import type { MemoryRecord } from "../schema/index.ts";
|
|
16
|
+
|
|
17
|
+
const execFileAsync = promisify(execFile);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run a git command in `cwd`, returning trimmed stdout. Every invocation is
|
|
21
|
+
* routed through {@link hardenedGitArgs} (the repo's single source of truth for
|
|
22
|
+
* `core.hooksPath=/dev/null`), so `init`/`commit`/`checkout` can never execute a
|
|
23
|
+
* hook from global templates or local config — keeping the harness deterministic
|
|
24
|
+
* and closing the CI code-execution vector.
|
|
25
|
+
*/
|
|
26
|
+
export async function git(cwd: string, ...args: string[]): Promise<string> {
|
|
27
|
+
const { stdout } = await execFileAsync("git", hardenedGitArgs(args), { cwd });
|
|
28
|
+
return stdout.trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a fresh temp git repo with a single seed commit on `main`, register it
|
|
33
|
+
* for cleanup in `roots`, and return its absolute path. This stands in for the
|
|
34
|
+
* local working copy S1 resolves a binding to — the substrate S3 writes and S4
|
|
35
|
+
* reads.
|
|
36
|
+
*/
|
|
37
|
+
export async function makeSubstrate(roots: string[]): Promise<string> {
|
|
38
|
+
const dir = await mkdtemp(join(tmpdir(), "s7-substrate-"));
|
|
39
|
+
roots.push(dir);
|
|
40
|
+
await git(dir, "init");
|
|
41
|
+
await writeFile(join(dir, "README.md"), "# context substrate\n", "utf8");
|
|
42
|
+
await git(dir, "add", "-A");
|
|
43
|
+
await git(
|
|
44
|
+
dir,
|
|
45
|
+
"-c",
|
|
46
|
+
"user.name=seed",
|
|
47
|
+
"-c",
|
|
48
|
+
"user.email=seed@nanobpm.local",
|
|
49
|
+
"commit",
|
|
50
|
+
"--no-gpg-sign",
|
|
51
|
+
"-m",
|
|
52
|
+
"seed",
|
|
53
|
+
);
|
|
54
|
+
// Normalise the default branch name regardless of the host git's default.
|
|
55
|
+
await git(dir, "branch", "-M", "main");
|
|
56
|
+
return dir;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Best-effort recursive cleanup of every registered temp root. Per-root failures
|
|
61
|
+
* (e.g. a transient EBUSY/EPERM) are swallowed so teardown can never fail an
|
|
62
|
+
* otherwise-correct suite.
|
|
63
|
+
*/
|
|
64
|
+
export async function cleanup(roots: readonly string[]): Promise<void> {
|
|
65
|
+
await Promise.all(
|
|
66
|
+
roots.map((dir) => rm(dir, { recursive: true, force: true }).catch(() => undefined)),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A well-formed measured/authoritative record, with per-test overrides. */
|
|
71
|
+
export function rec(overrides: Partial<MemoryRecord> = {}): MemoryRecord {
|
|
72
|
+
const base: MemoryRecord = {
|
|
73
|
+
schemaVersion: 1,
|
|
74
|
+
id: "rec-1",
|
|
75
|
+
scope: "epic",
|
|
76
|
+
scopeRef: "issue-303",
|
|
77
|
+
mode: "empirical",
|
|
78
|
+
provenance: "measured",
|
|
79
|
+
authority: "authoritative",
|
|
80
|
+
statement: "the measured throughput was 42 rps",
|
|
81
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
82
|
+
};
|
|
83
|
+
return { ...base, ...overrides };
|
|
84
|
+
}
|