@devrouter/cli 0.0.62 → 0.0.63

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.
@@ -978,6 +978,33 @@ function inspectManagedStopWorkspaceIds(endpoint, composeDirectory) {
978
978
  assertUniqueContainerIds(ids);
979
979
  return ids;
980
980
  }
981
+ function hasExactManagedStopMissingObjectError(stderr, id2) {
982
+ if (typeof stderr !== "string") return false;
983
+ const line = stderr.endsWith("\r\n") ? stderr.slice(0, -2) : stderr.endsWith("\n") ? stderr.slice(0, -1) : stderr;
984
+ return line.toLowerCase() === `error: no such object: ${id2}`;
985
+ }
986
+ function assertManagedStopContainersAbsent(endpoint, ids) {
987
+ assertManagedStopEndpoint(endpoint);
988
+ if (!Array.isArray(ids) || ids.length === 0 || ids.length > 256) {
989
+ throw new Error("Managed stop absence proof requires between 1 and 256 container ids.");
990
+ }
991
+ ids.forEach(assertFullContainerId);
992
+ assertUniqueContainerIds(ids);
993
+ const env = { ...process.env };
994
+ delete env.DOCKER_CONTEXT;
995
+ delete env.DOCKER_HOST;
996
+ for (const id2 of ids) {
997
+ const result = (0, import_node_child_process3.spawnSync)("docker", ["--host", endpoint, "inspect", "--format", "{{.Id}}", id2], {
998
+ env,
999
+ encoding: "utf-8",
1000
+ timeout: MANAGED_STOP_DOCKER_TIMEOUT_MS,
1001
+ maxBuffer: MANAGED_STOP_DOCKER_MAX_BUFFER
1002
+ });
1003
+ if (result.status !== 1 || result.error || result.signal || result.stdout !== "" && result.stdout !== "\n" && result.stdout !== "\r\n" || !hasExactManagedStopMissingObjectError(result.stderr, id2)) {
1004
+ throw new Error("Managed stop Docker inspection did not prove container absence.");
1005
+ }
1006
+ }
1007
+ }
981
1008
  function stopPinnedManagedContainer(endpoint, containerId) {
982
1009
  assertFullContainerId(containerId);
983
1010
  runManagedStopDocker(["stop", containerId], endpoint, 3e4);
@@ -988,11 +1015,13 @@ var import_node_child_process8 = require("child_process");
988
1015
 
989
1016
  // src/core/devpod-registry.ts
990
1017
  var import_node_child_process4 = require("child_process");
991
- function listDevpodWorkspacesRaw() {
1018
+ function listDevpodWorkspacesRaw(options = {}) {
992
1019
  const result = (0, import_node_child_process4.spawnSync)("devpod", ["list", "--output", "json", "--skip-pro"], {
993
1020
  encoding: "utf-8"
994
1021
  });
995
- if (result.status !== 0) {
1022
+ if (options.allowMissingExecutable && result.error?.code === "ENOENT")
1023
+ return [];
1024
+ if (result.error || result.status !== 0) {
996
1025
  const details = [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
997
1026
  throw new Error(`devpod list failed: ${details || "devpod is not installed or unavailable"}`);
998
1027
  }
@@ -3240,7 +3269,56 @@ function proveRetainedManagedStop(state) {
3240
3269
  }
3241
3270
  return result;
3242
3271
  }
3272
+ function absentRegistrationIdentity(state) {
3273
+ resetWorkspaceRuntimeCaches();
3274
+ if (resolveWorkspaceRuntimeOrDefault(state.repoPath) !== "devsy")
3275
+ throw new Error("Stop provider changed.");
3276
+ if (!state.workspace || !isLinkedWorktree(state.repoPath))
3277
+ throw new Error("Absent stop requires a linked workspace.");
3278
+ const record4 = readWorkspaceOwnership(state.repoPath, state.workspace);
3279
+ if (!record4 || record4.workspace !== state.workspace || record4.devpodId !== state.devpodId || !sameWorkspacePath(record4.worktreePath, state.repoPath) || resolveWorktreeWorkspace(state.repoPath) !== state.workspace || inspectWorkspaceOwnership(record4, listGitWorktrees(state.repoPath), void 0).ownerStatus !== "present")
3280
+ throw new Error("Absent stop workspace ownership changed.");
3281
+ for (const entries of [
3282
+ listDevsyWorkspaces(),
3283
+ listDevpodWorkspacesRaw({ allowMissingExecutable: true })
3284
+ ]) {
3285
+ if (entries.some(
3286
+ (entry) => entry.id === state.devpodId || sameWorkspacePath(entry.source.localFolder, state.repoPath)
3287
+ ))
3288
+ throw new Error("Absent stop requires both provider registrations to remain absent.");
3289
+ }
3290
+ return { record: record4, gitCommonDir: resolveGitCommonDir(state.repoPath) };
3291
+ }
3292
+ function proveManagedStop(state) {
3293
+ const baseline = validateManagedStopBaseline(state.stopBaseline, state);
3294
+ const owner = inspectDevsyWorkspaceOwnership(
3295
+ listDevsyWorkspaces(),
3296
+ state.devpodId,
3297
+ state.repoPath
3298
+ );
3299
+ if (owner.status !== "absent")
3300
+ return { status: "retained", containers: proveRetainedManagedStop(state) };
3301
+ if (baseline.sourceContainer) throw new Error("Absent stop cannot prove a source container.");
3302
+ const before = absentRegistrationIdentity(state);
3303
+ if (!(0, import_node_util2.isDeepStrictEqual)(readManagedRuntimeState(state.repoPath, state.workspace), state))
3304
+ throw new Error("Retained stop generation changed.");
3305
+ if (inspectManagedStopDaemon(baseline.endpoint) !== baseline.daemonId)
3306
+ throw new Error("Stop daemon changed.");
3307
+ assertManagedStopContainersAbsent(
3308
+ baseline.endpoint,
3309
+ baseline.containers.map((container) => container.id)
3310
+ );
3311
+ const uidBytes = Buffer.byteLength(baseline.uid);
3312
+ const runnerId = uidBytes === 16 || uidBytes === 40 ? baseline.uid : baseline.providerId;
3313
+ if (inspectManagedStopContainers(baseline.project, baseline.endpoint).length !== 0 || inspectManagedStopWorkspaceIds(baseline.endpoint, baseline.composeDirectory).length !== 0 || inspectProviderRunnerContainers(baseline.endpoint, runnerId).length !== 0)
3314
+ throw new Error("Absent stop observed a remaining workspace population.");
3315
+ if (inspectManagedStopDaemon(baseline.endpoint) !== baseline.daemonId || !(0, import_node_util2.isDeepStrictEqual)(absentRegistrationIdentity(state), before) || !(0, import_node_util2.isDeepStrictEqual)(readManagedRuntimeState(state.repoPath, state.workspace), state))
3316
+ throw new Error("Absent stop authority changed during inspection.");
3317
+ return { status: "proven-absent", containers: [] };
3318
+ }
3243
3319
  function stopFromManagedBaseline(state) {
3320
+ const proof = proveManagedStop(state);
3321
+ if (proof.status === "proven-absent") return proof.status;
3244
3322
  const baseline = validateManagedStopBaseline(state.stopBaseline, state);
3245
3323
  const stopped = /* @__PURE__ */ new Set();
3246
3324
  for (const initial of proveRetainedManagedStop(state))
@@ -3263,6 +3341,12 @@ function stopFromManagedBaseline(state) {
3263
3341
  }
3264
3342
  if (inspect().some((container) => container.state.Running))
3265
3343
  throw new Error("Retained workloads remain running.");
3344
+ return "retained";
3345
+ }
3346
+ function managedStopRouteReferences(state) {
3347
+ return state.desired.apps.flatMap(
3348
+ (name) => ["http", "tcp"].map((protocol) => ({ repoPath: state.repoPath, name, protocol }))
3349
+ );
3266
3350
  }
3267
3351
 
3268
3352
  // src/core/repo-config.ts
@@ -4621,7 +4705,7 @@ function loadRepoConfig(repoPath, read = (file) => import_node_fs14.default.read
4621
4705
  const config = parseConfig(parsed ?? {}, configPath);
4622
4706
  const requiredVersion = config.devrouter?.version;
4623
4707
  if (requiredVersion && !hasWarnedVersionMismatch) {
4624
- const cliVersion = true ? "0.0.62" : "0.0.0-dev";
4708
+ const cliVersion = true ? "0.0.63" : "0.0.0-dev";
4625
4709
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
4626
4710
  hasWarnedVersionMismatch = true;
4627
4711
  process.stderr.write(
@@ -5157,8 +5241,7 @@ function stopRetainedManagedDevsyWorkspace(options) {
5157
5241
  throw new Error("Managed stop requires the exact retained workspace identity.");
5158
5242
  }
5159
5243
  if (state.stopBaseline) {
5160
- stopFromManagedBaseline(state);
5161
- return true;
5244
+ return stopFromManagedBaseline(state) === "proven-absent" ? "proven-absent" : true;
5162
5245
  }
5163
5246
  const workspaceEnv = workspace ? { token: workspace, gitCommonDir: resolveGitCommonDir(repoPath) } : void 0;
5164
5247
  function registration2() {
@@ -5386,12 +5469,14 @@ function inspectExactOwnership(devsyId, worktreePath) {
5386
5469
  }
5387
5470
  function mutateOwnedDevsyWorkspace(action, devsyId, worktreePath) {
5388
5471
  return withMutationLock(`Devsy ${action}`, worktreePath, () => {
5389
- if (action === "stop" && stopRetainedManagedDevsyWorkspace({
5390
- repoPath: worktreePath,
5391
- devsyId,
5392
- stopProvider: () => runDevsyAction("stop", devsyId)
5393
- }))
5394
- return { status: "changed" };
5472
+ if (action === "stop") {
5473
+ const stopped = stopRetainedManagedDevsyWorkspace({
5474
+ repoPath: worktreePath,
5475
+ devsyId,
5476
+ stopProvider: () => runDevsyAction("stop", devsyId)
5477
+ });
5478
+ if (stopped) return { status: stopped === "proven-absent" ? "proven-absent" : "changed" };
5479
+ }
5395
5480
  const before = inspectExactOwnership(devsyId, worktreePath);
5396
5481
  if (before.status === "absent") return { status: "absent" };
5397
5482
  runDevsyAction(action, devsyId);
@@ -6206,7 +6291,7 @@ function applyReliabilityEvent(state, event, nowMs) {
6206
6291
  case "not-started":
6207
6292
  if (next.executionPolicy !== "manual" || next.operation?.kind !== "exec")
6208
6293
  return unchanged(next, "blocked");
6209
- if (next.operation.id !== event.operationId || next.operation.status !== "RUNNING")
6294
+ if (next.operation.id !== event.operationId || !["RUNNING", "DISPATCH_RECORDED"].includes(next.operation.status))
6210
6295
  return unchanged(next, "stale");
6211
6296
  next.operation = { ...next.operation, status: "NOT_LAUNCHED", exitCode: null };
6212
6297
  return transition(next, "accepted");
@@ -6427,222 +6512,546 @@ function workerGroupAbsent(pid) {
6427
6512
  }
6428
6513
  }
6429
6514
 
6430
- // src/core/workspace-ensure.ts
6431
- var import_node_child_process21 = require("child_process");
6432
- var import_node_fs23 = __toESM(require("fs"));
6433
- var import_node_path27 = __toESM(require("path"));
6434
-
6435
- // src/core/devpod-mutation.ts
6515
+ // src/core/traefik-route-health.ts
6436
6516
  var import_node_child_process16 = require("child_process");
6437
6517
  var import_node_fs19 = __toESM(require("fs"));
6438
6518
  var import_node_path24 = __toESM(require("path"));
6439
- var DEVPOD_MUTATION_LOCK_FILE = import_node_path24.default.join(DEVROUTER_HOME, "devpod-mutation.lock");
6440
- var DEVPOD_MUTATION_WAIT_MS = 18e5;
6441
- var DevpodStartPostconditionError = class extends Error {
6442
- };
6443
- function withMutationLock2(activity, target, operation) {
6444
- import_node_fs19.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
6445
- return withFileLockSync(
6446
- DEVPOD_MUTATION_LOCK_FILE,
6447
- {
6448
- activity,
6449
- target: `'${target}'`,
6450
- waitMs: DEVPOD_MUTATION_WAIT_MS,
6451
- fair: true,
6452
- onWait: createStderrWaitReporter(activity, `'${target}'`)
6453
- },
6454
- operation
6455
- );
6456
- }
6457
- function commandFailure2(result) {
6458
- return [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
6519
+ var ROUTER_API_BASE = "http://127.0.0.1:8080/api";
6520
+ var ROUTER_RELOAD_LOCK_FILE = import_node_path24.default.join(DEVROUTER_HOME, "router-reload.lock");
6521
+ var ROUTER_API_REQUEST_TIMEOUT_SECONDS = "2";
6522
+ var DEFAULT_INITIAL_TIMEOUT_MS = 3e3;
6523
+ var DEFAULT_RECOVERY_TIMEOUT_MS = 1e4;
6524
+ var DEFAULT_POLL_INTERVAL_MS = 250;
6525
+ var ROUTER_API_PAGE_SIZE = 1e3;
6526
+ function sleep(ms) {
6527
+ return new Promise((resolve) => setTimeout(resolve, ms));
6459
6528
  }
6460
- function runDevpodAction(action, devpodId, force = false) {
6461
- const args = action === "delete" ? [action, devpodId, ...force ? ["--force"] : [], "--ignore-not-found"] : [action, devpodId];
6462
- claimLifecycleEffect();
6463
- const result = (0, import_node_child_process16.spawnSync)("devpod", args, { encoding: "utf-8" });
6529
+ function inspectRouterApi(protocol) {
6530
+ const result = (0, import_node_child_process16.spawnSync)(
6531
+ "curl",
6532
+ [
6533
+ "--silent",
6534
+ "--show-error",
6535
+ "--fail",
6536
+ "--max-time",
6537
+ ROUTER_API_REQUEST_TIMEOUT_SECONDS,
6538
+ `${ROUTER_API_BASE}/${protocol}/routers?per_page=${ROUTER_API_PAGE_SIZE}`
6539
+ ],
6540
+ { encoding: "utf-8" }
6541
+ );
6464
6542
  if (result.status !== 0) {
6465
- throw new Error(
6466
- `devpod ${action}${force ? " --force" : ""} failed for '${devpodId}': ${commandFailure2(result) || "unknown error"}`
6467
- );
6543
+ return {
6544
+ ok: false,
6545
+ details: result.stderr?.trim() || `curl exited with status ${result.status ?? "unknown"}`
6546
+ };
6468
6547
  }
6548
+ let value;
6549
+ try {
6550
+ value = JSON.parse(result.stdout);
6551
+ } catch {
6552
+ return { ok: false, details: `${protocol.toUpperCase()} router API returned invalid JSON` };
6553
+ }
6554
+ if (!Array.isArray(value)) {
6555
+ return { ok: false, details: `${protocol.toUpperCase()} router API returned a non-array` };
6556
+ }
6557
+ if (value.some((item) => !isRecord3(item) || typeof item.name !== "string" || !item.name))
6558
+ return {
6559
+ ok: false,
6560
+ details: `${protocol.toUpperCase()} router API returned malformed entries`
6561
+ };
6562
+ return {
6563
+ ok: true,
6564
+ reachedPageLimit: value.length >= ROUTER_API_PAGE_SIZE,
6565
+ names: new Set(
6566
+ value.flatMap((item) => {
6567
+ if (!item || typeof item !== "object") return [];
6568
+ const name = item.name;
6569
+ return typeof name === "string" ? [name] : [];
6570
+ })
6571
+ )
6572
+ };
6469
6573
  }
6470
- function inspectExactOwnership2(devpodId, worktreePath) {
6471
- const ownership = inspectDevpodWorkspaceOwnership(
6472
- listDevpodWorkspaces(worktreePath),
6473
- devpodId,
6474
- worktreePath
6475
- );
6476
- if (ownership.status === "conflict") throw new Error(ownership.reason);
6477
- return ownership;
6478
- }
6479
- function mutateOwnedDevpodWorkspace(action, devpodId, worktreePath) {
6480
- return withMutationLock2(`DevPod ${action}`, worktreePath, () => {
6481
- const before = inspectExactOwnership2(devpodId, worktreePath);
6482
- if (before.status === "absent") return { status: "absent" };
6483
- runDevpodAction(action, devpodId);
6484
- let after = inspectExactOwnership2(devpodId, worktreePath);
6485
- if (action === "stop" && after.status !== "owned") {
6486
- throw new Error(`DevPod '${devpodId}' no longer owns '${worktreePath}' after provider stop.`);
6487
- }
6488
- if (action === "delete" && after.status === "owned") {
6489
- const runtime = inspectDevpodRuntimeStatus(devpodId, worktreePath);
6490
- if (runtime !== "not-found") {
6491
- throw new Error(
6492
- `DevPod '${devpodId}' still owns '${worktreePath}' after provider delete (runtime=${runtime}).`
6493
- );
6494
- }
6495
- after = inspectExactOwnership2(devpodId, worktreePath);
6496
- if (after.status === "owned") {
6497
- runDevpodAction("delete", devpodId, true);
6498
- after = inspectExactOwnership2(devpodId, worktreePath);
6499
- }
6500
- if (after.status !== "absent") {
6501
- throw new Error(
6502
- `DevPod '${devpodId}' still owns '${worktreePath}' after forced provider delete.`
6503
- );
6504
- }
6505
- }
6506
- return { status: "changed" };
6507
- });
6574
+ function isRecord3(value) {
6575
+ return value !== null && typeof value === "object" && !Array.isArray(value);
6508
6576
  }
6509
- function stopOwnedDevpodWorkspace(devpodId, worktreePath) {
6577
+ function inspectNamedRouterApi(protocol, resource, name) {
6578
+ const result = (0, import_node_child_process16.spawnSync)(
6579
+ "curl",
6580
+ [
6581
+ "--silent",
6582
+ "--show-error",
6583
+ "--fail",
6584
+ "--max-time",
6585
+ ROUTER_API_REQUEST_TIMEOUT_SECONDS,
6586
+ `${ROUTER_API_BASE}/${protocol}/${resource}/${encodeURIComponent(name)}`
6587
+ ],
6588
+ { encoding: "utf-8" }
6589
+ );
6590
+ if (result.status !== 0) {
6591
+ return {
6592
+ ok: false,
6593
+ details: result.stderr?.trim() || `curl exited with status ${result.status ?? "unknown"}`
6594
+ };
6595
+ }
6596
+ let value;
6510
6597
  try {
6511
- resetWorkspaceRuntimeCaches();
6512
- if (resolveWorkspaceRuntimeOrDefault(worktreePath) === "devsy") {
6513
- return stopOwnedDevsyWorkspace(devpodId, worktreePath);
6514
- }
6515
- return mutateOwnedDevpodWorkspace("stop", devpodId, worktreePath);
6516
- } finally {
6517
- resetWorkspaceRuntimeCaches();
6598
+ value = JSON.parse(result.stdout);
6599
+ } catch {
6600
+ return {
6601
+ ok: false,
6602
+ details: `${protocol.toUpperCase()} ${resource.slice(0, -1)} API returned invalid JSON`
6603
+ };
6604
+ }
6605
+ if (!isRecord3(value)) {
6606
+ return {
6607
+ ok: false,
6608
+ details: `${protocol.toUpperCase()} ${resource.slice(0, -1)} API returned a non-object`
6609
+ };
6518
6610
  }
6611
+ return { ok: true, value };
6519
6612
  }
6520
- function deleteOwnedDevpodWorkspace(devpodId, worktreePath) {
6521
- if (resolveWorkspaceRuntimeOrDefault(worktreePath) === "devsy") {
6522
- const result2 = deleteOwnedDevsyWorkspace(devpodId, worktreePath);
6523
- resetWorkspaceRuntimeCaches();
6524
- return result2;
6613
+ function renderExpectedRoute(route) {
6614
+ const protocol = route.protocol ?? "http";
6615
+ const routerName = buildHostRouteRouterName(route.repoPath, route.name);
6616
+ const routeState = {
6617
+ id: buildHostRouteId(route.repoPath, route.name),
6618
+ name: route.name,
6619
+ host: route.host,
6620
+ protocol,
6621
+ tcpProtocol: route.tcpProtocol,
6622
+ repoPath: route.repoPath,
6623
+ port: route.port,
6624
+ mode: route.mode,
6625
+ upstreamHost: route.upstreamHost,
6626
+ pid: route.pid,
6627
+ command: route.command,
6628
+ workspace: route.workspace,
6629
+ createdAt: "",
6630
+ updatedAt: ""
6631
+ };
6632
+ const document = buildHostRoutesDocument([routeState], false);
6633
+ const protocolDocument = document[protocol];
6634
+ if (!isRecord3(protocolDocument)) return void 0;
6635
+ const routers = protocolDocument.routers;
6636
+ const services = protocolDocument.services;
6637
+ if (!isRecord3(routers) || !isRecord3(services)) return void 0;
6638
+ const routerKey = routerName.slice(0, routerName.lastIndexOf("@"));
6639
+ const router = routers[routerKey];
6640
+ const service = services[routerKey];
6641
+ if (!isRecord3(router) || !isRecord3(service)) return void 0;
6642
+ const rule = router.rule;
6643
+ const serviceReference = router.service;
6644
+ const loadBalancer = service.loadBalancer;
6645
+ const servers = isRecord3(loadBalancer) ? loadBalancer.servers : void 0;
6646
+ if (typeof rule !== "string" || serviceReference !== routerKey || !isRecord3(loadBalancer) || !Array.isArray(servers) || servers.length !== 1 || !isRecord3(servers[0])) {
6647
+ return void 0;
6525
6648
  }
6526
- const result = mutateOwnedDevpodWorkspace("delete", devpodId, worktreePath);
6527
- resetWorkspaceRuntimeCaches();
6528
- return result;
6649
+ const serverField = protocol === "http" ? "url" : "address";
6650
+ const server = servers[0][serverField];
6651
+ if (typeof server !== "string") return void 0;
6652
+ const provider = routerName.slice(routerName.lastIndexOf("@")).trim();
6653
+ if (!provider.startsWith("@")) return void 0;
6654
+ return {
6655
+ protocol,
6656
+ routerName,
6657
+ serviceKey: routerKey,
6658
+ serviceName: `${routerKey}${provider}`,
6659
+ rule,
6660
+ serverField,
6661
+ server
6662
+ };
6529
6663
  }
6530
- async function startDevpodWorkspace(options) {
6531
- if (resolveWorkspaceRuntimeOrDefault(options.repoPath) === "devsy") {
6532
- try {
6533
- const result = await startDevsyWorkspace({
6534
- repoPath: options.repoPath,
6535
- devsyId: options.devpodId,
6536
- devcontainerPath: options.devcontainerPath,
6537
- recreate: options.recreate,
6538
- quiet: options.quiet,
6539
- workspace: options.workspace,
6540
- inactivityTimeout: readWorkspaceRuntimeConfig().devsyInactivityTimeout
6541
- });
6542
- resetWorkspaceRuntimeCaches();
6543
- return result;
6544
- } catch (error) {
6545
- if (error instanceof DevsyStartPostconditionError) {
6546
- resetWorkspaceRuntimeCaches();
6547
- throw new DevpodStartPostconditionError(error.message);
6548
- }
6549
- throw error;
6664
+ function inspectExpectedRouteMatch(route) {
6665
+ const expected = renderExpectedRoute(route);
6666
+ const routeName = buildHostRouteRouterName(route.repoPath, route.name);
6667
+ if (!expected) {
6668
+ return {
6669
+ ok: false,
6670
+ routes: [routeName],
6671
+ details: "host-route renderer returned an incomplete route configuration"
6672
+ };
6673
+ }
6674
+ const routerResult = inspectNamedRouterApi(expected.protocol, "routers", expected.routerName);
6675
+ if (!routerResult.ok) {
6676
+ return { ok: false, routes: [routeName], details: routerResult.details };
6677
+ }
6678
+ if (routerResult.value.status !== "enabled" || routerResult.value.name !== expected.routerName || routerResult.value.rule !== expected.rule || routerResult.value.service !== expected.serviceKey && routerResult.value.service !== expected.serviceName) {
6679
+ return {
6680
+ ok: false,
6681
+ routes: [routeName],
6682
+ details: "active Traefik router does not match the rendered route"
6683
+ };
6684
+ }
6685
+ const serviceResult = inspectNamedRouterApi(expected.protocol, "services", expected.serviceName);
6686
+ if (!serviceResult.ok) {
6687
+ return { ok: false, routes: [routeName], details: serviceResult.details };
6688
+ }
6689
+ const loadBalancer = serviceResult.value.loadBalancer;
6690
+ const servers = isRecord3(loadBalancer) ? loadBalancer.servers : void 0;
6691
+ const server = Array.isArray(servers) && servers.length === 1 ? servers[0] : void 0;
6692
+ if (serviceResult.value.status !== "enabled" || serviceResult.value.name !== expected.serviceName || !isRecord3(loadBalancer) || !Array.isArray(servers) || servers.length !== 1 || !isRecord3(server) || server[expected.serverField] !== expected.server) {
6693
+ return {
6694
+ ok: false,
6695
+ routes: [routeName],
6696
+ details: "active Traefik service does not match the rendered route"
6697
+ };
6698
+ }
6699
+ return { ok: true };
6700
+ }
6701
+ function inspectExpectedRouteMatches(routes) {
6702
+ const mismatched = [];
6703
+ const failures = [];
6704
+ for (const route of routes) {
6705
+ const result = inspectExpectedRouteMatch(route);
6706
+ if (!result.ok) {
6707
+ mismatched.push(...result.routes);
6708
+ failures.push(result.details);
6550
6709
  }
6551
6710
  }
6552
- const activity = options.recreate ? "DevPod recreate" : "DevPod start";
6553
- return withMutationLock2(activity, options.repoPath, () => {
6554
- const workspaces = listDevpodWorkspaces(options.repoPath);
6555
- let devpodId = options.devpodId ?? selectDevpodWorkspace(workspaces, options.repoPath)?.id;
6556
- if (devpodId) {
6557
- const before = inspectDevpodWorkspaceOwnership(workspaces, devpodId, options.repoPath);
6558
- if (before.status === "conflict") throw new Error(before.reason);
6559
- if (options.recreate && before.status !== "owned") {
6560
- throw new Error(`Cannot recreate DevPod '${devpodId}' without one exact owner.`);
6561
- }
6562
- } else if (options.recreate) {
6563
- throw new Error("Cannot recreate a DevPod before its exact id is known.");
6711
+ return mismatched.length === 0 ? { ok: true } : {
6712
+ ok: false,
6713
+ routes: mismatched,
6714
+ details: failures.join("; ")
6715
+ };
6716
+ }
6717
+ async function waitForExpectedRouteMatches(routes, timeoutMs, pollIntervalMs) {
6718
+ const deadline = Date.now() + timeoutMs;
6719
+ let result;
6720
+ do {
6721
+ result = inspectExpectedRouteMatches(routes);
6722
+ if (result.ok) return result;
6723
+ if (Date.now() < deadline) {
6724
+ await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
6564
6725
  }
6565
- const args = ["up", options.repoPath];
6566
- if (devpodId) args.push("--id", devpodId);
6567
- if (options.devcontainerPath) {
6568
- args.push("--devcontainer-path", options.devcontainerPath);
6726
+ } while (Date.now() < deadline);
6727
+ return result;
6728
+ }
6729
+ function inspectExpectedRoutes(routes, expectation) {
6730
+ const mismatched = [];
6731
+ const failures = [];
6732
+ for (const protocol of ["http", "tcp"]) {
6733
+ const expected = routes.filter((route) => (route.protocol ?? "http") === protocol).map((route) => buildHostRouteRouterName(route.repoPath, route.name));
6734
+ if (expected.length === 0) continue;
6735
+ const result = inspectRouterApi(protocol);
6736
+ if (!result.ok) {
6737
+ failures.push(result.details);
6738
+ mismatched.push(...expected);
6739
+ continue;
6569
6740
  }
6570
- args.push("--open-ide=false");
6571
- if (options.workspace) {
6572
- args.push(
6573
- "--workspace-env",
6574
- `WORKSPACE=${options.workspace.token}`,
6575
- "--workspace-env",
6576
- `DEVROUTER_WORKSPACE=${options.workspace.token}`
6741
+ const unexpected = expected.filter(
6742
+ (name) => expectation === "loaded" ? !result.names.has(name) : result.names.has(name)
6743
+ );
6744
+ const boundedAbsenceIsUncertain = result.reachedPageLimit && (expectation === "loaded" ? unexpected.length > 0 : unexpected.length === 0);
6745
+ if (boundedAbsenceIsUncertain) {
6746
+ failures.push(
6747
+ `${protocol.toUpperCase()} router API reached the ${ROUTER_API_PAGE_SIZE}-entry safety limit`
6577
6748
  );
6578
6749
  }
6579
- if (options.recreate) args.push("--recreate");
6580
- const env = { ...process.env };
6581
- if (options.workspace) {
6582
- env.WORKSPACE = options.workspace.token;
6583
- env.DEVROUTER_WORKSPACE = options.workspace.token;
6584
- env.DEVROUTER_GIT_COMMON_DIR = options.workspace.gitCommonDir;
6585
- env.DEVCONTAINER_COMPOSE_OVERLAY = "docker-compose.devrouter.yml";
6586
- } else {
6587
- delete env.WORKSPACE;
6588
- delete env.DEVROUTER_WORKSPACE;
6589
- delete env.DEVROUTER_GIT_COMMON_DIR;
6590
- delete env.DEVCONTAINER_COMPOSE_OVERLAY;
6750
+ mismatched.push(...unexpected);
6751
+ if (expectation === "removed" && unexpected.length === 0 && result.reachedPageLimit) {
6752
+ mismatched.push(...expected);
6591
6753
  }
6592
- claimLifecycleEffect();
6593
- const result = (0, import_node_child_process16.spawnSync)("devpod", args, {
6594
- stdio: options.quiet ? ["inherit", 2, "inherit"] : "inherit",
6595
- env
6596
- });
6597
- if (result.status !== 0) {
6598
- resetWorkspaceRuntimeCaches();
6599
- const message = `devpod up failed for '${devpodId ?? options.repoPath}'.`;
6600
- if (options.devcontainerPath) throw new DevpodStartPostconditionError(message);
6601
- throw new Error(message);
6754
+ }
6755
+ return mismatched.length === 0 ? { ok: true } : {
6756
+ ok: false,
6757
+ routes: mismatched,
6758
+ details: failures.length > 0 ? failures.join("; ") : expectation === "loaded" ? "router names are absent" : "router names are still present"
6759
+ };
6760
+ }
6761
+ async function waitForExpectedRoutes(routes, expectation, timeoutMs, pollIntervalMs) {
6762
+ const deadline = Date.now() + timeoutMs;
6763
+ let result;
6764
+ do {
6765
+ result = inspectExpectedRoutes(routes, expectation);
6766
+ if (result.ok) return result;
6767
+ if (Date.now() < deadline) {
6768
+ await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
6602
6769
  }
6603
- try {
6604
- const attached = listDevpodWorkspaces(options.repoPath);
6605
- devpodId ??= selectDevpodWorkspace(attached, options.repoPath)?.id;
6606
- if (!devpodId) {
6607
- throw new Error(`DevPod did not attach '${options.repoPath}' after startup.`);
6608
- }
6609
- const ownership = inspectDevpodWorkspaceOwnership(attached, devpodId, options.repoPath);
6610
- if (ownership.status === "conflict") throw new Error(ownership.reason);
6611
- if (ownership.status !== "owned") {
6612
- throw new Error(
6613
- `DevPod did not attach '${options.repoPath}' as '${devpodId}' after startup.`
6614
- );
6615
- }
6616
- resetWorkspaceRuntimeCaches();
6617
- return devpodId;
6618
- } catch (error) {
6619
- const message = error instanceof Error ? error.message : String(error);
6620
- throw new DevpodStartPostconditionError(message);
6770
+ } while (Date.now() < deadline);
6771
+ return result;
6772
+ }
6773
+ async function ensureTraefikRouteExpectation(routes, expectation, options) {
6774
+ if (routes.length === 0) return { restarted: false };
6775
+ const initialTimeoutMs = options.initialTimeoutMs ?? DEFAULT_INITIAL_TIMEOUT_MS;
6776
+ const recoveryTimeoutMs = options.recoveryTimeoutMs ?? DEFAULT_RECOVERY_TIMEOUT_MS;
6777
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
6778
+ const initial = await waitForExpectedRoutes(
6779
+ routes,
6780
+ expectation,
6781
+ initialTimeoutMs,
6782
+ pollIntervalMs
6783
+ );
6784
+ if (initial.ok) return { restarted: false };
6785
+ if (options.allowRestart === false)
6786
+ throw new Error(
6787
+ "Traefik routes did not reload; shared router restart is disabled for this operation."
6788
+ );
6789
+ import_node_fs19.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
6790
+ return withFileLock(
6791
+ ROUTER_RELOAD_LOCK_FILE,
6792
+ {
6793
+ activity: "Traefik route reload recovery",
6794
+ waitMs: initialTimeoutMs + recoveryTimeoutMs + 1e4,
6795
+ fair: true,
6796
+ onWait: createStderrWaitReporter("Traefik route reload recovery", "shared router")
6797
+ },
6798
+ async () => {
6799
+ const recheck = await waitForExpectedRoutes(routes, expectation, 0, pollIntervalMs);
6800
+ if (recheck.ok) return { restarted: false };
6801
+ claimLifecycleEffect();
6802
+ restartRouterStack();
6803
+ const recovered = await waitForExpectedRoutes(
6804
+ routes,
6805
+ expectation,
6806
+ recoveryTimeoutMs,
6807
+ pollIntervalMs
6808
+ );
6809
+ if (recovered.ok) return { restarted: true };
6810
+ const action = expectation === "loaded" ? "load" : "remove";
6811
+ throw new Error(
6812
+ `Traefik did not ${action} file-provider routes after one restart: ${recovered.routes.join(", ")} (${recovered.details}). Inspect: devrouter logs --tail 100`
6813
+ );
6621
6814
  }
6622
- });
6815
+ );
6623
6816
  }
6624
-
6625
- // src/core/http-route-probe.ts
6626
- var import_node_child_process17 = require("child_process");
6627
- var DEFAULT_MAX_TIME_SECONDS = 5;
6628
- var PARENT_TIMEOUT_PADDING_MS = 250;
6629
- var CONTRACT_MAX_BUFFER = 1024;
6630
- var MIME_TOKEN_RE2 = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
6631
- function httpRouteUrl(host) {
6632
- return `${isTLSEnabled() ? "https" : "http"}://${host}`;
6817
+ async function ensureTraefikRoutesLoaded(routes, options = {}) {
6818
+ return ensureTraefikRouteExpectation(routes, "loaded", options);
6633
6819
  }
6634
- function probeHttpRoute(host, options = {}) {
6635
- const maxTimeSeconds = options.maxTimeSeconds ?? DEFAULT_MAX_TIME_SECONDS;
6636
- if (!Number.isFinite(maxTimeSeconds) || maxTimeSeconds <= 0) {
6637
- throw new Error("maxTimeSeconds must be a positive finite number.");
6638
- }
6639
- if (options.readiness !== void 0) {
6640
- return probeHttpReadinessSync(host, options.readiness, maxTimeSeconds, options.repoPath);
6820
+ async function ensureTraefikRoutesMatch(routes, options = {}) {
6821
+ if (routes.length === 0) return;
6822
+ const timeoutMs = options.initialTimeoutMs ?? options.recoveryTimeoutMs ?? DEFAULT_INITIAL_TIMEOUT_MS;
6823
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
6824
+ const result = await waitForExpectedRouteMatches(routes, timeoutMs, pollIntervalMs);
6825
+ if (!result.ok) {
6826
+ throw new Error(
6827
+ `Traefik route configuration did not match rendered routes: ${result.routes.join(", ")} (${result.details})`
6828
+ );
6641
6829
  }
6642
- const tlsEnabled = isTLSEnabled();
6643
- const url = `${tlsEnabled ? "https" : "http"}://${host}`;
6644
- const args = [
6645
- "--silent",
6830
+ }
6831
+ async function ensureTraefikRoutesRemoved(routes, options = {}) {
6832
+ return ensureTraefikRouteExpectation(routes, "removed", options);
6833
+ }
6834
+ function assertTraefikRoutesRemoved(routes) {
6835
+ const result = inspectExpectedRoutes(routes, "removed");
6836
+ if (!result.ok) throw new Error(`Workspace route cessation is not proven: ${result.details}`);
6837
+ }
6838
+
6839
+ // src/core/workspace-ensure.ts
6840
+ var import_node_child_process21 = require("child_process");
6841
+ var import_node_fs23 = __toESM(require("fs"));
6842
+ var import_node_path27 = __toESM(require("path"));
6843
+
6844
+ // src/core/devpod-mutation.ts
6845
+ var import_node_child_process17 = require("child_process");
6846
+ var import_node_fs20 = __toESM(require("fs"));
6847
+ var import_node_path25 = __toESM(require("path"));
6848
+ var DEVPOD_MUTATION_LOCK_FILE = import_node_path25.default.join(DEVROUTER_HOME, "devpod-mutation.lock");
6849
+ var DEVPOD_MUTATION_WAIT_MS = 18e5;
6850
+ var DevpodStartPostconditionError = class extends Error {
6851
+ };
6852
+ function withMutationLock2(activity, target, operation) {
6853
+ import_node_fs20.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
6854
+ return withFileLockSync(
6855
+ DEVPOD_MUTATION_LOCK_FILE,
6856
+ {
6857
+ activity,
6858
+ target: `'${target}'`,
6859
+ waitMs: DEVPOD_MUTATION_WAIT_MS,
6860
+ fair: true,
6861
+ onWait: createStderrWaitReporter(activity, `'${target}'`)
6862
+ },
6863
+ operation
6864
+ );
6865
+ }
6866
+ function commandFailure2(result) {
6867
+ return [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
6868
+ }
6869
+ function runDevpodAction(action, devpodId, force = false) {
6870
+ const args = action === "delete" ? [action, devpodId, ...force ? ["--force"] : [], "--ignore-not-found"] : [action, devpodId];
6871
+ claimLifecycleEffect();
6872
+ const result = (0, import_node_child_process17.spawnSync)("devpod", args, { encoding: "utf-8" });
6873
+ if (result.status !== 0) {
6874
+ throw new Error(
6875
+ `devpod ${action}${force ? " --force" : ""} failed for '${devpodId}': ${commandFailure2(result) || "unknown error"}`
6876
+ );
6877
+ }
6878
+ }
6879
+ function inspectExactOwnership2(devpodId, worktreePath) {
6880
+ const ownership = inspectDevpodWorkspaceOwnership(
6881
+ listDevpodWorkspaces(worktreePath),
6882
+ devpodId,
6883
+ worktreePath
6884
+ );
6885
+ if (ownership.status === "conflict") throw new Error(ownership.reason);
6886
+ return ownership;
6887
+ }
6888
+ function mutateOwnedDevpodWorkspace(action, devpodId, worktreePath) {
6889
+ return withMutationLock2(`DevPod ${action}`, worktreePath, () => {
6890
+ const before = inspectExactOwnership2(devpodId, worktreePath);
6891
+ if (before.status === "absent") return { status: "absent" };
6892
+ runDevpodAction(action, devpodId);
6893
+ let after = inspectExactOwnership2(devpodId, worktreePath);
6894
+ if (action === "stop" && after.status !== "owned") {
6895
+ throw new Error(`DevPod '${devpodId}' no longer owns '${worktreePath}' after provider stop.`);
6896
+ }
6897
+ if (action === "delete" && after.status === "owned") {
6898
+ const runtime = inspectDevpodRuntimeStatus(devpodId, worktreePath);
6899
+ if (runtime !== "not-found") {
6900
+ throw new Error(
6901
+ `DevPod '${devpodId}' still owns '${worktreePath}' after provider delete (runtime=${runtime}).`
6902
+ );
6903
+ }
6904
+ after = inspectExactOwnership2(devpodId, worktreePath);
6905
+ if (after.status === "owned") {
6906
+ runDevpodAction("delete", devpodId, true);
6907
+ after = inspectExactOwnership2(devpodId, worktreePath);
6908
+ }
6909
+ if (after.status !== "absent") {
6910
+ throw new Error(
6911
+ `DevPod '${devpodId}' still owns '${worktreePath}' after forced provider delete.`
6912
+ );
6913
+ }
6914
+ }
6915
+ return { status: "changed" };
6916
+ });
6917
+ }
6918
+ function stopOwnedDevpodWorkspace(devpodId, worktreePath) {
6919
+ try {
6920
+ resetWorkspaceRuntimeCaches();
6921
+ if (resolveWorkspaceRuntimeOrDefault(worktreePath) === "devsy") {
6922
+ return stopOwnedDevsyWorkspace(devpodId, worktreePath);
6923
+ }
6924
+ return mutateOwnedDevpodWorkspace("stop", devpodId, worktreePath);
6925
+ } finally {
6926
+ resetWorkspaceRuntimeCaches();
6927
+ }
6928
+ }
6929
+ function deleteOwnedDevpodWorkspace(devpodId, worktreePath) {
6930
+ if (resolveWorkspaceRuntimeOrDefault(worktreePath) === "devsy") {
6931
+ const result2 = deleteOwnedDevsyWorkspace(devpodId, worktreePath);
6932
+ resetWorkspaceRuntimeCaches();
6933
+ return result2;
6934
+ }
6935
+ const result = mutateOwnedDevpodWorkspace("delete", devpodId, worktreePath);
6936
+ resetWorkspaceRuntimeCaches();
6937
+ return result;
6938
+ }
6939
+ async function startDevpodWorkspace(options) {
6940
+ if (resolveWorkspaceRuntimeOrDefault(options.repoPath) === "devsy") {
6941
+ try {
6942
+ const result = await startDevsyWorkspace({
6943
+ repoPath: options.repoPath,
6944
+ devsyId: options.devpodId,
6945
+ devcontainerPath: options.devcontainerPath,
6946
+ recreate: options.recreate,
6947
+ quiet: options.quiet,
6948
+ workspace: options.workspace,
6949
+ inactivityTimeout: readWorkspaceRuntimeConfig().devsyInactivityTimeout
6950
+ });
6951
+ resetWorkspaceRuntimeCaches();
6952
+ return result;
6953
+ } catch (error) {
6954
+ if (error instanceof DevsyStartPostconditionError) {
6955
+ resetWorkspaceRuntimeCaches();
6956
+ throw new DevpodStartPostconditionError(error.message);
6957
+ }
6958
+ throw error;
6959
+ }
6960
+ }
6961
+ const activity = options.recreate ? "DevPod recreate" : "DevPod start";
6962
+ return withMutationLock2(activity, options.repoPath, () => {
6963
+ const workspaces = listDevpodWorkspaces(options.repoPath);
6964
+ let devpodId = options.devpodId ?? selectDevpodWorkspace(workspaces, options.repoPath)?.id;
6965
+ if (devpodId) {
6966
+ const before = inspectDevpodWorkspaceOwnership(workspaces, devpodId, options.repoPath);
6967
+ if (before.status === "conflict") throw new Error(before.reason);
6968
+ if (options.recreate && before.status !== "owned") {
6969
+ throw new Error(`Cannot recreate DevPod '${devpodId}' without one exact owner.`);
6970
+ }
6971
+ } else if (options.recreate) {
6972
+ throw new Error("Cannot recreate a DevPod before its exact id is known.");
6973
+ }
6974
+ const args = ["up", options.repoPath];
6975
+ if (devpodId) args.push("--id", devpodId);
6976
+ if (options.devcontainerPath) {
6977
+ args.push("--devcontainer-path", options.devcontainerPath);
6978
+ }
6979
+ args.push("--open-ide=false");
6980
+ if (options.workspace) {
6981
+ args.push(
6982
+ "--workspace-env",
6983
+ `WORKSPACE=${options.workspace.token}`,
6984
+ "--workspace-env",
6985
+ `DEVROUTER_WORKSPACE=${options.workspace.token}`
6986
+ );
6987
+ }
6988
+ if (options.recreate) args.push("--recreate");
6989
+ const env = { ...process.env };
6990
+ if (options.workspace) {
6991
+ env.WORKSPACE = options.workspace.token;
6992
+ env.DEVROUTER_WORKSPACE = options.workspace.token;
6993
+ env.DEVROUTER_GIT_COMMON_DIR = options.workspace.gitCommonDir;
6994
+ env.DEVCONTAINER_COMPOSE_OVERLAY = "docker-compose.devrouter.yml";
6995
+ } else {
6996
+ delete env.WORKSPACE;
6997
+ delete env.DEVROUTER_WORKSPACE;
6998
+ delete env.DEVROUTER_GIT_COMMON_DIR;
6999
+ delete env.DEVCONTAINER_COMPOSE_OVERLAY;
7000
+ }
7001
+ claimLifecycleEffect();
7002
+ const result = (0, import_node_child_process17.spawnSync)("devpod", args, {
7003
+ stdio: options.quiet ? ["inherit", 2, "inherit"] : "inherit",
7004
+ env
7005
+ });
7006
+ if (result.status !== 0) {
7007
+ resetWorkspaceRuntimeCaches();
7008
+ const message = `devpod up failed for '${devpodId ?? options.repoPath}'.`;
7009
+ if (options.devcontainerPath) throw new DevpodStartPostconditionError(message);
7010
+ throw new Error(message);
7011
+ }
7012
+ try {
7013
+ const attached = listDevpodWorkspaces(options.repoPath);
7014
+ devpodId ??= selectDevpodWorkspace(attached, options.repoPath)?.id;
7015
+ if (!devpodId) {
7016
+ throw new Error(`DevPod did not attach '${options.repoPath}' after startup.`);
7017
+ }
7018
+ const ownership = inspectDevpodWorkspaceOwnership(attached, devpodId, options.repoPath);
7019
+ if (ownership.status === "conflict") throw new Error(ownership.reason);
7020
+ if (ownership.status !== "owned") {
7021
+ throw new Error(
7022
+ `DevPod did not attach '${options.repoPath}' as '${devpodId}' after startup.`
7023
+ );
7024
+ }
7025
+ resetWorkspaceRuntimeCaches();
7026
+ return devpodId;
7027
+ } catch (error) {
7028
+ const message = error instanceof Error ? error.message : String(error);
7029
+ throw new DevpodStartPostconditionError(message);
7030
+ }
7031
+ });
7032
+ }
7033
+
7034
+ // src/core/http-route-probe.ts
7035
+ var import_node_child_process18 = require("child_process");
7036
+ var DEFAULT_MAX_TIME_SECONDS = 5;
7037
+ var PARENT_TIMEOUT_PADDING_MS = 250;
7038
+ var CONTRACT_MAX_BUFFER = 1024;
7039
+ var MIME_TOKEN_RE2 = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
7040
+ function httpRouteUrl(host) {
7041
+ return `${isTLSEnabled() ? "https" : "http"}://${host}`;
7042
+ }
7043
+ function probeHttpRoute(host, options = {}) {
7044
+ const maxTimeSeconds = options.maxTimeSeconds ?? DEFAULT_MAX_TIME_SECONDS;
7045
+ if (!Number.isFinite(maxTimeSeconds) || maxTimeSeconds <= 0) {
7046
+ throw new Error("maxTimeSeconds must be a positive finite number.");
7047
+ }
7048
+ if (options.readiness !== void 0) {
7049
+ return probeHttpReadinessSync(host, options.readiness, maxTimeSeconds, options.repoPath);
7050
+ }
7051
+ const tlsEnabled = isTLSEnabled();
7052
+ const url = `${tlsEnabled ? "https" : "http"}://${host}`;
7053
+ const args = [
7054
+ "--silent",
6646
7055
  "--show-error",
6647
7056
  "--output",
6648
7057
  "/dev/null",
@@ -6656,1132 +7065,817 @@ function probeHttpRoute(host, options = {}) {
6656
7065
  args.push("--cacert", CERT_FILE);
6657
7066
  }
6658
7067
  args.push(url);
6659
- const result = (0, import_node_child_process17.spawnSync)("curl", args, { encoding: "utf-8" });
7068
+ const result = (0, import_node_child_process18.spawnSync)("curl", args, { encoding: "utf-8" });
6660
7069
  const stdout = result.stdout?.trim() ?? "";
6661
7070
  const status = /^\d{3}$/.test(stdout) ? Number(stdout) : void 0;
6662
7071
  const ok = result.status === 0 && status !== void 0 && status >= 100 && status < 500;
6663
7072
  const details = ok ? `HTTP ${status}` : [status === void 0 ? void 0 : `HTTP ${status}`, result.stderr?.trim()].filter(Boolean).join(": ") || `curl exited with status ${result.status ?? "unknown"}`;
6664
7073
  return { ok, status, details };
6665
7074
  }
6666
- function probeHttpReadinessSync(host, readiness, maxTimeSeconds, repoPath) {
6667
- const parentTimeoutMs = maxTimeSeconds * 1e3 + PARENT_TIMEOUT_PADDING_MS;
6668
- if (!Number.isFinite(parentTimeoutMs)) {
6669
- throw new Error("maxTimeSeconds is too large for a bounded probe timeout.");
6670
- }
6671
- const tlsEnabled = isTLSEnabled();
6672
- const args = buildHttpReadinessCurlArgs(host, readiness, maxTimeSeconds, tlsEnabled);
6673
- if (tlsEnabled) {
6674
- getMkcertRootCAPath({ repoPath });
6675
- }
6676
- let result;
6677
- try {
6678
- result = (0, import_node_child_process17.spawnSync)("curl", args, {
6679
- encoding: "utf-8",
6680
- maxBuffer: CONTRACT_MAX_BUFFER,
6681
- timeout: Math.ceil(parentTimeoutMs)
6682
- });
6683
- } catch {
6684
- return transportFailure();
6685
- }
6686
- if (result.status !== 0 || result.error) {
6687
- return transportFailure();
6688
- }
6689
- const metadata = typeof result.stdout === "string" ? result.stdout : "";
6690
- return parseHttpReadinessMetadata(metadata, readiness);
6691
- }
6692
- function buildHttpReadinessCurlArgs(host, readiness, maxTimeSeconds, tlsEnabled) {
6693
- const url = `${tlsEnabled ? "https" : "http"}://${host}${readiness.path}`;
6694
- const args = [
6695
- "--disable",
6696
- "--globoff",
6697
- "--silent",
6698
- "--show-error",
6699
- "--no-location",
6700
- "--noproxy",
6701
- "*",
6702
- "--output",
6703
- "/dev/null",
6704
- "--write-out",
6705
- "%{http_code} %{content_type}",
6706
- "--max-time",
6707
- String(maxTimeSeconds)
6708
- ];
6709
- if (tlsEnabled) args.push("--cacert", CERT_FILE);
6710
- args.push(url);
6711
- return args;
6712
- }
6713
- function parseHttpReadinessMetadata(metadata, readiness) {
6714
- if (metadata.length > CONTRACT_MAX_BUFFER) {
6715
- return transportFailure();
6716
- }
6717
- const match = /^(\d{3})\t([^\r\n]*)$/.exec(metadata);
6718
- if (!match) {
6719
- return transportFailure();
6720
- }
6721
- const status = Number(match[1]);
6722
- if (status < 100 || status > 599) {
6723
- return transportFailure();
6724
- }
6725
- const statuses = readiness.statuses ?? [200];
6726
- const statusAllowed = statuses.includes(status);
6727
- const mediaFailure = readiness.contentType ? contentTypeFailure(match[2], readiness.contentType) : void 0;
6728
- if (statusAllowed && mediaFailure === void 0) {
6729
- return { ok: true, status, details: `HTTP ${status}` };
6730
- }
6731
- const reasons = [
6732
- ...statusAllowed ? [] : ["status not allowed"],
6733
- ...mediaFailure ? [mediaFailure] : []
6734
- ];
6735
- return {
6736
- ok: false,
6737
- status,
6738
- details: `HTTP ${status}; ${reasons.join("; ")}`,
6739
- classification: "application-contract"
6740
- };
6741
- }
6742
- function contentTypeFailure(responseContentType, expectedContentType) {
6743
- const normalized = responseContentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
6744
- if (normalized.length === 0) {
6745
- return "content type missing";
6746
- }
6747
- const separator = normalized.indexOf("/");
6748
- if (separator <= 0 || separator === normalized.length - 1 || normalized.indexOf("/", separator + 1) !== -1 || !MIME_TOKEN_RE2.test(normalized.slice(0, separator)) || !MIME_TOKEN_RE2.test(normalized.slice(separator + 1))) {
6749
- return "content type invalid";
6750
- }
6751
- return normalized === expectedContentType.toLowerCase() ? void 0 : "content type mismatch";
6752
- }
6753
- function transportFailure() {
6754
- return {
6755
- ok: false,
6756
- details: "HTTP route probe transport failure",
6757
- classification: "transport"
6758
- };
6759
- }
6760
-
6761
- // src/core/managed-host-preparation.ts
6762
- var import_node_child_process18 = require("child_process");
6763
- var import_node_fs20 = __toESM(require("fs"));
6764
- var PREPARATION_TIMEOUT_MS = 6e4;
6765
- var CONFIG_READ_ERROR = "Managed host preparation could not read .devrouter.yml.";
6766
- var CONFIG_CHANGED_ERROR = "Managed host preparation changed .devrouter.yml; refusing to continue.";
6767
- var START_ERROR = "Managed host preparation could not start.";
6768
- var FAILURE_ERROR = "Managed host preparation failed.";
6769
- var TIMEOUT_ERROR = "Managed host preparation timed out after 60 seconds.";
6770
- function runPreparationCommand(argv, cwd) {
6771
- return new Promise((resolve) => {
6772
- let child;
6773
- try {
6774
- child = (0, import_node_child_process18.spawn)(argv[0], argv.slice(1), {
6775
- cwd,
6776
- detached: true,
6777
- shell: false,
6778
- stdio: "ignore"
6779
- });
6780
- } catch {
6781
- resolve("start-error");
6782
- return;
6783
- }
6784
- let timedOut = false;
6785
- const deadline = setTimeout(() => {
6786
- timedOut = true;
6787
- if (child.pid !== void 0 && child.exitCode === null && child.signalCode === null) {
6788
- try {
6789
- process.kill(-child.pid, "SIGKILL");
6790
- } catch {
6791
- }
6792
- }
6793
- }, PREPARATION_TIMEOUT_MS);
6794
- child.once("error", () => {
6795
- clearTimeout(deadline);
6796
- resolve("start-error");
6797
- });
6798
- child.once("close", (code) => {
6799
- clearTimeout(deadline);
6800
- resolve(timedOut ? "timeout" : code === 0 ? "success" : "failure");
6801
- });
6802
- });
6803
- }
6804
- function readConfigBytes(configPath) {
6805
- try {
6806
- return import_node_fs20.default.readFileSync(configPath);
6807
- } catch {
6808
- return void 0;
6809
- }
6810
- }
6811
- async function runManagedHostPreparation(repoPath, config) {
6812
- const prepareCommand = config.managedRuntime?.devcontainer.prepareCommand;
6813
- if (!prepareCommand) return;
6814
- if (process.platform === "win32") {
6815
- throw new Error("Managed host preparation requires POSIX process-group ownership.");
6816
- }
6817
- const resolvedRepoPath = resolveRepoPath(repoPath);
6818
- const configPath = getRepoConfigPath(resolvedRepoPath);
6819
- const before = readConfigBytes(configPath);
6820
- if (!before) {
6821
- throw new Error(CONFIG_READ_ERROR);
6822
- }
6823
- const result = await runPreparationCommand(prepareCommand, resolvedRepoPath);
6824
- const after = readConfigBytes(configPath);
6825
- if (!after?.equals(before)) {
6826
- throw new Error(CONFIG_CHANGED_ERROR);
6827
- }
6828
- if (result === "start-error") {
6829
- throw new Error(START_ERROR);
6830
- }
6831
- if (result === "timeout") {
6832
- throw new Error(TIMEOUT_ERROR);
6833
- }
6834
- if (result === "failure") {
6835
- throw new Error(FAILURE_ERROR);
6836
- }
6837
- }
6838
-
6839
- // src/core/managed-post-start.ts
6840
- var import_node_child_process19 = require("child_process");
6841
- var import_node_crypto10 = require("crypto");
6842
- var import_node_fs21 = __toESM(require("fs"));
6843
- var import_node_path25 = __toESM(require("path"));
6844
- var MANAGED_MARKER = "devrouter:managed devcontainer";
6845
- var MANAGED_ADAPTER_PATH = ".devcontainer/post-start.sh";
6846
- var RUNTIME_HELPER_PATH = "/tmp/devrouter/bin/devrouter-process";
6847
- var ADAPTER_WRAPPER = `adapter_snapshot="$1"
6848
- shift
6849
- readonly DEVROUTER_PROCESS_HELPER DEVROUTER_PROCESS_ADAPTER_SHA256
6850
- source "$adapter_snapshot"
6851
- `;
6852
- function renderDeliveryScript(targetPath) {
6853
- if (!/^\/tmp\/devrouter\/bin\/[a-zA-Z0-9._-]+$/.test(targetPath)) {
6854
- throw new Error(`Unsafe managed runtime delivery path: ${targetPath}`);
6855
- }
6856
- const temporaryPrefix = import_node_path25.default.posix.basename(targetPath);
6857
- return `set -eu
6858
- umask 077
6859
- runtime_root=/tmp/devrouter
6860
- runtime_bin="$runtime_root/bin"
6861
- target="${targetPath}"
6862
- if [ -L "$runtime_root" ] || [ -L "$runtime_bin" ]; then
6863
- echo "Refusing symlinked devrouter runtime path." >&2
6864
- exit 1
6865
- fi
6866
- mkdir -p "$runtime_bin"
6867
- chmod 700 "$runtime_root" "$runtime_bin"
6868
- temporary="$(mktemp "$runtime_bin/.${temporaryPrefix}.XXXXXX")"
6869
- trap 'rm -f "$temporary"' EXIT
6870
- cat > "$temporary"
6871
- chmod 700 "$temporary"
6872
- mv -f "$temporary" "$target"
6873
- trap - EXIT
6874
- `;
6875
- }
6876
- function commandFailure3(result) {
6877
- return [result.error?.message, result.stderr, result.stdout].filter(Boolean).map((value) => String(value).trim()).filter(Boolean).join("\n");
6878
- }
6879
- function deliverRuntimeFile(containerId, targetPath, contents, label) {
6880
- const result = (0, import_node_child_process19.spawnSync)(
6881
- "docker",
6882
- ["exec", "-i", containerId, "sh", "-c", renderDeliveryScript(targetPath)],
6883
- { input: contents, encoding: "utf-8" }
6884
- );
6885
- if (result.status !== 0) {
6886
- const details = commandFailure3(result);
6887
- throw new Error(`Could not deliver ${label}${details ? `: ${details}` : "."}`);
6888
- }
6889
- }
6890
- function readRegularFileBytes(filePath) {
6891
- if (!import_node_fs21.default.existsSync(filePath) || !import_node_fs21.default.lstatSync(filePath).isFile()) return void 0;
6892
- return import_node_fs21.default.readFileSync(filePath);
6893
- }
6894
- function readRegularFile(filePath) {
6895
- return readRegularFileBytes(filePath)?.toString("utf-8");
6896
- }
6897
- function adapterFingerprint(adapter) {
6898
- return (0, import_node_crypto10.createHash)("sha256").update(adapter).digest("hex");
6899
- }
6900
- function resolveProcessHelperPath() {
6901
- const candidates = [
6902
- import_node_path25.default.resolve(__dirname, "..", "bin", "devrouter-process"),
6903
- import_node_path25.default.resolve(__dirname, "..", "..", "bin", "devrouter-process")
6904
- ];
6905
- const helperPath = candidates.find((candidate) => import_node_fs21.default.existsSync(candidate));
6906
- if (!helperPath) {
6907
- throw new Error("Could not locate the packaged devrouter-process helper.");
6908
- }
6909
- return helperPath;
6910
- }
6911
- function resolveManagedPostStartPlan(repoPath) {
6912
- const adapterPath = import_node_path25.default.join(repoPath, MANAGED_ADAPTER_PATH);
6913
- const adapterBytes = readRegularFileBytes(adapterPath);
6914
- const adapterText = adapterBytes?.toString("utf-8");
6915
- const dockerfilePath = import_node_path25.default.join(repoPath, ".devcontainer", "Dockerfile");
6916
- const devcontainerPath = import_node_path25.default.join(repoPath, ".devcontainer", "devcontainer.json");
6917
- const dockerfile = readRegularFile(dockerfilePath) ?? "";
6918
- const devcontainer = readRegularFile(devcontainerPath) ?? "";
6919
- const devrouterPattern = [adapterText, dockerfile, devcontainer].filter((value) => value !== void 0).some(
6920
- (value) => value.includes("DEVROUTER_PROCESS_HELPER") || value.includes("devrouter-process")
6921
- );
6922
- if (adapterBytes === void 0) {
6923
- if (devrouterPattern) {
6924
- throw new Error(
6925
- "Devrouter lifecycle wiring is incomplete: add the managed post-start adapter or remove the stale devrouter-process references."
6926
- );
6927
- }
6928
- return { kind: "unmanaged" };
6929
- }
6930
- const adapter = adapterBytes.toString("utf-8");
6931
- if (!adapter.includes(MANAGED_MARKER)) {
6932
- if (devrouterPattern) {
6933
- throw new Error(
6934
- "Devrouter-looking post-start adapter is missing the 'devrouter:managed devcontainer' marker. Regenerate or migrate the devcontainer, then retry devrouter ensure."
6935
- );
6936
- }
6937
- return { kind: "unmanaged" };
6938
- }
6939
- assertManagedDevcontainerLifecycle(repoPath);
6940
- if (adapter.includes("DEVROUTER_PROCESS_HELPER")) {
6941
- return {
6942
- kind: "runtime",
6943
- adapterPath: MANAGED_ADAPTER_PATH,
6944
- adapterSha256: adapterFingerprint(adapterBytes),
6945
- adapterContents: adapterBytes
6946
- };
6947
- }
6948
- if (dockerfile.includes("devrouter-process") && devcontainer.includes("postStartCommand")) {
6949
- return { kind: "legacy" };
6950
- }
6951
- throw new Error(
6952
- "Managed post-start must use DEVROUTER_PROCESS_HELPER before removing the legacy image helper and postStartCommand. Regenerate or migrate the devcontainer, then retry devrouter ensure."
6953
- );
6954
- }
6955
- function runManagedPostStart(options) {
6956
- if (options.plan.kind !== "runtime") return;
6957
- const helper = import_node_fs21.default.readFileSync(resolveProcessHelperPath());
6958
- deliverRuntimeFile(
6959
- options.container.id,
6960
- RUNTIME_HELPER_PATH,
6961
- helper,
6962
- "the managed process helper"
6963
- );
6964
- const runtimeAdapterPath = `/tmp/devrouter/bin/managed-post-start-${options.plan.adapterSha256}`;
6965
- deliverRuntimeFile(
6966
- options.container.id,
6967
- runtimeAdapterPath,
6968
- options.plan.adapterContents,
6969
- "the managed post-start adapter"
6970
- );
6971
- const started = (0, import_node_child_process19.spawnSync)(
6972
- "docker",
6973
- [
6974
- "exec",
6975
- "--workdir",
6976
- options.container.workspacePath,
6977
- "--env",
6978
- `DEVROUTER_PROCESS_HELPER=${RUNTIME_HELPER_PATH}`,
6979
- "--env",
6980
- `DEVROUTER_PROCESS_ADAPTER_SHA256=${options.plan.adapterSha256}`,
6981
- ...options.profile ? ["--env", `DEVROUTER_PROFILE=${options.profile}`] : [],
6982
- ...options.processes ? [
6983
- "--env",
6984
- `DEVROUTER_PROCESS_SET=${options.processes.join(",")}`,
6985
- "--env",
6986
- "DEVROUTER_PROCESS_FINGERPRINT_ENV=DEVROUTER_PROFILE,DEVROUTER_PROCESS_SET"
6987
- ] : [],
6988
- options.container.id,
6989
- "bash",
6990
- "-c",
6991
- ADAPTER_WRAPPER,
6992
- options.plan.adapterPath,
6993
- runtimeAdapterPath
6994
- ],
6995
- { stdio: options.quiet ? ["ignore", 2, "inherit"] : "inherit" }
6996
- );
6997
- if (started.status !== 0) {
6998
- const details = commandFailure3(started);
6999
- throw new Error(`Managed post-start failed${details ? `: ${details}.` : "."}`);
7000
- }
7001
- }
7002
- function assertSafeProcessName(name) {
7003
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) {
7004
- throw new Error(`Managed process '${name}' is not a safe exact identifier.`);
7005
- }
7006
- }
7007
- function runManagedProcessAction(options) {
7008
- assertSafeProcessName(options.name);
7009
- const result = (0, import_node_child_process19.spawnSync)(
7010
- "docker",
7011
- [
7012
- "exec",
7013
- "--workdir",
7014
- options.container.workspacePath,
7015
- "--env",
7016
- `DEVROUTER_PROCESS_HELPER=${RUNTIME_HELPER_PATH}`,
7017
- options.container.id,
7018
- "bash",
7019
- "-c",
7020
- 'exec "$DEVROUTER_PROCESS_HELPER" "$1" --name "$2"',
7021
- "devrouter-process",
7022
- options.action,
7023
- options.name
7024
- ],
7025
- {
7026
- encoding: "utf-8",
7027
- stdio: options.action === "status" ? ["ignore", "pipe", "pipe"] : ["ignore", 2, "inherit"]
7028
- }
7029
- );
7030
- if (options.action === "stop") {
7031
- if (result.status !== 0) {
7032
- throw new Error(`Could not stop managed process '${options.name}'.`);
7033
- }
7034
- return "stopped";
7035
- }
7036
- try {
7037
- const parsed = JSON.parse(result.stdout.trim());
7038
- if (parsed.status === "running" || parsed.status === "stopped" || parsed.status === "foreign" || parsed.status === "drifted") {
7039
- return parsed.status;
7040
- }
7041
- } catch {
7042
- }
7043
- return "drifted";
7044
- }
7045
-
7046
- // src/core/managed-runtime-status.ts
7047
- var EMPTY_RESOURCES = {
7048
- apps: [],
7049
- services: [],
7050
- processes: []
7051
- };
7052
- function sortedUnique(values) {
7053
- return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right));
7054
- }
7055
- function selectedResources(values, allValues, hasResolvedProfile) {
7056
- if (values === void 0) return hasResolvedProfile ? [] : sortedUnique(allValues);
7057
- return values.length === 1 && values[0] === "*" ? sortedUnique(allValues) : sortedUnique(values);
7058
- }
7059
- function containerResourceStatus(container) {
7060
- if (!container) return "missing";
7061
- if (!container.state.Running) return "stopped";
7062
- const health = container.state.Health?.Status;
7063
- if (health === "starting") return "starting";
7064
- if (health === "unhealthy") return "unhealthy";
7065
- return "healthy";
7066
- }
7067
- function isActiveResourceStatus(status) {
7068
- return status === "running" || status === "healthy" || status === "starting" || status === "unhealthy";
7069
- }
7070
- function isReadyResourceStatus(status) {
7071
- return status === "running" || status === "healthy";
7072
- }
7073
- function isStoppedOrMissingResourceStatus(status) {
7074
- return status === "stopped" || status === "missing";
7075
- }
7076
- function exactRoutes(repoPath, workspace) {
7077
- try {
7078
- const routes = listHostRouteState().filter(
7079
- (route) => sameWorkspacePath(route.repoPath, repoPath) && route.workspace === workspace
7080
- );
7081
- return { apps: sortedUnique(routes.map((route) => route.name)), drift: [] };
7082
- } catch {
7083
- return { apps: [], drift: ["managed route state could not be inspected"] };
7084
- }
7085
- }
7086
- function compareResourceSet(label, expected, actual) {
7087
- if (expected.length !== actual.length || expected.some((value, index) => value !== actual[index])) {
7088
- return `active ${label} differ from the selected profile`;
7089
- }
7090
- return void 0;
7091
- }
7092
- function resourceStatusesForMissing(names, status) {
7093
- return Object.fromEntries(names.map((name) => [name, status]));
7094
- }
7095
- function inspectManagedRuntime(options) {
7096
- const {
7097
- repoPath,
7098
- config,
7099
- registeredServices,
7100
- registeredProcesses,
7101
- desiredServices,
7102
- desiredProcesses,
7103
- workspace,
7104
- state
7105
- } = options;
7106
- const drift = [];
7107
- const baseServices = sortedUnique(config.managedRuntime?.devcontainer.baseServices ?? []);
7108
- const serviceStatuses = resourceStatusesForMissing(registeredServices, "missing");
7109
- const baseStatuses = resourceStatusesForMissing(baseServices, "missing");
7110
- const processStatuses = resourceStatusesForMissing(registeredProcesses, "missing");
7111
- const activeServices = [];
7112
- const activeProcesses = [];
7113
- let plan;
7114
- try {
7115
- plan = inspectManagedDevcontainerConfig({
7116
- repoPath,
7117
- config,
7118
- profile: {
7119
- apps: ["*"],
7120
- devcontainerServices: desiredServices,
7121
- processes: ["*"]
7122
- },
7123
- linked: workspace !== void 0
7124
- });
7125
- } catch {
7126
- drift.push("managed Dev Container configuration could not be inspected");
7127
- return {
7128
- plan,
7129
- primaryReady: false,
7130
- primaryActive: false,
7131
- baseStatuses,
7132
- serviceStatuses,
7133
- processStatuses,
7134
- activeServices,
7135
- activeProcesses,
7136
- drift
7137
- };
7138
- }
7139
- let containers;
7140
- try {
7141
- containers = inspectWorkspaceContainers();
7142
- } catch {
7143
- drift.push("managed workspace containers could not be inspected");
7144
- return {
7145
- plan,
7146
- primaryReady: false,
7147
- primaryActive: false,
7148
- baseStatuses,
7149
- serviceStatuses,
7150
- processStatuses,
7151
- activeServices,
7152
- activeProcesses,
7153
- drift
7154
- };
7155
- }
7156
- if (!plan) {
7157
- drift.push("managed Dev Container configuration could not be inspected");
7158
- return {
7159
- plan,
7160
- primaryReady: false,
7161
- primaryActive: false,
7162
- baseStatuses,
7163
- serviceStatuses,
7164
- processStatuses,
7165
- activeServices,
7166
- activeProcesses,
7167
- drift
7168
- };
7075
+ function probeHttpReadinessSync(host, readiness, maxTimeSeconds, repoPath) {
7076
+ const parentTimeoutMs = maxTimeSeconds * 1e3 + PARENT_TIMEOUT_PADDING_MS;
7077
+ if (!Number.isFinite(parentTimeoutMs)) {
7078
+ throw new Error("maxTimeSeconds is too large for a bounded probe timeout.");
7169
7079
  }
7170
- const resolvedPlan = plan;
7171
- let generatedConfigSha256;
7172
- let generatedConfigMissing = false;
7080
+ const tlsEnabled = isTLSEnabled();
7081
+ const args = buildHttpReadinessCurlArgs(host, readiness, maxTimeSeconds, tlsEnabled);
7082
+ if (tlsEnabled) {
7083
+ getMkcertRootCAPath({ repoPath });
7084
+ }
7085
+ let result;
7173
7086
  try {
7174
- const generated = inspectManagedDevcontainerGeneratedConfig(resolvedPlan);
7175
- generatedConfigSha256 = generated.sha256;
7176
- if (generated.status === "missing") {
7177
- generatedConfigMissing = true;
7178
- } else if (generated.status === "foreign") {
7179
- drift.push("managed generated Dev Container configuration is not devrouter-owned");
7180
- } else if (generated.status === "drifted") {
7181
- drift.push("managed generated Dev Container configuration changed");
7182
- }
7087
+ result = (0, import_node_child_process18.spawnSync)("curl", args, {
7088
+ encoding: "utf-8",
7089
+ maxBuffer: CONTRACT_MAX_BUFFER,
7090
+ timeout: Math.ceil(parentTimeoutMs)
7091
+ });
7183
7092
  } catch {
7184
- drift.push("managed generated Dev Container configuration could not be inspected");
7185
- }
7186
- const appContainers = workspaceAppContainers(containers, repoPath).filter(
7187
- (container) => hasExactComposeIdentity(container, {
7188
- repoPath,
7189
- service: resolvedPlan.primaryService,
7190
- composeFiles: resolvedPlan.composeFiles
7191
- })
7192
- );
7193
- const projectScopedPrimary = state?.composeProject ? appContainers.filter(
7194
- (container) => container.labels["com.docker.compose.project"] === state.composeProject
7195
- ) : appContainers;
7196
- let primary;
7197
- let composeProject = state?.composeProject;
7198
- if (projectScopedPrimary.length === 1) {
7199
- primary = projectScopedPrimary[0];
7200
- composeProject = primary.labels["com.docker.compose.project"] ?? composeProject;
7201
- } else if (projectScopedPrimary.length > 1) {
7202
- drift.push("managed primary container identity is ambiguous");
7203
- } else if (appContainers.length > 0) {
7204
- drift.push(
7205
- state?.composeProject ? "managed primary container is foreign" : "managed primary container identity is ambiguous"
7206
- );
7093
+ return transportFailure();
7207
7094
  }
7208
- if (!composeProject && appContainers.length === 1) {
7209
- composeProject = appContainers[0].labels["com.docker.compose.project"];
7095
+ if (result.status !== 0 || result.error) {
7096
+ return transportFailure();
7210
7097
  }
7211
- if (primary && !composeProject) {
7212
- drift.push("managed primary container has no Compose project identity");
7098
+ const metadata = typeof result.stdout === "string" ? result.stdout : "";
7099
+ return parseHttpReadinessMetadata(metadata, readiness);
7100
+ }
7101
+ function buildHttpReadinessCurlArgs(host, readiness, maxTimeSeconds, tlsEnabled) {
7102
+ const url = `${tlsEnabled ? "https" : "http"}://${host}${readiness.path}`;
7103
+ const args = [
7104
+ "--disable",
7105
+ "--globoff",
7106
+ "--silent",
7107
+ "--show-error",
7108
+ "--no-location",
7109
+ "--noproxy",
7110
+ "*",
7111
+ "--output",
7112
+ "/dev/null",
7113
+ "--write-out",
7114
+ "%{http_code} %{content_type}",
7115
+ "--max-time",
7116
+ String(maxTimeSeconds)
7117
+ ];
7118
+ if (tlsEnabled) args.push("--cacert", CERT_FILE);
7119
+ args.push(url);
7120
+ return args;
7121
+ }
7122
+ function parseHttpReadinessMetadata(metadata, readiness) {
7123
+ if (metadata.length > CONTRACT_MAX_BUFFER) {
7124
+ return transportFailure();
7213
7125
  }
7214
- const exactService = (service) => containers.filter(
7215
- (container) => hasExactComposeIdentity(container, {
7216
- repoPath,
7217
- service,
7218
- composeProject,
7219
- composeFiles: resolvedPlan.composeFiles
7220
- })
7221
- );
7222
- const unscopedService = (service) => containers.filter(
7223
- (container) => hasExactComposeIdentity(container, {
7224
- repoPath,
7225
- service,
7226
- composeFiles: resolvedPlan.composeFiles
7227
- })
7228
- );
7229
- const inspectService = (service, statuses) => {
7230
- const exact = exactService(service);
7231
- if (exact.length > 1) {
7232
- statuses[service] = "drifted";
7233
- drift.push(`managed service '${service}' identity is ambiguous`);
7234
- return;
7235
- }
7236
- if (exact.length === 0) {
7237
- const foreign = unscopedService(service);
7238
- statuses[service] = foreign.length > 0 ? "foreign" : "missing";
7239
- if (foreign.length > 0) drift.push(`managed service '${service}' is foreign`);
7240
- return;
7241
- }
7242
- const status = containerResourceStatus(exact[0]);
7243
- statuses[service] = status;
7244
- if (isActiveResourceStatus(status)) {
7245
- if (statuses === serviceStatuses) activeServices.push(service);
7246
- }
7247
- };
7248
- for (const service of registeredServices) inspectService(service, serviceStatuses);
7249
- for (const service of baseServices) inspectService(service, baseStatuses);
7250
- const managedServicesActive = activeServices.length > 0 || Object.values(baseStatuses).some(isActiveResourceStatus);
7251
- const primaryWorkspacePath = primary?.mounts.find(
7252
- (mount) => mount.Type === "bind" && sameWorkspacePath(mount.Source, repoPath)
7253
- )?.Destination;
7254
- const primaryActive = Boolean(primary?.state.Running);
7255
- if (generatedConfigMissing && (state || primaryActive || managedServicesActive)) {
7256
- drift.push("managed generated Dev Container configuration is missing");
7126
+ const match = /^(\d{3})\t([^\r\n]*)$/.exec(metadata);
7127
+ if (!match) {
7128
+ return transportFailure();
7257
7129
  }
7258
- const primaryHealth = primary?.state.Health?.Status;
7259
- const primaryReady = primaryActive && primaryHealth !== "starting" && primaryHealth !== "unhealthy";
7260
- if (!primary) {
7261
- if (managedServicesActive) {
7262
- drift.push("managed primary container is missing");
7263
- }
7264
- } else if (!primaryActive) {
7265
- if (managedServicesActive) {
7266
- drift.push("managed primary container is stopped");
7267
- }
7268
- } else if (primaryHealth === "unhealthy") {
7269
- drift.push("managed primary container is unhealthy");
7130
+ const status = Number(match[1]);
7131
+ if (status < 100 || status > 599) {
7132
+ return transportFailure();
7270
7133
  }
7271
- if (primary && primaryActive && primaryWorkspacePath) {
7272
- for (const process2 of registeredProcesses) {
7273
- let status;
7274
- try {
7275
- status = runManagedProcessAction({
7276
- container: { id: primary.id, workspacePath: primaryWorkspacePath },
7277
- name: process2,
7278
- action: "status",
7279
- quiet: true
7280
- });
7281
- } catch {
7282
- status = "drifted";
7283
- }
7284
- processStatuses[process2] = status;
7285
- if (status === "running") activeProcesses.push(process2);
7286
- if (desiredProcesses.includes(process2) && status !== "running") {
7287
- drift.push(`managed process '${process2}' is not running`);
7288
- }
7289
- }
7290
- } else {
7291
- if (primary && !primaryActive) {
7292
- for (const process2 of registeredProcesses) processStatuses[process2] = "stopped";
7293
- }
7294
- if (desiredProcesses.length > 0 && (primaryActive || managedServicesActive)) {
7295
- drift.push("managed processes cannot be proven without the primary container");
7296
- }
7134
+ const statuses = readiness.statuses ?? [200];
7135
+ const statusAllowed = statuses.includes(status);
7136
+ const mediaFailure = readiness.contentType ? contentTypeFailure(match[2], readiness.contentType) : void 0;
7137
+ if (statusAllowed && mediaFailure === void 0) {
7138
+ return { ok: true, status, details: `HTTP ${status}` };
7297
7139
  }
7298
- for (const service of desiredServices) {
7299
- const status = serviceStatuses[service] ?? "missing";
7300
- if (!isReadyResourceStatus(status) && status !== "starting" && (primaryActive || managedServicesActive)) {
7301
- drift.push(`managed service '${service}' is not ready`);
7302
- }
7140
+ const reasons = [
7141
+ ...statusAllowed ? [] : ["status not allowed"],
7142
+ ...mediaFailure ? [mediaFailure] : []
7143
+ ];
7144
+ return {
7145
+ ok: false,
7146
+ status,
7147
+ details: `HTTP ${status}; ${reasons.join("; ")}`,
7148
+ classification: "application-contract"
7149
+ };
7150
+ }
7151
+ function contentTypeFailure(responseContentType, expectedContentType) {
7152
+ const normalized = responseContentType.split(";", 1)[0]?.trim().toLowerCase() ?? "";
7153
+ if (normalized.length === 0) {
7154
+ return "content type missing";
7303
7155
  }
7304
- for (const service of baseServices) {
7305
- const status = baseStatuses[service] ?? "missing";
7306
- if (!isReadyResourceStatus(status) && status !== "starting" && (primaryActive || managedServicesActive)) {
7307
- drift.push(`managed base service '${service}' is not ready`);
7308
- }
7156
+ const separator = normalized.indexOf("/");
7157
+ if (separator <= 0 || separator === normalized.length - 1 || normalized.indexOf("/", separator + 1) !== -1 || !MIME_TOKEN_RE2.test(normalized.slice(0, separator)) || !MIME_TOKEN_RE2.test(normalized.slice(separator + 1))) {
7158
+ return "content type invalid";
7309
7159
  }
7160
+ return normalized === expectedContentType.toLowerCase() ? void 0 : "content type mismatch";
7161
+ }
7162
+ function transportFailure() {
7310
7163
  return {
7311
- plan,
7312
- generatedConfigSha256,
7313
- composeProject,
7314
- primaryReady,
7315
- primaryActive,
7316
- baseStatuses,
7317
- serviceStatuses,
7318
- processStatuses,
7319
- activeServices: sortedUnique(activeServices),
7320
- activeProcesses: sortedUnique(activeProcesses),
7321
- drift
7164
+ ok: false,
7165
+ details: "HTTP route probe transport failure",
7166
+ classification: "transport"
7322
7167
  };
7323
7168
  }
7324
- function collectManagedRuntimeStatus(options) {
7325
- if (!options.config.managedRuntime) {
7326
- return {
7327
- mode: "legacy",
7328
- status: "legacy",
7329
- profile: options.profile,
7330
- desired: { ...EMPTY_RESOURCES },
7331
- active: { ...EMPTY_RESOURCES },
7332
- serviceStatuses: {},
7333
- baseServiceStatuses: {},
7334
- processStatuses: {},
7335
- drift: []
7336
- };
7337
- }
7338
- const managedRuntime = options.config.managedRuntime;
7339
- const hasResolvedProfile = options.resolvedProfile !== void 0;
7340
- const routedApps = options.config.apps.filter((app) => app.kind !== "dependency").map((app) => app.name);
7341
- const registeredServices = sortedUnique(managedRuntime.devcontainer.profileServices);
7342
- const registeredProcesses = sortedUnique(managedRuntime.processes);
7343
- const desiredApps = selectedResources(
7344
- options.resolvedProfile?.apps,
7345
- routedApps,
7346
- hasResolvedProfile
7347
- );
7348
- const desiredServices = selectedResources(
7349
- options.resolvedProfile?.devcontainerServices,
7350
- registeredServices,
7351
- hasResolvedProfile
7352
- );
7353
- const desiredProcesses = selectedResources(
7354
- options.resolvedProfile?.processes,
7355
- registeredProcesses,
7356
- hasResolvedProfile
7357
- );
7358
- let state;
7359
- let stateReadFailed = false;
7169
+
7170
+ // src/core/managed-host-preparation.ts
7171
+ var import_node_child_process19 = require("child_process");
7172
+ var import_node_fs21 = __toESM(require("fs"));
7173
+ var PREPARATION_TIMEOUT_MS = 6e4;
7174
+ var CONFIG_READ_ERROR = "Managed host preparation could not read .devrouter.yml.";
7175
+ var CONFIG_CHANGED_ERROR = "Managed host preparation changed .devrouter.yml; refusing to continue.";
7176
+ var START_ERROR = "Managed host preparation could not start.";
7177
+ var FAILURE_ERROR = "Managed host preparation failed.";
7178
+ var TIMEOUT_ERROR = "Managed host preparation timed out after 60 seconds.";
7179
+ function runPreparationCommand(argv, cwd) {
7180
+ return new Promise((resolve) => {
7181
+ let child;
7182
+ try {
7183
+ child = (0, import_node_child_process19.spawn)(argv[0], argv.slice(1), {
7184
+ cwd,
7185
+ detached: true,
7186
+ shell: false,
7187
+ stdio: "ignore"
7188
+ });
7189
+ } catch {
7190
+ resolve("start-error");
7191
+ return;
7192
+ }
7193
+ let timedOut = false;
7194
+ const deadline = setTimeout(() => {
7195
+ timedOut = true;
7196
+ if (child.pid !== void 0 && child.exitCode === null && child.signalCode === null) {
7197
+ try {
7198
+ process.kill(-child.pid, "SIGKILL");
7199
+ } catch {
7200
+ }
7201
+ }
7202
+ }, PREPARATION_TIMEOUT_MS);
7203
+ child.once("error", () => {
7204
+ clearTimeout(deadline);
7205
+ resolve("start-error");
7206
+ });
7207
+ child.once("close", (code) => {
7208
+ clearTimeout(deadline);
7209
+ resolve(timedOut ? "timeout" : code === 0 ? "success" : "failure");
7210
+ });
7211
+ });
7212
+ }
7213
+ function readConfigBytes(configPath) {
7360
7214
  try {
7361
- state = options.candidateState ?? readManagedRuntimeState(options.repoPath, options.workspace);
7215
+ return import_node_fs21.default.readFileSync(configPath);
7362
7216
  } catch {
7363
- stateReadFailed = true;
7217
+ return void 0;
7364
7218
  }
7365
- const drift = [];
7366
- if (stateReadFailed) drift.push("managed runtime state could not be read");
7367
- if (state?.status === "degraded") drift.push("the last managed transition is degraded");
7368
- if (state) {
7369
- const desiredState = {
7370
- apps: sortedUnique(state.desired.apps),
7371
- services: sortedUnique(state.desired.services),
7372
- processes: sortedUnique(state.desired.processes)
7373
- };
7374
- const desired = {
7375
- apps: desiredApps,
7376
- services: desiredServices,
7377
- processes: desiredProcesses
7378
- };
7379
- for (const [label, expected, actual] of [
7380
- ["apps", desired.apps, desiredState.apps],
7381
- ["services", desired.services, desiredState.services],
7382
- ["processes", desired.processes, desiredState.processes]
7383
- ]) {
7384
- const mismatch = compareResourceSet(label, expected, actual);
7385
- if (mismatch) drift.push(mismatch);
7386
- }
7387
- if (state.profile !== options.profile)
7388
- drift.push("active profile differs from the selected profile");
7219
+ }
7220
+ async function runManagedHostPreparation(repoPath, config) {
7221
+ const prepareCommand = config.managedRuntime?.devcontainer.prepareCommand;
7222
+ if (!prepareCommand) return;
7223
+ if (process.platform === "win32") {
7224
+ throw new Error("Managed host preparation requires POSIX process-group ownership.");
7389
7225
  }
7390
- const inspection = inspectManagedRuntime({
7391
- repoPath: options.repoPath,
7392
- config: options.config,
7393
- registeredServices,
7394
- registeredProcesses,
7395
- desiredServices,
7396
- desiredProcesses,
7397
- workspace: options.workspace,
7398
- state
7399
- });
7400
- drift.push(...inspection.drift);
7401
- if (state && inspection.plan && state.sourceConfigSha256 !== inspection.plan.sourceConfigSha256) {
7402
- drift.push("managed Dev Container source configuration changed");
7226
+ const resolvedRepoPath = resolveRepoPath(repoPath);
7227
+ const configPath = getRepoConfigPath(resolvedRepoPath);
7228
+ const before = readConfigBytes(configPath);
7229
+ if (!before) {
7230
+ throw new Error(CONFIG_READ_ERROR);
7403
7231
  }
7404
- if (state && inspection.plan && state.effectiveConfigSha256 !== inspection.plan.effectiveConfigSha256) {
7405
- drift.push("managed Dev Container effective configuration changed");
7232
+ const result = await runPreparationCommand(prepareCommand, resolvedRepoPath);
7233
+ const after = readConfigBytes(configPath);
7234
+ if (!after?.equals(before)) {
7235
+ throw new Error(CONFIG_CHANGED_ERROR);
7406
7236
  }
7407
- if (state && inspection.generatedConfigSha256 && state.effectiveConfigSha256 !== inspection.generatedConfigSha256) {
7408
- drift.push("managed generated Dev Container configuration differs from last successful state");
7237
+ if (result === "start-error") {
7238
+ throw new Error(START_ERROR);
7409
7239
  }
7410
- const routes = exactRoutes(options.repoPath, options.workspace);
7411
- drift.push(...routes.drift);
7412
- const activeApps = routes.apps;
7413
- const anyActive = inspection.primaryActive || inspection.activeServices.length > 0 || Object.values(inspection.baseStatuses).some(isActiveResourceStatus) || inspection.activeProcesses.length > 0 || activeApps.length > 0;
7414
- if (activeApps.length > 0 && !inspection.primaryActive) {
7415
- drift.push("active app routes exist without a running primary container");
7240
+ if (result === "timeout") {
7241
+ throw new Error(TIMEOUT_ERROR);
7416
7242
  }
7417
- if (desiredApps.some((app) => !activeApps.includes(app)) && anyActive) {
7418
- drift.push("one or more selected app routes are missing");
7243
+ if (result === "failure") {
7244
+ throw new Error(FAILURE_ERROR);
7419
7245
  }
7420
- if (activeApps.some((app) => !desiredApps.includes(app))) {
7421
- drift.push("one or more active app routes are outside the selected profile");
7246
+ }
7247
+
7248
+ // src/core/managed-post-start.ts
7249
+ var import_node_child_process20 = require("child_process");
7250
+ var import_node_crypto10 = require("crypto");
7251
+ var import_node_fs22 = __toESM(require("fs"));
7252
+ var import_node_path26 = __toESM(require("path"));
7253
+ var MANAGED_MARKER = "devrouter:managed devcontainer";
7254
+ var MANAGED_ADAPTER_PATH = ".devcontainer/post-start.sh";
7255
+ var RUNTIME_HELPER_PATH = "/tmp/devrouter/bin/devrouter-process";
7256
+ var ADAPTER_WRAPPER = `adapter_snapshot="$1"
7257
+ shift
7258
+ readonly DEVROUTER_PROCESS_HELPER DEVROUTER_PROCESS_ADAPTER_SHA256
7259
+ source "$adapter_snapshot"
7260
+ `;
7261
+ function renderDeliveryScript(targetPath) {
7262
+ if (!/^\/tmp\/devrouter\/bin\/[a-zA-Z0-9._-]+$/.test(targetPath)) {
7263
+ throw new Error(`Unsafe managed runtime delivery path: ${targetPath}`);
7422
7264
  }
7423
- if (inspection.activeServices.some((service) => !desiredServices.includes(service))) {
7424
- drift.push("one or more active services are outside the selected profile");
7265
+ const temporaryPrefix = import_node_path26.default.posix.basename(targetPath);
7266
+ return `set -eu
7267
+ umask 077
7268
+ runtime_root=/tmp/devrouter
7269
+ runtime_bin="$runtime_root/bin"
7270
+ target="${targetPath}"
7271
+ if [ -L "$runtime_root" ] || [ -L "$runtime_bin" ]; then
7272
+ echo "Refusing symlinked devrouter runtime path." >&2
7273
+ exit 1
7274
+ fi
7275
+ mkdir -p "$runtime_bin"
7276
+ chmod 700 "$runtime_root" "$runtime_bin"
7277
+ temporary="$(mktemp "$runtime_bin/.${temporaryPrefix}.XXXXXX")"
7278
+ trap 'rm -f "$temporary"' EXIT
7279
+ cat > "$temporary"
7280
+ chmod 700 "$temporary"
7281
+ mv -f "$temporary" "$target"
7282
+ trap - EXIT
7283
+ `;
7284
+ }
7285
+ function commandFailure3(result) {
7286
+ return [result.error?.message, result.stderr, result.stdout].filter(Boolean).map((value) => String(value).trim()).filter(Boolean).join("\n");
7287
+ }
7288
+ function deliverRuntimeFile(containerId, targetPath, contents, label) {
7289
+ const result = (0, import_node_child_process20.spawnSync)(
7290
+ "docker",
7291
+ ["exec", "-i", containerId, "sh", "-c", renderDeliveryScript(targetPath)],
7292
+ { input: contents, encoding: "utf-8" }
7293
+ );
7294
+ if (result.status !== 0) {
7295
+ const details = commandFailure3(result);
7296
+ throw new Error(`Could not deliver ${label}${details ? `: ${details}` : "."}`);
7425
7297
  }
7426
- if (inspection.activeProcesses.some((process2) => !desiredProcesses.includes(process2))) {
7427
- drift.push("one or more active processes are outside the selected profile");
7298
+ }
7299
+ function readRegularFileBytes(filePath) {
7300
+ if (!import_node_fs22.default.existsSync(filePath) || !import_node_fs22.default.lstatSync(filePath).isFile()) return void 0;
7301
+ return import_node_fs22.default.readFileSync(filePath);
7302
+ }
7303
+ function readRegularFile(filePath) {
7304
+ return readRegularFileBytes(filePath)?.toString("utf-8");
7305
+ }
7306
+ function adapterFingerprint(adapter) {
7307
+ return (0, import_node_crypto10.createHash)("sha256").update(adapter).digest("hex");
7308
+ }
7309
+ function resolveProcessHelperPath() {
7310
+ const candidates = [
7311
+ import_node_path26.default.resolve(__dirname, "..", "bin", "devrouter-process"),
7312
+ import_node_path26.default.resolve(__dirname, "..", "..", "bin", "devrouter-process")
7313
+ ];
7314
+ const helperPath = candidates.find((candidate) => import_node_fs22.default.existsSync(candidate));
7315
+ if (!helperPath) {
7316
+ throw new Error("Could not locate the packaged devrouter-process helper.");
7428
7317
  }
7429
- const positivelyStopped = !inspection.primaryActive && Object.values(inspection.baseStatuses).every(isStoppedOrMissingResourceStatus) && Object.values(inspection.serviceStatuses).every(isStoppedOrMissingResourceStatus) && activeApps.length === 0;
7430
- const onlyDegradedIncident = state?.status === "degraded" && drift.length === 1;
7431
- const requiredServicesReady = desiredServices.every(
7432
- (service) => isReadyResourceStatus(inspection.serviceStatuses[service] ?? "missing")
7318
+ return helperPath;
7319
+ }
7320
+ function resolveManagedPostStartPlan(repoPath) {
7321
+ const adapterPath = import_node_path26.default.join(repoPath, MANAGED_ADAPTER_PATH);
7322
+ const adapterBytes = readRegularFileBytes(adapterPath);
7323
+ const adapterText = adapterBytes?.toString("utf-8");
7324
+ const dockerfilePath = import_node_path26.default.join(repoPath, ".devcontainer", "Dockerfile");
7325
+ const devcontainerPath = import_node_path26.default.join(repoPath, ".devcontainer", "devcontainer.json");
7326
+ const dockerfile = readRegularFile(dockerfilePath) ?? "";
7327
+ const devcontainer = readRegularFile(devcontainerPath) ?? "";
7328
+ const devrouterPattern = [adapterText, dockerfile, devcontainer].filter((value) => value !== void 0).some(
7329
+ (value) => value.includes("DEVROUTER_PROCESS_HELPER") || value.includes("devrouter-process")
7433
7330
  );
7434
- const baseServicesReady = Object.values(inspection.baseStatuses).every(isReadyResourceStatus);
7435
- const processesReady = desiredProcesses.every(
7436
- (process2) => isReadyResourceStatus(inspection.processStatuses[process2] ?? "missing")
7331
+ if (adapterBytes === void 0) {
7332
+ if (devrouterPattern) {
7333
+ throw new Error(
7334
+ "Devrouter lifecycle wiring is incomplete: add the managed post-start adapter or remove the stale devrouter-process references."
7335
+ );
7336
+ }
7337
+ return { kind: "unmanaged" };
7338
+ }
7339
+ const adapter = adapterBytes.toString("utf-8");
7340
+ if (!adapter.includes(MANAGED_MARKER)) {
7341
+ if (devrouterPattern) {
7342
+ throw new Error(
7343
+ "Devrouter-looking post-start adapter is missing the 'devrouter:managed devcontainer' marker. Regenerate or migrate the devcontainer, then retry devrouter ensure."
7344
+ );
7345
+ }
7346
+ return { kind: "unmanaged" };
7347
+ }
7348
+ assertManagedDevcontainerLifecycle(repoPath);
7349
+ if (adapter.includes("DEVROUTER_PROCESS_HELPER")) {
7350
+ return {
7351
+ kind: "runtime",
7352
+ adapterPath: MANAGED_ADAPTER_PATH,
7353
+ adapterSha256: adapterFingerprint(adapterBytes),
7354
+ adapterContents: adapterBytes
7355
+ };
7356
+ }
7357
+ if (dockerfile.includes("devrouter-process") && devcontainer.includes("postStartCommand")) {
7358
+ return { kind: "legacy" };
7359
+ }
7360
+ throw new Error(
7361
+ "Managed post-start must use DEVROUTER_PROCESS_HELPER before removing the legacy image helper and postStartCommand. Regenerate or migrate the devcontainer, then retry devrouter ensure."
7362
+ );
7363
+ }
7364
+ function runManagedPostStart(options) {
7365
+ if (options.plan.kind !== "runtime") return;
7366
+ const helper = import_node_fs22.default.readFileSync(resolveProcessHelperPath());
7367
+ deliverRuntimeFile(
7368
+ options.container.id,
7369
+ RUNTIME_HELPER_PATH,
7370
+ helper,
7371
+ "the managed process helper"
7372
+ );
7373
+ const runtimeAdapterPath = `/tmp/devrouter/bin/managed-post-start-${options.plan.adapterSha256}`;
7374
+ deliverRuntimeFile(
7375
+ options.container.id,
7376
+ runtimeAdapterPath,
7377
+ options.plan.adapterContents,
7378
+ "the managed post-start adapter"
7379
+ );
7380
+ const started2 = (0, import_node_child_process20.spawnSync)(
7381
+ "docker",
7382
+ [
7383
+ "exec",
7384
+ "--workdir",
7385
+ options.container.workspacePath,
7386
+ "--env",
7387
+ `DEVROUTER_PROCESS_HELPER=${RUNTIME_HELPER_PATH}`,
7388
+ "--env",
7389
+ `DEVROUTER_PROCESS_ADAPTER_SHA256=${options.plan.adapterSha256}`,
7390
+ ...options.profile ? ["--env", `DEVROUTER_PROFILE=${options.profile}`] : [],
7391
+ ...options.processes ? [
7392
+ "--env",
7393
+ `DEVROUTER_PROCESS_SET=${options.processes.join(",")}`,
7394
+ "--env",
7395
+ "DEVROUTER_PROCESS_FINGERPRINT_ENV=DEVROUTER_PROFILE,DEVROUTER_PROCESS_SET"
7396
+ ] : [],
7397
+ options.container.id,
7398
+ "bash",
7399
+ "-c",
7400
+ ADAPTER_WRAPPER,
7401
+ options.plan.adapterPath,
7402
+ runtimeAdapterPath
7403
+ ],
7404
+ { stdio: options.quiet ? ["ignore", 2, "inherit"] : "inherit" }
7437
7405
  );
7438
- const routesReady = desiredApps.every((app) => activeApps.includes(app));
7439
- const ready = inspection.primaryReady && requiredServicesReady && baseServicesReady && processesReady && routesReady && !stateReadFailed && !drift.length;
7440
- const status = positivelyStopped && onlyDegradedIncident ? "stopped" : state?.status === "degraded" ? "failed-transition" : drift.length > 0 ? "drifted" : ready ? "ready" : anyActive ? "starting" : "stopped";
7441
- const active = {
7442
- apps: activeApps,
7443
- services: inspection.activeServices,
7444
- processes: inspection.activeProcesses
7445
- };
7446
- return {
7447
- mode: "managed",
7448
- status,
7449
- profile: options.profile,
7450
- ...state?.profile ? { activeProfile: state.profile } : {},
7451
- ...options.workspace !== void 0 ? { workspace: options.workspace } : {},
7452
- ...state?.devpodId ? { devpodId: state.devpodId } : {},
7453
- ...inspection.composeProject ? { composeProject: inspection.composeProject } : {},
7454
- desired: {
7455
- apps: desiredApps,
7456
- services: desiredServices,
7457
- processes: desiredProcesses
7458
- },
7459
- active,
7460
- serviceStatuses: inspection.serviceStatuses,
7461
- baseServiceStatuses: inspection.baseStatuses,
7462
- processStatuses: inspection.processStatuses,
7463
- drift: sortedUnique(drift),
7464
- ...inspection.plan?.sourceConfigSha256 || state?.sourceConfigSha256 ? { sourceConfigSha256: inspection.plan?.sourceConfigSha256 ?? state?.sourceConfigSha256 } : {},
7465
- ...inspection.plan?.effectiveConfigSha256 || state?.effectiveConfigSha256 ? {
7466
- effectiveConfigSha256: inspection.plan?.effectiveConfigSha256 ?? state?.effectiveConfigSha256
7467
- } : {},
7468
- ...state?.transitionPhase ? { transitionPhase: state.transitionPhase } : {}
7469
- };
7406
+ if (started2.status !== 0) {
7407
+ const details = commandFailure3(started2);
7408
+ throw new Error(`Managed post-start failed${details ? `: ${details}.` : "."}`);
7409
+ }
7470
7410
  }
7471
-
7472
- // src/core/traefik-route-health.ts
7473
- var import_node_child_process20 = require("child_process");
7474
- var import_node_fs22 = __toESM(require("fs"));
7475
- var import_node_path26 = __toESM(require("path"));
7476
- var ROUTER_API_BASE = "http://127.0.0.1:8080/api";
7477
- var ROUTER_RELOAD_LOCK_FILE = import_node_path26.default.join(DEVROUTER_HOME, "router-reload.lock");
7478
- var ROUTER_API_REQUEST_TIMEOUT_SECONDS = "2";
7479
- var DEFAULT_INITIAL_TIMEOUT_MS = 3e3;
7480
- var DEFAULT_RECOVERY_TIMEOUT_MS = 1e4;
7481
- var DEFAULT_POLL_INTERVAL_MS = 250;
7482
- var ROUTER_API_PAGE_SIZE = 1e3;
7483
- function sleep(ms) {
7484
- return new Promise((resolve) => setTimeout(resolve, ms));
7411
+ function assertSafeProcessName(name) {
7412
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) {
7413
+ throw new Error(`Managed process '${name}' is not a safe exact identifier.`);
7414
+ }
7485
7415
  }
7486
- function inspectRouterApi(protocol) {
7416
+ function runManagedProcessAction(options) {
7417
+ assertSafeProcessName(options.name);
7487
7418
  const result = (0, import_node_child_process20.spawnSync)(
7488
- "curl",
7419
+ "docker",
7489
7420
  [
7490
- "--silent",
7491
- "--show-error",
7492
- "--fail",
7493
- "--max-time",
7494
- ROUTER_API_REQUEST_TIMEOUT_SECONDS,
7495
- `${ROUTER_API_BASE}/${protocol}/routers?per_page=${ROUTER_API_PAGE_SIZE}`
7421
+ "exec",
7422
+ "--workdir",
7423
+ options.container.workspacePath,
7424
+ "--env",
7425
+ `DEVROUTER_PROCESS_HELPER=${RUNTIME_HELPER_PATH}`,
7426
+ options.container.id,
7427
+ "bash",
7428
+ "-c",
7429
+ 'exec "$DEVROUTER_PROCESS_HELPER" "$1" --name "$2"',
7430
+ "devrouter-process",
7431
+ options.action,
7432
+ options.name
7496
7433
  ],
7497
- { encoding: "utf-8" }
7434
+ {
7435
+ encoding: "utf-8",
7436
+ stdio: options.action === "status" ? ["ignore", "pipe", "pipe"] : ["ignore", 2, "inherit"]
7437
+ }
7498
7438
  );
7499
- if (result.status !== 0) {
7500
- return {
7501
- ok: false,
7502
- details: result.stderr?.trim() || `curl exited with status ${result.status ?? "unknown"}`
7503
- };
7439
+ if (options.action === "stop") {
7440
+ if (result.status !== 0) {
7441
+ throw new Error(`Could not stop managed process '${options.name}'.`);
7442
+ }
7443
+ return "stopped";
7504
7444
  }
7505
- let value;
7506
7445
  try {
7507
- value = JSON.parse(result.stdout);
7446
+ const parsed = JSON.parse(result.stdout.trim());
7447
+ if (parsed.status === "running" || parsed.status === "stopped" || parsed.status === "foreign" || parsed.status === "drifted") {
7448
+ return parsed.status;
7449
+ }
7508
7450
  } catch {
7509
- return { ok: false, details: `${protocol.toUpperCase()} router API returned invalid JSON` };
7510
7451
  }
7511
- if (!Array.isArray(value)) {
7512
- return { ok: false, details: `${protocol.toUpperCase()} router API returned a non-array` };
7452
+ return "drifted";
7453
+ }
7454
+
7455
+ // src/core/managed-runtime-status.ts
7456
+ var EMPTY_RESOURCES = {
7457
+ apps: [],
7458
+ services: [],
7459
+ processes: []
7460
+ };
7461
+ function sortedUnique(values) {
7462
+ return Array.from(new Set(values)).sort((left, right) => left.localeCompare(right));
7463
+ }
7464
+ function selectedResources(values, allValues, hasResolvedProfile) {
7465
+ if (values === void 0) return hasResolvedProfile ? [] : sortedUnique(allValues);
7466
+ return values.length === 1 && values[0] === "*" ? sortedUnique(allValues) : sortedUnique(values);
7467
+ }
7468
+ function containerResourceStatus(container) {
7469
+ if (!container) return "missing";
7470
+ if (!container.state.Running) return "stopped";
7471
+ const health = container.state.Health?.Status;
7472
+ if (health === "starting") return "starting";
7473
+ if (health === "unhealthy") return "unhealthy";
7474
+ return "healthy";
7475
+ }
7476
+ function isActiveResourceStatus(status) {
7477
+ return status === "running" || status === "healthy" || status === "starting" || status === "unhealthy";
7478
+ }
7479
+ function isReadyResourceStatus(status) {
7480
+ return status === "running" || status === "healthy";
7481
+ }
7482
+ function isStoppedOrMissingResourceStatus(status) {
7483
+ return status === "stopped" || status === "missing";
7484
+ }
7485
+ function exactRoutes(repoPath, workspace) {
7486
+ try {
7487
+ const routes = listHostRouteState().filter(
7488
+ (route) => sameWorkspacePath(route.repoPath, repoPath) && route.workspace === workspace
7489
+ );
7490
+ return { apps: sortedUnique(routes.map((route) => route.name)), drift: [] };
7491
+ } catch {
7492
+ return { apps: [], drift: ["managed route state could not be inspected"] };
7513
7493
  }
7514
- return {
7515
- ok: true,
7516
- reachedPageLimit: value.length >= ROUTER_API_PAGE_SIZE,
7517
- names: new Set(
7518
- value.flatMap((item) => {
7519
- if (!item || typeof item !== "object") return [];
7520
- const name = item.name;
7521
- return typeof name === "string" ? [name] : [];
7522
- })
7523
- )
7524
- };
7525
7494
  }
7526
- function isRecord3(value) {
7527
- return value !== null && typeof value === "object" && !Array.isArray(value);
7495
+ function compareResourceSet(label, expected, actual) {
7496
+ if (expected.length !== actual.length || expected.some((value, index) => value !== actual[index])) {
7497
+ return `active ${label} differ from the selected profile`;
7498
+ }
7499
+ return void 0;
7528
7500
  }
7529
- function inspectNamedRouterApi(protocol, resource, name) {
7530
- const result = (0, import_node_child_process20.spawnSync)(
7531
- "curl",
7532
- [
7533
- "--silent",
7534
- "--show-error",
7535
- "--fail",
7536
- "--max-time",
7537
- ROUTER_API_REQUEST_TIMEOUT_SECONDS,
7538
- `${ROUTER_API_BASE}/${protocol}/${resource}/${encodeURIComponent(name)}`
7539
- ],
7540
- { encoding: "utf-8" }
7541
- );
7542
- if (result.status !== 0) {
7501
+ function resourceStatusesForMissing(names, status) {
7502
+ return Object.fromEntries(names.map((name) => [name, status]));
7503
+ }
7504
+ function inspectManagedRuntime(options) {
7505
+ const {
7506
+ repoPath,
7507
+ config,
7508
+ registeredServices,
7509
+ registeredProcesses,
7510
+ desiredServices,
7511
+ desiredProcesses,
7512
+ workspace,
7513
+ state
7514
+ } = options;
7515
+ const drift = [];
7516
+ const baseServices = sortedUnique(config.managedRuntime?.devcontainer.baseServices ?? []);
7517
+ const serviceStatuses = resourceStatusesForMissing(registeredServices, "missing");
7518
+ const baseStatuses = resourceStatusesForMissing(baseServices, "missing");
7519
+ const processStatuses = resourceStatusesForMissing(registeredProcesses, "missing");
7520
+ const activeServices = [];
7521
+ const activeProcesses = [];
7522
+ let plan;
7523
+ try {
7524
+ plan = inspectManagedDevcontainerConfig({
7525
+ repoPath,
7526
+ config,
7527
+ profile: {
7528
+ apps: ["*"],
7529
+ devcontainerServices: desiredServices,
7530
+ processes: ["*"]
7531
+ },
7532
+ linked: workspace !== void 0
7533
+ });
7534
+ } catch {
7535
+ drift.push("managed Dev Container configuration could not be inspected");
7543
7536
  return {
7544
- ok: false,
7545
- details: result.stderr?.trim() || `curl exited with status ${result.status ?? "unknown"}`
7537
+ plan,
7538
+ primaryReady: false,
7539
+ primaryActive: false,
7540
+ baseStatuses,
7541
+ serviceStatuses,
7542
+ processStatuses,
7543
+ activeServices,
7544
+ activeProcesses,
7545
+ drift
7546
+ };
7547
+ }
7548
+ let containers;
7549
+ try {
7550
+ containers = inspectWorkspaceContainers();
7551
+ } catch {
7552
+ drift.push("managed workspace containers could not be inspected");
7553
+ return {
7554
+ plan,
7555
+ primaryReady: false,
7556
+ primaryActive: false,
7557
+ baseStatuses,
7558
+ serviceStatuses,
7559
+ processStatuses,
7560
+ activeServices,
7561
+ activeProcesses,
7562
+ drift
7563
+ };
7564
+ }
7565
+ if (!plan) {
7566
+ drift.push("managed Dev Container configuration could not be inspected");
7567
+ return {
7568
+ plan,
7569
+ primaryReady: false,
7570
+ primaryActive: false,
7571
+ baseStatuses,
7572
+ serviceStatuses,
7573
+ processStatuses,
7574
+ activeServices,
7575
+ activeProcesses,
7576
+ drift
7546
7577
  };
7547
7578
  }
7548
- let value;
7579
+ const resolvedPlan = plan;
7580
+ let generatedConfigSha256;
7581
+ let generatedConfigMissing = false;
7549
7582
  try {
7550
- value = JSON.parse(result.stdout);
7583
+ const generated = inspectManagedDevcontainerGeneratedConfig(resolvedPlan);
7584
+ generatedConfigSha256 = generated.sha256;
7585
+ if (generated.status === "missing") {
7586
+ generatedConfigMissing = true;
7587
+ } else if (generated.status === "foreign") {
7588
+ drift.push("managed generated Dev Container configuration is not devrouter-owned");
7589
+ } else if (generated.status === "drifted") {
7590
+ drift.push("managed generated Dev Container configuration changed");
7591
+ }
7551
7592
  } catch {
7552
- return {
7553
- ok: false,
7554
- details: `${protocol.toUpperCase()} ${resource.slice(0, -1)} API returned invalid JSON`
7555
- };
7593
+ drift.push("managed generated Dev Container configuration could not be inspected");
7556
7594
  }
7557
- if (!isRecord3(value)) {
7558
- return {
7559
- ok: false,
7560
- details: `${protocol.toUpperCase()} ${resource.slice(0, -1)} API returned a non-object`
7561
- };
7595
+ const appContainers = workspaceAppContainers(containers, repoPath).filter(
7596
+ (container) => hasExactComposeIdentity(container, {
7597
+ repoPath,
7598
+ service: resolvedPlan.primaryService,
7599
+ composeFiles: resolvedPlan.composeFiles
7600
+ })
7601
+ );
7602
+ const projectScopedPrimary = state?.composeProject ? appContainers.filter(
7603
+ (container) => container.labels["com.docker.compose.project"] === state.composeProject
7604
+ ) : appContainers;
7605
+ let primary;
7606
+ let composeProject = state?.composeProject;
7607
+ if (projectScopedPrimary.length === 1) {
7608
+ primary = projectScopedPrimary[0];
7609
+ composeProject = primary.labels["com.docker.compose.project"] ?? composeProject;
7610
+ } else if (projectScopedPrimary.length > 1) {
7611
+ drift.push("managed primary container identity is ambiguous");
7612
+ } else if (appContainers.length > 0) {
7613
+ drift.push(
7614
+ state?.composeProject ? "managed primary container is foreign" : "managed primary container identity is ambiguous"
7615
+ );
7562
7616
  }
7563
- return { ok: true, value };
7564
- }
7565
- function renderExpectedRoute(route) {
7566
- const protocol = route.protocol ?? "http";
7567
- const routerName = buildHostRouteRouterName(route.repoPath, route.name);
7568
- const routeState = {
7569
- id: buildHostRouteId(route.repoPath, route.name),
7570
- name: route.name,
7571
- host: route.host,
7572
- protocol,
7573
- tcpProtocol: route.tcpProtocol,
7574
- repoPath: route.repoPath,
7575
- port: route.port,
7576
- mode: route.mode,
7577
- upstreamHost: route.upstreamHost,
7578
- pid: route.pid,
7579
- command: route.command,
7580
- workspace: route.workspace,
7581
- createdAt: "",
7582
- updatedAt: ""
7617
+ if (!composeProject && appContainers.length === 1) {
7618
+ composeProject = appContainers[0].labels["com.docker.compose.project"];
7619
+ }
7620
+ if (primary && !composeProject) {
7621
+ drift.push("managed primary container has no Compose project identity");
7622
+ }
7623
+ const exactService = (service) => containers.filter(
7624
+ (container) => hasExactComposeIdentity(container, {
7625
+ repoPath,
7626
+ service,
7627
+ composeProject,
7628
+ composeFiles: resolvedPlan.composeFiles
7629
+ })
7630
+ );
7631
+ const unscopedService = (service) => containers.filter(
7632
+ (container) => hasExactComposeIdentity(container, {
7633
+ repoPath,
7634
+ service,
7635
+ composeFiles: resolvedPlan.composeFiles
7636
+ })
7637
+ );
7638
+ const inspectService = (service, statuses) => {
7639
+ const exact = exactService(service);
7640
+ if (exact.length > 1) {
7641
+ statuses[service] = "drifted";
7642
+ drift.push(`managed service '${service}' identity is ambiguous`);
7643
+ return;
7644
+ }
7645
+ if (exact.length === 0) {
7646
+ const foreign = unscopedService(service);
7647
+ statuses[service] = foreign.length > 0 ? "foreign" : "missing";
7648
+ if (foreign.length > 0) drift.push(`managed service '${service}' is foreign`);
7649
+ return;
7650
+ }
7651
+ const status = containerResourceStatus(exact[0]);
7652
+ statuses[service] = status;
7653
+ if (isActiveResourceStatus(status)) {
7654
+ if (statuses === serviceStatuses) activeServices.push(service);
7655
+ }
7583
7656
  };
7584
- const document = buildHostRoutesDocument([routeState], false);
7585
- const protocolDocument = document[protocol];
7586
- if (!isRecord3(protocolDocument)) return void 0;
7587
- const routers = protocolDocument.routers;
7588
- const services = protocolDocument.services;
7589
- if (!isRecord3(routers) || !isRecord3(services)) return void 0;
7590
- const routerKey = routerName.slice(0, routerName.lastIndexOf("@"));
7591
- const router = routers[routerKey];
7592
- const service = services[routerKey];
7593
- if (!isRecord3(router) || !isRecord3(service)) return void 0;
7594
- const rule = router.rule;
7595
- const serviceReference = router.service;
7596
- const loadBalancer = service.loadBalancer;
7597
- const servers = isRecord3(loadBalancer) ? loadBalancer.servers : void 0;
7598
- if (typeof rule !== "string" || serviceReference !== routerKey || !isRecord3(loadBalancer) || !Array.isArray(servers) || servers.length !== 1 || !isRecord3(servers[0])) {
7599
- return void 0;
7657
+ for (const service of registeredServices) inspectService(service, serviceStatuses);
7658
+ for (const service of baseServices) inspectService(service, baseStatuses);
7659
+ const managedServicesActive = activeServices.length > 0 || Object.values(baseStatuses).some(isActiveResourceStatus);
7660
+ const primaryWorkspacePath = primary?.mounts.find(
7661
+ (mount) => mount.Type === "bind" && sameWorkspacePath(mount.Source, repoPath)
7662
+ )?.Destination;
7663
+ const primaryActive = Boolean(primary?.state.Running);
7664
+ if (generatedConfigMissing && (state || primaryActive || managedServicesActive)) {
7665
+ drift.push("managed generated Dev Container configuration is missing");
7666
+ }
7667
+ const primaryHealth = primary?.state.Health?.Status;
7668
+ const primaryReady = primaryActive && primaryHealth !== "starting" && primaryHealth !== "unhealthy";
7669
+ if (!primary) {
7670
+ if (managedServicesActive) {
7671
+ drift.push("managed primary container is missing");
7672
+ }
7673
+ } else if (!primaryActive) {
7674
+ if (managedServicesActive) {
7675
+ drift.push("managed primary container is stopped");
7676
+ }
7677
+ } else if (primaryHealth === "unhealthy") {
7678
+ drift.push("managed primary container is unhealthy");
7679
+ }
7680
+ if (primary && primaryActive && primaryWorkspacePath) {
7681
+ for (const process2 of registeredProcesses) {
7682
+ let status;
7683
+ try {
7684
+ status = runManagedProcessAction({
7685
+ container: { id: primary.id, workspacePath: primaryWorkspacePath },
7686
+ name: process2,
7687
+ action: "status",
7688
+ quiet: true
7689
+ });
7690
+ } catch {
7691
+ status = "drifted";
7692
+ }
7693
+ processStatuses[process2] = status;
7694
+ if (status === "running") activeProcesses.push(process2);
7695
+ if (desiredProcesses.includes(process2) && status !== "running") {
7696
+ drift.push(`managed process '${process2}' is not running`);
7697
+ }
7698
+ }
7699
+ } else {
7700
+ if (primary && !primaryActive) {
7701
+ for (const process2 of registeredProcesses) processStatuses[process2] = "stopped";
7702
+ }
7703
+ if (desiredProcesses.length > 0 && (primaryActive || managedServicesActive)) {
7704
+ drift.push("managed processes cannot be proven without the primary container");
7705
+ }
7706
+ }
7707
+ for (const service of desiredServices) {
7708
+ const status = serviceStatuses[service] ?? "missing";
7709
+ if (!isReadyResourceStatus(status) && status !== "starting" && (primaryActive || managedServicesActive)) {
7710
+ drift.push(`managed service '${service}' is not ready`);
7711
+ }
7712
+ }
7713
+ for (const service of baseServices) {
7714
+ const status = baseStatuses[service] ?? "missing";
7715
+ if (!isReadyResourceStatus(status) && status !== "starting" && (primaryActive || managedServicesActive)) {
7716
+ drift.push(`managed base service '${service}' is not ready`);
7717
+ }
7600
7718
  }
7601
- const serverField = protocol === "http" ? "url" : "address";
7602
- const server = servers[0][serverField];
7603
- if (typeof server !== "string") return void 0;
7604
- const provider = routerName.slice(routerName.lastIndexOf("@")).trim();
7605
- if (!provider.startsWith("@")) return void 0;
7606
7719
  return {
7607
- protocol,
7608
- routerName,
7609
- serviceKey: routerKey,
7610
- serviceName: `${routerKey}${provider}`,
7611
- rule,
7612
- serverField,
7613
- server
7720
+ plan,
7721
+ generatedConfigSha256,
7722
+ composeProject,
7723
+ primaryReady,
7724
+ primaryActive,
7725
+ baseStatuses,
7726
+ serviceStatuses,
7727
+ processStatuses,
7728
+ activeServices: sortedUnique(activeServices),
7729
+ activeProcesses: sortedUnique(activeProcesses),
7730
+ drift
7614
7731
  };
7615
7732
  }
7616
- function inspectExpectedRouteMatch(route) {
7617
- const expected = renderExpectedRoute(route);
7618
- const routeName = buildHostRouteRouterName(route.repoPath, route.name);
7619
- if (!expected) {
7733
+ function collectManagedRuntimeStatus(options) {
7734
+ if (!options.config.managedRuntime) {
7620
7735
  return {
7621
- ok: false,
7622
- routes: [routeName],
7623
- details: "host-route renderer returned an incomplete route configuration"
7736
+ mode: "legacy",
7737
+ status: "legacy",
7738
+ profile: options.profile,
7739
+ desired: { ...EMPTY_RESOURCES },
7740
+ active: { ...EMPTY_RESOURCES },
7741
+ serviceStatuses: {},
7742
+ baseServiceStatuses: {},
7743
+ processStatuses: {},
7744
+ drift: []
7745
+ };
7746
+ }
7747
+ const managedRuntime = options.config.managedRuntime;
7748
+ const hasResolvedProfile = options.resolvedProfile !== void 0;
7749
+ const routedApps = options.config.apps.filter((app) => app.kind !== "dependency").map((app) => app.name);
7750
+ const registeredServices = sortedUnique(managedRuntime.devcontainer.profileServices);
7751
+ const registeredProcesses = sortedUnique(managedRuntime.processes);
7752
+ const desiredApps = selectedResources(
7753
+ options.resolvedProfile?.apps,
7754
+ routedApps,
7755
+ hasResolvedProfile
7756
+ );
7757
+ const desiredServices = selectedResources(
7758
+ options.resolvedProfile?.devcontainerServices,
7759
+ registeredServices,
7760
+ hasResolvedProfile
7761
+ );
7762
+ const desiredProcesses = selectedResources(
7763
+ options.resolvedProfile?.processes,
7764
+ registeredProcesses,
7765
+ hasResolvedProfile
7766
+ );
7767
+ let state;
7768
+ let stateReadFailed = false;
7769
+ try {
7770
+ state = options.candidateState ?? readManagedRuntimeState(options.repoPath, options.workspace);
7771
+ } catch {
7772
+ stateReadFailed = true;
7773
+ }
7774
+ const drift = [];
7775
+ if (stateReadFailed) drift.push("managed runtime state could not be read");
7776
+ if (state?.status === "degraded") drift.push("the last managed transition is degraded");
7777
+ if (state) {
7778
+ const desiredState = {
7779
+ apps: sortedUnique(state.desired.apps),
7780
+ services: sortedUnique(state.desired.services),
7781
+ processes: sortedUnique(state.desired.processes)
7782
+ };
7783
+ const desired = {
7784
+ apps: desiredApps,
7785
+ services: desiredServices,
7786
+ processes: desiredProcesses
7624
7787
  };
7788
+ for (const [label, expected, actual] of [
7789
+ ["apps", desired.apps, desiredState.apps],
7790
+ ["services", desired.services, desiredState.services],
7791
+ ["processes", desired.processes, desiredState.processes]
7792
+ ]) {
7793
+ const mismatch = compareResourceSet(label, expected, actual);
7794
+ if (mismatch) drift.push(mismatch);
7795
+ }
7796
+ if (state.profile !== options.profile)
7797
+ drift.push("active profile differs from the selected profile");
7625
7798
  }
7626
- const routerResult = inspectNamedRouterApi(expected.protocol, "routers", expected.routerName);
7627
- if (!routerResult.ok) {
7628
- return { ok: false, routes: [routeName], details: routerResult.details };
7799
+ const inspection = inspectManagedRuntime({
7800
+ repoPath: options.repoPath,
7801
+ config: options.config,
7802
+ registeredServices,
7803
+ registeredProcesses,
7804
+ desiredServices,
7805
+ desiredProcesses,
7806
+ workspace: options.workspace,
7807
+ state
7808
+ });
7809
+ drift.push(...inspection.drift);
7810
+ if (state && inspection.plan && state.sourceConfigSha256 !== inspection.plan.sourceConfigSha256) {
7811
+ drift.push("managed Dev Container source configuration changed");
7629
7812
  }
7630
- if (routerResult.value.status !== "enabled" || routerResult.value.name !== expected.routerName || routerResult.value.rule !== expected.rule || routerResult.value.service !== expected.serviceKey && routerResult.value.service !== expected.serviceName) {
7631
- return {
7632
- ok: false,
7633
- routes: [routeName],
7634
- details: "active Traefik router does not match the rendered route"
7635
- };
7813
+ if (state && inspection.plan && state.effectiveConfigSha256 !== inspection.plan.effectiveConfigSha256) {
7814
+ drift.push("managed Dev Container effective configuration changed");
7636
7815
  }
7637
- const serviceResult = inspectNamedRouterApi(expected.protocol, "services", expected.serviceName);
7638
- if (!serviceResult.ok) {
7639
- return { ok: false, routes: [routeName], details: serviceResult.details };
7816
+ if (state && inspection.generatedConfigSha256 && state.effectiveConfigSha256 !== inspection.generatedConfigSha256) {
7817
+ drift.push("managed generated Dev Container configuration differs from last successful state");
7640
7818
  }
7641
- const loadBalancer = serviceResult.value.loadBalancer;
7642
- const servers = isRecord3(loadBalancer) ? loadBalancer.servers : void 0;
7643
- const server = Array.isArray(servers) && servers.length === 1 ? servers[0] : void 0;
7644
- if (serviceResult.value.status !== "enabled" || serviceResult.value.name !== expected.serviceName || !isRecord3(loadBalancer) || !Array.isArray(servers) || servers.length !== 1 || !isRecord3(server) || server[expected.serverField] !== expected.server) {
7645
- return {
7646
- ok: false,
7647
- routes: [routeName],
7648
- details: "active Traefik service does not match the rendered route"
7649
- };
7819
+ const routes = exactRoutes(options.repoPath, options.workspace);
7820
+ drift.push(...routes.drift);
7821
+ const activeApps = routes.apps;
7822
+ const anyActive = inspection.primaryActive || inspection.activeServices.length > 0 || Object.values(inspection.baseStatuses).some(isActiveResourceStatus) || inspection.activeProcesses.length > 0 || activeApps.length > 0;
7823
+ if (activeApps.length > 0 && !inspection.primaryActive) {
7824
+ drift.push("active app routes exist without a running primary container");
7650
7825
  }
7651
- return { ok: true };
7652
- }
7653
- function inspectExpectedRouteMatches(routes) {
7654
- const mismatched = [];
7655
- const failures = [];
7656
- for (const route of routes) {
7657
- const result = inspectExpectedRouteMatch(route);
7658
- if (!result.ok) {
7659
- mismatched.push(...result.routes);
7660
- failures.push(result.details);
7661
- }
7826
+ if (desiredApps.some((app) => !activeApps.includes(app)) && anyActive) {
7827
+ drift.push("one or more selected app routes are missing");
7662
7828
  }
7663
- return mismatched.length === 0 ? { ok: true } : {
7664
- ok: false,
7665
- routes: mismatched,
7666
- details: failures.join("; ")
7667
- };
7668
- }
7669
- async function waitForExpectedRouteMatches(routes, timeoutMs, pollIntervalMs) {
7670
- const deadline = Date.now() + timeoutMs;
7671
- let result;
7672
- do {
7673
- result = inspectExpectedRouteMatches(routes);
7674
- if (result.ok) return result;
7675
- if (Date.now() < deadline) {
7676
- await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
7677
- }
7678
- } while (Date.now() < deadline);
7679
- return result;
7680
- }
7681
- function inspectExpectedRoutes(routes, expectation) {
7682
- const mismatched = [];
7683
- const failures = [];
7684
- for (const protocol of ["http", "tcp"]) {
7685
- const expected = routes.filter((route) => (route.protocol ?? "http") === protocol).map((route) => buildHostRouteRouterName(route.repoPath, route.name));
7686
- if (expected.length === 0) continue;
7687
- const result = inspectRouterApi(protocol);
7688
- if (!result.ok) {
7689
- failures.push(result.details);
7690
- mismatched.push(...expected);
7691
- continue;
7692
- }
7693
- const unexpected = expected.filter(
7694
- (name) => expectation === "loaded" ? !result.names.has(name) : result.names.has(name)
7695
- );
7696
- const boundedAbsenceIsUncertain = result.reachedPageLimit && (expectation === "loaded" ? unexpected.length > 0 : unexpected.length === 0);
7697
- if (boundedAbsenceIsUncertain) {
7698
- failures.push(
7699
- `${protocol.toUpperCase()} router API reached the ${ROUTER_API_PAGE_SIZE}-entry safety limit`
7700
- );
7701
- }
7702
- mismatched.push(...unexpected);
7703
- if (expectation === "removed" && unexpected.length === 0 && result.reachedPageLimit) {
7704
- mismatched.push(...expected);
7705
- }
7829
+ if (activeApps.some((app) => !desiredApps.includes(app))) {
7830
+ drift.push("one or more active app routes are outside the selected profile");
7706
7831
  }
7707
- return mismatched.length === 0 ? { ok: true } : {
7708
- ok: false,
7709
- routes: mismatched,
7710
- details: failures.length > 0 ? failures.join("; ") : expectation === "loaded" ? "router names are absent" : "router names are still present"
7711
- };
7712
- }
7713
- async function waitForExpectedRoutes(routes, expectation, timeoutMs, pollIntervalMs) {
7714
- const deadline = Date.now() + timeoutMs;
7715
- let result;
7716
- do {
7717
- result = inspectExpectedRoutes(routes, expectation);
7718
- if (result.ok) return result;
7719
- if (Date.now() < deadline) {
7720
- await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
7721
- }
7722
- } while (Date.now() < deadline);
7723
- return result;
7724
- }
7725
- async function ensureTraefikRouteExpectation(routes, expectation, options) {
7726
- if (routes.length === 0) return { restarted: false };
7727
- const initialTimeoutMs = options.initialTimeoutMs ?? DEFAULT_INITIAL_TIMEOUT_MS;
7728
- const recoveryTimeoutMs = options.recoveryTimeoutMs ?? DEFAULT_RECOVERY_TIMEOUT_MS;
7729
- const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
7730
- const initial = await waitForExpectedRoutes(
7731
- routes,
7732
- expectation,
7733
- initialTimeoutMs,
7734
- pollIntervalMs
7832
+ if (inspection.activeServices.some((service) => !desiredServices.includes(service))) {
7833
+ drift.push("one or more active services are outside the selected profile");
7834
+ }
7835
+ if (inspection.activeProcesses.some((process2) => !desiredProcesses.includes(process2))) {
7836
+ drift.push("one or more active processes are outside the selected profile");
7837
+ }
7838
+ const positivelyStopped = !inspection.primaryActive && Object.values(inspection.baseStatuses).every(isStoppedOrMissingResourceStatus) && Object.values(inspection.serviceStatuses).every(isStoppedOrMissingResourceStatus) && activeApps.length === 0;
7839
+ const onlyDegradedIncident = state?.status === "degraded" && drift.length === 1;
7840
+ const requiredServicesReady = desiredServices.every(
7841
+ (service) => isReadyResourceStatus(inspection.serviceStatuses[service] ?? "missing")
7735
7842
  );
7736
- if (initial.ok) return { restarted: false };
7737
- if (options.allowRestart === false)
7738
- throw new Error(
7739
- "Traefik routes did not reload; shared router restart is disabled for this operation."
7740
- );
7741
- import_node_fs22.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
7742
- return withFileLock(
7743
- ROUTER_RELOAD_LOCK_FILE,
7744
- {
7745
- activity: "Traefik route reload recovery",
7746
- waitMs: initialTimeoutMs + recoveryTimeoutMs + 1e4,
7747
- fair: true,
7748
- onWait: createStderrWaitReporter("Traefik route reload recovery", "shared router")
7749
- },
7750
- async () => {
7751
- const recheck = await waitForExpectedRoutes(routes, expectation, 0, pollIntervalMs);
7752
- if (recheck.ok) return { restarted: false };
7753
- claimLifecycleEffect();
7754
- restartRouterStack();
7755
- const recovered = await waitForExpectedRoutes(
7756
- routes,
7757
- expectation,
7758
- recoveryTimeoutMs,
7759
- pollIntervalMs
7760
- );
7761
- if (recovered.ok) return { restarted: true };
7762
- const action = expectation === "loaded" ? "load" : "remove";
7763
- throw new Error(
7764
- `Traefik did not ${action} file-provider routes after one restart: ${recovered.routes.join(", ")} (${recovered.details}). Inspect: devrouter logs --tail 100`
7765
- );
7766
- }
7843
+ const baseServicesReady = Object.values(inspection.baseStatuses).every(isReadyResourceStatus);
7844
+ const processesReady = desiredProcesses.every(
7845
+ (process2) => isReadyResourceStatus(inspection.processStatuses[process2] ?? "missing")
7767
7846
  );
7768
- }
7769
- async function ensureTraefikRoutesLoaded(routes, options = {}) {
7770
- return ensureTraefikRouteExpectation(routes, "loaded", options);
7771
- }
7772
- async function ensureTraefikRoutesMatch(routes, options = {}) {
7773
- if (routes.length === 0) return;
7774
- const timeoutMs = options.initialTimeoutMs ?? options.recoveryTimeoutMs ?? DEFAULT_INITIAL_TIMEOUT_MS;
7775
- const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
7776
- const result = await waitForExpectedRouteMatches(routes, timeoutMs, pollIntervalMs);
7777
- if (!result.ok) {
7778
- throw new Error(
7779
- `Traefik route configuration did not match rendered routes: ${result.routes.join(", ")} (${result.details})`
7780
- );
7781
- }
7782
- }
7783
- async function ensureTraefikRoutesRemoved(routes, options = {}) {
7784
- return ensureTraefikRouteExpectation(routes, "removed", options);
7847
+ const routesReady = desiredApps.every((app) => activeApps.includes(app));
7848
+ const ready = inspection.primaryReady && requiredServicesReady && baseServicesReady && processesReady && routesReady && !stateReadFailed && !drift.length;
7849
+ const status = positivelyStopped && onlyDegradedIncident ? "stopped" : state?.status === "degraded" ? "failed-transition" : drift.length > 0 ? "drifted" : ready ? "ready" : anyActive ? "starting" : "stopped";
7850
+ const active = {
7851
+ apps: activeApps,
7852
+ services: inspection.activeServices,
7853
+ processes: inspection.activeProcesses
7854
+ };
7855
+ return {
7856
+ mode: "managed",
7857
+ status,
7858
+ profile: options.profile,
7859
+ ...state?.profile ? { activeProfile: state.profile } : {},
7860
+ ...options.workspace !== void 0 ? { workspace: options.workspace } : {},
7861
+ ...state?.devpodId ? { devpodId: state.devpodId } : {},
7862
+ ...inspection.composeProject ? { composeProject: inspection.composeProject } : {},
7863
+ desired: {
7864
+ apps: desiredApps,
7865
+ services: desiredServices,
7866
+ processes: desiredProcesses
7867
+ },
7868
+ active,
7869
+ serviceStatuses: inspection.serviceStatuses,
7870
+ baseServiceStatuses: inspection.baseStatuses,
7871
+ processStatuses: inspection.processStatuses,
7872
+ drift: sortedUnique(drift),
7873
+ ...inspection.plan?.sourceConfigSha256 || state?.sourceConfigSha256 ? { sourceConfigSha256: inspection.plan?.sourceConfigSha256 ?? state?.sourceConfigSha256 } : {},
7874
+ ...inspection.plan?.effectiveConfigSha256 || state?.effectiveConfigSha256 ? {
7875
+ effectiveConfigSha256: inspection.plan?.effectiveConfigSha256 ?? state?.effectiveConfigSha256
7876
+ } : {},
7877
+ ...state?.transitionPhase ? { transitionPhase: state.transitionPhase } : {}
7878
+ };
7785
7879
  }
7786
7880
 
7787
7881
  // src/core/workspace-ensure.ts
@@ -9348,6 +9442,7 @@ var cancelled = false;
9348
9442
  var lockHeld = false;
9349
9443
  var stopProjects = [];
9350
9444
  var stopBaselineState;
9445
+ var BUSY_EXEC_WAIT_MS = 30 * 60 * 1e3;
9351
9446
  function matchesFence(record4, fence) {
9352
9447
  return Object.entries(reliabilityFence(record4.state)).every(
9353
9448
  ([key, value]) => value === fence[key]
@@ -9465,7 +9560,7 @@ async function executeLifecycleWorker(request, operation) {
9465
9560
  withMutationLock(
9466
9561
  "Verify retained stop",
9467
9562
  request.repoPath,
9468
- () => proveRetainedManagedStop(retained)
9563
+ () => proveManagedStop(retained)
9469
9564
  );
9470
9565
  } else {
9471
9566
  const containers = inspectWorkspaceContainers();
@@ -9546,9 +9641,11 @@ function proveLifecycleStopped() {
9546
9641
  const settle = () => {
9547
9642
  claimLifecycleEffect();
9548
9643
  if (stopBaselineState) {
9549
- if (proveRetainedManagedStop(stopBaselineState).some((container) => container.state.Running)) {
9644
+ const proof = proveManagedStop(stopBaselineState);
9645
+ if (proof.containers.some((container) => container.state.Running))
9550
9646
  throw new Error("Retained workspace workloads remain running.");
9551
- }
9647
+ if (proof.status === "proven-absent")
9648
+ assertTraefikRoutesRemoved(managedStopRouteReferences(stopBaselineState));
9552
9649
  } else {
9553
9650
  for (const project of stopProjects) {
9554
9651
  const containers = inspectManagedStopContainers(project);
@@ -9911,6 +10008,9 @@ var import_node_path29 = __toESM(require("path"));
9911
10008
 
9912
10009
  // src/core/route-state.ts
9913
10010
  var import_node_fs24 = __toESM(require("fs"));
10011
+ function listRoutesForWorktreePath(worktreePath, routes = listHostRouteState()) {
10012
+ return routes.filter((route) => sameWorkspacePath(route.repoPath, worktreePath));
10013
+ }
9914
10014
  function removeWorkspaceRoutesForWorktree(workspace, worktreePath) {
9915
10015
  return removeHostRoutesWhere(
9916
10016
  (route) => route.workspace === workspace && sameWorkspacePath(route.repoPath, worktreePath)
@@ -9962,8 +10062,24 @@ async function mutateWorkspaceRuntime(action, resolved, worktrees, quiet = false
9962
10062
  "Retained managed runtime has no exact provider registration; routes were preserved."
9963
10063
  );
9964
10064
  }
9965
- const routes = removeWorkspaceRoutesForWorktree(resolved.workspace, resolved.worktreePath);
9966
- await ensureTraefikRoutesRemoved(routes);
10065
+ const retained = action === "stop" ? readManagedRuntimeState(resolved.worktreePath, resolved.workspace) : void 0;
10066
+ let absent = false;
10067
+ const remove = () => {
10068
+ if (retained?.stopBaseline) {
10069
+ absent = proveManagedStop(retained).status === "proven-absent";
10070
+ if (absent && listRoutesForWorktreePath(resolved.worktreePath).some(
10071
+ (route) => route.workspace !== resolved.workspace || !retained.desired.apps.includes(route.name)
10072
+ ))
10073
+ throw new Error("Absent stop contains routes outside the retained desired set.");
10074
+ }
10075
+ claimLifecycleEffect();
10076
+ return removeWorkspaceRoutesForWorktree(resolved.workspace, resolved.worktreePath);
10077
+ };
10078
+ const routes = retained?.stopBaseline ? withMutationLock("Revalidate stop routes", resolved.worktreePath, remove) : remove();
10079
+ await ensureTraefikRoutesRemoved([
10080
+ ...routes,
10081
+ ...absent && retained ? managedStopRouteReferences(retained) : []
10082
+ ]);
9967
10083
  if (!quiet) {
9968
10084
  process.stdout.write(
9969
10085
  `Freed ${routes.length} route(s) for workspace '${resolved.workspace}'.
@@ -9974,6 +10090,7 @@ async function mutateWorkspaceRuntime(action, resolved, worktrees, quiet = false
9974
10090
  ...mutation.status === "changed" ? { devpodId } : {},
9975
10091
  freedRoutes: routes.length,
9976
10092
  providerChanged: mutation.status === "changed",
10093
+ ...absent ? { runtimeAbsent: true } : {},
9977
10094
  workspace: resolved.workspace
9978
10095
  };
9979
10096
  }
@@ -10037,7 +10154,7 @@ async function environmentStop(repoPath, options = {}) {
10037
10154
  repoPath,
10038
10155
  workspace: result.workspace,
10039
10156
  ...result.devpodId ? { devpodId: result.devpodId } : {},
10040
- stopped: !options.delete && result.providerChanged,
10157
+ stopped: !options.delete && (result.providerChanged || result.runtimeAbsent === true),
10041
10158
  ...options.delete ? { deleted: result.providerChanged } : {},
10042
10159
  freedRoutes: result.freedRoutes
10043
10160
  };
@@ -10070,10 +10187,16 @@ async function environmentStop(repoPath, options = {}) {
10070
10187
  }
10071
10188
 
10072
10189
  // src/lifecycle-worker.ts
10073
- process.on("SIGTERM", cancelLifecycleWorker);
10074
- process.on("SIGINT", cancelLifecycleWorker);
10075
- process.on("disconnect", cancelLifecycleWorker);
10190
+ var started = false;
10191
+ function cancel() {
10192
+ cancelLifecycleWorker();
10193
+ if (!started && process.connected) process.disconnect();
10194
+ }
10195
+ process.on("SIGTERM", cancel);
10196
+ process.on("SIGINT", cancel);
10197
+ process.on("disconnect", cancel);
10076
10198
  process.once("message", async (message) => {
10199
+ started = true;
10077
10200
  let result;
10078
10201
  try {
10079
10202
  const request = message.request;