@devrouter/cli 0.0.62 → 0.0.64

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