@hraness/direct 0.7.5

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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +436 -0
  3. package/dist/core/index.js +162 -0
  4. package/dist/index-1csg00w4.js +1167 -0
  5. package/dist/index-6mdfd2ey.js +464 -0
  6. package/dist/index-7n1h75n6.js +616 -0
  7. package/dist/index.js +232 -0
  8. package/dist/react.js +32 -0
  9. package/dist/testing/index.js +1069 -0
  10. package/dist/tooling/bombadil.js +2117 -0
  11. package/dist/tooling/browser-verification-entry.js +1499 -0
  12. package/dist/tooling/bundle-boundary.js +119 -0
  13. package/dist/web.js +605 -0
  14. package/package.json +179 -0
  15. package/skills/direct/AGENTS.md +13 -0
  16. package/skills/direct/SKILL.md +49 -0
  17. package/skills/direct/agents/openai.yaml +4 -0
  18. package/skills/direct/references/adoption.md +131 -0
  19. package/skills/direct/references/install.md +91 -0
  20. package/skills/direct/references/verification.md +247 -0
  21. package/src/core/coverage.ts +336 -0
  22. package/src/core/definition.ts +378 -0
  23. package/src/core/effects.ts +88 -0
  24. package/src/core/fixture.ts +185 -0
  25. package/src/core/ids.ts +77 -0
  26. package/src/core/index.ts +13 -0
  27. package/src/core/json-value.ts +7 -0
  28. package/src/core/json.ts +593 -0
  29. package/src/core/query.ts +230 -0
  30. package/src/core/reason.ts +16 -0
  31. package/src/core/resource.ts +10 -0
  32. package/src/core/result.ts +19 -0
  33. package/src/core/runtime.ts +229 -0
  34. package/src/core/scenario.ts +149 -0
  35. package/src/core/store.ts +784 -0
  36. package/src/index.ts +51 -0
  37. package/src/react.ts +54 -0
  38. package/src/testing/activity.ts +228 -0
  39. package/src/testing/coverage-binding.ts +99 -0
  40. package/src/testing/evidence.ts +59 -0
  41. package/src/testing/index.ts +22 -0
  42. package/src/testing/manifest.ts +559 -0
  43. package/src/testing/probe.ts +446 -0
  44. package/src/testing/scripted-transport.ts +775 -0
  45. package/src/testing/session.ts +525 -0
  46. package/src/tooling/bombadil-campaign.ts +288 -0
  47. package/src/tooling/bombadil-internal.d.ts +46 -0
  48. package/src/tooling/bombadil-runner.ts +1424 -0
  49. package/src/tooling/bombadil.ts +27 -0
  50. package/src/tooling/browser-verification-entry.ts +32 -0
  51. package/src/tooling/browser-verification.ts +916 -0
  52. package/src/tooling/bundle-boundary.ts +159 -0
  53. package/src/web/browser-bridge.ts +296 -0
  54. package/src/web/browser.ts +277 -0
  55. package/src/web/fetch-firewall.ts +251 -0
  56. package/src/web.ts +27 -0
@@ -0,0 +1,230 @@
1
+ import {
2
+ DEFAULT_MAX_FIXTURE_BYTES,
3
+ parseFixtureJson,
4
+ type FixtureParseOptions,
5
+ } from "./fixture.js";
6
+ import { parseScenarioId, type ScenarioId } from "./ids.js";
7
+ import {
8
+ stableHash,
9
+ tagStableHash,
10
+ utf8ByteLength,
11
+ type TaggedStableHash,
12
+ } from "./json.js";
13
+ import type { JsonValue } from "./json-value.js";
14
+ import { err, ok, type Result } from "./result.js";
15
+ import type { LogicalRuntimeSnapshot } from "./runtime.js";
16
+ import type { ScenarioCatalog } from "./scenario.js";
17
+
18
+ export const SCENARIO_QUERY_KEY = "__direct_scenario" as const;
19
+ export const FIXTURE_QUERY_KEY = "__direct_fixture" as const;
20
+ const FIXTURE_QUERY_PREFIX_BYTES = utf8ByteLength(`?${FIXTURE_QUERY_KEY}=`);
21
+
22
+ /** Worst-case percent-encoded query bytes for a bounded fixture JSON string. */
23
+ export function maximumFixtureQueryBytes(maxFixtureBytes: number): number {
24
+ return (maxFixtureBytes * 3) + FIXTURE_QUERY_PREFIX_BYTES;
25
+ }
26
+
27
+ export const DEFAULT_MAX_QUERY_BYTES = maximumFixtureQueryBytes(DEFAULT_MAX_FIXTURE_BYTES);
28
+
29
+ export interface ActiveDirect<World extends JsonValue, Route extends string> {
30
+ readonly kind: "active";
31
+ readonly source: "scenario" | "fixture";
32
+ readonly scenario: ScenarioId;
33
+ readonly route: Route;
34
+ readonly world: World;
35
+ readonly runtime: LogicalRuntimeSnapshot;
36
+ readonly activationHash: TaggedStableHash;
37
+ }
38
+
39
+ export interface InactiveDirect {
40
+ readonly kind: "inactive";
41
+ }
42
+
43
+ export type DirectActivation<World extends JsonValue, Route extends string> =
44
+ | InactiveDirect
45
+ | ActiveDirect<World, Route>;
46
+
47
+ export type QueryErrorCode =
48
+ | "duplicate-parameter"
49
+ | "invalid-encoding"
50
+ | "invalid-fixture"
51
+ | "invalid-query"
52
+ | "invalid-scenario"
53
+ | "mismatched-scenario"
54
+ | "oversized-query"
55
+ | "unknown-parameter"
56
+ | "unknown-scenario";
57
+
58
+ export interface QueryError {
59
+ readonly code: QueryErrorCode;
60
+ readonly message: string;
61
+ }
62
+
63
+ export interface DirectQueryOptions<World extends JsonValue, Route extends string>
64
+ extends FixtureParseOptions<World, Route> {
65
+ readonly maxQueryBytes?: number;
66
+ }
67
+
68
+ function queryError(code: QueryErrorCode, message: string): QueryError {
69
+ return { code, message };
70
+ }
71
+
72
+ function decodeQueryPart(value: string): Result<string, QueryError> {
73
+ try {
74
+ return ok(decodeURIComponent(value.replaceAll("+", " ")));
75
+ } catch {
76
+ return err(queryError("invalid-encoding", "Direct query contains invalid percent encoding"));
77
+ }
78
+ }
79
+
80
+ function queryBody(source: string): string {
81
+ const question = source.indexOf("?");
82
+ const candidate = question >= 0 ? source.slice(question + 1) : source.startsWith("?") ? source.slice(1) : source;
83
+ const fragment = candidate.indexOf("#");
84
+ return fragment >= 0 ? candidate.slice(0, fragment) : candidate;
85
+ }
86
+
87
+ interface ParsedActivationQuery {
88
+ readonly scenario: string | null;
89
+ readonly fixture: string | null;
90
+ }
91
+
92
+ function parseActivationParameters(source: string): Result<ParsedActivationQuery, QueryError> {
93
+ let scenario: string | null = null;
94
+ let fixture: string | null = null;
95
+ const body = queryBody(source);
96
+ if (body.length === 0) {
97
+ return ok({ scenario, fixture });
98
+ }
99
+ for (const part of body.split("&")) {
100
+ if (part.length === 0) {
101
+ continue;
102
+ }
103
+ const equals = part.indexOf("=");
104
+ const encodedKey = equals < 0 ? part : part.slice(0, equals);
105
+ const encodedValue = equals < 0 ? "" : part.slice(equals + 1);
106
+ const key = decodeQueryPart(encodedKey);
107
+ if (!key.ok) {
108
+ return key;
109
+ }
110
+ const reserved = key.value.startsWith("__direct_");
111
+ if (key.value !== SCENARIO_QUERY_KEY && key.value !== FIXTURE_QUERY_KEY) {
112
+ if (reserved) {
113
+ return err(queryError("unknown-parameter", `Unknown Direct query parameter: ${key.value}`));
114
+ }
115
+ continue;
116
+ }
117
+ const value = decodeQueryPart(encodedValue);
118
+ if (!value.ok) {
119
+ return value;
120
+ }
121
+ if (key.value === SCENARIO_QUERY_KEY) {
122
+ if (scenario !== null) {
123
+ return err(queryError("duplicate-parameter", `Duplicate ${SCENARIO_QUERY_KEY} parameter`));
124
+ }
125
+ scenario = value.value;
126
+ } else {
127
+ if (fixture !== null) {
128
+ return err(queryError("duplicate-parameter", `Duplicate ${FIXTURE_QUERY_KEY} parameter`));
129
+ }
130
+ fixture = value.value;
131
+ }
132
+ }
133
+ return ok({ scenario, fixture });
134
+ }
135
+
136
+ function activationHash<World extends JsonValue, Route extends string>(
137
+ source: "scenario" | "fixture",
138
+ scenario: ScenarioId,
139
+ route: Route,
140
+ world: World,
141
+ runtime: LogicalRuntimeSnapshot,
142
+ ): TaggedStableHash {
143
+ const hashed = stableHash({ source, scenario, route, world, runtime });
144
+ if (!hashed.ok) {
145
+ throw new Error(hashed.error.message);
146
+ }
147
+ return tagStableHash(hashed.value);
148
+ }
149
+
150
+ export function activateDirectScenario<World extends JsonValue, Route extends string>(
151
+ id: unknown,
152
+ scenarios: ScenarioCatalog<World, Route>,
153
+ ): Result<ActiveDirect<World, Route>, QueryError> {
154
+ const parsed = parseScenarioId(id);
155
+ if (!parsed.ok) {
156
+ return err(queryError("invalid-scenario", parsed.error.message));
157
+ }
158
+ const scenario = scenarios.get(parsed.value);
159
+ if (scenario === undefined) {
160
+ return err(queryError("unknown-scenario", `Unknown scenario: ${parsed.value}`));
161
+ }
162
+ return ok(Object.freeze({
163
+ kind: "active",
164
+ source: "scenario",
165
+ scenario: scenario.id,
166
+ route: scenario.route,
167
+ world: scenario.world,
168
+ runtime: scenario.runtime,
169
+ activationHash: activationHash("scenario", scenario.id, scenario.route, scenario.world, scenario.runtime),
170
+ }));
171
+ }
172
+
173
+ export function parseDirectQuery<World extends JsonValue, Route extends string>(
174
+ source: unknown,
175
+ options: DirectQueryOptions<World, Route>,
176
+ ): Result<DirectActivation<World, Route>, QueryError> {
177
+ const maxBytes = options.maxQueryBytes ?? DEFAULT_MAX_QUERY_BYTES;
178
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) {
179
+ throw new Error("Query maxQueryBytes must be a positive safe integer");
180
+ }
181
+ if (typeof source !== "string") {
182
+ return err(queryError("invalid-query", "Direct query source must be a string"));
183
+ }
184
+ if (utf8ByteLength(source) > maxBytes) {
185
+ return err(queryError("oversized-query", "Direct query exceeds its byte limit"));
186
+ }
187
+ const parameters = parseActivationParameters(source);
188
+ if (!parameters.ok) {
189
+ return parameters;
190
+ }
191
+ if (parameters.value.scenario === null && parameters.value.fixture === null) {
192
+ return ok(Object.freeze({ kind: "inactive" }));
193
+ }
194
+
195
+ const requestedScenario = parameters.value.scenario === null
196
+ ? null
197
+ : activateDirectScenario(parameters.value.scenario, options.scenarios);
198
+ if (requestedScenario !== null && !requestedScenario.ok) {
199
+ return requestedScenario;
200
+ }
201
+ if (parameters.value.fixture === null) {
202
+ return requestedScenario ?? err(queryError("invalid-scenario", "Missing scenario activation"));
203
+ }
204
+
205
+ const fixture = parseFixtureJson(parameters.value.fixture, options);
206
+ if (!fixture.ok) {
207
+ return err(queryError("invalid-fixture", fixture.error.message));
208
+ }
209
+ if (requestedScenario !== null && requestedScenario.value.scenario !== fixture.value.scenario) {
210
+ return err(queryError(
211
+ "mismatched-scenario",
212
+ `${SCENARIO_QUERY_KEY} does not match the fixture scenario`,
213
+ ));
214
+ }
215
+ return ok(Object.freeze({
216
+ kind: "active",
217
+ source: "fixture",
218
+ scenario: fixture.value.scenario,
219
+ route: fixture.value.route,
220
+ world: fixture.value.world,
221
+ runtime: fixture.value.runtime,
222
+ activationHash: activationHash(
223
+ "fixture",
224
+ fixture.value.scenario,
225
+ fixture.value.route,
226
+ fixture.value.world,
227
+ fixture.value.runtime,
228
+ ),
229
+ }));
230
+ }
@@ -0,0 +1,16 @@
1
+ /** Render a foreign thrown value without trusting its prototype, getters, or coercion hooks. */
2
+ export function renderUnknownReason(reason: unknown, fallback = "Unknown failure"): string {
3
+ try {
4
+ if ((typeof reason === "object" && reason !== null) || typeof reason === "function") {
5
+ const message = Reflect.get(reason, "message") as unknown;
6
+ if (typeof message === "string") return message;
7
+ }
8
+ } catch {
9
+ // Fall through to guarded primitive coercion.
10
+ }
11
+ try {
12
+ return String(reason);
13
+ } catch {
14
+ return fallback;
15
+ }
16
+ }
@@ -0,0 +1,10 @@
1
+ import type { JsonValue } from "./json-value.js";
2
+
3
+ export type ResourceState<Value extends JsonValue, Failure extends JsonValue = string> =
4
+ | { readonly status: "idle" }
5
+ | { readonly status: "loading" }
6
+ | { readonly status: "ready"; readonly value: Value }
7
+ | { readonly status: "empty" }
8
+ | { readonly status: "error"; readonly error: Failure }
9
+ | { readonly status: "offline"; readonly error: Failure | null }
10
+ | { readonly status: "unauthorized"; readonly reason: Failure | null };
@@ -0,0 +1,19 @@
1
+ /** An explicit success-or-failure value whose failure path stays visible. */
2
+ export type Result<Value, Failure = Error> =
3
+ | { readonly ok: true; readonly value: Value }
4
+ | { readonly ok: false; readonly error: Failure };
5
+
6
+ export type UnknownRecord = Record<string, unknown>;
7
+
8
+ export function ok<Value>(value: Value): Result<Value, never> {
9
+ return { ok: true, value };
10
+ }
11
+
12
+ export function err<Failure>(error: Failure): Result<never, Failure> {
13
+ return { ok: false, error };
14
+ }
15
+
16
+ /** Narrow a foreign value before reading named fields from it. */
17
+ export function isRecord(value: unknown): value is UnknownRecord {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
@@ -0,0 +1,229 @@
1
+ import { parseOperationId, type OperationId } from "./ids.js";
2
+ import { parseJsonValue } from "./json.js";
3
+ import { renderUnknownReason } from "./reason.js";
4
+ import { err, isRecord, ok, type Result } from "./result.js";
5
+
6
+ export const LOGICAL_RUNTIME_SCHEMA = "direct.runtime/v1" as const;
7
+ export const MAX_HOST_TIMER_MILLISECONDS = 2_147_483_647;
8
+
9
+ export interface LogicalRuntimeSnapshot {
10
+ readonly schema: typeof LOGICAL_RUNTIME_SCHEMA;
11
+ readonly nowMs: number;
12
+ readonly nextOperation: number;
13
+ readonly acceleration: number;
14
+ }
15
+
16
+ export const DEFAULT_LOGICAL_RUNTIME_SNAPSHOT = Object.freeze({
17
+ schema: LOGICAL_RUNTIME_SCHEMA,
18
+ nowMs: 0,
19
+ nextOperation: 1,
20
+ acceleration: 100,
21
+ }) satisfies LogicalRuntimeSnapshot;
22
+
23
+ export type RuntimeErrorCode =
24
+ | "invalid-duration"
25
+ | "invalid-runtime"
26
+ | "sleep-failed"
27
+ | "time-overflow"
28
+ | "wait-cancelled";
29
+
30
+ export interface RuntimeError {
31
+ readonly code: RuntimeErrorCode;
32
+ readonly message: string;
33
+ }
34
+
35
+ export type LogicalSleep = (wallMilliseconds: number, signal?: AbortSignal) => Promise<void>;
36
+
37
+ export interface LogicalRuntime {
38
+ readonly now: () => number;
39
+ readonly snapshot: () => LogicalRuntimeSnapshot;
40
+ readonly nextOperationId: (namespace?: string) => OperationId;
41
+ readonly advance: (logicalMilliseconds: number) => Result<number, RuntimeError>;
42
+ readonly wait: (
43
+ logicalMilliseconds: number,
44
+ signal?: AbortSignal,
45
+ ) => Promise<Result<number, RuntimeError>>;
46
+ }
47
+
48
+ const RUNTIME_KEYS = new Set(["schema", "nowMs", "nextOperation", "acceleration"]);
49
+ const NAMESPACE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
50
+
51
+ export function parseLogicalRuntimeSnapshot(input: unknown): Result<LogicalRuntimeSnapshot, RuntimeError> {
52
+ const parsedJson = parseJsonValue(input);
53
+ if (!parsedJson.ok || !isRecord(parsedJson.value)) {
54
+ return err({ code: "invalid-runtime", message: "Logical runtime must be an object" });
55
+ }
56
+ for (const key of Object.keys(parsedJson.value)) {
57
+ if (!RUNTIME_KEYS.has(key)) {
58
+ return err({ code: "invalid-runtime", message: `Unknown logical runtime key: ${key}` });
59
+ }
60
+ }
61
+ const record = parsedJson.value;
62
+ if (record.schema !== LOGICAL_RUNTIME_SCHEMA) {
63
+ return err({ code: "invalid-runtime", message: `Logical runtime schema must be ${LOGICAL_RUNTIME_SCHEMA}` });
64
+ }
65
+ if (typeof record.nowMs !== "number" || !Number.isSafeInteger(record.nowMs) || record.nowMs < 0) {
66
+ return err({ code: "invalid-runtime", message: "Logical nowMs must be a non-negative safe integer" });
67
+ }
68
+ if (
69
+ typeof record.nextOperation !== "number"
70
+ || !Number.isSafeInteger(record.nextOperation)
71
+ || record.nextOperation < 1
72
+ ) {
73
+ return err({ code: "invalid-runtime", message: "Logical nextOperation must be a positive safe integer" });
74
+ }
75
+ if (
76
+ typeof record.acceleration !== "number"
77
+ || !Number.isFinite(record.acceleration)
78
+ || record.acceleration < 1
79
+ || record.acceleration > 1_000_000
80
+ ) {
81
+ return err({ code: "invalid-runtime", message: "Logical acceleration must be in [1, 1000000]" });
82
+ }
83
+ return ok(Object.freeze({
84
+ schema: LOGICAL_RUNTIME_SCHEMA,
85
+ nowMs: record.nowMs,
86
+ nextOperation: record.nextOperation,
87
+ acceleration: record.acceleration,
88
+ }));
89
+ }
90
+
91
+ function sleepTimerChunk(wallMilliseconds: number, signal?: AbortSignal): Promise<void> {
92
+ return new Promise((resolve) => {
93
+ if (signal?.aborted === true) {
94
+ resolve();
95
+ return;
96
+ }
97
+ let timeout: ReturnType<typeof setTimeout> | null = null;
98
+ let settled = false;
99
+ const finish = (): void => {
100
+ if (settled) return;
101
+ settled = true;
102
+ if (timeout !== null) clearTimeout(timeout);
103
+ signal?.removeEventListener("abort", finish);
104
+ resolve();
105
+ };
106
+ signal?.addEventListener("abort", finish, { once: true });
107
+ timeout = setTimeout(finish, wallMilliseconds);
108
+ });
109
+ }
110
+
111
+ async function defaultSleep(wallMilliseconds: number, signal?: AbortSignal): Promise<void> {
112
+ let remaining = wallMilliseconds;
113
+ while (remaining > 0 && signal?.aborted !== true) {
114
+ const chunk = Math.min(remaining, MAX_HOST_TIMER_MILLISECONDS);
115
+ await sleepTimerChunk(chunk, signal);
116
+ remaining -= chunk;
117
+ }
118
+ }
119
+
120
+ function parseDuration(logicalMilliseconds: number): Result<number, RuntimeError> {
121
+ return Number.isSafeInteger(logicalMilliseconds) && logicalMilliseconds >= 0
122
+ ? ok(logicalMilliseconds)
123
+ : err({ code: "invalid-duration", message: "Logical durations must be non-negative safe integers" });
124
+ }
125
+
126
+ function isWaitCancelled(signal: AbortSignal | undefined): boolean {
127
+ return signal?.aborted === true;
128
+ }
129
+
130
+ function waitCancelled(): Result<never, RuntimeError> {
131
+ return err({
132
+ code: "wait-cancelled",
133
+ message: "Logical wait was cancelled",
134
+ });
135
+ }
136
+
137
+ function nextLogicalTime(nowMs: number, duration: number): Result<number, RuntimeError> {
138
+ const nextNow = nowMs + duration;
139
+ return Number.isSafeInteger(nextNow)
140
+ ? ok(nextNow)
141
+ : err({ code: "time-overflow", message: "Logical time exceeds the safe integer range" });
142
+ }
143
+
144
+ export function createLogicalRuntime(
145
+ initial: LogicalRuntimeSnapshot = DEFAULT_LOGICAL_RUNTIME_SNAPSHOT,
146
+ sleep: LogicalSleep = defaultSleep,
147
+ ): LogicalRuntime {
148
+ const parsed = parseLogicalRuntimeSnapshot(initial);
149
+ if (!parsed.ok) {
150
+ throw new Error(parsed.error.message);
151
+ }
152
+ let nowMs = parsed.value.nowMs;
153
+ let nextOperation = parsed.value.nextOperation;
154
+ const acceleration = parsed.value.acceleration;
155
+ let waitTail = Promise.resolve();
156
+
157
+ const snapshot = (): LogicalRuntimeSnapshot => Object.freeze({
158
+ schema: LOGICAL_RUNTIME_SCHEMA,
159
+ nowMs,
160
+ nextOperation,
161
+ acceleration,
162
+ });
163
+
164
+ const advance = (logicalMilliseconds: number): Result<number, RuntimeError> => {
165
+ const duration = parseDuration(logicalMilliseconds);
166
+ if (!duration.ok) {
167
+ return duration;
168
+ }
169
+ const nextNow = nextLogicalTime(nowMs, duration.value);
170
+ if (!nextNow.ok) {
171
+ return nextNow;
172
+ }
173
+ nowMs = nextNow.value;
174
+ return ok(nowMs);
175
+ };
176
+
177
+ const wait = (
178
+ logicalMilliseconds: number,
179
+ signal?: AbortSignal,
180
+ ): Promise<Result<number, RuntimeError>> => {
181
+ const duration = parseDuration(logicalMilliseconds);
182
+ if (!duration.ok) {
183
+ return Promise.resolve(duration);
184
+ }
185
+ const run = waitTail.then(async () => {
186
+ if (isWaitCancelled(signal)) return waitCancelled();
187
+ const target = nextLogicalTime(nowMs, duration.value);
188
+ if (!target.ok) return target;
189
+ const wallMilliseconds = Math.ceil(duration.value / acceleration);
190
+ try {
191
+ if (wallMilliseconds > 0) {
192
+ await sleep(wallMilliseconds, signal);
193
+ }
194
+ } catch (reason) {
195
+ if (isWaitCancelled(signal)) return waitCancelled();
196
+ return err<RuntimeError>({
197
+ code: "sleep-failed",
198
+ message: renderUnknownReason(reason, "Logical sleep failed"),
199
+ });
200
+ }
201
+ if (isWaitCancelled(signal)) return waitCancelled();
202
+ return advance(duration.value);
203
+ });
204
+ waitTail = run.then(() => undefined, () => undefined);
205
+ return run;
206
+ };
207
+
208
+ return Object.freeze({
209
+ now: () => nowMs,
210
+ snapshot,
211
+ nextOperationId: (namespace = "operation") => {
212
+ if (!NAMESPACE_PATTERN.test(namespace) || namespace.length > 48) {
213
+ throw new Error("Operation namespaces must be lowercase hyphen-separated ASCII identifiers");
214
+ }
215
+ if (!Number.isSafeInteger(nextOperation) || nextOperation >= Number.MAX_SAFE_INTEGER) {
216
+ throw new Error("Operation sequence exceeds the safe integer range");
217
+ }
218
+ const candidate = `${namespace}-${String(nextOperation).padStart(6, "0")}`;
219
+ nextOperation += 1;
220
+ const parsedOperation = parseOperationId(candidate);
221
+ if (!parsedOperation.ok) {
222
+ throw new Error(parsedOperation.error.message);
223
+ }
224
+ return parsedOperation.value;
225
+ },
226
+ advance,
227
+ wait,
228
+ });
229
+ }
@@ -0,0 +1,149 @@
1
+ import { parseScenarioId, type ScenarioId } from "./ids.js";
2
+ import { parseAndCloneWorld, type WorldParser } from "./json.js";
3
+ import type { JsonValue } from "./json-value.js";
4
+ import { err, ok, type Result } from "./result.js";
5
+ import {
6
+ DEFAULT_LOGICAL_RUNTIME_SNAPSHOT,
7
+ parseLogicalRuntimeSnapshot,
8
+ type LogicalRuntimeSnapshot,
9
+ } from "./runtime.js";
10
+
11
+ /** Maximum scenarios retained by one definition and discovery manifest. */
12
+ export const MAX_DIRECT_SCENARIOS = 256 as const;
13
+
14
+ export interface ScenarioDefinitionInput<World extends JsonValue, Route extends string> {
15
+ readonly id: string;
16
+ readonly title: string;
17
+ readonly description?: string;
18
+ readonly route: Route;
19
+ readonly world: World;
20
+ readonly runtime?: LogicalRuntimeSnapshot;
21
+ }
22
+
23
+ export interface ScenarioDefinition<World extends JsonValue, Route extends string> {
24
+ readonly id: ScenarioId;
25
+ readonly title: string;
26
+ readonly description: string | null;
27
+ readonly route: Route;
28
+ readonly world: World;
29
+ readonly runtime: LogicalRuntimeSnapshot;
30
+ }
31
+
32
+ export type ScenarioCatalogErrorCode =
33
+ | "duplicate-scenario"
34
+ | "invalid-description"
35
+ | "invalid-route"
36
+ | "invalid-runtime"
37
+ | "invalid-scenario"
38
+ | "invalid-title"
39
+ | "invalid-world"
40
+ | "too-many-scenarios"
41
+ | "unknown-scenario";
42
+
43
+ export interface ScenarioCatalogError {
44
+ readonly code: ScenarioCatalogErrorCode;
45
+ readonly scenario: unknown;
46
+ readonly message: string;
47
+ }
48
+
49
+ export interface ScenarioCatalog<World extends JsonValue, Route extends string> {
50
+ readonly size: number;
51
+ readonly list: () => readonly ScenarioDefinition<World, Route>[];
52
+ readonly get: (id: ScenarioId) => ScenarioDefinition<World, Route> | undefined;
53
+ readonly resolve: (id: unknown) => Result<ScenarioDefinition<World, Route>, ScenarioCatalogError>;
54
+ }
55
+
56
+ function validText(value: string, maximum: number): boolean {
57
+ if (value.trim().length === 0 || value.length > maximum) {
58
+ return false;
59
+ }
60
+ for (const character of value) {
61
+ const code = character.charCodeAt(0);
62
+ if ((code < 32 && code !== 9 && code !== 10 && code !== 13) || code === 127) {
63
+ return false;
64
+ }
65
+ }
66
+ return true;
67
+ }
68
+
69
+ function validRoute(value: string): boolean {
70
+ if (value.trim().length === 0 || value.length > 256) return false;
71
+ for (const character of value) {
72
+ const code = character.charCodeAt(0);
73
+ if (code < 32 || code === 127) return false;
74
+ }
75
+ return true;
76
+ }
77
+
78
+ function scenarioError(code: ScenarioCatalogErrorCode, scenario: unknown, message: string): ScenarioCatalogError {
79
+ return { code, scenario, message };
80
+ }
81
+
82
+ export function createScenarioCatalog<World extends JsonValue, Route extends string>(
83
+ inputs: readonly ScenarioDefinitionInput<World, Route>[],
84
+ parseWorld: WorldParser<World>,
85
+ ): Result<ScenarioCatalog<World, Route>, ScenarioCatalogError> {
86
+ if (inputs.length > MAX_DIRECT_SCENARIOS) {
87
+ return err(scenarioError(
88
+ "too-many-scenarios",
89
+ inputs.length,
90
+ `Direct definitions support at most ${String(MAX_DIRECT_SCENARIOS)} scenarios`,
91
+ ));
92
+ }
93
+ const definitions: ScenarioDefinition<World, Route>[] = [];
94
+ const byId = new Map<ScenarioId, ScenarioDefinition<World, Route>>();
95
+
96
+ for (const input of inputs) {
97
+ const id = parseScenarioId(input.id);
98
+ if (!id.ok) {
99
+ return err(scenarioError("invalid-scenario", input.id, id.error.message));
100
+ }
101
+ if (byId.has(id.value)) {
102
+ return err(scenarioError("duplicate-scenario", id.value, `Duplicate scenario: ${id.value}`));
103
+ }
104
+ if (!validText(input.title, 160)) {
105
+ return err(scenarioError("invalid-title", id.value, "Scenario titles must contain 1-160 visible characters"));
106
+ }
107
+ if (input.description !== undefined && !validText(input.description, 2_000)) {
108
+ return err(scenarioError("invalid-description", id.value, "Scenario descriptions must contain 1-2000 visible characters"));
109
+ }
110
+ if (!validRoute(input.route)) {
111
+ return err(scenarioError("invalid-route", id.value, "Scenario routes must contain 1-256 visible characters"));
112
+ }
113
+ const runtime = parseLogicalRuntimeSnapshot(input.runtime ?? DEFAULT_LOGICAL_RUNTIME_SNAPSHOT);
114
+ if (!runtime.ok) {
115
+ return err(scenarioError("invalid-runtime", id.value, runtime.error.message));
116
+ }
117
+ const world = parseAndCloneWorld(input.world, parseWorld);
118
+ if (!world.ok) {
119
+ return err(scenarioError("invalid-world", id.value, world.error.message));
120
+ }
121
+ const definition = Object.freeze({
122
+ id: id.value,
123
+ title: input.title,
124
+ description: input.description ?? null,
125
+ route: input.route,
126
+ world: world.value,
127
+ runtime: runtime.value,
128
+ });
129
+ definitions.push(definition);
130
+ byId.set(id.value, definition);
131
+ }
132
+
133
+ const frozenDefinitions = Object.freeze(definitions);
134
+ return ok(Object.freeze({
135
+ size: frozenDefinitions.length,
136
+ list: () => frozenDefinitions,
137
+ get: (id: ScenarioId) => byId.get(id),
138
+ resolve: (input: unknown) => {
139
+ const id = parseScenarioId(input);
140
+ if (!id.ok) {
141
+ return err(scenarioError("invalid-scenario", input, id.error.message));
142
+ }
143
+ const definition = byId.get(id.value);
144
+ return definition === undefined
145
+ ? err(scenarioError("unknown-scenario", id.value, `Unknown scenario: ${id.value}`))
146
+ : ok(definition);
147
+ },
148
+ }));
149
+ }