@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.
Files changed (32) hide show
  1. package/dist/context/adversarial/harness.d.ts +24 -0
  2. package/dist/context/adversarial/harness.js +65 -0
  3. package/dist/runtime/core/context.d.ts +16 -0
  4. package/dist/runtime/core/modules/scheduler.d.ts +24 -0
  5. package/dist/runtime/core/modules/scheduler.js +23 -4
  6. package/dist/runtime/core/modules/workers.d.ts +2 -1
  7. package/dist/runtime/core/modules/workers.js +11 -2
  8. package/dist/runtime/core/runtime.d.ts +8 -6
  9. package/dist/runtime/core/runtime.js +12 -3
  10. package/package.json +2 -2
  11. package/src/context/adversarial/conformance-drift.test.ts +89 -0
  12. package/src/context/adversarial/governance.test.ts +97 -0
  13. package/src/context/adversarial/harness.ts +84 -0
  14. package/src/context/adversarial/pii-guard.test.ts +119 -0
  15. package/src/context/adversarial/provenance-forgery.test.ts +126 -0
  16. package/src/context/adversarial/seams.test.ts +104 -0
  17. package/src/context/adversarial/sharing.test.ts +84 -0
  18. package/src/runtime/core/context.ts +16 -0
  19. package/src/runtime/core/extensions.test.ts +2 -0
  20. package/src/runtime/core/modules/actions.test.ts +2 -0
  21. package/src/runtime/core/modules/api.test.ts +3 -1
  22. package/src/runtime/core/modules/cancel.test.ts +2 -0
  23. package/src/runtime/core/modules/connectors.test.ts +2 -0
  24. package/src/runtime/core/modules/instance-tracking.test.ts +3 -41
  25. package/src/runtime/core/modules/scheduler.test-utils.ts +62 -0
  26. package/src/runtime/core/modules/scheduler.test.ts +44 -0
  27. package/src/runtime/core/modules/scheduler.ts +39 -4
  28. package/src/runtime/core/modules/surfaces.test.ts +4 -0
  29. package/src/runtime/core/modules/triggers.test.ts +4 -45
  30. package/src/runtime/core/modules/workers.test.ts +95 -0
  31. package/src/runtime/core/modules/workers.ts +16 -2
  32. package/src/runtime/core/runtime.ts +19 -9
@@ -0,0 +1,119 @@
1
+ // Slice S7 (adversarial) — the no-PII guard holds BY CONSTRUCTION and under
2
+ // evasion.
3
+ //
4
+ // The DEFAULT S3 write path (no special caller wiring) must reject a
5
+ // PII-carrying record because S3 pre-registers the mandatory S6 guard. These
6
+ // tests prove that, then attempt obfuscated / nested / edge-case PII and confirm
7
+ // the guard still blocks it — and that a caller cannot bypass it.
8
+
9
+ import assert from "node:assert/strict";
10
+ import { after, test } from "node:test";
11
+ import { ContextWriter } from "../git/index.ts";
12
+ import { PiiGuardError, type PiiGuard, classifyPii, preCommitPiiGuard } from "../pii/index.ts";
13
+ import { ContextRetriever } from "../retrieval/index.ts";
14
+ import { cleanup, makeSubstrate, rec } from "./harness.ts";
15
+
16
+ const TEMP_ROOTS: string[] = [];
17
+ after(() => cleanup(TEMP_ROOTS));
18
+
19
+ test("the DEFAULT write path (no wiring) rejects a PII-carrying record", async () => {
20
+ const dir = await makeSubstrate(TEMP_ROOTS);
21
+ // Plain default construction — the caller does NOT opt into any guard.
22
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
23
+
24
+ // The mandatory PII guard is present — and FIRST — by construction.
25
+ assert.equal(
26
+ writer.guards[0],
27
+ preCommitPiiGuard,
28
+ "the mandatory PII guard must be registered first",
29
+ );
30
+
31
+ await assert.rejects(
32
+ () => writer.appendRecord(rec({ id: "pii-plain", statement: "reach me at alice@example.com" })),
33
+ PiiGuardError,
34
+ );
35
+
36
+ // Governance path is guarded too.
37
+ await assert.rejects(
38
+ () =>
39
+ writer.proposePrior(
40
+ rec({
41
+ id: "pii-propose",
42
+ provenance: "agent-retro",
43
+ mode: "normative",
44
+ authority: "hypothesis",
45
+ statement: "ssn 123-45-6789 belongs to the reporter",
46
+ }),
47
+ ),
48
+ PiiGuardError,
49
+ );
50
+
51
+ // Nothing leaked into the store.
52
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
53
+ assert.equal((await retriever.all()).length, 0);
54
+ });
55
+
56
+ test("PII hidden in a NESTED field (evidence array) is still blocked", async () => {
57
+ const dir = await makeSubstrate(TEMP_ROOTS);
58
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
59
+ await assert.rejects(
60
+ () =>
61
+ writer.appendRecord(
62
+ rec({
63
+ id: "pii-nested",
64
+ statement: "clean summary",
65
+ evidence: ["run-1", "leaked contact alice@example.com in logs"],
66
+ }),
67
+ ),
68
+ PiiGuardError,
69
+ );
70
+ });
71
+
72
+ test("OBFUSCATED email evades a naive scan but not the guard", () => {
73
+ // The classifier's de-obfuscation pass catches spelled-out separators.
74
+ const obfuscated = "contact bob [at] example [dot] com for details";
75
+ const result = classifyPii(obfuscated);
76
+ assert.equal(result.clean, false, "obfuscated email must be detected");
77
+ assert.equal(result.findings.some((f) => f.kind === "email"), true);
78
+
79
+ // The default guard throws on it, mirroring the write path.
80
+ assert.throws(() => preCommitPiiGuard.assert(obfuscated), PiiGuardError);
81
+ });
82
+
83
+ test("clean content passes the guard and lands", async () => {
84
+ const dir = await makeSubstrate(TEMP_ROOTS);
85
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
86
+ await writer.appendRecord(rec({ id: "clean-1", statement: "throughput improved to 42 rps" }));
87
+
88
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
89
+ assert.equal((await retriever.all()).some((s) => s.record.id === "clean-1"), true);
90
+ });
91
+
92
+ test("an extra caller guard can only make enforcement STRICTER, never bypass PII", async () => {
93
+ const dir = await makeSubstrate(TEMP_ROOTS);
94
+ // A DISTINCT, always-throwing caller guard. If a caller-supplied guard could
95
+ // displace the mandatory PII guard — or run before it — a PII write would
96
+ // surface THIS sentinel error instead of PiiGuardError.
97
+ const alwaysThrows: PiiGuard = {
98
+ name: "always-throws",
99
+ inspect: () => ({ clean: true, findings: [] }),
100
+ assert: () => {
101
+ throw new Error("extra caller guard ran");
102
+ },
103
+ };
104
+ const writer = new ContextWriter(
105
+ { localPath: dir, ref: "main" },
106
+ { guards: [alwaysThrows] },
107
+ );
108
+ // The mandatory guard is still FIRST; the caller guard is only appended.
109
+ assert.equal(writer.guards[0], preCommitPiiGuard, "mandatory PII guard must run first");
110
+ assert.equal(writer.guards.includes(alwaysThrows), true, "caller guard is added, not dropped");
111
+
112
+ // A PII write is rejected by the mandatory guard (which runs first) — with
113
+ // PiiGuardError, NOT the sentinel — proving the caller guard neither displaced
114
+ // nor pre-empted mandatory enforcement.
115
+ await assert.rejects(
116
+ () => writer.appendRecord(rec({ id: "pii-extra", statement: "email me: eve@example.org" })),
117
+ PiiGuardError,
118
+ );
119
+ });
@@ -0,0 +1,126 @@
1
+ // Slice S7 (adversarial) — PROVENANCE / AUTHORITY forgery resistance.
2
+ //
3
+ // The headline invariant of the whole layer is "hypothesis-never-fix": an
4
+ // unratified / agent-retrospective prior can never be REPRESENTED as a measured
5
+ // or authoritative fact. These tests attack that invariant directly — at the
6
+ // schema, at the write path, and after ratification — at EVERY scope.
7
+
8
+ import assert from "node:assert/strict";
9
+ import { after, test } from "node:test";
10
+ import { ContextWriter } from "../git/index.ts";
11
+ import { ContextRetriever } from "../retrieval/index.ts";
12
+ import {
13
+ MemoryRecordValidationError,
14
+ type MemoryScope,
15
+ SCOPE_LADDER,
16
+ assertMemoryRecord,
17
+ isAuthoritative,
18
+ validateMemoryRecord,
19
+ } from "../schema/index.ts";
20
+ import { cleanup, makeSubstrate, rec } from "./harness.ts";
21
+
22
+ const TEMP_ROOTS: string[] = [];
23
+ after(() => cleanup(TEMP_ROOTS));
24
+
25
+ /** The forgery target: an agent-retro prior masquerading as an authoritative fact. */
26
+ function forgedAtScope(scope: MemoryScope): Record<string, unknown> {
27
+ return {
28
+ schemaVersion: 1,
29
+ id: `forged-${scope}`,
30
+ scope,
31
+ mode: "empirical",
32
+ provenance: "agent-retro",
33
+ authority: "authoritative",
34
+ statement: "a hypothesis laundered into a fact",
35
+ createdAt: "2026-01-01T00:00:00Z",
36
+ };
37
+ }
38
+
39
+ test("schema: agent-retro can NEVER be authoritative — at every scope", () => {
40
+ for (const scope of SCOPE_LADDER) {
41
+ const result = validateMemoryRecord(forgedAtScope(scope));
42
+ assert.equal(result.ok, false, `scope ${scope}: forged record must be rejected`);
43
+ if (!result.ok) {
44
+ assert.equal(
45
+ result.errors.some((e) => e.code === "invariant-violation"),
46
+ true,
47
+ `scope ${scope}: expected an invariant-violation`,
48
+ );
49
+ }
50
+ assert.throws(() => assertMemoryRecord(forgedAtScope(scope)), MemoryRecordValidationError);
51
+ }
52
+ });
53
+
54
+ test("schema: no field permutation reaches authoritative via agent-retro", () => {
55
+ // The ONLY provenances that may be authoritative are human/measured/instance.
56
+ for (const mode of ["normative", "empirical"] as const) {
57
+ const result = validateMemoryRecord({
58
+ schemaVersion: 1,
59
+ id: "x",
60
+ scope: "repo",
61
+ mode,
62
+ provenance: "agent-retro",
63
+ authority: "authoritative",
64
+ statement: "s",
65
+ createdAt: "2026-01-01T00:00:00Z",
66
+ });
67
+ assert.equal(result.ok, false, `agent-retro/${mode}/authoritative must be rejected`);
68
+ }
69
+ });
70
+
71
+ test("write path: a forged agent-retro-authoritative record cannot be persisted", async () => {
72
+ const dir = await makeSubstrate(TEMP_ROOTS);
73
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
74
+
75
+ // Neither write path will commit it: append validates+rejects the invariant
76
+ // (and also refuses agent-retro directly), propose validates+rejects it too.
77
+ await assert.rejects(() => writer.appendRecord(forgedAtScope("epic")));
78
+ await assert.rejects(() => writer.proposePrior(forgedAtScope("epic")));
79
+
80
+ // And nothing landed in the store.
81
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
82
+ const all = await retriever.all();
83
+ assert.equal(all.some((s) => s.record.id === "forged-epic"), false);
84
+ assert.equal(all.length, 0);
85
+ });
86
+
87
+ test("write path: agent-retro is refused on the DIRECT append path (must go via governance)", async () => {
88
+ const dir = await makeSubstrate(TEMP_ROOTS);
89
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
90
+ // A perfectly valid agent-retro hypothesis is still not directly appendable.
91
+ await assert.rejects(() =>
92
+ writer.appendRecord(
93
+ rec({
94
+ id: "retro-direct",
95
+ provenance: "agent-retro",
96
+ mode: "normative",
97
+ authority: "hypothesis",
98
+ statement: "prefer X over Y",
99
+ }),
100
+ ),
101
+ );
102
+ });
103
+
104
+ test("ratification does NOT upgrade authority — the merged record stays a hypothesis", async () => {
105
+ const dir = await makeSubstrate(TEMP_ROOTS);
106
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
107
+
108
+ const proposal = await writer.proposePrior(
109
+ rec({
110
+ id: "retro-1",
111
+ provenance: "agent-retro",
112
+ mode: "normative",
113
+ authority: "hypothesis",
114
+ statement: "a proposed prior",
115
+ }),
116
+ );
117
+ await writer.ratify(proposal);
118
+
119
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
120
+ const found = (await retriever.all()).find((s) => s.record.id === "retro-1");
121
+ assert.ok(found, "the ratified proposal is now on the authoritative line");
122
+ // Ratified ≠ authoritative: the record is an ACCEPTED hypothesis, never a fact.
123
+ assert.equal(found.record.authority, "hypothesis");
124
+ assert.equal(found.record.provenance, "agent-retro");
125
+ assert.equal(isAuthoritative(found.record), false);
126
+ });
@@ -0,0 +1,104 @@
1
+ // Slice S7 (adversarial) — the pluggable SEAMS don't leak a backend assumption.
2
+ //
3
+ // Retrieval must be correct from a COLD/empty derived cache (git is
4
+ // authoritative, the cache is a disposable projection), and the binding / read
5
+ // seams must NOT hard-wire "exclusively public git" — local paths, file:// URLs
6
+ // and ssh remotes are first-class, and a custom RecordSource can back retrieval
7
+ // without any git at all.
8
+
9
+ import assert from "node:assert/strict";
10
+ import { after, test } from "node:test";
11
+ import { resolveContextIdentity, sameContext } from "../binding/index.ts";
12
+ import { ContextWriter } from "../git/index.ts";
13
+ import {
14
+ ContextRetriever,
15
+ type NamespaceSelector,
16
+ type RecordSource,
17
+ type StoredRecord,
18
+ } from "../retrieval/index.ts";
19
+ import type { MemoryRecord } from "../schema/index.ts";
20
+ import { cleanup, makeSubstrate, rec } from "./harness.ts";
21
+
22
+ const TEMP_ROOTS: string[] = [];
23
+ after(() => cleanup(TEMP_ROOTS));
24
+
25
+ test("retrieval is correct on a COLD cache — the first query falls back to git", async () => {
26
+ const dir = await makeSubstrate(TEMP_ROOTS);
27
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
28
+ await writer.appendRecord(rec({ id: "cold-1", scope: "epic", scopeRef: "issue-303" }));
29
+ await writer.appendRecord(rec({ id: "cold-2", scope: "repo", scopeRef: "nano-ide", statement: "s2" }));
30
+
31
+ // Brand-new retriever: the derived cache is cold. This FIRST query has no
32
+ // warmed snapshot to read, so it falls back to git — and, as a side effect,
33
+ // populates the snapshot. We assert the cold-path result is complete.
34
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
35
+ const cold = await retriever.query({});
36
+ assert.equal(cold.length, 2, "a cold cache must still return every record via git");
37
+
38
+ // A path-scoped structured query on a COLD cache reads just that partition
39
+ // without forcing a full snapshot build. Use a FRESH retriever so the cache is
40
+ // genuinely cold here — the `query({})` above already warmed `retriever`.
41
+ const nsRetriever = new ContextRetriever({ localPath: dir, ref: "main" });
42
+ assert.equal(nsRetriever.cache.isWarm, false, "a fresh retriever's cache must be cold");
43
+ const scoped = await nsRetriever.byNamespace("repo", "nano-ide");
44
+ assert.equal(scoped.length, 1);
45
+ assert.equal(scoped[0].record.id, "cold-2");
46
+ assert.equal(nsRetriever.cache.isWarm, false, "a single-namespace query must not warm the full cache");
47
+ });
48
+
49
+ test("git is authoritative — the derived cache is disposable and rebuildable", async () => {
50
+ const dir = await makeSubstrate(TEMP_ROOTS);
51
+ const writer = new ContextWriter({ localPath: dir, ref: "main" });
52
+ await writer.appendRecord(rec({ id: "auth-1" }));
53
+
54
+ const retriever = new ContextRetriever({ localPath: dir, ref: "main" });
55
+ assert.equal((await retriever.all()).length, 1); // warms the cache
56
+
57
+ // A write happens in git AFTER the cache was warmed.
58
+ await writer.appendRecord(rec({ id: "auth-2", scope: "repo", scopeRef: "nano-ide", statement: "later" }));
59
+
60
+ // The stale snapshot doesn't see it; invalidating rebuilds purely from git.
61
+ retriever.invalidate();
62
+ const rebuilt = await retriever.all();
63
+ assert.equal(rebuilt.length, 2, "cache must rebuild the full truth from git");
64
+ assert.equal(rebuilt.some((s) => s.record.id === "auth-2"), true);
65
+
66
+ // The forward-compat embedding seam is opt-in and absent in the MVP.
67
+ assert.equal(retriever.embeddingIndex, undefined);
68
+ });
69
+
70
+ test("the READ seam is pluggable — retrieval works over a non-git RecordSource", async () => {
71
+ const record: MemoryRecord = rec({ id: "mem-1", subject: "latency", statement: "backend-agnostic" });
72
+ const stored: StoredRecord = { path: "records/epic/issue-303/mem-1.md", record };
73
+
74
+ // An in-memory source that never touches git — proving the seam isn't
75
+ // hard-wired to "exclusively public git".
76
+ const memorySource: RecordSource = {
77
+ async list(_selector?: NamespaceSelector) {
78
+ return [stored];
79
+ },
80
+ async read(relPath: string) {
81
+ return relPath === stored.path ? stored : undefined;
82
+ },
83
+ };
84
+
85
+ const retriever = new ContextRetriever("/unused/path", { source: memorySource });
86
+ const all = await retriever.all();
87
+ assert.equal(all.length, 1);
88
+ assert.equal(all[0].record.id, "mem-1");
89
+ const bySubject = await retriever.query({ subject: "latency" });
90
+ assert.equal(bySubject.length, 1);
91
+ });
92
+
93
+ test("binding identity is NOT hard-wired to public GitHub — local/file/ssh are first-class", () => {
94
+ // A local path and its file:// spelling name the SAME (self-hosted) context.
95
+ const localPath = "/srv/substrates/ctx";
96
+ const local = resolveContextIdentity({ repo: localPath, ref: "main" });
97
+ assert.ok(local.key.length > 0, "a local-path substrate must resolve to an identity");
98
+ assert.equal(sameContext({ repo: localPath, ref: "main" }, { repo: `file://${localPath}`, ref: "main" }), true);
99
+
100
+ // An ssh remote resolves too, and differs from an unrelated host.
101
+ const ssh = resolveContextIdentity({ repo: "git@example.com:team/ctx.git", ref: "main" });
102
+ assert.ok(ssh.key.length > 0);
103
+ assert.notEqual(ssh.key, local.key);
104
+ });
@@ -0,0 +1,84 @@
1
+ // Slice S7 (adversarial) — cross-instance SHARING semantics.
2
+ //
3
+ // Proves the "private-per-app / shared-on-same-name" contract can't be broken:
4
+ // two instances naming the SAME context (repo+ref) see each other's ratified
5
+ // writes, and two instances naming DISTINCT contexts stay isolated. Exercised at
6
+ // both the S1 identity layer and end-to-end through S3's write path + S4 reads.
7
+
8
+ import assert from "node:assert/strict";
9
+ import { after, test } from "node:test";
10
+ import {
11
+ type ContextBinding,
12
+ contextIdentityKey,
13
+ resolveContextIdentity,
14
+ sameContext,
15
+ } from "../binding/index.ts";
16
+ import { ContextWriter } from "../git/index.ts";
17
+ import { ContextRetriever } from "../retrieval/index.ts";
18
+ import { cleanup, makeSubstrate, rec } from "./harness.ts";
19
+
20
+ const TEMP_ROOTS: string[] = [];
21
+ after(() => cleanup(TEMP_ROOTS));
22
+
23
+ const binding = (repo: string, ref: string): ContextBinding => ({ repo, ref });
24
+
25
+ test("identity: same (repo, ref) name shares; a distinct name stays private", () => {
26
+ const a = binding("nanobpm/nano-ide", "main");
27
+ const same = binding("nanobpm/nano-ide", "main");
28
+ const otherRef = binding("nanobpm/nano-ide", "release");
29
+ const otherRepo = binding("nanobpm/nano-workforce", "main");
30
+
31
+ assert.equal(sameContext(a, same), true);
32
+ assert.equal(contextIdentityKey(a), contextIdentityKey(same));
33
+
34
+ // A different ref OR a different repo is a genuinely different context.
35
+ assert.equal(sameContext(a, otherRef), false);
36
+ assert.equal(sameContext(a, otherRepo), false);
37
+ assert.notEqual(contextIdentityKey(a), contextIdentityKey(otherRef));
38
+ assert.notEqual(contextIdentityKey(a), contextIdentityKey(otherRepo));
39
+
40
+ // On-disk slug tracks the key: shared names → shared location; distinct → not.
41
+ assert.equal(resolveContextIdentity(a).slug, resolveContextIdentity(same).slug);
42
+ assert.notEqual(resolveContextIdentity(a).slug, resolveContextIdentity(otherRef).slug);
43
+ });
44
+
45
+ test("identity: equivalent repo spellings collapse to one shared substrate", () => {
46
+ // Shorthand, HTTPS clone URL, and a credentialed URL all name one context.
47
+ // The credentialed form uses the repo's redacted userinfo placeholder (`***@`)
48
+ // — it still exercises the "userinfo must not affect identity" invariant
49
+ // without embedding a credential-in-URL literal that trips secret scanning.
50
+ const shorthand = binding("nanobpm/nano-ide", "main");
51
+ const https = binding("https://github.com/nanobpm/nano-ide.git", "main");
52
+ const credential = binding("https://***@github.com/nanobpm/nano-ide.git", "main");
53
+
54
+ assert.equal(sameContext(shorthand, https), true);
55
+ assert.equal(sameContext(shorthand, credential), true, "credentials must not split identity");
56
+ });
57
+
58
+ test("end-to-end: a second instance on the same context reads the first's write", async () => {
59
+ const shared = await makeSubstrate(TEMP_ROOTS);
60
+
61
+ // Instance A writes a ratified fact into the shared substrate.
62
+ const writerA = new ContextWriter({ localPath: shared, ref: "main" });
63
+ await writerA.appendRecord(
64
+ rec({ id: "shared-fact", scope: "epic", scopeRef: "issue-303", statement: "A observed 42 rps" }),
65
+ );
66
+
67
+ // Instance B — a distinct object naming the SAME context — sees it.
68
+ const retrieverB = new ContextRetriever({ localPath: shared, ref: "main" });
69
+ const seenByB = await retrieverB.all();
70
+ assert.equal(seenByB.some((s) => s.record.id === "shared-fact"), true);
71
+ });
72
+
73
+ test("end-to-end: a distinct-name instance cannot see another context's writes", async () => {
74
+ const shared = await makeSubstrate(TEMP_ROOTS);
75
+ const other = await makeSubstrate(TEMP_ROOTS);
76
+
77
+ const writerA = new ContextWriter({ localPath: shared, ref: "main" });
78
+ await writerA.appendRecord(rec({ id: "private-fact", statement: "only visible in shared" }));
79
+
80
+ const retrieverOther = new ContextRetriever({ localPath: other, ref: "main" });
81
+ const seen = await retrieverOther.all();
82
+ assert.equal(seen.some((s) => s.record.id === "private-fact"), false);
83
+ assert.equal(seen.length, 0, "an unrelated context must stay empty/private");
84
+ });
@@ -42,6 +42,22 @@ export interface AppApi {
42
42
  */
43
43
  sdk?: EngineSdkClient;
44
44
  env(name: string): string | undefined;
45
+ /**
46
+ * App clock seam — current time in ms since epoch, sourced from the runtime's injectable
47
+ * scheduler (`CreateUrbanAppOptions.scheduler`). The real wall clock in production; the virtual
48
+ * clock under the test kit. A handler doing time-bounded work (poll loops, backoff,
49
+ * budgets) should read `app.now()` instead of `Date.now()` so a whole-app `advanceTime()`
50
+ * bounds it deterministically rather than the loop burning real wall-time. See
51
+ * {@link AppApi.wait}.
52
+ */
53
+ now(): number;
54
+ /**
55
+ * Sleep `ms` on the app clock, resolving once that much time has elapsed on the runtime
56
+ * scheduler. Real timers in production (no behavior change on the live transport); under
57
+ * the test kit it advances with `advanceTime()`. Prefer over a hand-rolled `setTimeout`
58
+ * sleep in time-bounded work so the delay shares the engine's (virtual) clock.
59
+ */
60
+ wait(ms: number): Promise<void>;
45
61
  /**
46
62
  * Structured logger (see {@link Logger}). Callable for back-compat
47
63
  * (`log("info", msg, fields)`), but prefer the level methods and bound context:
@@ -27,6 +27,8 @@ function fakeApi(): AppApi {
27
27
  data: unused(),
28
28
  engine: unused(),
29
29
  env: () => undefined,
30
+ now: () => 0,
31
+ wait: () => Promise.resolve(),
30
32
  log: createLogger(() => {}),
31
33
  };
32
34
  }
@@ -45,6 +45,8 @@ function appFixture(over: Partial<AppApi> = {}): AppApi {
45
45
  data,
46
46
  engine,
47
47
  env: () => undefined,
48
+ now: () => 0,
49
+ wait: () => Promise.resolve(),
48
50
  log: createLogger(() => {}),
49
51
  ...over,
50
52
  };
@@ -39,7 +39,7 @@ const engine: EngineClient = {
39
39
  const data = new DataLayer(new Map(), undefined, {});
40
40
 
41
41
  function appFixture(): AppApi {
42
- return { manifest: { schemaVersion: 1, id: "t", name: "T" }, data, engine, env: () => undefined, log: createLogger(() => {}) };
42
+ return { manifest: { schemaVersion: 1, id: "t", name: "T" }, data, engine, env: () => undefined, now: () => 0, wait: () => Promise.resolve(), log: createLogger(() => {}) };
43
43
  }
44
44
 
45
45
  const spec = JSON.stringify({
@@ -148,6 +148,8 @@ test("a delegate's app.log is bound to the request's correlation context", async
148
148
  data,
149
149
  engine,
150
150
  env: () => undefined,
151
+ now: () => 0,
152
+ wait: () => Promise.resolve(),
151
153
  log: createLogger((level, msg, fields) => {
152
154
  captured.push({ level, msg, fields });
153
155
  }),
@@ -111,6 +111,8 @@ async function withHarness(engine: EngineClient, seedStatus = "converging"): Pro
111
111
  data,
112
112
  engine,
113
113
  env: () => undefined,
114
+ now: () => 0,
115
+ wait: () => Promise.resolve(),
114
116
  log: createLogger((level, msg) => {
115
117
  logs.push({ level, msg });
116
118
  }),
@@ -86,6 +86,8 @@ function makeApp(env: Record<string, string> = {}): AppApi {
86
86
  data: new DataLayer(new Map(), undefined, {}),
87
87
  engine: new MiniEngine(),
88
88
  env: (n: string) => env[n],
89
+ now: () => 0,
90
+ wait: () => Promise.resolve(),
89
91
  log: createLogger(() => {}),
90
92
  };
91
93
  }
@@ -17,53 +17,13 @@ import {
17
17
  WAITING_HUMAN_PROBE_CONCURRENCY,
18
18
  } from "./instance-tracking.ts";
19
19
  import { MAX_TIMER_DELAY_MS, type SchedulerDeps } from "./scheduler.ts";
20
+ import { fakeScheduler } from "./scheduler.test-utils.ts";
20
21
 
21
22
  // A real-timer flush drains the entire pending microtask chain (find → engine → update)
22
23
  // each time a fake timer fires — deeper than a fixed number of `await Promise.resolve()`s.
23
24
  const realSetTimeout = globalThis.setTimeout;
24
25
  const flush = () => new Promise<void>((r) => realSetTimeout(r, 0));
25
26
 
26
- // A deterministic timer + clock seam (mirrors triggers.test.ts): `advance` fires every due
27
- // timer in order, draining microtasks between fires so the async reconcile completes.
28
- function fakeScheduler(): SchedulerDeps & { advance: (ms: number) => Promise<void>; pending: () => number } {
29
- let clock = 0;
30
- let seq = 0;
31
- const timers = new Map<number, { at: number; fn: () => void }>();
32
- return {
33
- now: () => clock,
34
- setTimer: (fn, delayMs) => {
35
- const id = ++seq;
36
- timers.set(id, { at: clock + delayMs, fn });
37
- return id;
38
- },
39
- clearTimer: (h) => {
40
- if (typeof h === "number") timers.delete(h);
41
- },
42
- pending: () => timers.size,
43
- advance: async (ms) => {
44
- const target = clock + ms;
45
- // deno-lint-ignore no-constant-condition
46
- while (true) {
47
- let nextId = -1;
48
- let nextAt = Infinity;
49
- for (const [id, t] of timers) {
50
- if (t.at <= target && t.at < nextAt) {
51
- nextAt = t.at;
52
- nextId = id;
53
- }
54
- }
55
- if (nextId < 0) break;
56
- const t = timers.get(nextId)!;
57
- timers.delete(nextId);
58
- clock = t.at;
59
- t.fn();
60
- await flush();
61
- }
62
- clock = target;
63
- },
64
- };
65
- }
66
-
67
27
  /** An EngineClient driven by a fixed map of processInstanceKey → state (for searchProcessInstances)
68
28
  * and an optional map of processInstanceKey → number-of-open-user-tasks (for openUserTasks, the
69
29
  * wait-on-human edge). Records the keys asked for on each call. `openUserTasks` models the engine
@@ -167,6 +127,8 @@ async function withHarness(
167
127
  data,
168
128
  engine,
169
129
  env: () => undefined,
130
+ now: () => 0,
131
+ wait: () => Promise.resolve(),
170
132
  log: createLogger((level, msg) => {
171
133
  logs.push({ level, msg });
172
134
  }),
@@ -0,0 +1,62 @@
1
+ // Shared test-only fake for the injectable {@link SchedulerDeps} timer/clock seam. A single
2
+ // canonical virtual-clock scheduler for every time-based test (workers, triggers, the
3
+ // instance-tracking reconciler) so their virtual-time behavior can't drift apart — the drift
4
+ // surface three per-file copies used to create. Excluded from the published build via the
5
+ // `**/*.test-utils.ts` rule in tsconfig.build.json.
6
+
7
+ import type { SchedulerDeps } from "./scheduler.ts";
8
+
9
+ // A real-timer flush drains the entire pending microtask chain each time a fake timer fires —
10
+ // deeper than a fixed number of `await Promise.resolve()`s, so an async follow-on chain (find →
11
+ // engine → update, or a worker loop's next iteration) fully settles before the next timer fires.
12
+ const realSetTimeout = globalThis.setTimeout;
13
+ const flush = (): Promise<void> => new Promise<void>((resolve) => realSetTimeout(resolve, 0));
14
+
15
+ /** A deterministic virtual-clock {@link SchedulerDeps}: timers fire only when the clock is advanced
16
+ * past their deadline. `advance(ms)` fires every timer due within the interval in order, draining
17
+ * microtasks between fires so an async loop's follow-on chain settles before the next fire, then
18
+ * parks the clock at the target. `pending()` reports the number of armed timers. A non-positive /
19
+ * non-finite delay clamps to fire at the current instant, mirroring the production
20
+ * `schedulerClock` / `setTimer` clamp. Lets a test bound time-based work over virtual time without
21
+ * touching the real wall clock. */
22
+ export function fakeScheduler(
23
+ startMs = 0,
24
+ ): SchedulerDeps & { advance: (ms: number) => Promise<void>; pending: () => number } {
25
+ let clock = startMs;
26
+ let seq = 0;
27
+ const timers = new Map<number, { at: number; fn: () => void }>();
28
+ return {
29
+ now: () => clock,
30
+ setTimer: (fn, delayMs) => {
31
+ const id = ++seq;
32
+ timers.set(id, { at: clock + (Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0), fn });
33
+ return id;
34
+ },
35
+ clearTimer: (h) => {
36
+ if (typeof h === "number") timers.delete(h);
37
+ },
38
+ pending: () => timers.size,
39
+ advance: async (ms) => {
40
+ const target = clock + Math.max(0, ms);
41
+ // Fire due timers one at a time; a timer's callback may schedule a new one.
42
+ for (;;) {
43
+ let nextId = -1;
44
+ let nextAt = Number.POSITIVE_INFINITY;
45
+ for (const [id, t] of timers) {
46
+ if (t.at <= target && t.at < nextAt) {
47
+ nextAt = t.at;
48
+ nextId = id;
49
+ }
50
+ }
51
+ if (nextId < 0) break;
52
+ const t = timers.get(nextId);
53
+ if (!t) break;
54
+ timers.delete(nextId);
55
+ clock = t.at;
56
+ t.fn();
57
+ await flush();
58
+ }
59
+ clock = target;
60
+ },
61
+ };
62
+ }