substrate-ai 0.15.0 → 0.16.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/cli/index.js CHANGED
@@ -4,7 +4,7 @@ import { createLogger } from "../logger-KeHncl-f.js";
4
4
  import { createEventBus } from "../helpers-CElYrONe.js";
5
5
  import { AdapterRegistry, BudgetConfigSchema, CURRENT_CONFIG_FORMAT_VERSION, CURRENT_TASK_GRAPH_VERSION, ConfigError, CostTrackerConfigSchema, DEFAULT_CONFIG, DoltClient, DoltNotInstalled, EXPERIMENT_RESULT, GlobalSettingsSchema, IngestionServer, MonitorDatabaseImpl, OPERATIONAL_FINDING, PartialGlobalSettingsSchema, PartialProviderConfigSchema, ProvidersSchema, RoutingRecommender, STORY_METRICS, TelemetryConfigSchema, addTokenUsage, aggregateTokenUsageForRun, checkDoltInstalled, compareRunMetrics, createAmendmentRun, createConfigSystem, createDecision, createDoltClient, createPipelineRun, getActiveDecisions, getAllCostEntriesFiltered, getBaselineRunMetrics, getDecisionsByCategory, getDecisionsByPhaseForRun, getLatestCompletedRun, getLatestRun, getPipelineRunById, getPlanningCostTotal, getRetryableEscalations, getRunMetrics, getSessionCostSummary, getSessionCostSummaryFiltered, getStoryMetricsForRun, getTokenUsageSummary, incrementRunRestarts, initSchema, initializeDolt, listRequirements, listRunMetrics, loadParentRunDecisions, supersedeDecision, tagRunAsBaseline, updatePipelineRun } from "../dist-CLvAwmT7.js";
6
6
  import "../adapter-registry-DXLMTmfD.js";
7
- import { AdapterTelemetryPersistence, AppError, DoltRepoMapMetaRepository, DoltSymbolRepository, ERR_REPO_MAP_STORAGE_WRITE, GitClient, GrammarLoader, RepoMapInjector, RepoMapModule, RepoMapQueryEngine, RepoMapStorage, SymbolParser, createContextCompiler, createDispatcher, createEventEmitter, createImplementationOrchestrator, createPackLoader, createPhaseOrchestrator, createStopAfterGate, createTelemetryAdvisor, formatPhaseCompletionSummary, getFactoryRunSummaries, getScenarioResultsForRun, listGraphRuns, registerFactoryCommand, registerRunCommand, registerScenariosCommand, resolveStoryKeys, runAnalysisPhase, runPlanningPhase, runSolutioningPhase, validateStopAfterFromConflict } from "../run-bhGoAbu9.js";
7
+ import { AdapterTelemetryPersistence, AppError, DoltRepoMapMetaRepository, DoltSymbolRepository, ERR_REPO_MAP_STORAGE_WRITE, GitClient, GrammarLoader, RepoMapInjector, RepoMapModule, RepoMapQueryEngine, RepoMapStorage, SymbolParser, createContextCompiler, createDispatcher, createEventEmitter, createImplementationOrchestrator, createPackLoader, createPhaseOrchestrator, createStopAfterGate, createTelemetryAdvisor, formatPhaseCompletionSummary, getFactoryRunSummaries, getScenarioResultsForRun, getTwinRunsForRun, listGraphRuns, registerFactoryCommand, registerRunCommand, registerScenariosCommand, resolveStoryKeys, runAnalysisPhase, runPlanningPhase, runSolutioningPhase, validateStopAfterFromConflict } from "../run-z84Uu_U2.js";
8
8
  import "../errors-D1LU8CZ9.js";
9
9
  import "../routing-CcBOCuC9.js";
10
10
  import "../decisions-C0pz9Clx.js";
@@ -4359,7 +4359,7 @@ async function runSupervisorAction(options, deps = {}) {
4359
4359
  await initSchema(expAdapter);
4360
4360
  const { runRunAction: runPipeline } = await import(
4361
4361
  /* @vite-ignore */
4362
- "../run-BQmRdbhV.js"
4362
+ "../run-Q2TZstl9.js"
4363
4363
  );
4364
4364
  const runStoryFn = async (opts) => {
4365
4365
  const exitCode = await runPipeline({
@@ -4998,6 +4998,19 @@ async function runMetricsAction(options) {
4998
4998
  const execAt = String(r.executed_at).slice(0, 19);
4999
4999
  process.stdout.write(` ${String(r.iteration).padStart(3)} ${scoreStr.padStart(7)} ${passesStr.padStart(7)} ${passedTotal.padStart(13)} ${execAt.padEnd(20)}\n`);
5000
5000
  }
5001
+ try {
5002
+ const twinRuns = await getTwinRunsForRun(adapter, resolvedRunId);
5003
+ if (twinRuns.length > 0) {
5004
+ process.stdout.write("\nTwins:\n");
5005
+ for (const twin of twinRuns) {
5006
+ const ports = twin.ports.map((p) => `${p.host}:${p.container}`).join(", ");
5007
+ const stoppedAt = twin.stopped_at ?? "still running";
5008
+ process.stdout.write(` ${twin.twin_name} [${twin.status}] ports: ${ports || "none"} started: ${twin.started_at} stopped: ${stoppedAt} health failures: ${twin.health_failure_count}\n`);
5009
+ }
5010
+ }
5011
+ } catch (err) {
5012
+ logger$10.debug({ err }, "getTwinRunsForRun failed — twin_runs table may not exist yet");
5013
+ }
5001
5014
  }
5002
5015
  return 0;
5003
5016
  }
@@ -2,7 +2,7 @@ import "./health-DswaC1q5.js";
2
2
  import "./logger-KeHncl-f.js";
3
3
  import "./helpers-CElYrONe.js";
4
4
  import "./dist-CLvAwmT7.js";
5
- import { normalizeGraphSummaryToStatus, registerRunCommand, runRunAction } from "./run-bhGoAbu9.js";
5
+ import { normalizeGraphSummaryToStatus, registerRunCommand, runRunAction } from "./run-z84Uu_U2.js";
6
6
  import "./routing-CcBOCuC9.js";
7
7
  import "./decisions-C0pz9Clx.js";
8
8
 
@@ -7,12 +7,13 @@ import { access, readFile, readdir, stat } from "fs/promises";
7
7
  import { EventEmitter } from "node:events";
8
8
  import yaml from "js-yaml";
9
9
  import * as actualFS from "node:fs";
10
- import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
10
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
11
11
  import { execFile, execSync, spawn } from "node:child_process";
12
- import path, { dirname as dirname$1, extname as extname$1, join as join$1, posix, win32 } from "node:path";
12
+ import path, { dirname as dirname$1, extname as extname$1, join as join$1, posix, resolve as resolve$1, win32 } from "node:path";
13
+ import { tmpdir } from "node:os";
13
14
  import { createHash, randomUUID } from "node:crypto";
14
15
  import { z } from "zod";
15
- import { lstat, mkdir as mkdir$1, readFile as readFile$1, readdir as readdir$1, readlink, realpath, stat as stat$1, writeFile as writeFile$1 } from "node:fs/promises";
16
+ import { access as access$1, lstat, mkdir as mkdir$1, readFile as readFile$1, readdir as readdir$1, readlink, realpath, stat as stat$1, unlink, writeFile as writeFile$1 } from "node:fs/promises";
16
17
  import { existsSync as existsSync$1, lstatSync, mkdirSync as mkdirSync$1, readFileSync as readFileSync$1, readdir as readdir$2, readdirSync as readdirSync$1, readlinkSync, realpathSync, unlinkSync, writeFileSync as writeFileSync$1 } from "fs";
17
18
  import { fileURLToPath } from "node:url";
18
19
  import { createHash as createHash$1 } from "crypto";
@@ -23174,6 +23175,7 @@ function createGraphExecutor() {
23174
23175
  firstResumedFidelity = "";
23175
23176
  const startedAt = Date.now();
23176
23177
  let outcome = await dispatchWithRetry(nodeToDispatch, context, graph, config, nodeRetries);
23178
+ if (Date.now() - startedAt < 50) await new Promise((r) => setTimeout(r, 50));
23177
23179
  if (outcome.status === "PARTIAL_SUCCESS" && !nodeToDispatch.allowPartial) outcome = {
23178
23180
  ...outcome,
23179
23181
  status: "FAIL",
@@ -27315,7 +27317,7 @@ var Pattern = class Pattern {
27315
27317
  #isUNC;
27316
27318
  #isAbsolute;
27317
27319
  #followGlobstar = true;
27318
- constructor(patternList, globList, index, platform) {
27320
+ constructor(patternList, globList, index, platform$1) {
27319
27321
  if (!isPatternList(patternList)) throw new TypeError("empty pattern list");
27320
27322
  if (!isGlobList(globList)) throw new TypeError("empty glob list");
27321
27323
  if (globList.length !== patternList.length) throw new TypeError("mismatched pattern list and glob list lengths");
@@ -27324,7 +27326,7 @@ var Pattern = class Pattern {
27324
27326
  this.#patternList = patternList;
27325
27327
  this.#globList = globList;
27326
27328
  this.#index = index;
27327
- this.#platform = platform;
27329
+ this.#platform = platform$1;
27328
27330
  if (this.#index === 0) {
27329
27331
  if (this.isUNC()) {
27330
27332
  const [p0, p1, p2, p3, ...prest] = this.#patternList;
@@ -27471,12 +27473,12 @@ var Ignore = class {
27471
27473
  absoluteChildren;
27472
27474
  platform;
27473
27475
  mmopts;
27474
- constructor(ignored, { nobrace, nocase, noext, noglobstar, platform = defaultPlatform$1 }) {
27476
+ constructor(ignored, { nobrace, nocase, noext, noglobstar, platform: platform$1 = defaultPlatform$1 }) {
27475
27477
  this.relative = [];
27476
27478
  this.absolute = [];
27477
27479
  this.relativeChildren = [];
27478
27480
  this.absoluteChildren = [];
27479
- this.platform = platform;
27481
+ this.platform = platform$1;
27480
27482
  this.mmopts = {
27481
27483
  dot: true,
27482
27484
  nobrace,
@@ -27484,7 +27486,7 @@ var Ignore = class {
27484
27486
  noext,
27485
27487
  noglobstar,
27486
27488
  optimizationLevel: 2,
27487
- platform,
27489
+ platform: platform$1,
27488
27490
  nocomment: true,
27489
27491
  nonegate: true
27490
27492
  };
@@ -28375,14 +28377,24 @@ function getExecutionCommand(filePath) {
28375
28377
  }
28376
28378
  /**
28377
28379
  * Run a single scenario file and return its result.
28380
+ *
28381
+ * @param entry - Scenario entry with name and path.
28382
+ * @param projectRoot - Working directory for scenario execution.
28383
+ * @param env - Optional extra environment variables to inject into the subprocess.
28384
+ * When provided, merged on top of `process.env`.
28378
28385
  */
28379
- function runScenario(entry, projectRoot) {
28386
+ function runScenario(entry, projectRoot, env) {
28380
28387
  return new Promise((resolve$2) => {
28381
28388
  const startTime = Date.now();
28382
28389
  const command = getExecutionCommand(entry.path);
28390
+ const spawnEnv = env != null ? {
28391
+ ...process.env,
28392
+ ...env
28393
+ } : void 0;
28383
28394
  const child = spawn$1(command, [], {
28384
28395
  cwd: projectRoot,
28385
- shell: true
28396
+ shell: true,
28397
+ env: spawnEnv
28386
28398
  });
28387
28399
  let stdoutBuf = "";
28388
28400
  let stderrBuf = "";
@@ -28426,25 +28438,93 @@ function runScenario(entry, projectRoot) {
28426
28438
  });
28427
28439
  }
28428
28440
  /**
28441
+ * Build a ScenarioRunResult where every scenario is marked as failed due to
28442
+ * a twin startup error. No scripts were executed.
28443
+ */
28444
+ function buildStartupFailureResult(scenarioEntries, err) {
28445
+ const scenarios = scenarioEntries.map((entry) => ({
28446
+ name: entry.name,
28447
+ status: "fail",
28448
+ exitCode: -1,
28449
+ stdout: "",
28450
+ stderr: err.message,
28451
+ durationMs: 0
28452
+ }));
28453
+ return {
28454
+ scenarios,
28455
+ summary: {
28456
+ total: scenarios.length,
28457
+ passed: 0,
28458
+ failed: scenarios.length
28459
+ },
28460
+ durationMs: 0
28461
+ };
28462
+ }
28463
+ /**
28429
28464
  * Create a ScenarioRunner that executes all scenarios in a manifest.
28430
28465
  *
28431
- * @param _options - Optional configuration (currently reserved for future use).
28466
+ * @param options - Optional configuration including twin coordinator and timeout.
28432
28467
  */
28433
- function createScenarioRunner(_options) {
28468
+ function createScenarioRunner(options) {
28434
28469
  return { async run(manifest, projectRoot) {
28435
28470
  const startTime = Date.now();
28436
- const scenarios = await Promise.all(manifest.scenarios.map((entry) => runScenario(entry, projectRoot)));
28437
- const passed = scenarios.filter((s$1) => s$1.status === "pass").length;
28438
- const failed = scenarios.filter((s$1) => s$1.status === "fail").length;
28439
- return {
28440
- scenarios,
28441
- summary: {
28442
- total: scenarios.length,
28443
- passed,
28444
- failed
28445
- },
28446
- durationMs: Date.now() - startTime
28447
- };
28471
+ const { twinCoordinator } = options ?? {};
28472
+ const requiresTwins = (manifest.twins?.length ?? 0) > 0 && twinCoordinator != null;
28473
+ if (!requiresTwins) {
28474
+ if (options?.twinHealthMonitor) {
28475
+ const healthStatus = options.twinHealthMonitor.getStatus();
28476
+ const unhealthyNames = Object.entries(healthStatus).filter(([, s$1]) => s$1 === "unhealthy").map(([name]) => name);
28477
+ if (unhealthyNames.length > 0) {
28478
+ const msg = unhealthyNames.map((n$1) => `Twin '${n$1}' is unhealthy`).join("; ");
28479
+ return buildStartupFailureResult(manifest.scenarios, new Error(msg));
28480
+ }
28481
+ }
28482
+ const scenarios = await Promise.all(manifest.scenarios.map((entry) => runScenario(entry, projectRoot)));
28483
+ const passed = scenarios.filter((s$1) => s$1.status === "pass").length;
28484
+ const failed = scenarios.filter((s$1) => s$1.status === "fail").length;
28485
+ return {
28486
+ scenarios,
28487
+ summary: {
28488
+ total: scenarios.length,
28489
+ passed,
28490
+ failed
28491
+ },
28492
+ durationMs: Date.now() - startTime
28493
+ };
28494
+ }
28495
+ let twinEnv;
28496
+ try {
28497
+ twinEnv = await twinCoordinator.startTwins(manifest.twins);
28498
+ } catch (err) {
28499
+ return buildStartupFailureResult(manifest.scenarios, err);
28500
+ }
28501
+ if (options?.twinHealthMonitor) {
28502
+ const healthStatus = options.twinHealthMonitor.getStatus();
28503
+ const unhealthyNames = Object.entries(healthStatus).filter(([, s$1]) => s$1 === "unhealthy").map(([name]) => name);
28504
+ if (unhealthyNames.length > 0) {
28505
+ const msg = unhealthyNames.map((n$1) => `Twin '${n$1}' is unhealthy`).join("; ");
28506
+ try {
28507
+ await twinCoordinator.stopTwins();
28508
+ } catch {}
28509
+ return buildStartupFailureResult(manifest.scenarios, new Error(msg));
28510
+ }
28511
+ }
28512
+ try {
28513
+ const scenarios = await Promise.all(manifest.scenarios.map((entry) => runScenario(entry, projectRoot, twinEnv)));
28514
+ const passed = scenarios.filter((s$1) => s$1.status === "pass").length;
28515
+ const failed = scenarios.filter((s$1) => s$1.status === "fail").length;
28516
+ return {
28517
+ scenarios,
28518
+ summary: {
28519
+ total: scenarios.length,
28520
+ passed,
28521
+ failed
28522
+ },
28523
+ durationMs: Date.now() - startTime
28524
+ };
28525
+ } finally {
28526
+ await twinCoordinator.stopTwins();
28527
+ }
28448
28528
  } };
28449
28529
  }
28450
28530
 
@@ -28487,6 +28567,455 @@ function registerScenariosCommand(program) {
28487
28567
  });
28488
28568
  }
28489
28569
 
28570
+ //#endregion
28571
+ //#region packages/factory/dist/twins/schema.js
28572
+ /**
28573
+ * Validates and transforms a "host:container" port string into a PortMapping object.
28574
+ */
28575
+ const portMappingStringSchema = z.string().regex(/^\d+:\d+$/, "Port mapping must be in \"host:container\" format (e.g., \"5432:5432\")").transform((val) => {
28576
+ const parts = val.split(":");
28577
+ const host = Number(parts[0]);
28578
+ const container = Number(parts[1]);
28579
+ return {
28580
+ host,
28581
+ container
28582
+ };
28583
+ });
28584
+ /**
28585
+ * Validates a healthcheck configuration object.
28586
+ */
28587
+ const twinHealthcheckSchema = z.object({
28588
+ url: z.string().url("Healthcheck URL must be a valid URL"),
28589
+ interval_ms: z.number().int().positive().default(500),
28590
+ timeout_ms: z.number().int().positive().default(1e4)
28591
+ });
28592
+ /**
28593
+ * Validates a full twin definition. Unknown fields are rejected via `.strict()`.
28594
+ */
28595
+ const twinDefinitionSchema = z.object({
28596
+ name: z.string().min(1, "Twin name must not be empty"),
28597
+ image: z.string().min(1, "Twin image must not be empty"),
28598
+ ports: z.array(portMappingStringSchema).default([]),
28599
+ environment: z.record(z.string(), z.string()).default({}),
28600
+ healthcheck: twinHealthcheckSchema.optional()
28601
+ }).strict();
28602
+ const TwinDefinitionSchema = twinDefinitionSchema;
28603
+
28604
+ //#endregion
28605
+ //#region packages/factory/dist/twins/types.js
28606
+ /**
28607
+ * Twin Registry — Type definitions for TwinDefinition and related interfaces.
28608
+ *
28609
+ * Story 47-1.
28610
+ */
28611
+ /**
28612
+ * Thrown when a single twin definition file fails validation or parsing.
28613
+ */
28614
+ var TwinDefinitionError = class extends Error {
28615
+ sourceFile;
28616
+ constructor(message, sourceFile) {
28617
+ super(message);
28618
+ this.sourceFile = sourceFile;
28619
+ this.name = "TwinDefinitionError";
28620
+ }
28621
+ };
28622
+ /**
28623
+ * Thrown when a registry-level constraint is violated (e.g., duplicate twin names).
28624
+ */
28625
+ var TwinRegistryError = class extends Error {
28626
+ constructor(message) {
28627
+ super(message);
28628
+ this.name = "TwinRegistryError";
28629
+ }
28630
+ };
28631
+
28632
+ //#endregion
28633
+ //#region packages/factory/dist/twins/registry.js
28634
+ var TwinRegistry = class {
28635
+ _twins = new Map();
28636
+ /**
28637
+ * Discovers and validates all *.yaml and *.yml twin definition files in the given directory.
28638
+ * Non-recursive — only top-level files are processed.
28639
+ *
28640
+ * @throws {TwinDefinitionError} if a file contains invalid YAML or fails schema validation
28641
+ * @throws {TwinRegistryError} if two files declare the same twin name
28642
+ */
28643
+ async discover(dir) {
28644
+ let entries;
28645
+ try {
28646
+ entries = await readdir$1(dir);
28647
+ } catch (err) {
28648
+ throw new TwinDefinitionError(`Failed to read directory: ${dir} — ${err.message}`);
28649
+ }
28650
+ const yamlFiles = entries.filter((f$1) => f$1.endsWith(".yaml") || f$1.endsWith(".yml"));
28651
+ const perFileErrors = [];
28652
+ for (const filename of yamlFiles) {
28653
+ const filePath = resolve$1(join$1(dir, filename));
28654
+ let raw;
28655
+ try {
28656
+ raw = await readFile$1(filePath, "utf-8");
28657
+ } catch (err) {
28658
+ perFileErrors.push(new TwinDefinitionError(`Failed to read file: ${filePath} — ${err.message}`, filePath));
28659
+ continue;
28660
+ }
28661
+ let parsed;
28662
+ try {
28663
+ parsed = yaml.load(raw);
28664
+ } catch (err) {
28665
+ perFileErrors.push(new TwinDefinitionError(`Twin definition at ${filePath} contains invalid YAML: ${err.message}`, filePath));
28666
+ continue;
28667
+ }
28668
+ const result = TwinDefinitionSchema.safeParse(parsed);
28669
+ if (!result.success) {
28670
+ const firstIssue = result.error.issues[0];
28671
+ const fieldPath = firstIssue?.path?.join(".") ?? "unknown";
28672
+ const fieldMessage = firstIssue?.message ?? result.error.message;
28673
+ let message;
28674
+ const issueCode = firstIssue?.code;
28675
+ if (issueCode === "unrecognized_keys") {
28676
+ const keys = firstIssue.keys ?? [];
28677
+ message = `Twin definition at ${filePath} contains unrecognised field(s): ${keys.join(", ")}`;
28678
+ } else if (issueCode === "unrecognized_key") {
28679
+ const key = firstIssue.key ?? fieldPath;
28680
+ message = `Twin definition at ${filePath} contains unrecognised field(s): ${key}`;
28681
+ } else {
28682
+ const parsedObj = parsed;
28683
+ const isMissingField = parsedObj && typeof parsedObj === "object" && fieldPath !== "unknown" && !(fieldPath in parsedObj);
28684
+ if (isMissingField) message = `Twin definition at ${filePath} is missing required field: ${fieldPath}`;
28685
+ else message = `Twin definition at ${filePath} failed validation — ${fieldPath}: ${fieldMessage}`;
28686
+ }
28687
+ perFileErrors.push(new TwinDefinitionError(message, filePath));
28688
+ continue;
28689
+ }
28690
+ const data = result.data;
28691
+ const twin = {
28692
+ name: data.name,
28693
+ image: data.image,
28694
+ ports: data.ports,
28695
+ environment: data.environment,
28696
+ sourceFile: filePath,
28697
+ ...data.healthcheck !== void 0 && { healthcheck: data.healthcheck }
28698
+ };
28699
+ if (this._twins.has(twin.name)) {
28700
+ const existing = this._twins.get(twin.name);
28701
+ throw new TwinRegistryError(`Duplicate twin name "${twin.name}" found in: ${existing.sourceFile} and ${filePath}`);
28702
+ }
28703
+ this._twins.set(twin.name, twin);
28704
+ }
28705
+ if (perFileErrors.length > 0) throw perFileErrors[0];
28706
+ }
28707
+ /**
28708
+ * Returns all discovered twin definitions.
28709
+ */
28710
+ list() {
28711
+ return Array.from(this._twins.values());
28712
+ }
28713
+ /**
28714
+ * Returns a twin definition by name, or undefined if not found.
28715
+ */
28716
+ get(name) {
28717
+ return this._twins.get(name);
28718
+ }
28719
+ /**
28720
+ * Polls the health endpoint of a twin definition until healthy or timed out.
28721
+ *
28722
+ * @param twin - The twin definition to poll
28723
+ * @param options - Optional overrides (e.g., mock fetch for testing)
28724
+ * @returns HealthPollResult indicating success or timeout
28725
+ */
28726
+ async pollHealth(twin, options) {
28727
+ const { healthcheck } = twin;
28728
+ if (!healthcheck) return {
28729
+ healthy: true,
28730
+ attempts: 0
28731
+ };
28732
+ const fetchFn = options?.fetch ?? globalThis.fetch;
28733
+ const { url, interval_ms = 500, timeout_ms = 1e4 } = healthcheck;
28734
+ const startTime = Date.now();
28735
+ let attempts = 0;
28736
+ while (true) {
28737
+ attempts++;
28738
+ try {
28739
+ const response = await fetchFn(url);
28740
+ if (response.ok) return {
28741
+ healthy: true,
28742
+ attempts
28743
+ };
28744
+ } catch {}
28745
+ const elapsed = Date.now() - startTime;
28746
+ if (elapsed >= timeout_ms) return {
28747
+ healthy: false,
28748
+ error: `Health check timed out after ${timeout_ms}ms`
28749
+ };
28750
+ await new Promise((resolve$2) => setTimeout(resolve$2, interval_ms));
28751
+ if (Date.now() - startTime >= timeout_ms) return {
28752
+ healthy: false,
28753
+ error: `Health check timed out after ${timeout_ms}ms`
28754
+ };
28755
+ }
28756
+ }
28757
+ };
28758
+ /**
28759
+ * Factory function that creates a new TwinRegistry instance.
28760
+ */
28761
+ function createTwinRegistry() {
28762
+ return new TwinRegistry();
28763
+ }
28764
+
28765
+ //#endregion
28766
+ //#region packages/factory/dist/twins/docker-compose.js
28767
+ /**
28768
+ * Thrown when Docker is unavailable or the compose lifecycle fails.
28769
+ */
28770
+ var TwinError = class extends Error {
28771
+ constructor(message) {
28772
+ super(message);
28773
+ this.name = "TwinError";
28774
+ }
28775
+ };
28776
+ /**
28777
+ * Generates a Docker Compose v3.8 YAML string from an array of twin definitions.
28778
+ * Built manually with template strings — no external YAML library required.
28779
+ */
28780
+ function generateComposeYaml(twins) {
28781
+ const lines = [];
28782
+ lines.push("version: '3.8'");
28783
+ lines.push("services:");
28784
+ for (const twin of twins) {
28785
+ lines.push(` ${twin.name}:`);
28786
+ lines.push(` image: ${twin.image}`);
28787
+ if (twin.ports.length > 0) {
28788
+ lines.push(" ports:");
28789
+ for (const port of twin.ports) lines.push(` - "${port.host}:${port.container}"`);
28790
+ }
28791
+ if (twin.environment && Object.keys(twin.environment).length > 0) {
28792
+ lines.push(" environment:");
28793
+ for (const [key, value] of Object.entries(twin.environment)) lines.push(` ${key}: ${value}`);
28794
+ }
28795
+ }
28796
+ return lines.join("\n") + "\n";
28797
+ }
28798
+ /**
28799
+ * Polls a twin's health endpoint until it returns a 2xx response or exhausts attempts.
28800
+ *
28801
+ * Algorithm (from story 47-2 dev notes):
28802
+ * attempts = 0
28803
+ * while attempts < maxAttempts:
28804
+ * try fetch → if ok, return
28805
+ * catch (connection refused) → continue
28806
+ * attempts++
28807
+ * sleep(intervalMs)
28808
+ * throw TwinError
28809
+ */
28810
+ async function pollTwinHealth(twin, maxAttempts, healthIntervalMs) {
28811
+ if (!twin.healthcheck?.url) return;
28812
+ const url = twin.healthcheck.url;
28813
+ const intervalMs = twin.healthcheck.interval_ms ?? healthIntervalMs;
28814
+ let attempts = 0;
28815
+ while (attempts < maxAttempts) {
28816
+ try {
28817
+ const response = await fetch(url);
28818
+ if (response.ok) return;
28819
+ } catch {}
28820
+ attempts++;
28821
+ if (attempts < maxAttempts) await new Promise((resolve$2) => setTimeout(resolve$2, intervalMs));
28822
+ }
28823
+ throw new TwinError(`Twin '${twin.name}' failed health check after ${maxAttempts} attempts`);
28824
+ }
28825
+ /**
28826
+ * Creates a TwinManager that orchestrates Docker Compose for digital twin services.
28827
+ *
28828
+ * The event bus is injected — do NOT import a global singleton or create one internally.
28829
+ *
28830
+ * @param eventBus - Typed event bus for emitting twin lifecycle events
28831
+ * @param options - Optional health check configuration
28832
+ * @returns TwinManager with start() and stop() methods
28833
+ */
28834
+ function createTwinManager(eventBus, options) {
28835
+ const maxHealthAttempts = options?.maxHealthAttempts ?? 30;
28836
+ const healthIntervalMs = options?.healthIntervalMs ?? 1e3;
28837
+ /** Temp directory containing the generated docker-compose.yml. Null when not started. */
28838
+ let composeDir = null;
28839
+ /** Twins that were passed to start() — used by stop() to emit twin:stopped events. */
28840
+ let startedTwins = [];
28841
+ return {
28842
+ async start(twins) {
28843
+ try {
28844
+ execSync("docker info", { stdio: "ignore" });
28845
+ } catch {
28846
+ throw new TwinError("Docker not found — twins require Docker");
28847
+ }
28848
+ const yaml$1 = generateComposeYaml(twins);
28849
+ const dir = join$1(tmpdir(), randomUUID());
28850
+ mkdirSync(dir, { recursive: true });
28851
+ writeFileSync(join$1(dir, "docker-compose.yml"), yaml$1, "utf-8");
28852
+ composeDir = dir;
28853
+ startedTwins = twins;
28854
+ try {
28855
+ execSync("docker compose up -d", {
28856
+ cwd: dir,
28857
+ stdio: "pipe"
28858
+ });
28859
+ } catch (err) {
28860
+ const error = err;
28861
+ const stderr = error.stderr?.toString() ?? "";
28862
+ throw new TwinError(`docker compose up failed: ${stderr}`);
28863
+ }
28864
+ for (const twin of twins) await pollTwinHealth(twin, maxHealthAttempts, healthIntervalMs);
28865
+ for (const twin of twins) eventBus.emit("twin:started", {
28866
+ twinName: twin.name,
28867
+ ports: twin.ports,
28868
+ healthStatus: "healthy"
28869
+ });
28870
+ },
28871
+ async stop() {
28872
+ if (!composeDir) return;
28873
+ const dir = composeDir;
28874
+ try {
28875
+ execSync("docker compose down --remove-orphans", {
28876
+ cwd: dir,
28877
+ stdio: "pipe"
28878
+ });
28879
+ } catch {}
28880
+ rmSync(dir, {
28881
+ recursive: true,
28882
+ force: true
28883
+ });
28884
+ for (const twin of startedTwins) eventBus.emit("twin:stopped", { twinName: twin.name });
28885
+ composeDir = null;
28886
+ startedTwins = [];
28887
+ },
28888
+ getComposeDir() {
28889
+ return composeDir;
28890
+ }
28891
+ };
28892
+ }
28893
+
28894
+ //#endregion
28895
+ //#region packages/factory/dist/twins/templates.js
28896
+ /**
28897
+ * Twin Template Catalog — pre-built twin definition templates for common external services.
28898
+ *
28899
+ * Story 47-4.
28900
+ */
28901
+ const localstackTemplate = {
28902
+ name: "localstack",
28903
+ description: "LocalStack — AWS cloud service emulator (S3, SQS, DynamoDB)",
28904
+ definition: {
28905
+ name: "localstack",
28906
+ image: "localstack/localstack:latest",
28907
+ ports: ["4566:4566"],
28908
+ environment: { SERVICES: "s3,sqs,dynamodb" },
28909
+ healthcheck: {
28910
+ url: "http://localhost:4566/_localstack/health",
28911
+ interval_ms: 500,
28912
+ timeout_ms: 1e4
28913
+ }
28914
+ }
28915
+ };
28916
+ const wiremockTemplate = {
28917
+ name: "wiremock",
28918
+ description: "WireMock — HTTP API mock and stub server",
28919
+ definition: {
28920
+ name: "wiremock",
28921
+ image: "wiremock/wiremock:latest",
28922
+ ports: ["8080:8080"],
28923
+ environment: {},
28924
+ healthcheck: {
28925
+ url: "http://localhost:8080/__admin/health",
28926
+ interval_ms: 500,
28927
+ timeout_ms: 1e4
28928
+ }
28929
+ }
28930
+ };
28931
+ /**
28932
+ * Map of all built-in twin templates, keyed by template name.
28933
+ */
28934
+ const TWIN_TEMPLATES = new Map([[localstackTemplate.name, localstackTemplate], [wiremockTemplate.name, wiremockTemplate]]);
28935
+ /**
28936
+ * Returns all available twin template entries.
28937
+ */
28938
+ function listTwinTemplates() {
28939
+ return Array.from(TWIN_TEMPLATES.values());
28940
+ }
28941
+ /**
28942
+ * Returns the twin template entry for the given name, or `undefined` if not found.
28943
+ */
28944
+ function getTwinTemplate(name) {
28945
+ return TWIN_TEMPLATES.get(name);
28946
+ }
28947
+
28948
+ //#endregion
28949
+ //#region packages/factory/dist/twins/run-state.js
28950
+ /**
28951
+ * Returns the absolute path to the run-state JSON file for the given project.
28952
+ *
28953
+ * @param projectDir - Absolute path to the project root (usually `process.cwd()`)
28954
+ */
28955
+ function runStatePath(projectDir) {
28956
+ return path.join(projectDir, ".substrate", "twins", ".run-state.json");
28957
+ }
28958
+ /**
28959
+ * Reads and JSON-parses the run-state file.
28960
+ *
28961
+ * @returns The parsed `TwinRunState`, or `null` if the file does not exist.
28962
+ * @throws If any I/O error other than ENOENT occurs, or if the JSON is invalid.
28963
+ */
28964
+ async function readRunState(projectDir) {
28965
+ const filePath = runStatePath(projectDir);
28966
+ try {
28967
+ const content = await readFile$1(filePath, "utf-8");
28968
+ return JSON.parse(content);
28969
+ } catch (err) {
28970
+ if (err.code === "ENOENT") return null;
28971
+ throw err;
28972
+ }
28973
+ }
28974
+ /**
28975
+ * Writes the given run state to disk, creating parent directories as needed.
28976
+ *
28977
+ * @param projectDir - Absolute path to the project root
28978
+ * @param state - The `TwinRunState` to persist
28979
+ */
28980
+ async function writeRunState(projectDir, state) {
28981
+ const filePath = runStatePath(projectDir);
28982
+ await mkdir$1(path.dirname(filePath), { recursive: true });
28983
+ await writeFile$1(filePath, JSON.stringify(state, null, 2), "utf-8");
28984
+ }
28985
+ /**
28986
+ * Deletes the run-state file. No-op if the file does not exist.
28987
+ *
28988
+ * @param projectDir - Absolute path to the project root
28989
+ */
28990
+ async function clearRunState(projectDir) {
28991
+ const filePath = runStatePath(projectDir);
28992
+ try {
28993
+ await unlink(filePath);
28994
+ } catch (err) {
28995
+ if (err.code !== "ENOENT") throw err;
28996
+ }
28997
+ }
28998
+
28999
+ //#endregion
29000
+ //#region packages/factory/dist/twins/persistence.js
29001
+ /**
29002
+ * Retrieve all twin run summaries for a given run_id, with health failure counts.
29003
+ *
29004
+ * Uses two portable queries and merges results client-side.
29005
+ *
29006
+ * @returns Array of TwinRunSummary — empty array if no twins found for this run.
29007
+ */
29008
+ async function getTwinRunsForRun(adapter, runId) {
29009
+ const rows = await adapter.query("SELECT * FROM twin_runs WHERE run_id = ?", [runId]);
29010
+ const failureCounts = await adapter.query("SELECT twin_name, COUNT(*) as cnt FROM twin_health_failures WHERE run_id = ? GROUP BY twin_name", [runId]);
29011
+ const failureMap = new Map(failureCounts.map((r) => [r.twin_name, r.cnt]));
29012
+ return rows.map((row) => ({
29013
+ ...row,
29014
+ ports: row.ports_json ? JSON.parse(row.ports_json) : [],
29015
+ health_failure_count: failureMap.get(row.twin_name) ?? 0
29016
+ }));
29017
+ }
29018
+
28490
29019
  //#endregion
28491
29020
  //#region packages/factory/dist/config.js
28492
29021
  /**
@@ -28500,7 +29029,7 @@ const FactoryConfigSchema = z.object({
28500
29029
  scenario_dir: z.string().default(".substrate/scenarios/"),
28501
29030
  satisfaction_threshold: z.number().min(0).max(1).default(.8),
28502
29031
  budget_cap_usd: z.number().min(0).default(0),
28503
- wall_clock_cap_seconds: z.number().min(0).default(0),
29032
+ wall_clock_cap_seconds: z.number().min(0).default(3600),
28504
29033
  plateau_window: z.number().int().min(2).default(3),
28505
29034
  plateau_threshold: z.number().min(0).max(1).default(.05),
28506
29035
  backend: z.enum(["cli", "direct"]).default("cli"),
@@ -28610,6 +29139,27 @@ async function factorySchema(adapter) {
28610
29139
  )
28611
29140
  `);
28612
29141
  await adapter.exec("CREATE INDEX IF NOT EXISTS idx_scenario_results_run ON scenario_results(run_id)");
29142
+ await adapter.exec(`
29143
+ CREATE TABLE IF NOT EXISTS twin_runs (
29144
+ id VARCHAR(255) PRIMARY KEY,
29145
+ run_id VARCHAR(255),
29146
+ twin_name TEXT NOT NULL,
29147
+ started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
29148
+ stopped_at DATETIME,
29149
+ status VARCHAR(32) NOT NULL DEFAULT 'running',
29150
+ ports_json TEXT
29151
+ )
29152
+ `);
29153
+ await adapter.exec(`
29154
+ CREATE TABLE IF NOT EXISTS twin_health_failures (
29155
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
29156
+ twin_name TEXT NOT NULL,
29157
+ run_id VARCHAR(255),
29158
+ checked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
29159
+ error_message TEXT NOT NULL
29160
+ )
29161
+ `);
29162
+ await adapter.exec("CREATE INDEX IF NOT EXISTS idx_twin_health_failures_twin ON twin_health_failures(twin_name)");
28613
29163
  }
28614
29164
 
28615
29165
  //#endregion
@@ -28769,6 +29319,160 @@ function registerFactoryCommand(program) {
28769
29319
  else process.stdout.write(`✗ ${passedCount}/${TOTAL_RULE_COUNT} rules passed, ${errors.length} ${errLabel}, ${warnings.length} ${warnLabel}\n`);
28770
29320
  if (errors.length > 0) process.exit(1);
28771
29321
  });
29322
+ const twinsCmd = factoryCmd.command("twins").description("Digital twin template management");
29323
+ twinsCmd.command("templates").description("List available built-in twin templates").action(() => {
29324
+ const templates = listTwinTemplates();
29325
+ for (const t of templates) process.stdout.write(` ${t.name.padEnd(16)} ${t.description}\n`);
29326
+ });
29327
+ twinsCmd.command("init").description("Initialize a twin definition file from a built-in template").requiredOption("--template <name>", "Template name (e.g. localstack, wiremock)").option("--force", "Overwrite existing file if it already exists").action(async (opts) => {
29328
+ try {
29329
+ const entry = getTwinTemplate(opts.template);
29330
+ if (!entry) {
29331
+ const available = listTwinTemplates().map((t) => t.name).join(", ");
29332
+ process.stderr.write(`Error: Unknown template '${opts.template}'. Available: ${available}\n`);
29333
+ process.exit(1);
29334
+ return;
29335
+ }
29336
+ const targetPath = path.join(process.cwd(), ".substrate", "twins", `${opts.template}.yaml`);
29337
+ if (!opts.force) try {
29338
+ await access$1(targetPath);
29339
+ process.stderr.write(`Error: File already exists: ${targetPath} — use --force to overwrite\n`);
29340
+ process.exit(1);
29341
+ return;
29342
+ } catch {}
29343
+ await mkdir$1(path.dirname(targetPath), { recursive: true });
29344
+ const yamlContent = yaml.dump(entry.definition);
29345
+ await writeFile$1(targetPath, yamlContent, "utf-8");
29346
+ process.stdout.write(`Created ${targetPath}\n`);
29347
+ } catch (err) {
29348
+ const msg = err instanceof Error ? err.message : String(err);
29349
+ process.stderr.write(`Error: ${msg}\n`);
29350
+ process.exit(1);
29351
+ }
29352
+ });
29353
+ twinsCmd.command("start").description("Start all discovered twin definitions via Docker Compose").action(async () => {
29354
+ try {
29355
+ const projectDir = process.cwd();
29356
+ const twinsDir = path.join(projectDir, ".substrate", "twins");
29357
+ const registry = createTwinRegistry();
29358
+ try {
29359
+ await registry.discover(twinsDir);
29360
+ } catch (err) {
29361
+ const msg = err instanceof Error ? err.message : String(err);
29362
+ process.stderr.write(`Error: ${msg}\n`);
29363
+ process.exit(1);
29364
+ return;
29365
+ }
29366
+ const twins = registry.list();
29367
+ if (twins.length === 0) {
29368
+ process.stderr.write("No twin definitions found in .substrate/twins/\n");
29369
+ process.exit(1);
29370
+ return;
29371
+ }
29372
+ const eventBus = new TypedEventBusImpl();
29373
+ eventBus.on("twin:started", (e) => {
29374
+ process.stdout.write(` Started: ${e.twinName}\n`);
29375
+ });
29376
+ const manager = createTwinManager(eventBus);
29377
+ try {
29378
+ await manager.start(twins);
29379
+ } catch (err) {
29380
+ const msg = err instanceof Error ? err.message : String(err);
29381
+ process.stderr.write(`Error: ${msg}\n`);
29382
+ process.exit(1);
29383
+ return;
29384
+ }
29385
+ const composeDir = manager.getComposeDir();
29386
+ await writeRunState(projectDir, {
29387
+ composeDir,
29388
+ twinNames: twins.map((t) => t.name),
29389
+ startedAt: new Date().toISOString()
29390
+ });
29391
+ process.stdout.write("\nAll twins started successfully.\n");
29392
+ } catch (err) {
29393
+ const msg = err instanceof Error ? err.message : String(err);
29394
+ process.stderr.write(`Error: ${msg}\n`);
29395
+ process.exit(1);
29396
+ }
29397
+ });
29398
+ twinsCmd.command("stop").description("Stop all running twins").action(async () => {
29399
+ try {
29400
+ const projectDir = process.cwd();
29401
+ const state = await readRunState(projectDir);
29402
+ if (!state) {
29403
+ process.stderr.write("No twins are currently running\n");
29404
+ process.exit(1);
29405
+ return;
29406
+ }
29407
+ try {
29408
+ execSync("docker compose down --remove-orphans", {
29409
+ cwd: state.composeDir,
29410
+ stdio: "pipe"
29411
+ });
29412
+ } catch {}
29413
+ rmSync(state.composeDir, {
29414
+ recursive: true,
29415
+ force: true
29416
+ });
29417
+ await clearRunState(projectDir);
29418
+ process.stdout.write(`Stopped twins: ${state.twinNames.join(", ")}\n`);
29419
+ } catch (err) {
29420
+ const msg = err instanceof Error ? err.message : String(err);
29421
+ process.stderr.write(`Error: ${msg}\n`);
29422
+ process.exit(1);
29423
+ }
29424
+ });
29425
+ twinsCmd.command("status").description("Show status of all discovered twins").action(async () => {
29426
+ try {
29427
+ const projectDir = process.cwd();
29428
+ const state = await readRunState(projectDir);
29429
+ const runningNames = new Set(state?.twinNames ?? []);
29430
+ const registry = createTwinRegistry();
29431
+ let twins = [];
29432
+ try {
29433
+ await registry.discover(path.join(projectDir, ".substrate", "twins"));
29434
+ twins = registry.list();
29435
+ } catch {}
29436
+ if (twins.length === 0) {
29437
+ process.stdout.write("No twin definitions found in .substrate/twins/\n");
29438
+ return;
29439
+ }
29440
+ for (const twin of twins) {
29441
+ const status = runningNames.has(twin.name) ? "running" : "stopped";
29442
+ const portsStr = twin.ports.length > 0 ? twin.ports.map((p) => `${p.host}:${p.container}`).join(", ") : "—";
29443
+ process.stdout.write(` ${twin.name.padEnd(20)} ${status.padEnd(10)} ${portsStr}\n`);
29444
+ }
29445
+ } catch (err) {
29446
+ const msg = err instanceof Error ? err.message : String(err);
29447
+ process.stderr.write(`Error: ${msg}\n`);
29448
+ process.exit(1);
29449
+ }
29450
+ });
29451
+ twinsCmd.command("list").description("List all discovered twin definitions").action(async () => {
29452
+ try {
29453
+ const projectDir = process.cwd();
29454
+ const registry = createTwinRegistry();
29455
+ let twins = [];
29456
+ try {
29457
+ await registry.discover(path.join(projectDir, ".substrate", "twins"));
29458
+ twins = registry.list();
29459
+ } catch {}
29460
+ if (twins.length === 0) {
29461
+ process.stdout.write("No twin definitions found in .substrate/twins/\n");
29462
+ return;
29463
+ }
29464
+ process.stdout.write(" NAME IMAGE PORTS HEALTHCHECK\n");
29465
+ for (const twin of twins) {
29466
+ const ports = twin.ports.length > 0 ? twin.ports.map((p) => `${p.host}:${p.container}`).join(", ") : "—";
29467
+ const healthcheck = twin.healthcheck?.url ?? "—";
29468
+ process.stdout.write(` ${twin.name.padEnd(20)} ${twin.image.padEnd(38)} ${ports.padEnd(16)} ${healthcheck}\n`);
29469
+ }
29470
+ } catch (err) {
29471
+ const msg = err instanceof Error ? err.message : String(err);
29472
+ process.stderr.write(`Error: ${msg}\n`);
29473
+ process.exit(1);
29474
+ }
29475
+ });
28772
29476
  }
28773
29477
 
28774
29478
  //#endregion
@@ -30243,5 +30947,5 @@ function registerRunCommand(program, _version = "0.0.0", projectRoot = process.c
30243
30947
  }
30244
30948
 
30245
30949
  //#endregion
30246
- export { AdapterTelemetryPersistence, AppError, DoltRepoMapMetaRepository, DoltSymbolRepository, ERR_REPO_MAP_STORAGE_WRITE, GitClient, GrammarLoader, RepoMapInjector, RepoMapModule, RepoMapQueryEngine, RepoMapStorage, SymbolParser, createContextCompiler, createDispatcher, createEventEmitter, createImplementationOrchestrator, createPackLoader, createPhaseOrchestrator, createStopAfterGate, createTelemetryAdvisor, formatPhaseCompletionSummary, getFactoryRunSummaries, getScenarioResultsForRun, listGraphRuns, normalizeGraphSummaryToStatus, registerFactoryCommand, registerRunCommand, registerScenariosCommand, resolveStoryKeys, runAnalysisPhase, runPlanningPhase, runRunAction, runSolutioningPhase, validateStopAfterFromConflict };
30247
- //# sourceMappingURL=run-bhGoAbu9.js.map
30950
+ export { AdapterTelemetryPersistence, AppError, DoltRepoMapMetaRepository, DoltSymbolRepository, ERR_REPO_MAP_STORAGE_WRITE, GitClient, GrammarLoader, RepoMapInjector, RepoMapModule, RepoMapQueryEngine, RepoMapStorage, SymbolParser, createContextCompiler, createDispatcher, createEventEmitter, createImplementationOrchestrator, createPackLoader, createPhaseOrchestrator, createStopAfterGate, createTelemetryAdvisor, formatPhaseCompletionSummary, getFactoryRunSummaries, getScenarioResultsForRun, getTwinRunsForRun, listGraphRuns, normalizeGraphSummaryToStatus, registerFactoryCommand, registerRunCommand, registerScenariosCommand, resolveStoryKeys, runAnalysisPhase, runPlanningPhase, runRunAction, runSolutioningPhase, validateStopAfterFromConflict };
30951
+ //# sourceMappingURL=run-z84Uu_U2.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "substrate-ai",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Substrate — multi-agent orchestration daemon for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "MIT",