@devrouter/cli 0.0.51 → 0.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/devrouter.js CHANGED
@@ -249,6 +249,8 @@ profiles:
249
249
  - Validation is strict at config load: unknown keys, non-routed \`apps\`, non-dependency \`dependencies\`, \`readiness\` outside the profile's apps, and multiple defaults are rejected.
250
250
  - Selection: \`devrouter ensure <path> --profile <name>\`. Comma-separated selections (\`--profile manage,pwa\`) merge with deduplication; the canonical name is sorted-unique so order never affects identity or fingerprints. A wildcard member collapses to everything.
251
251
  - Managed adapters receive \`DEVROUTER_PROFILE\` (canonical resolved name) in the post-start env; profile switches replace the owned process group via the fingerprint.
252
+ - Adapters may pass \`--prepare-command <command>\` to \`devrouter-process ensure\` for synchronous dependency preparation under the process lock before application launch. Unchanged owned processes skip preparation; preparation participates in default fingerprints and must not daemonize or detach.
253
+ - Exact managed Devsy stop proves the complete retained container population, ownership and configuration before stopping residual services after a stopped primary. Unknown evidence blocks cleanup, and an original provider failure remains an error even if residual cleanup succeeds.
252
254
 
253
255
  ### Managed devcontainer resources
254
256
 
@@ -400,7 +402,7 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
400
402
  - \`devrouter -V [--repo .]\`: show installed CLI version, local repo version, and next upgrade target
401
403
  - \`devrouter upgrade [version] [--repo .]\`: list upgrade targets or print target Agent Adaptation Prompt
402
404
  - \`devrouter setup --yes [--repo .] [--json] [--workspace-runtime <devpod|devsy>]\`: first-run machine setup plus structured diagnostics; explicit Devsy selection acquires its verified agent
403
- - \`devrouter ensure [path] [--profile <name>] [--open] [--json]\`: canonical startup/reconciliation for primary and linked checkouts; a managed profile selects its independent resource dimensions
405
+ - \`devrouter ensure [path] [--profile <name>] [--repair] [--open] [--json]\`: canonical startup/reconciliation for primary and linked checkouts; explicit repair uses the recorded degraded profile and unchanged retained configuration
404
406
  - \`devrouter profile resolve --repo <path> [--profile <selection>] [--json]\`: resolve exact profile resources for automation without starting or inspecting a runtime
405
407
  - \`devrouter profile plan --repo <path> [--profile <selection>] --contract <repo-relative-yaml> [--output <path>] [--json]\`: validate repository-owned resource policy and emit literal bindings without runtime access
406
408
  - \`devrouter stop [path] [--delete] [--json]\`: stop the exact workspace runtime and remove exact routes; \`--delete\` explicitly deletes its ownership-proven data without removing the checkout
@@ -425,7 +427,7 @@ Run several worktrees of one repo in parallel without host/route collisions. A *
425
427
  - \`devrouter app exec <name> [--shell] [--env <env>] [--workspace <slug>] -- <cmd>\`: one-shot command with resolved dep env
426
428
  - \`devrouter app rm <name> [--keep-config]\`: remove app entry (\`--keep-config\` frees only the live route/hostname, leaves \`.devrouter.yml\` untouched)
427
429
  - \`devrouter workspace up <branch> [--path <dir>] [--no-devpod] [--open]\`: create a worktree and start/prove it unless create-only mode is requested
428
- - \`devrouter workspace ensure [path] [--profile <name>] [--open] [--json]\`: compatibility alias of \`devrouter ensure\`
430
+ - \`devrouter workspace ensure [path] [--profile <name>] [--repair] [--open] [--json]\`: compatibility alias of \`devrouter ensure\`
429
431
  - \`devrouter workspace ls [--json]\`: list ownership, Git, workspace runtime, route, path, and branch evidence
430
432
  - \`devrouter workspace cleanup [--repo .] [--inactive-for 30d] [--check-merged] [--measure-size] [--json]\`: report-only cleanup evidence and exact guarded suggestions; no \`--yes\` or apply mode
431
433
  - \`devrouter workspace stop <workspace|branch>\`: stop DevPod and routes; preserve checkout, owner record, and data
@@ -2691,7 +2693,7 @@ function loadRepoConfig(repoPath) {
2691
2693
  const config = parseConfig(parsed ?? {}, configPath);
2692
2694
  const requiredVersion = config.devrouter?.version;
2693
2695
  if (requiredVersion && !hasWarnedVersionMismatch) {
2694
- const cliVersion = true ? "0.0.51" : "0.0.0-dev";
2696
+ const cliVersion = true ? "0.0.53" : "0.0.0-dev";
2695
2697
  if (cliVersion !== "0.0.0-dev" && compareSemver(requiredVersion, cliVersion) > 0) {
2696
2698
  hasWarnedVersionMismatch = true;
2697
2699
  process.stderr.write(
@@ -3227,6 +3229,8 @@ function buildOnboardingPrompt(options = {}) {
3227
3229
  "- Devsy agent readiness: run `devrouter setup --yes --workspace-runtime devsy` once so Devrouter acquires and verifies the pinned agent in its own machine cache. `doctor` reports `ready`, `missing`, `stale`, or `invalid` without network access, and `ensure` fails before the provider queue when readiness is not `ready`. An explicit `DEVSY_AGENT_BINARY` remains authoritative and must match a pinned official asset.",
3228
3230
  "- A managed adapter plus `postCreateCommand` requires `waitFor` exactly `postCreateCommand` or `postStartCommand`; managed selective config preserves lifecycle fields and changes only `runServices`.",
3229
3231
  "- Managed process reuse fingerprints command argv, workspace identity, and the exact adapter snapshot. If a non-secret runtime value also affects reuse, set `DEVROUTER_PROCESS_FINGERPRINT_ENV` to its comma-separated environment names; secret-like names are rejected and raw values are never persisted.",
3232
+ "- Managed adapters may use devrouter-process ensure --prepare-command <command> for synchronous dependency preparation under the process lock before app launch. Unchanged owned processes skip preparation; it participates in default fingerprints and must not daemonize or detach.",
3233
+ "- Exact managed Devsy stop validates complete retained container membership, ownership and configuration before stopping residual services after a stopped primary. Unknown evidence blocks cleanup; original provider failures remain errors even after successful residual cleanup.",
3230
3234
  "- Owner status is `present`, `missing`, `locked`, or `conflict`. Dirty or locked full down fails before side effects. `workspace gc` is a dry run; only `--yes` deletes exact eligible missing resources and records, never Git worktrees, branches, or prune state.",
3231
3235
  "- `devrouter workspace cleanup --repo . --inactive-for 30d --check-merged --json` is report-only and never mutates the workspace runtime, routes, ownership, Git, Docker, applications, worktrees, or branches. It reports ownership, workspace runtime registration, runtime state (`running|stopped|busy|not-found|absent|unknown`), checkout, advisory activity, route, and integration evidence. Local DevPod/Devsy list/status checks always run; `--check-merged` alone enables read-only origin and matching GitHub/GitLab checks. Treat `not-found` as stale runtime after Docker pruning; busy, unavailable, or conflicting evidence suppresses destructive suggestions. Explicit `gc`/`down` can remove exact stale registration only after expected-ID `NotFound` proof and ownership revalidation. Runtime `lastUsed` remains advisory.",
3232
3236
  '- `--measure-size` adds per-workspace storage consumption and stays read-only, but walks each worktree and runs `docker ps` / `docker inspect --size`, so leave it off for a quick report. Each row reports `worktree` and `containerWritable` bytes, which are reclaimable; `imageShared` bytes, which are not, because those image layers are shared with other containers and overlap across rows; and `reclaimable`, the sum of the reclaimable fields. Never add `imageShared` across rows. Any figure that cannot be trusted reports `{"status": "unknown", "reason": ...}` rather than zero, and a workspace with no container reports a measured `0`.',
@@ -3273,6 +3277,7 @@ function buildOnboardingPrompt(options = {}) {
3273
3277
  "- devrouter repo devcontainer write --repo <REPO_PATH> --yes",
3274
3278
  "- devrouter repo devcontainer verify --repo <REPO_PATH> --json",
3275
3279
  "- Normal primary or linked startup: devrouter ensure <REPO_PATH> --json",
3280
+ "- Degraded recovery: explicit devrouter ensure <REPO_PATH> --repair --json validates the recorded profile and unchanged retained configuration; stopped primary requires all project containers stopped and zero checkout routes. It starts only retained IDs, never provider bootstrap/recreation. Inspect failures before retrying.",
3276
3281
  "- Managed selective startup: devrouter ensure <REPO_PATH> --profile <name> --json",
3277
3282
  "- Container seed/migration: devrouter exec <REPO_PATH> -- <command...>",
3278
3283
  "- Compatibility verification only after ensure: devrouter repo devcontainer verify --repo <REPO_PATH> --live --yes --json",
@@ -3391,7 +3396,7 @@ var init_ai_prompt = __esm({
3391
3396
  purpose: "Write/update devrouter section in the repo's AGENTS.md and install the devrouter skill."
3392
3397
  },
3393
3398
  {
3394
- command: "devrouter ensure [path] [--profile <name>] [--open] [--json]",
3399
+ command: "devrouter ensure [path] [--profile <name>] [--repair] [--open] [--json]",
3395
3400
  purpose: "Canonical startup and proof for an exact primary or linked checkout; atomically publishes routes after readiness."
3396
3401
  },
3397
3402
  {
@@ -5691,19 +5696,12 @@ function assertSafeContainerId(containerId) {
5691
5696
  throw new Error(`Container '${containerId}' is not a safe exact identifier.`);
5692
5697
  }
5693
5698
  }
5694
- function startExactManagedServices(options) {
5695
- if (options.services.length === 0) return;
5696
- const project = safeComposeProject(options.composeProject);
5697
- const services = Array.from(new Set(options.services));
5698
- if (services.some((service) => !options.plan.composeServices.includes(service))) {
5699
- throw new Error("Exact Compose startup contains a service outside the validated model.");
5700
- }
5701
- const fileArgs = options.plan.composeFiles.flatMap((file) => ["-f", file]);
5699
+ function managedComposeEnvironment(workspace) {
5702
5700
  const env = { ...process.env };
5703
- if (options.workspace) {
5704
- env.WORKSPACE = options.workspace.token;
5705
- env.DEVROUTER_WORKSPACE = options.workspace.token;
5706
- env.DEVROUTER_GIT_COMMON_DIR = options.workspace.gitCommonDir;
5701
+ if (workspace) {
5702
+ env.WORKSPACE = workspace.token;
5703
+ env.DEVROUTER_WORKSPACE = workspace.token;
5704
+ env.DEVROUTER_GIT_COMMON_DIR = workspace.gitCommonDir;
5707
5705
  env.DEVCONTAINER_COMPOSE_OVERLAY = "docker-compose.devrouter.yml";
5708
5706
  } else {
5709
5707
  delete env.WORKSPACE;
@@ -5711,6 +5709,87 @@ function startExactManagedServices(options) {
5711
5709
  delete env.DEVROUTER_GIT_COMMON_DIR;
5712
5710
  delete env.DEVCONTAINER_COMPOSE_OVERLAY;
5713
5711
  }
5712
+ return env;
5713
+ }
5714
+ function assertReadableComposeFile(file) {
5715
+ try {
5716
+ if (!import_node_fs15.default.statSync(file).isFile()) throw new Error("not a regular file");
5717
+ import_node_fs15.default.accessSync(file, import_node_fs15.default.constants.R_OK);
5718
+ } catch {
5719
+ throw new Error("Managed container Compose configuration contains an unreadable file.");
5720
+ }
5721
+ }
5722
+ function assertManagedContainerConfigUnchanged(options) {
5723
+ if (options.containers.length === 0) {
5724
+ throw new Error("Managed container configuration cannot be verified without containers.");
5725
+ }
5726
+ for (const container of options.containers) {
5727
+ const project = container.labels["com.docker.compose.project"];
5728
+ const service = container.labels["com.docker.compose.service"];
5729
+ const workingDirectory = container.labels["com.docker.compose.project.working_dir"];
5730
+ const recordedConfigFiles = container.labels["com.docker.compose.project.config_files"];
5731
+ const expectedHash = container.labels["com.docker.compose.config-hash"];
5732
+ if (!project || !service || !workingDirectory || !recordedConfigFiles || !expectedHash || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(service) || !/^[a-f0-9]{64}$/.test(expectedHash)) {
5733
+ throw new Error("Managed container is missing valid Compose identity and hash labels.");
5734
+ }
5735
+ const composeProject = safeComposeProject(project);
5736
+ const configFiles = recordedConfigFiles.split(",").map((file) => file.trim());
5737
+ if (configFiles.some((file) => file.length === 0)) {
5738
+ throw new Error("Managed container has an invalid recorded Compose file list.");
5739
+ }
5740
+ if (options.plan.composeFiles.some(
5741
+ (expected) => !configFiles.some((actual) => sameWorkspacePath(actual, expected))
5742
+ )) {
5743
+ throw new Error("Managed container Compose files do not include the managed plan files.");
5744
+ }
5745
+ for (const file of configFiles) assertReadableComposeFile(file);
5746
+ const fileArgs = configFiles.flatMap((file) => ["-f", file]);
5747
+ const result = (() => {
5748
+ try {
5749
+ return (0, import_node_child_process7.spawnSync)(
5750
+ "docker",
5751
+ [
5752
+ "compose",
5753
+ "--project-name",
5754
+ composeProject,
5755
+ "--project-directory",
5756
+ workingDirectory,
5757
+ ...fileArgs,
5758
+ "config",
5759
+ "--hash",
5760
+ service
5761
+ ],
5762
+ {
5763
+ cwd: workingDirectory,
5764
+ encoding: "utf-8",
5765
+ env: managedComposeEnvironment(options.workspace),
5766
+ stdio: ["ignore", "pipe", "ignore"],
5767
+ timeout: COMPOSE_HASH_TIMEOUT_MS
5768
+ }
5769
+ );
5770
+ } catch {
5771
+ throw new Error(`Could not verify managed Compose configuration for service '${service}'.`);
5772
+ }
5773
+ })();
5774
+ if (result.status !== 0 || result.error) {
5775
+ throw new Error(`Could not verify managed Compose configuration for service '${service}'.`);
5776
+ }
5777
+ const output2 = typeof result.stdout === "string" ? result.stdout.trim() : "";
5778
+ const match = /^(\S+)\s+([a-f0-9]{64})$/.exec(output2);
5779
+ if (!match || match[1] !== service || match[2] !== expectedHash) {
5780
+ throw new Error(`Managed Compose configuration changed for service '${service}'.`);
5781
+ }
5782
+ }
5783
+ }
5784
+ function startExactManagedServices(options) {
5785
+ if (options.services.length === 0) return;
5786
+ const project = safeComposeProject(options.composeProject);
5787
+ const services = Array.from(new Set(options.services));
5788
+ if (services.some((service) => !options.plan.composeServices.includes(service))) {
5789
+ throw new Error("Exact Compose startup contains a service outside the validated model.");
5790
+ }
5791
+ const fileArgs = options.plan.composeFiles.flatMap((file) => ["-f", file]);
5792
+ const env = managedComposeEnvironment(options.workspace);
5714
5793
  const result = (0, import_node_child_process7.spawnSync)(
5715
5794
  "docker",
5716
5795
  [
@@ -5739,17 +5818,18 @@ function startExactManagedServices(options) {
5739
5818
  );
5740
5819
  }
5741
5820
  }
5742
- function stopExactManagedService(containerId, service) {
5821
+ function stopExactManagedService(containerId, service, options) {
5743
5822
  assertSafeContainerId(containerId);
5744
5823
  const result = (0, import_node_child_process7.spawnSync)("docker", ["stop", containerId], {
5745
5824
  encoding: "utf-8",
5746
- stdio: "inherit"
5825
+ stdio: "inherit",
5826
+ ...options ? { timeout: options.timeoutMs } : {}
5747
5827
  });
5748
- if (result.status !== 0) {
5828
+ if (result.status !== 0 || result.error) {
5749
5829
  throw new Error(`Could not stop exact managed service '${service}' (${containerId}).`);
5750
5830
  }
5751
5831
  }
5752
- var import_node_child_process7, import_node_crypto7, import_node_fs15, import_node_path15, import_yaml4, MANAGED_DEVCONTAINER_PATH, MANAGED_DEVCONTAINER_MARKER;
5832
+ var import_node_child_process7, import_node_crypto7, import_node_fs15, import_node_path15, import_yaml4, MANAGED_DEVCONTAINER_PATH, MANAGED_DEVCONTAINER_MARKER, COMPOSE_HASH_TIMEOUT_MS;
5753
5833
  var init_devcontainer_profile = __esm({
5754
5834
  "src/core/devcontainer-profile.ts"() {
5755
5835
  "use strict";
@@ -5761,12 +5841,206 @@ var init_devcontainer_profile = __esm({
5761
5841
  init_atomic_file();
5762
5842
  init_devcontainer_config();
5763
5843
  init_paths();
5844
+ init_workspace();
5764
5845
  MANAGED_DEVCONTAINER_PATH = ".devcontainer/devcontainer.devrouter.json";
5765
5846
  MANAGED_DEVCONTAINER_MARKER = "// devrouter:managed devcontainer profile";
5847
+ COMPOSE_HASH_TIMEOUT_MS = 1e4;
5766
5848
  }
5767
5849
  });
5768
5850
 
5769
5851
  // src/core/devpod-environment.ts
5852
+ function isRecord2(value) {
5853
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5854
+ }
5855
+ function assertFullContainerId(value) {
5856
+ if (typeof value !== "string" || !FULL_CONTAINER_ID_PATTERN.test(value)) {
5857
+ throw new Error("Managed stop Docker inspection returned an invalid container id.");
5858
+ }
5859
+ }
5860
+ function assertUniqueContainerIds(ids) {
5861
+ if (new Set(ids).size !== ids.length) {
5862
+ throw new Error("Managed stop Docker inspection returned duplicate container ids.");
5863
+ }
5864
+ }
5865
+ function parseDockerLines(stdout) {
5866
+ const lines = stdout.split(/\r?\n/);
5867
+ while (lines.at(-1) === "") lines.pop();
5868
+ if (lines.some((line) => line === "")) {
5869
+ throw new Error("Managed stop Docker inspection returned an empty record.");
5870
+ }
5871
+ return lines;
5872
+ }
5873
+ function runManagedStopDocker(args) {
5874
+ const result = (0, import_node_child_process8.spawnSync)("docker", args, {
5875
+ encoding: "utf-8",
5876
+ timeout: MANAGED_STOP_DOCKER_TIMEOUT_MS,
5877
+ maxBuffer: MANAGED_STOP_DOCKER_MAX_BUFFER
5878
+ });
5879
+ if (result.error || result.status !== 0) {
5880
+ throw new Error("Managed stop Docker inspection failed.");
5881
+ }
5882
+ if (typeof result.stdout !== "string") {
5883
+ throw new Error("Managed stop Docker inspection returned invalid output.");
5884
+ }
5885
+ return result.stdout;
5886
+ }
5887
+ function listManagedStopContainerIds(composeProject) {
5888
+ const stdout = runManagedStopDocker([
5889
+ "ps",
5890
+ "-a",
5891
+ "--no-trunc",
5892
+ "--filter",
5893
+ `label=${MANAGED_STOP_COMPOSE_LABEL}=${composeProject}`,
5894
+ "--format",
5895
+ "{{.ID}}"
5896
+ ]);
5897
+ const ids = parseDockerLines(stdout);
5898
+ ids.forEach(assertFullContainerId);
5899
+ assertUniqueContainerIds(ids);
5900
+ return ids;
5901
+ }
5902
+ function assertString(value) {
5903
+ if (typeof value !== "string") {
5904
+ throw new Error("Managed stop Docker inspection returned an invalid field.");
5905
+ }
5906
+ }
5907
+ function validateManagedStopSnapshot(value, composeProject) {
5908
+ if (!isRecord2(value)) {
5909
+ throw new Error("Managed stop Docker inspection returned an invalid record.");
5910
+ }
5911
+ assertFullContainerId(value.id);
5912
+ if (!isRecord2(value.state)) {
5913
+ throw new Error("Managed stop Docker inspection returned an invalid state.");
5914
+ }
5915
+ const status = value.state.Status;
5916
+ if (status !== "running" && status !== "exited" && status !== "created") {
5917
+ throw new Error("Managed stop Docker inspection returned an invalid state status.");
5918
+ }
5919
+ if (typeof value.state.Running !== "boolean") {
5920
+ throw new Error("Managed stop Docker inspection returned an invalid running flag.");
5921
+ }
5922
+ if (typeof value.state.Paused !== "boolean" || value.state.Paused) {
5923
+ throw new Error("Managed stop Docker inspection returned an invalid paused flag.");
5924
+ }
5925
+ if (typeof value.state.Restarting !== "boolean" || value.state.Restarting) {
5926
+ throw new Error("Managed stop Docker inspection returned an invalid restarting flag.");
5927
+ }
5928
+ if (typeof value.state.Dead !== "boolean" || value.state.Dead) {
5929
+ throw new Error("Managed stop Docker inspection returned an invalid dead flag.");
5930
+ }
5931
+ if (status === "running" !== value.state.Running) {
5932
+ throw new Error("Managed stop Docker inspection returned contradictory state.");
5933
+ }
5934
+ let health;
5935
+ if (value.state.Health !== void 0 && value.state.Health !== null) {
5936
+ if (!isRecord2(value.state.Health)) {
5937
+ throw new Error("Managed stop Docker inspection returned an invalid health field.");
5938
+ }
5939
+ assertString(value.state.Health.Status);
5940
+ health = { Status: value.state.Health.Status };
5941
+ }
5942
+ if (!isRecord2(value.labels)) {
5943
+ throw new Error("Managed stop Docker inspection returned invalid labels.");
5944
+ }
5945
+ for (const label of MANAGED_STOP_IDENTITY_LABELS) {
5946
+ assertString(value.labels[label]);
5947
+ }
5948
+ if (value.labels[MANAGED_STOP_COMPOSE_LABEL] !== composeProject) {
5949
+ throw new Error("Managed stop Docker inspection returned a foreign Compose project.");
5950
+ }
5951
+ if (!Array.isArray(value.mounts)) {
5952
+ throw new Error("Managed stop Docker inspection returned invalid mounts.");
5953
+ }
5954
+ const mounts = value.mounts.map((mount) => {
5955
+ if (!isRecord2(mount)) {
5956
+ throw new Error("Managed stop Docker inspection returned an invalid mount.");
5957
+ }
5958
+ assertString(mount.Type);
5959
+ assertString(mount.Source);
5960
+ assertString(mount.Destination);
5961
+ return {
5962
+ Type: mount.Type,
5963
+ Source: mount.Source,
5964
+ Destination: mount.Destination
5965
+ };
5966
+ });
5967
+ if (!isRecord2(value.networks)) {
5968
+ throw new Error("Managed stop Docker inspection returned invalid networks.");
5969
+ }
5970
+ return {
5971
+ id: value.id,
5972
+ state: {
5973
+ ...health ? { Health: health } : {},
5974
+ Status: status,
5975
+ Running: value.state.Running,
5976
+ Paused: false,
5977
+ Restarting: false,
5978
+ Dead: false
5979
+ },
5980
+ labels: {
5981
+ "com.docker.compose.project": value.labels["com.docker.compose.project"],
5982
+ "com.docker.compose.service": value.labels["com.docker.compose.service"],
5983
+ "com.docker.compose.project.working_dir": value.labels["com.docker.compose.project.working_dir"],
5984
+ "com.docker.compose.project.config_files": value.labels["com.docker.compose.project.config_files"],
5985
+ "com.docker.compose.config-hash": value.labels["com.docker.compose.config-hash"]
5986
+ },
5987
+ mounts,
5988
+ networks: {}
5989
+ };
5990
+ }
5991
+ function inspectManagedStopPopulation(ids, composeProject) {
5992
+ const stdout = runManagedStopDocker([
5993
+ "inspect",
5994
+ "--format",
5995
+ MANAGED_STOP_INSPECT_TEMPLATE,
5996
+ ...ids
5997
+ ]);
5998
+ const lines = parseDockerLines(stdout);
5999
+ if (lines.length !== ids.length) {
6000
+ throw new Error("Managed stop Docker inspection returned an incomplete population.");
6001
+ }
6002
+ const expected = new Set(ids);
6003
+ const seen = /* @__PURE__ */ new Set();
6004
+ const snapshots = lines.map((line) => {
6005
+ let parsed;
6006
+ try {
6007
+ parsed = JSON.parse(line);
6008
+ } catch {
6009
+ throw new Error("Managed stop Docker inspection returned malformed JSON.");
6010
+ }
6011
+ const snapshot = validateManagedStopSnapshot(parsed, composeProject);
6012
+ if (!expected.has(snapshot.id) || seen.has(snapshot.id)) {
6013
+ throw new Error("Managed stop Docker inspection returned an unexpected population.");
6014
+ }
6015
+ seen.add(snapshot.id);
6016
+ return snapshot;
6017
+ });
6018
+ return snapshots;
6019
+ }
6020
+ function requireSameContainerPopulation(expected, actual) {
6021
+ if (expected.length !== actual.length) {
6022
+ throw new Error("Managed stop Docker inspection observed a changed population.");
6023
+ }
6024
+ const expectedSet = new Set(expected);
6025
+ if (actual.some((id) => !expectedSet.has(id))) {
6026
+ throw new Error("Managed stop Docker inspection observed a changed population.");
6027
+ }
6028
+ }
6029
+ function inspectManagedStopContainers(composeProject) {
6030
+ if (typeof composeProject !== "string" || composeProject.length === 0 || composeProject !== composeProject.trim() || !MANAGED_STOP_PROJECT_PATTERN.test(composeProject)) {
6031
+ throw new Error("Managed stop Docker inspection requires a safe Compose project.");
6032
+ }
6033
+ const listedIds = listManagedStopContainerIds(composeProject);
6034
+ if (listedIds.length === 0) {
6035
+ const confirmedIds2 = listManagedStopContainerIds(composeProject);
6036
+ requireSameContainerPopulation(listedIds, confirmedIds2);
6037
+ return [];
6038
+ }
6039
+ const snapshots = inspectManagedStopPopulation(listedIds, composeProject);
6040
+ const confirmedIds = listManagedStopContainerIds(composeProject);
6041
+ requireSameContainerPopulation(listedIds, confirmedIds);
6042
+ return snapshots;
6043
+ }
5770
6044
  function inspectWorkspaceContainers(options) {
5771
6045
  let ids = options?.ids;
5772
6046
  if (!ids) {
@@ -5850,18 +6124,31 @@ function resolveRunningWorkspaceContainer(repoPath) {
5850
6124
  }
5851
6125
  return { id: container.id, workspacePath: repoMount.Destination };
5852
6126
  }
5853
- var import_node_child_process8, import_node_path16, SAFE_INSPECT_TEMPLATE, SIZE_INSPECT_TEMPLATE;
6127
+ var import_node_child_process8, import_node_path16, SAFE_INSPECT_TEMPLATE, SIZE_INSPECT_TEMPLATE, MANAGED_STOP_INSPECT_TEMPLATE, MANAGED_STOP_PROJECT_PATTERN, FULL_CONTAINER_ID_PATTERN, MANAGED_STOP_DOCKER_TIMEOUT_MS, MANAGED_STOP_DOCKER_MAX_BUFFER, MANAGED_STOP_COMPOSE_LABEL, MANAGED_STOP_IDENTITY_LABELS;
5854
6128
  var init_devpod_environment = __esm({
5855
6129
  "src/core/devpod-environment.ts"() {
5856
6130
  "use strict";
5857
6131
  import_node_child_process8 = require("child_process");
5858
6132
  import_node_path16 = __toESM(require("path"));
5859
6133
  init_workspace();
5860
- SAFE_INSPECT_TEMPLATE = '{"id":{{json .Id}},"state":{"Running":{{json .State.Running}},"Health":{{with (index .State "Health")}}{"Status":{{json .Status}}}{{else}}null{{end}}},"labels":{"com.docker.compose.project":{{json (index .Config.Labels "com.docker.compose.project")}},"com.docker.compose.service":{{json (index .Config.Labels "com.docker.compose.service")}},"com.docker.compose.project.working_dir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"com.docker.compose.project.config_files":{{json (index .Config.Labels "com.docker.compose.project.config_files")}}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}}}';
6134
+ SAFE_INSPECT_TEMPLATE = '{"id":{{json .Id}},"state":{"Running":{{json .State.Running}},"Health":{{with (index .State "Health")}}{"Status":{{json .Status}}}{{else}}null{{end}}},"labels":{"com.docker.compose.project":{{json (index .Config.Labels "com.docker.compose.project")}},"com.docker.compose.service":{{json (index .Config.Labels "com.docker.compose.service")}},"com.docker.compose.project.working_dir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"com.docker.compose.project.config_files":{{json (index .Config.Labels "com.docker.compose.project.config_files")}},"com.docker.compose.config-hash":{{json (index .Config.Labels "com.docker.compose.config-hash")}}},"mounts":{{json .Mounts}},"networks":{{json .NetworkSettings.Networks}}}';
5861
6135
  SIZE_INSPECT_TEMPLATE = SAFE_INSPECT_TEMPLATE.replace(
5862
6136
  /}$/,
5863
6137
  ',"sizeRw":{{json (index . "SizeRw")}},"sizeRootFs":{{json (index . "SizeRootFs")}}}'
5864
6138
  );
6139
+ MANAGED_STOP_INSPECT_TEMPLATE = '{"id":{{json .Id}},"state":{"Status":{{json .State.Status}},"Running":{{json .State.Running}},"Paused":{{json .State.Paused}},"Restarting":{{json .State.Restarting}},"Dead":{{json .State.Dead}},"Health":{{with (index .State "Health")}}{"Status":{{json .Status}}}{{else}}null{{end}}},"labels":{"com.docker.compose.project":{{json (index .Config.Labels "com.docker.compose.project")}},"com.docker.compose.service":{{json (index .Config.Labels "com.docker.compose.service")}},"com.docker.compose.project.working_dir":{{json (index .Config.Labels "com.docker.compose.project.working_dir")}},"com.docker.compose.project.config_files":{{json (index .Config.Labels "com.docker.compose.project.config_files")}},"com.docker.compose.config-hash":{{json (index .Config.Labels "com.docker.compose.config-hash")}}},"mounts":[{{range $index, $mount := .Mounts}}{{if $index}},{{end}}{"Type":{{json $mount.Type}},"Source":{{json $mount.Source}},"Destination":{{json $mount.Destination}}}{{end}}],"networks":{}}';
6140
+ MANAGED_STOP_PROJECT_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
6141
+ FULL_CONTAINER_ID_PATTERN = /^[0-9a-f]{64}$/;
6142
+ MANAGED_STOP_DOCKER_TIMEOUT_MS = 5e3;
6143
+ MANAGED_STOP_DOCKER_MAX_BUFFER = 1024 * 1024;
6144
+ MANAGED_STOP_COMPOSE_LABEL = "com.docker.compose.project";
6145
+ MANAGED_STOP_IDENTITY_LABELS = [
6146
+ "com.docker.compose.project",
6147
+ "com.docker.compose.service",
6148
+ "com.docker.compose.project.working_dir",
6149
+ "com.docker.compose.project.config_files",
6150
+ "com.docker.compose.config-hash"
6151
+ ];
5865
6152
  }
5866
6153
  });
5867
6154
 
@@ -6172,7 +6459,7 @@ function collectManagedRuntimeStatus(options) {
6172
6459
  let state;
6173
6460
  let stateReadFailed = false;
6174
6461
  try {
6175
- state = readManagedRuntimeState(options.repoPath, options.workspace);
6462
+ state = options.candidateState ?? readManagedRuntimeState(options.repoPath, options.workspace);
6176
6463
  } catch {
6177
6464
  stateReadFailed = true;
6178
6465
  }
@@ -6830,7 +7117,8 @@ function listDevsyWorkspaces() {
6830
7117
  }
6831
7118
  const workspace = {
6832
7119
  id: candidate.id,
6833
- source: { localFolder: source.localFolder }
7120
+ source: { localFolder: source.localFolder },
7121
+ ...typeof candidate.context === "string" ? { context: candidate.context } : {}
6834
7122
  };
6835
7123
  if ("lastUsed" in candidate) {
6836
7124
  if (typeof candidate.lastUsed === "string") {
@@ -7509,372 +7797,898 @@ var init_devpod_workspaces = __esm({
7509
7797
  }
7510
7798
  });
7511
7799
 
7512
- // src/core/devsy-mutation.ts
7513
- function failedStartMayHaveAttached(devsyId, repoPath) {
7514
- try {
7515
- const attached = listDevsyWorkspaces();
7516
- const attachedId = devsyId ?? selectDevsyWorkspace(attached, repoPath)?.id;
7517
- if (!attachedId) return false;
7518
- return inspectDevsyWorkspaceOwnership(attached, attachedId, repoPath).status !== "absent";
7519
- } catch {
7520
- return true;
7521
- }
7522
- }
7523
- function withMutationLock(activity, target, operation) {
7524
- import_node_fs20.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
7525
- return withFileLockSync(
7526
- DEVSY_MUTATION_LOCK_FILE,
7527
- {
7528
- activity,
7529
- target: `'${target}'`,
7530
- waitMs: DEVSY_MUTATION_WAIT_MS,
7531
- fair: true,
7532
- onWait: createStderrWaitReporter(activity, `'${target}'`)
7533
- },
7534
- operation
7535
- );
7800
+ // src/core/route-publication.ts
7801
+ function routedAppsFromConfig(config) {
7802
+ return config.apps.filter((app) => app.kind !== "dependency");
7536
7803
  }
7537
- function withMutationLockAsync(activity, target, operation) {
7538
- import_node_fs20.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
7539
- return withFileLock(
7540
- DEVSY_MUTATION_LOCK_FILE,
7541
- {
7542
- activity,
7543
- target: `'${target}'`,
7544
- waitMs: DEVSY_MUTATION_WAIT_MS,
7545
- fair: true,
7546
- onWait: createStderrWaitReporter(activity, `'${target}'`)
7547
- },
7548
- operation
7804
+ function configuredProxyAppsFromConfig(config) {
7805
+ return routedAppsFromConfig(config).filter(
7806
+ (app) => app.runtime === "proxy"
7549
7807
  );
7550
7808
  }
7551
- function commandFailure2(result) {
7552
- return [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
7553
- }
7554
- function runDevsyUp(args, env, quiet) {
7555
- return new Promise((resolve) => {
7556
- const child = (0, import_node_child_process15.spawn)("devsy", args, {
7557
- stdio: ["inherit", quiet ? 2 : "inherit", "pipe"],
7558
- env
7559
- });
7560
- const stderr = child.stderr;
7561
- if (!stderr) throw new Error("Devsy startup stderr pipe was not created.");
7562
- let stderrTail = Buffer.alloc(0);
7563
- let spawnError;
7564
- stderr.on("data", (chunk) => {
7565
- const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
7566
- const writable = process.stderr.write(value);
7567
- if (!writable) {
7568
- stderr.pause();
7569
- process.stderr.once("drain", () => stderr.resume());
7570
- }
7571
- if (value.length >= DEVSY_STDERR_TAIL_BYTES) {
7572
- stderrTail = Buffer.from(value.subarray(value.length - DEVSY_STDERR_TAIL_BYTES));
7573
- } else {
7574
- const combined = Buffer.concat([stderrTail, value]);
7575
- stderrTail = combined.length > DEVSY_STDERR_TAIL_BYTES ? combined.subarray(combined.length - DEVSY_STDERR_TAIL_BYTES) : combined;
7576
- }
7577
- });
7578
- child.once("error", (error) => {
7579
- spawnError = error;
7580
- });
7581
- child.once("close", (status) => {
7582
- resolve({ status, error: spawnError, stderrTail: stderrTail.toString("utf-8") });
7583
- });
7584
- });
7585
- }
7586
- function runDevsyAction(action2, devsyId, force = false) {
7587
- const args = action2 === "delete" ? ["delete", devsyId, ...force ? ["--force"] : [], "--ignore-not-found"] : ["stop", devsyId];
7588
- const result = (0, import_node_child_process15.spawnSync)("devsy", ["workspace", ...args], { encoding: "utf-8" });
7589
- if (result.status !== 0) {
7809
+ function proxyAppsFromConfig(config) {
7810
+ const routedApps = routedAppsFromConfig(config);
7811
+ const proxyApps = configuredProxyAppsFromConfig(config);
7812
+ const unsupported = routedApps.filter((app) => app.runtime !== "proxy");
7813
+ if (unsupported.length > 0) {
7590
7814
  throw new Error(
7591
- `devsy workspace ${action2}${force ? " --force" : ""} failed for '${devsyId}': ${commandFailure2(result) || "unknown error"}`
7815
+ `Environment reconciliation supports proxy runtime apps only; unsupported: ${unsupported.map((app) => `${app.name} (${app.runtime})`).join(", ")}`
7592
7816
  );
7593
7817
  }
7818
+ return proxyApps;
7594
7819
  }
7595
- function inspectExactOwnership(devsyId, worktreePath) {
7596
- const ownership = inspectDevsyWorkspaceOwnership(listDevsyWorkspaces(), devsyId, worktreePath);
7597
- if (ownership.status === "conflict") throw new Error(ownership.reason);
7598
- return ownership;
7599
- }
7600
- function mutateOwnedDevsyWorkspace(action2, devsyId, worktreePath) {
7601
- return withMutationLock(`Devsy ${action2}`, worktreePath, () => {
7602
- const before = inspectExactOwnership(devsyId, worktreePath);
7603
- if (before.status === "absent") return { status: "absent" };
7604
- runDevsyAction(action2, devsyId);
7605
- let after = inspectExactOwnership(devsyId, worktreePath);
7606
- if (action2 === "stop" && after.status !== "owned") {
7607
- throw new Error(
7608
- `Devsy workspace '${devsyId}' no longer owns '${worktreePath}' after provider stop.`
7609
- );
7610
- }
7611
- if (action2 === "delete" && after.status === "owned") {
7612
- const runtime = inspectDevsyRuntimeStatus(devsyId);
7613
- if (runtime !== "not-found") {
7614
- throw new Error(
7615
- `Devsy workspace '${devsyId}' still owns '${worktreePath}' after provider delete (runtime=${runtime}).`
7616
- );
7617
- }
7618
- after = inspectExactOwnership(devsyId, worktreePath);
7619
- if (after.status === "owned") {
7620
- runDevsyAction("delete", devsyId, true);
7621
- after = inspectExactOwnership(devsyId, worktreePath);
7622
- }
7623
- if (after.status !== "absent") {
7820
+ async function ensureRouteInfrastructure(apps, options = {}) {
7821
+ ensureRouterFiles();
7822
+ await ensureNetwork(DEVNET_NAME);
7823
+ const tlsCoverage = await ensureTLSHostsCovered(
7824
+ apps.map((app) => app.host),
7825
+ options
7826
+ );
7827
+ for (const app of apps) {
7828
+ if (app.protocol === "tcp") {
7829
+ if (!isTLSEnabled()) {
7624
7830
  throw new Error(
7625
- `Devsy workspace '${devsyId}' still owns '${worktreePath}' after forced provider delete.`
7831
+ `TCP route '${app.name}' requires TLS. Run: ${tlsSetupCommand(options.repoPath)}`
7626
7832
  );
7627
7833
  }
7834
+ activateTcpProtocol(app.tcpProtocol);
7628
7835
  }
7629
- return { status: "changed" };
7630
- });
7631
- }
7632
- function stopOwnedDevsyWorkspace(devsyId, worktreePath) {
7633
- return mutateOwnedDevsyWorkspace("stop", devsyId, worktreePath);
7634
- }
7635
- function deleteOwnedDevsyWorkspace(devsyId, worktreePath) {
7636
- return mutateOwnedDevsyWorkspace("delete", devsyId, worktreePath);
7637
- }
7638
- function assertDevsyTarget(devsyId, repoPath) {
7639
- const workspaces = listDevsyWorkspaces();
7640
- const existing = selectDevsyWorkspace(workspaces, repoPath);
7641
- const id = devsyId ?? existing?.id;
7642
- if (id) {
7643
- const before = inspectDevsyWorkspaceOwnership(workspaces, id, repoPath);
7644
- if (before.status === "conflict") throw new Error(before.reason);
7645
7836
  }
7646
- return id;
7837
+ startRouterStack();
7838
+ return tlsCoverage;
7647
7839
  }
7648
- async function startDevsyWorkspace(options) {
7649
- const activity = options.recreate ? "Devsy recreate" : "Devsy start";
7650
- let agent;
7840
+ async function replacePublishedProxyRoutes(repoPath, config, workspace, options = {}) {
7841
+ const apps = proxyAppsFromConfig(config);
7842
+ const tlsCoverage = options.prepareInfrastructure === false ? { refreshed: false } : await ensureRouteInfrastructure(apps, { repoPath });
7843
+ const routes = apps.map((app) => {
7844
+ const upstream = parseUpstream(app.upstream);
7845
+ return {
7846
+ name: app.name,
7847
+ host: app.host,
7848
+ protocol: app.protocol,
7849
+ tcpProtocol: app.protocol === "tcp" ? app.tcpProtocol : void 0,
7850
+ repoPath,
7851
+ port: upstream.port,
7852
+ mode: "proxy",
7853
+ upstreamHost: upstream.upstreamHost,
7854
+ workspace
7855
+ };
7856
+ });
7857
+ replaceHostRoutesForRepo(repoPath, routes);
7858
+ return { routes, tlsRefreshed: tlsCoverage.refreshed };
7859
+ }
7860
+ var init_route_publication = __esm({
7861
+ "src/core/route-publication.ts"() {
7862
+ "use strict";
7863
+ init_docker();
7864
+ init_host_routes();
7865
+ init_router();
7866
+ init_tls();
7867
+ }
7868
+ });
7869
+
7870
+ // src/core/workspace-ownership.ts
7871
+ function commandError(command, repoPath, stderr) {
7872
+ return new Error(
7873
+ `${command} failed for '${repoPath}': ${stderr?.trim() || "not a Git repository"}`
7874
+ );
7875
+ }
7876
+ function resolveGitCommonDir(repoPath) {
7877
+ const result = (0, import_node_child_process15.spawnSync)("git", ["-C", repoPath, "rev-parse", "--git-common-dir"], {
7878
+ encoding: "utf-8",
7879
+ env: READ_ONLY_GIT_ENV
7880
+ });
7881
+ const output2 = result.stdout.trim();
7882
+ if (result.status !== 0 || !output2) {
7883
+ throw commandError("Could not resolve the Git common directory", repoPath, result.stderr);
7884
+ }
7885
+ return comparableWorkspacePath(import_node_path20.default.isAbsolute(output2) ? output2 : import_node_path20.default.resolve(repoPath, output2));
7886
+ }
7887
+ function resolveGitTopLevel(repoPath) {
7888
+ const result = (0, import_node_child_process15.spawnSync)("git", ["-C", repoPath, "rev-parse", "--show-toplevel"], {
7889
+ encoding: "utf-8",
7890
+ env: READ_ONLY_GIT_ENV
7891
+ });
7892
+ const output2 = result.stdout.trim();
7893
+ if (result.status !== 0 || !output2) {
7894
+ throw commandError("Could not resolve the Git checkout root", repoPath, result.stderr);
7895
+ }
7896
+ return comparableWorkspacePath(output2);
7897
+ }
7898
+ function listGitWorktrees(repoPath) {
7899
+ const result = (0, import_node_child_process15.spawnSync)("git", ["-C", repoPath, "worktree", "list", "--porcelain"], {
7900
+ encoding: "utf-8",
7901
+ env: READ_ONLY_GIT_ENV
7902
+ });
7903
+ if (result.status !== 0) {
7904
+ throw commandError("git worktree list", repoPath, result.stderr);
7905
+ }
7906
+ const worktrees = [];
7907
+ let current = {};
7908
+ const finish = () => {
7909
+ if (!current.path) return;
7910
+ worktrees.push({
7911
+ path: comparableWorkspacePath(current.path),
7912
+ branch: current.branch,
7913
+ locked: current.locked ?? false,
7914
+ prunable: current.prunable ?? false
7915
+ });
7916
+ current = {};
7917
+ };
7918
+ for (const line of `${result.stdout}
7919
+ `.split("\n")) {
7920
+ if (line.startsWith("worktree ")) {
7921
+ finish();
7922
+ current.path = line.slice("worktree ".length).trim();
7923
+ } else if (line.startsWith("branch ")) {
7924
+ current.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
7925
+ } else if (line === "locked" || line.startsWith("locked ")) {
7926
+ current.locked = true;
7927
+ } else if (line === "prunable" || line.startsWith("prunable ")) {
7928
+ current.prunable = true;
7929
+ } else if (line === "") {
7930
+ finish();
7931
+ }
7932
+ }
7933
+ return worktrees;
7934
+ }
7935
+ function ownershipDirectory(repoPath) {
7936
+ return import_node_path20.default.join(resolveGitCommonDir(repoPath), OWNERSHIP_DIR);
7937
+ }
7938
+ function validateWorkspace(value, label) {
7939
+ if (typeof value !== "string" || wsFromBranch(value) !== value) {
7940
+ throw new Error(`invalid workspace ownership ${label}`);
7941
+ }
7942
+ return value;
7943
+ }
7944
+ function validateTimestamp(value, label) {
7945
+ if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
7946
+ throw new Error(`invalid workspace ownership ${label}`);
7947
+ }
7948
+ return value;
7949
+ }
7950
+ function validateRecord(value, expectedWorkspace) {
7951
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
7952
+ throw new Error("invalid workspace ownership record");
7953
+ }
7954
+ const candidate = value;
7955
+ if (candidate.version !== OWNERSHIP_VERSION) {
7956
+ throw new Error(`unsupported workspace ownership version '${String(candidate.version)}'`);
7957
+ }
7958
+ const workspace = validateWorkspace(candidate.workspace, "workspace");
7959
+ if (expectedWorkspace && workspace !== expectedWorkspace) {
7960
+ throw new Error(
7961
+ `workspace ownership file '${expectedWorkspace}' contains identity '${workspace}'`
7962
+ );
7963
+ }
7964
+ if (typeof candidate.worktreePath !== "string" || !import_node_path20.default.isAbsolute(candidate.worktreePath)) {
7965
+ throw new Error("invalid workspace ownership worktreePath");
7966
+ }
7967
+ if (candidate.branch !== null && typeof candidate.branch !== "string") {
7968
+ throw new Error("invalid workspace ownership branch");
7969
+ }
7970
+ const devpodId = validateWorkspace(candidate.devpodId, "devpodId");
7971
+ return {
7972
+ version: OWNERSHIP_VERSION,
7973
+ workspace,
7974
+ worktreePath: comparableWorkspacePath(candidate.worktreePath),
7975
+ branch: candidate.branch,
7976
+ devpodId,
7977
+ createdAt: validateTimestamp(candidate.createdAt, "createdAt"),
7978
+ updatedAt: validateTimestamp(candidate.updatedAt, "updatedAt")
7979
+ };
7980
+ }
7981
+ function recordPath(repoPath, workspace) {
7982
+ return import_node_path20.default.join(
7983
+ ownershipDirectory(repoPath),
7984
+ `${validateWorkspace(workspace, "workspace")}.json`
7985
+ );
7986
+ }
7987
+ function readRecordFile(filePath, expectedWorkspace) {
7988
+ let parsed;
7651
7989
  try {
7652
- agent = requireReadyDevsyAgent();
7990
+ parsed = JSON.parse(import_node_fs20.default.readFileSync(filePath, "utf-8"));
7653
7991
  } catch (error) {
7654
- if (!(error instanceof DevsyAgentReadinessError)) throw error;
7655
- const repair = devsyAgentRepairSuggestion(error.inspection);
7656
- throw new Error(`${error.message}. ${repair}`);
7992
+ if (error instanceof SyntaxError) {
7993
+ throw new Error(`invalid workspace ownership JSON at '${filePath}'`);
7994
+ }
7995
+ throw error;
7657
7996
  }
7658
- return withMutationLockAsync(activity, options.repoPath, async () => {
7659
- let devsyId = assertDevsyTarget(options.devsyId, options.repoPath);
7660
- if (devsyId && options.recreate) {
7661
- const attached = listDevsyWorkspaces();
7662
- const ownership = inspectDevsyWorkspaceOwnership(attached, devsyId, options.repoPath);
7663
- if (ownership.status !== "owned") {
7664
- throw new Error(`Cannot recreate Devsy workspace '${devsyId}' without one exact owner.`);
7997
+ return validateRecord(parsed, expectedWorkspace);
7998
+ }
7999
+ function readWorkspaceOwnership(repoPath, workspace) {
8000
+ const filePath = recordPath(repoPath, workspace);
8001
+ try {
8002
+ return readRecordFile(filePath, workspace);
8003
+ } catch (error) {
8004
+ if (error.code === "ENOENT") return void 0;
8005
+ throw error;
8006
+ }
8007
+ }
8008
+ function listWorkspaceOwnership(repoPath) {
8009
+ const directory = ownershipDirectory(repoPath);
8010
+ return listWorkspaceOwnershipInDirectory(directory);
8011
+ }
8012
+ function listWorkspaceOwnershipInDirectory(directory) {
8013
+ let entries;
8014
+ try {
8015
+ entries = import_node_fs20.default.readdirSync(directory, { withFileTypes: true });
8016
+ } catch (error) {
8017
+ if (error.code === "ENOENT") return [];
8018
+ throw error;
8019
+ }
8020
+ return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
8021
+ const workspace = entry.name.slice(0, -".json".length);
8022
+ validateWorkspace(workspace, "filename");
8023
+ return readRecordFile(import_node_path20.default.join(directory, entry.name), workspace);
8024
+ });
8025
+ }
8026
+ function writeWorkspaceOwnershipInDirectory(directory, input2) {
8027
+ const workspace = validateWorkspace(input2.workspace, "workspace");
8028
+ const devpodId = validateWorkspace(input2.devpodId, "devpodId");
8029
+ const worktreePath = comparableWorkspacePath(input2.worktreePath);
8030
+ const filePath = import_node_path20.default.join(directory, `${workspace}.json`);
8031
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8032
+ const records = listWorkspaceOwnershipInDirectory(directory);
8033
+ const existing = records.find((record2) => record2.workspace === workspace);
8034
+ if (existing && !sameWorkspacePath(existing.worktreePath, worktreePath)) {
8035
+ throw new Error(
8036
+ `Workspace '${workspace}' already belongs to '${existing.worktreePath}', refusing '${worktreePath}'.`
8037
+ );
8038
+ }
8039
+ const pathOwner = records.find(
8040
+ (record2) => record2.workspace !== workspace && sameWorkspacePath(record2.worktreePath, worktreePath)
8041
+ );
8042
+ if (pathOwner) {
8043
+ throw new Error(
8044
+ `Worktree '${worktreePath}' is already owned by workspace '${pathOwner.workspace}'.`
8045
+ );
8046
+ }
8047
+ if (existing && existing.devpodId !== devpodId) {
8048
+ throw new Error(
8049
+ `Workspace '${workspace}' already owns DevPod '${existing.devpodId}', refusing '${devpodId}'.`
8050
+ );
8051
+ }
8052
+ const record = {
8053
+ version: OWNERSHIP_VERSION,
8054
+ workspace,
8055
+ worktreePath,
8056
+ branch: input2.branch ?? null,
8057
+ devpodId,
8058
+ createdAt: existing?.createdAt ?? validateTimestamp(now, "createdAt"),
8059
+ updatedAt: validateTimestamp(now, "updatedAt")
8060
+ };
8061
+ writeFileAtomically(filePath, `${JSON.stringify(record, null, 2)}
8062
+ `);
8063
+ return record;
8064
+ }
8065
+ function removeWorkspaceOwnershipInDirectory(directory, workspace) {
8066
+ const filePath = import_node_path20.default.join(directory, `${validateWorkspace(workspace, "workspace")}.json`);
8067
+ try {
8068
+ import_node_fs20.default.rmSync(filePath);
8069
+ return true;
8070
+ } catch (error) {
8071
+ if (error.code === "ENOENT") return false;
8072
+ throw error;
8073
+ }
8074
+ }
8075
+ function sameOwnershipRecord(left, right) {
8076
+ return left.version === right.version && left.workspace === right.workspace && sameWorkspacePath(left.worktreePath, right.worktreePath) && left.branch === right.branch && left.devpodId === right.devpodId && left.createdAt === right.createdAt && left.updatedAt === right.updatedAt;
8077
+ }
8078
+ function removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected) {
8079
+ const filePath = import_node_path20.default.join(
8080
+ directory,
8081
+ `${validateWorkspace(expected.workspace, "workspace")}.json`
8082
+ );
8083
+ let current;
8084
+ try {
8085
+ current = readRecordFile(filePath, expected.workspace);
8086
+ } catch (error) {
8087
+ if (error.code === "ENOENT") return "absent";
8088
+ throw error;
8089
+ }
8090
+ if (!sameOwnershipRecord(current, expected)) return "changed";
8091
+ import_node_fs20.default.rmSync(filePath);
8092
+ return "removed";
8093
+ }
8094
+ function withWorkspaceOwnershipTransaction(repoPath, operation, options = {}) {
8095
+ const directory = ownershipDirectory(repoPath);
8096
+ import_node_fs20.default.mkdirSync(directory, { recursive: true });
8097
+ return withFileLockSync(
8098
+ import_node_path20.default.join(directory, ".lock"),
8099
+ {
8100
+ activity: "workspace ownership transaction",
8101
+ target: `'${repoPath}'`,
8102
+ waitMs: options.waitMs ?? 5e3
8103
+ },
8104
+ () => operation({
8105
+ list: () => listWorkspaceOwnershipInDirectory(directory),
8106
+ write: (input2) => writeWorkspaceOwnershipInDirectory(directory, input2),
8107
+ remove: (workspace) => removeWorkspaceOwnershipInDirectory(directory, workspace),
8108
+ removeIfMatches: (expected) => removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected)
8109
+ })
8110
+ );
8111
+ }
8112
+ function providerPathOwner(providerWorkspaces, worktreePath) {
8113
+ const owners = providerWorkspaces.filter(
8114
+ (workspace) => sameWorkspacePath(workspace.source.localFolder, worktreePath)
8115
+ );
8116
+ if (owners.length > 1) {
8117
+ throw new Error(
8118
+ `Worktree '${worktreePath}' is registered by multiple workspace runtimes; no identity was claimed.`
8119
+ );
8120
+ }
8121
+ return owners[0];
8122
+ }
8123
+ function persistedWorkspaceOwners(repoPath, worktreePath) {
8124
+ const owners = /* @__PURE__ */ new Map();
8125
+ for (const worktree of listGitWorktrees(repoPath)) {
8126
+ if (sameWorkspacePath(worktree.path, worktreePath)) continue;
8127
+ let workspace;
8128
+ try {
8129
+ workspace = readPersistedWorkspace(worktree.path);
8130
+ } catch (error) {
8131
+ if (!import_node_fs20.default.existsSync(worktree.path)) continue;
8132
+ throw error;
8133
+ }
8134
+ if (!workspace) continue;
8135
+ const existing = owners.get(workspace);
8136
+ if (existing && !sameWorkspacePath(existing, worktree.path)) {
8137
+ throw new Error(`Persisted workspace identity '${workspace}' belongs to multiple worktrees.`);
8138
+ }
8139
+ owners.set(workspace, worktree.path);
8140
+ }
8141
+ return owners;
8142
+ }
8143
+ function claimConflict(workspace, devpodId, worktreePath, records, providerWorkspaces, persistedOwners, exactRecord) {
8144
+ const recordOwner = records.find(
8145
+ (record) => record !== exactRecord && (record.workspace === workspace || record.devpodId === devpodId)
8146
+ );
8147
+ if (recordOwner) {
8148
+ return `workspace owner record '${recordOwner.workspace}' for '${recordOwner.worktreePath}'`;
8149
+ }
8150
+ const providerOwner = providerWorkspaces.find(
8151
+ (providerWorkspace) => providerWorkspace.id === devpodId && !sameWorkspacePath(providerWorkspace.source.localFolder, worktreePath)
8152
+ );
8153
+ if (providerOwner) {
8154
+ return `workspace runtime identity '${providerOwner.id}' already belongs to '${providerOwner.source.localFolder}'`;
8155
+ }
8156
+ const persistedOwner = persistedOwners.get(workspace);
8157
+ if (persistedOwner) {
8158
+ return `persisted checkout metadata for '${persistedOwner}'`;
8159
+ }
8160
+ return void 0;
8161
+ }
8162
+ function claimWorkspaceIdentity(repoPath, input2) {
8163
+ const worktreePath = comparableWorkspacePath(repoPath);
8164
+ const exactProvider = providerPathOwner(input2.providerWorkspaces, worktreePath);
8165
+ return withWorkspaceOwnershipTransaction(repoPath, (transaction) => {
8166
+ const records = transaction.list();
8167
+ const exactRecords = records.filter(
8168
+ (record) => sameWorkspacePath(record.worktreePath, worktreePath)
8169
+ );
8170
+ if (exactRecords.length > 1) {
8171
+ throw new Error(
8172
+ `Worktree '${worktreePath}' has multiple workspace owner records; no identity was claimed.`
8173
+ );
8174
+ }
8175
+ const exactRecord = exactRecords[0];
8176
+ const persisted = readPersistedWorkspace(worktreePath);
8177
+ const persistedOwners = persistedWorkspaceOwners(repoPath, worktreePath);
8178
+ if (exactRecord) {
8179
+ if (persisted && persisted !== exactRecord.workspace) {
8180
+ throw new Error(
8181
+ `Persisted workspace identity '${persisted}' disagrees with owner record '${exactRecord.workspace}'.`
8182
+ );
8183
+ }
8184
+ if (exactProvider && exactProvider.id !== exactRecord.devpodId) {
8185
+ throw new Error(
8186
+ `Workspace runtime '${exactProvider.id}' disagrees with owner record '${exactRecord.devpodId}'.`
8187
+ );
7665
8188
  }
8189
+ const conflict2 = claimConflict(
8190
+ exactRecord.workspace,
8191
+ exactRecord.devpodId,
8192
+ worktreePath,
8193
+ records,
8194
+ input2.providerWorkspaces,
8195
+ persistedOwners,
8196
+ exactRecord
8197
+ );
8198
+ if (conflict2) {
8199
+ throw new Error(
8200
+ `Workspace '${exactRecord.workspace}' conflicts with ${conflict2}; no identity was claimed.`
8201
+ );
8202
+ }
8203
+ if (!persisted) persistWorkspace(worktreePath, exactRecord.workspace);
8204
+ return transaction.write({
8205
+ workspace: exactRecord.workspace,
8206
+ worktreePath,
8207
+ branch: input2.branch ?? null,
8208
+ devpodId: exactRecord.devpodId
8209
+ });
7666
8210
  }
7667
- if (!devsyId && options.recreate) {
7668
- throw new Error("Cannot recreate a Devsy workspace before its exact id is known.");
8211
+ if (persisted && exactProvider && persisted !== exactProvider.id) {
8212
+ throw new Error(
8213
+ `Persisted workspace identity '${persisted}' disagrees with workspace runtime '${exactProvider.id}'.`
8214
+ );
7669
8215
  }
7670
- const args = ["workspace", "up", options.repoPath];
7671
- if (devsyId) args.push("--id", devsyId);
7672
- if (options.devcontainerPath) args.push("--devcontainer", options.devcontainerPath);
7673
- args.push("--ide-launch", "skip");
7674
- if (options.workspace) {
7675
- args.push(
7676
- "--workspace-env",
7677
- `WORKSPACE=${options.workspace.token}`,
7678
- "--workspace-env",
7679
- `DEVROUTER_WORKSPACE=${options.workspace.token}`
8216
+ let workspace = persisted ?? exactProvider?.id;
8217
+ let devpodId = exactProvider?.id ?? persisted;
8218
+ if (!workspace || !devpodId) {
8219
+ if (input2.unavailableRuntimes.length > 0) {
8220
+ throw new Error(
8221
+ `Cannot claim a new workspace identity because these runtime registries are unavailable: ${input2.unavailableRuntimes.join(", ")}.`
8222
+ );
8223
+ }
8224
+ const candidate = workspaceIdentityCandidates(input2.source).find(
8225
+ (next) => !claimConflict(
8226
+ next,
8227
+ next,
8228
+ worktreePath,
8229
+ records,
8230
+ input2.providerWorkspaces,
8231
+ persistedOwners
8232
+ )
7680
8233
  );
8234
+ if (!candidate) {
8235
+ throw new Error(
8236
+ `Could not allocate a collision-safe workspace identity for '${worktreePath}'.`
8237
+ );
8238
+ }
8239
+ workspace = candidate;
8240
+ devpodId = candidate;
8241
+ }
8242
+ const conflict = claimConflict(
8243
+ workspace,
8244
+ devpodId,
8245
+ worktreePath,
8246
+ records,
8247
+ input2.providerWorkspaces,
8248
+ persistedOwners
8249
+ );
8250
+ if (conflict) {
8251
+ throw new Error(
8252
+ `Workspace '${workspace}' conflicts with ${conflict}; no identity was claimed.`
8253
+ );
8254
+ }
8255
+ const written = transaction.write({
8256
+ workspace,
8257
+ worktreePath,
8258
+ branch: input2.branch ?? null,
8259
+ devpodId
8260
+ });
8261
+ try {
8262
+ persistWorkspace(worktreePath, workspace);
8263
+ } catch (error) {
8264
+ const cleanup = transaction.removeIfMatches(written);
8265
+ if (cleanup !== "removed") {
8266
+ const detail = error instanceof Error ? error.message : String(error);
8267
+ throw new Error(
8268
+ `Could not persist workspace identity and owner-record rollback was '${cleanup}': ${detail}`
8269
+ );
8270
+ }
8271
+ throw error;
8272
+ }
8273
+ return written;
8274
+ });
8275
+ }
8276
+ function removeWorkspaceOwnership(repoPath, workspace) {
8277
+ return withWorkspaceOwnershipTransaction(
8278
+ repoPath,
8279
+ (transaction) => transaction.remove(workspace)
8280
+ );
8281
+ }
8282
+ function inspectWorkspaceOwnership(record, worktrees, devpods) {
8283
+ const worktree = worktrees.find(
8284
+ (candidate) => sameWorkspacePath(candidate.path, record.worktreePath)
8285
+ );
8286
+ const devpodOwnership = devpods ? inspectDevpodWorkspaceOwnership(devpods, record.devpodId, record.worktreePath) : void 0;
8287
+ const devpodStatus = devpodOwnership?.status ?? "unknown";
8288
+ if (devpodStatus === "conflict") {
8289
+ return { ownerStatus: "conflict", devpodStatus, worktree };
8290
+ }
8291
+ if (worktree?.locked) {
8292
+ return { ownerStatus: "locked", devpodStatus, worktree };
8293
+ }
8294
+ if ((!worktree || worktree.prunable) && import_node_fs20.default.existsSync(record.worktreePath)) {
8295
+ return { ownerStatus: "conflict", devpodStatus, worktree };
8296
+ }
8297
+ if (!worktree || worktree.prunable) {
8298
+ return { ownerStatus: "missing", devpodStatus, worktree };
8299
+ }
8300
+ let persisted;
8301
+ try {
8302
+ persisted = readPersistedWorkspace(worktree.path);
8303
+ } catch {
8304
+ return { ownerStatus: "conflict", devpodStatus, worktree };
8305
+ }
8306
+ return {
8307
+ ownerStatus: persisted === record.workspace ? "present" : "conflict",
8308
+ devpodStatus,
8309
+ worktree
8310
+ };
8311
+ }
8312
+ function listMissingWorkspaceOwnership(repoPath) {
8313
+ const worktrees = listGitWorktrees(repoPath);
8314
+ return listWorkspaceOwnership(repoPath).filter(
8315
+ (record) => inspectWorkspaceOwnership(record, worktrees, void 0).ownerStatus === "missing"
8316
+ );
8317
+ }
8318
+ var import_node_child_process15, import_node_fs20, import_node_path20, READ_ONLY_GIT_ENV, OWNERSHIP_VERSION, OWNERSHIP_DIR;
8319
+ var init_workspace_ownership = __esm({
8320
+ "src/core/workspace-ownership.ts"() {
8321
+ "use strict";
8322
+ import_node_child_process15 = require("child_process");
8323
+ import_node_fs20 = __toESM(require("fs"));
8324
+ import_node_path20 = __toESM(require("path"));
8325
+ init_atomic_file();
8326
+ init_devpod_workspaces();
8327
+ init_file_lock();
8328
+ init_workspace();
8329
+ READ_ONLY_GIT_ENV = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
8330
+ OWNERSHIP_VERSION = 1;
8331
+ OWNERSHIP_DIR = import_node_path20.default.join("devrouter", "workspaces");
8332
+ }
8333
+ });
8334
+
8335
+ // src/core/managed-devsy-stop.ts
8336
+ function sameSet(left, right) {
8337
+ return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
8338
+ }
8339
+ function containerIdentity(container) {
8340
+ return JSON.stringify({ id: container.id, labels: container.labels, mounts: container.mounts });
8341
+ }
8342
+ function stopRetainedManagedDevsyWorkspace(options) {
8343
+ const { repoPath, devsyId } = options;
8344
+ const linked = isLinkedWorktree(repoPath);
8345
+ const workspace = linked ? resolveWorktreeWorkspace(repoPath) : void 0;
8346
+ const retainedState = readManagedRuntimeState(repoPath, workspace);
8347
+ if (!retainedState) return false;
8348
+ const state = retainedState;
8349
+ if (state.devpodId !== devsyId || linked && !workspace) {
8350
+ throw new Error("Managed stop requires the exact retained workspace identity.");
8351
+ }
8352
+ const workspaceEnv = workspace ? { token: workspace, gitCommonDir: resolveGitCommonDir(repoPath) } : void 0;
8353
+ function registration() {
8354
+ resetWorkspaceRuntimeCaches();
8355
+ if (resolveWorkspaceRuntimeOrDefault(repoPath) !== "devsy") {
8356
+ throw new Error("Managed stop provider selection changed.");
8357
+ }
8358
+ const owner = inspectDevsyWorkspaceOwnership(listDevsyWorkspaces(), devsyId, repoPath);
8359
+ if (owner.status !== "owned") {
8360
+ throw new Error("Managed stop requires one exact retained Devsy registration.");
8361
+ }
8362
+ if (workspace) {
8363
+ const record = readWorkspaceOwnership(repoPath, workspace);
8364
+ if (!record || record.devpodId !== devsyId || !sameWorkspacePath(record.worktreePath, repoPath) || resolveWorktreeWorkspace(repoPath) !== workspace || resolveGitCommonDir(repoPath) !== workspaceEnv?.gitCommonDir) {
8365
+ throw new Error("Managed stop workspace ownership changed.");
8366
+ }
7681
8367
  }
7682
- if (options.inactivityTimeout) {
7683
- args.push("--provider-option", `INACTIVITY_TIMEOUT=${options.inactivityTimeout}`);
8368
+ return owner.workspace;
8369
+ }
8370
+ const context = registration().context;
8371
+ if (!context || !/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(context) || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(devsyId)) {
8372
+ throw new Error("Managed stop requires a valid exact Devsy context and workspace ID.");
8373
+ }
8374
+ const devsyRoot = import_node_path21.default.resolve(process.env.DEVSY_HOME || import_node_path21.default.join(import_node_os2.default.homedir(), ".devsy"));
8375
+ const featureDirectory = import_node_path21.default.join(
8376
+ devsyRoot,
8377
+ "contexts",
8378
+ context,
8379
+ "workspaces",
8380
+ devsyId,
8381
+ "agent",
8382
+ ".docker-compose"
8383
+ );
8384
+ function prove(previous) {
8385
+ if (registration().context !== context || JSON.stringify(readManagedRuntimeState(repoPath, workspace)) !== JSON.stringify(state)) {
8386
+ throw new Error("Managed stop retained context or runtime record changed.");
7684
8387
  }
7685
- if (options.recreate) args.push("--recreate");
7686
- const env = { ...process.env };
7687
- env.DEVSY_AGENT_BINARY = agent.binaryPath;
7688
- if (options.workspace) {
7689
- env.WORKSPACE = options.workspace.token;
7690
- env.DEVROUTER_WORKSPACE = options.workspace.token;
7691
- env.DEVROUTER_GIT_COMMON_DIR = options.workspace.gitCommonDir;
7692
- env.DEVCONTAINER_COMPOSE_OVERLAY = "docker-compose.devrouter.yml";
7693
- } else {
7694
- delete env.WORKSPACE;
7695
- delete env.DEVROUTER_WORKSPACE;
7696
- delete env.DEVROUTER_GIT_COMMON_DIR;
7697
- delete env.DEVCONTAINER_COMPOSE_OVERLAY;
8388
+ const runtime = loadRuntimeConfig(repoPath, workspace ?? "", state.profile);
8389
+ const managed = runtime.config.managedRuntime;
8390
+ if (!managed || runtime.profile !== state.profile || runtime.workspace !== workspace) {
8391
+ throw new Error("Managed stop requires the recorded managed profile.");
7698
8392
  }
7699
- const result = await runDevsyUp(args, env, options.quiet ?? false);
7700
- if (result.status !== 0) {
7701
- let message = `devsy workspace up failed for '${devsyId ?? options.repoPath}'.`;
7702
- if (result.error?.message) message += ` ${result.error.message}`;
7703
- if (AGENT_ACQUISITION_RE.test(result.stderrTail)) {
7704
- message += ` Devsy rejected the verified agent source. Run: ${DEVSY_AGENT_SETUP_COMMAND}`;
8393
+ const plan = inspectManagedDevcontainerConfig({
8394
+ repoPath,
8395
+ config: runtime.config,
8396
+ profile: runtime.resolvedProfile,
8397
+ linked
8398
+ });
8399
+ const selectedProcesses = runtime.resolvedProfile?.processes;
8400
+ const processes = !runtime.resolvedProfile || selectedProcesses?.length === 1 && selectedProcesses[0] === "*" ? managed.processes : selectedProcesses ?? [];
8401
+ if (state.sourceConfigSha256 !== plan.sourceConfigSha256 || state.effectiveConfigSha256 !== plan.effectiveConfigSha256 || !sameSet(
8402
+ state.desired.apps,
8403
+ proxyAppsFromConfig(runtime.config).map((app) => app.name)
8404
+ ) || !sameSet(state.desired.services, plan.desiredProfileServices) || !sameSet(state.desired.processes, processes) || inspectManagedDevcontainerGeneratedConfig(plan).status !== "valid") {
8405
+ throw new Error("Managed stop requires unchanged recorded resources and configuration.");
8406
+ }
8407
+ const containers = inspectManagedStopContainers(state.composeProject);
8408
+ const services = /* @__PURE__ */ new Set();
8409
+ for (const container of containers) {
8410
+ const service = container.labels["com.docker.compose.service"] ?? "";
8411
+ if (!plan.nativeRunServices.includes(service) || services.has(service) || container.labels["com.docker.compose.project"] !== state.composeProject || !sameWorkspacePath(
8412
+ container.labels["com.docker.compose.project.working_dir"] ?? "",
8413
+ plan.composeDirectory
8414
+ )) {
8415
+ throw new Error("Managed stop found unexpected or duplicate project membership.");
7705
8416
  }
7706
- if (failedStartMayHaveAttached(devsyId, options.repoPath)) {
7707
- throw new DevsyStartPostconditionError(message);
8417
+ services.add(service);
8418
+ const files = (container.labels["com.docker.compose.project.config_files"] ?? "").split(",").map((file) => file.trim());
8419
+ if (files.some((file) => !file || !import_node_path21.default.isAbsolute(file)) || new Set(files.map((file) => import_node_path21.default.resolve(file))).size !== files.length || files.length < plan.composeFiles.length || plan.composeFiles.some((file, index) => !sameWorkspacePath(file, files[index]))) {
8420
+ throw new Error("Managed stop Compose file identity changed.");
7708
8421
  }
7709
- throw new Error(message);
8422
+ for (const file of files.slice(plan.composeFiles.length)) {
8423
+ if (import_node_path21.default.dirname(file) !== featureDirectory || !/^docker-compose\.devcontainer\.containerFeatures-[a-zA-Z0-9_-]+\.yml$/.test(
8424
+ import_node_path21.default.basename(file)
8425
+ ) || import_node_fs21.default.realpathSync(file) !== import_node_path21.default.join(import_node_fs21.default.realpathSync(devsyRoot), import_node_path21.default.relative(devsyRoot, file))) {
8426
+ throw new Error("Managed stop refuses a foreign or escaped provider Compose file.");
8427
+ }
8428
+ }
8429
+ if (previous) {
8430
+ const retained = previous.find((entry) => entry.id === container.id);
8431
+ if (!retained || containerIdentity(retained) !== containerIdentity(container) || !retained.state.Running && container.state.Running) {
8432
+ throw new Error("Managed stop container identity or quiescent state changed.");
8433
+ }
8434
+ }
8435
+ }
8436
+ if (plan.desiredServices.some((service) => !services.has(service)) || previous && !sameSet(
8437
+ previous.map((c) => c.id),
8438
+ containers.map((c) => c.id)
8439
+ )) {
8440
+ throw new Error("Managed stop cannot prove the complete retained service population.");
8441
+ }
8442
+ const primary = containers.find(
8443
+ (c) => c.labels["com.docker.compose.service"] === plan.primaryService
8444
+ );
8445
+ if (primary?.mounts.filter(
8446
+ (mount) => mount.Type === "bind" && sameWorkspacePath(mount.Source, repoPath)
8447
+ ).length !== 1) {
8448
+ throw new Error("Managed stop cannot prove the exact primary workspace mount.");
7710
8449
  }
8450
+ assertManagedContainerConfigUnchanged({ plan, containers, workspace: workspaceEnv });
8451
+ if (registration().context !== context) {
8452
+ throw new Error("Managed stop provider context changed during inspection.");
8453
+ }
8454
+ const status = inspectDevsyRuntimeStatus(devsyId);
8455
+ if (status !== "running" && status !== "stopped" || status === "running" !== primary.state.Running) {
8456
+ throw new Error("Managed stop requires consistent provider and primary container state.");
8457
+ }
8458
+ return { containers, primary, status };
8459
+ }
8460
+ const initial = prove();
8461
+ let providerError;
8462
+ let providerFailed = false;
8463
+ if (initial.status === "running") {
7711
8464
  try {
7712
- const attached = listDevsyWorkspaces();
7713
- devsyId ??= selectDevsyWorkspace(attached, options.repoPath)?.id;
7714
- if (!devsyId) {
7715
- throw new Error(`Devsy did not attach '${options.repoPath}' after startup.`);
8465
+ options.stopProvider();
8466
+ } catch (error) {
8467
+ providerFailed = true;
8468
+ providerError = error;
8469
+ }
8470
+ }
8471
+ try {
8472
+ const stopped = () => {
8473
+ const current = prove(initial.containers);
8474
+ if (current.status !== "stopped" || current.primary.state.Running) {
8475
+ throw new Error("Managed stop has not stopped the exact primary container.");
7716
8476
  }
7717
- const ownership = inspectDevsyWorkspaceOwnership(attached, devsyId, options.repoPath);
7718
- if (ownership.status === "conflict") throw new Error(ownership.reason);
7719
- if (ownership.status !== "owned") {
7720
- throw new Error(
7721
- `Devsy did not attach '${options.repoPath}' as '${devsyId}' after startup.`
7722
- );
8477
+ return current.containers;
8478
+ };
8479
+ stopped();
8480
+ for (const retained of initial.containers) {
8481
+ if (!retained.state.Running || retained.id === initial.primary.id) continue;
8482
+ const current = stopped().find((entry) => entry.id === retained.id);
8483
+ if (current?.state.Running) {
8484
+ stopExactManagedService(current.id, current.labels["com.docker.compose.service"] ?? "", {
8485
+ timeoutMs: 3e4
8486
+ });
7723
8487
  }
7724
- return devsyId;
7725
- } catch (error) {
7726
- const message = error instanceof Error ? error.message : String(error);
7727
- throw new DevsyStartPostconditionError(message);
7728
8488
  }
7729
- });
8489
+ if (stopped().some((container) => container.state.Running)) {
8490
+ throw new Error("Managed stop left a retained service running.");
8491
+ }
8492
+ } catch (error) {
8493
+ if (providerFailed) {
8494
+ throw new AggregateError(
8495
+ [providerError, error],
8496
+ "Devsy provider stop failed; complete retained shutdown could not be verified.",
8497
+ { cause: providerError }
8498
+ );
8499
+ }
8500
+ throw error;
8501
+ }
8502
+ if (providerFailed) throw providerError;
8503
+ return true;
7730
8504
  }
7731
- var import_node_child_process15, import_node_fs20, import_node_path20, DEVSY_MUTATION_LOCK_FILE, DEVSY_MUTATION_WAIT_MS, DevsyStartPostconditionError, AGENT_ACQUISITION_RE, DEVSY_STDERR_TAIL_BYTES;
7732
- var init_devsy_mutation = __esm({
7733
- "src/core/devsy-mutation.ts"() {
8505
+ var import_node_fs21, import_node_os2, import_node_path21;
8506
+ var init_managed_devsy_stop = __esm({
8507
+ "src/core/managed-devsy-stop.ts"() {
7734
8508
  "use strict";
7735
- import_node_child_process15 = require("child_process");
7736
- import_node_fs20 = __toESM(require("fs"));
7737
- import_node_path20 = __toESM(require("path"));
7738
- init_devsy_agent();
8509
+ import_node_fs21 = __toESM(require("fs"));
8510
+ import_node_os2 = __toESM(require("os"));
8511
+ import_node_path21 = __toESM(require("path"));
8512
+ init_devcontainer_profile();
8513
+ init_devpod_environment();
7739
8514
  init_devsy_workspaces();
7740
- init_file_lock();
7741
- init_router();
7742
- DEVSY_MUTATION_LOCK_FILE = import_node_path20.default.join(DEVROUTER_HOME, "devsy-mutation.lock");
7743
- DEVSY_MUTATION_WAIT_MS = 18e5;
7744
- DevsyStartPostconditionError = class extends Error {
7745
- };
7746
- AGENT_ACQUISITION_RE = /inject agent.*agent binary not found/i;
7747
- DEVSY_STDERR_TAIL_BYTES = 8192;
8515
+ init_managed_runtime_state();
8516
+ init_repo_config();
8517
+ init_route_publication();
8518
+ init_workspace();
8519
+ init_workspace_ownership();
8520
+ init_workspace_runtime();
7748
8521
  }
7749
8522
  });
7750
8523
 
7751
- // src/core/devpod-mutation.ts
7752
- function withMutationLock2(activity, target, operation) {
7753
- import_node_fs21.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
8524
+ // src/core/devsy-mutation.ts
8525
+ function failedStartMayHaveAttached(devsyId, repoPath) {
8526
+ try {
8527
+ const attached = listDevsyWorkspaces();
8528
+ const attachedId = devsyId ?? selectDevsyWorkspace(attached, repoPath)?.id;
8529
+ if (!attachedId) return false;
8530
+ return inspectDevsyWorkspaceOwnership(attached, attachedId, repoPath).status !== "absent";
8531
+ } catch {
8532
+ return true;
8533
+ }
8534
+ }
8535
+ function withMutationLock(activity, target, operation) {
8536
+ import_node_fs22.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
7754
8537
  return withFileLockSync(
7755
- DEVPOD_MUTATION_LOCK_FILE,
8538
+ DEVSY_MUTATION_LOCK_FILE,
7756
8539
  {
7757
8540
  activity,
7758
8541
  target: `'${target}'`,
7759
- waitMs: DEVPOD_MUTATION_WAIT_MS,
8542
+ waitMs: DEVSY_MUTATION_WAIT_MS,
7760
8543
  fair: true,
7761
8544
  onWait: createStderrWaitReporter(activity, `'${target}'`)
7762
8545
  },
7763
8546
  operation
7764
8547
  );
7765
8548
  }
7766
- function commandFailure3(result) {
8549
+ function withMutationLockAsync(activity, target, operation) {
8550
+ import_node_fs22.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
8551
+ return withFileLock(
8552
+ DEVSY_MUTATION_LOCK_FILE,
8553
+ {
8554
+ activity,
8555
+ target: `'${target}'`,
8556
+ waitMs: DEVSY_MUTATION_WAIT_MS,
8557
+ fair: true,
8558
+ onWait: createStderrWaitReporter(activity, `'${target}'`)
8559
+ },
8560
+ operation
8561
+ );
8562
+ }
8563
+ function commandFailure2(result) {
7767
8564
  return [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
7768
8565
  }
7769
- function runDevpodAction(action2, devpodId, force = false) {
7770
- const args = action2 === "delete" ? [action2, devpodId, ...force ? ["--force"] : [], "--ignore-not-found"] : [action2, devpodId];
7771
- const result = (0, import_node_child_process16.spawnSync)("devpod", args, { encoding: "utf-8" });
8566
+ function runDevsyUp(args, env, quiet) {
8567
+ return new Promise((resolve) => {
8568
+ const child = (0, import_node_child_process16.spawn)("devsy", args, {
8569
+ stdio: ["inherit", quiet ? 2 : "inherit", "pipe"],
8570
+ env
8571
+ });
8572
+ const stderr = child.stderr;
8573
+ if (!stderr) throw new Error("Devsy startup stderr pipe was not created.");
8574
+ let stderrTail = Buffer.alloc(0);
8575
+ let spawnError;
8576
+ stderr.on("data", (chunk) => {
8577
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
8578
+ const writable = process.stderr.write(value);
8579
+ if (!writable) {
8580
+ stderr.pause();
8581
+ process.stderr.once("drain", () => stderr.resume());
8582
+ }
8583
+ if (value.length >= DEVSY_STDERR_TAIL_BYTES) {
8584
+ stderrTail = Buffer.from(value.subarray(value.length - DEVSY_STDERR_TAIL_BYTES));
8585
+ } else {
8586
+ const combined = Buffer.concat([stderrTail, value]);
8587
+ stderrTail = combined.length > DEVSY_STDERR_TAIL_BYTES ? combined.subarray(combined.length - DEVSY_STDERR_TAIL_BYTES) : combined;
8588
+ }
8589
+ });
8590
+ child.once("error", (error) => {
8591
+ spawnError = error;
8592
+ });
8593
+ child.once("close", (status) => {
8594
+ resolve({ status, error: spawnError, stderrTail: stderrTail.toString("utf-8") });
8595
+ });
8596
+ });
8597
+ }
8598
+ function runDevsyAction(action2, devsyId, force = false) {
8599
+ const args = action2 === "delete" ? ["delete", devsyId, ...force ? ["--force"] : [], "--ignore-not-found"] : ["stop", devsyId];
8600
+ const result = (0, import_node_child_process16.spawnSync)("devsy", ["workspace", ...args], { encoding: "utf-8" });
7772
8601
  if (result.status !== 0) {
7773
8602
  throw new Error(
7774
- `devpod ${action2}${force ? " --force" : ""} failed for '${devpodId}': ${commandFailure3(result) || "unknown error"}`
8603
+ `devsy workspace ${action2}${force ? " --force" : ""} failed for '${devsyId}': ${commandFailure2(result) || "unknown error"}`
7775
8604
  );
7776
8605
  }
7777
8606
  }
7778
- function inspectExactOwnership2(devpodId, worktreePath) {
7779
- const ownership = inspectDevpodWorkspaceOwnership(
7780
- listDevpodWorkspaces(worktreePath),
7781
- devpodId,
7782
- worktreePath
7783
- );
8607
+ function inspectExactOwnership(devsyId, worktreePath) {
8608
+ const ownership = inspectDevsyWorkspaceOwnership(listDevsyWorkspaces(), devsyId, worktreePath);
7784
8609
  if (ownership.status === "conflict") throw new Error(ownership.reason);
7785
8610
  return ownership;
7786
8611
  }
7787
- function mutateOwnedDevpodWorkspace(action2, devpodId, worktreePath) {
7788
- return withMutationLock2(`DevPod ${action2}`, worktreePath, () => {
7789
- const before = inspectExactOwnership2(devpodId, worktreePath);
8612
+ function mutateOwnedDevsyWorkspace(action2, devsyId, worktreePath) {
8613
+ return withMutationLock(`Devsy ${action2}`, worktreePath, () => {
8614
+ if (action2 === "stop" && stopRetainedManagedDevsyWorkspace({
8615
+ repoPath: worktreePath,
8616
+ devsyId,
8617
+ stopProvider: () => runDevsyAction("stop", devsyId)
8618
+ }))
8619
+ return { status: "changed" };
8620
+ const before = inspectExactOwnership(devsyId, worktreePath);
7790
8621
  if (before.status === "absent") return { status: "absent" };
7791
- runDevpodAction(action2, devpodId);
7792
- let after = inspectExactOwnership2(devpodId, worktreePath);
8622
+ runDevsyAction(action2, devsyId);
8623
+ let after = inspectExactOwnership(devsyId, worktreePath);
7793
8624
  if (action2 === "stop" && after.status !== "owned") {
7794
- throw new Error(`DevPod '${devpodId}' no longer owns '${worktreePath}' after provider stop.`);
8625
+ throw new Error(
8626
+ `Devsy workspace '${devsyId}' no longer owns '${worktreePath}' after provider stop.`
8627
+ );
7795
8628
  }
7796
8629
  if (action2 === "delete" && after.status === "owned") {
7797
- const runtime = inspectDevpodRuntimeStatus(devpodId, worktreePath);
8630
+ const runtime = inspectDevsyRuntimeStatus(devsyId);
7798
8631
  if (runtime !== "not-found") {
7799
8632
  throw new Error(
7800
- `DevPod '${devpodId}' still owns '${worktreePath}' after provider delete (runtime=${runtime}).`
8633
+ `Devsy workspace '${devsyId}' still owns '${worktreePath}' after provider delete (runtime=${runtime}).`
7801
8634
  );
7802
8635
  }
7803
- after = inspectExactOwnership2(devpodId, worktreePath);
8636
+ after = inspectExactOwnership(devsyId, worktreePath);
7804
8637
  if (after.status === "owned") {
7805
- runDevpodAction("delete", devpodId, true);
7806
- after = inspectExactOwnership2(devpodId, worktreePath);
8638
+ runDevsyAction("delete", devsyId, true);
8639
+ after = inspectExactOwnership(devsyId, worktreePath);
7807
8640
  }
7808
8641
  if (after.status !== "absent") {
7809
8642
  throw new Error(
7810
- `DevPod '${devpodId}' still owns '${worktreePath}' after forced provider delete.`
8643
+ `Devsy workspace '${devsyId}' still owns '${worktreePath}' after forced provider delete.`
7811
8644
  );
7812
8645
  }
7813
8646
  }
7814
8647
  return { status: "changed" };
7815
8648
  });
7816
8649
  }
7817
- function stopOwnedDevpodWorkspace(devpodId, worktreePath) {
7818
- if (resolveWorkspaceRuntimeOrDefault(worktreePath) === "devsy") {
7819
- const result2 = stopOwnedDevsyWorkspace(devpodId, worktreePath);
7820
- resetWorkspaceRuntimeCaches();
7821
- return result2;
7822
- }
7823
- const result = mutateOwnedDevpodWorkspace("stop", devpodId, worktreePath);
7824
- resetWorkspaceRuntimeCaches();
7825
- return result;
8650
+ function stopOwnedDevsyWorkspace(devsyId, worktreePath) {
8651
+ return mutateOwnedDevsyWorkspace("stop", devsyId, worktreePath);
7826
8652
  }
7827
- function deleteOwnedDevpodWorkspace(devpodId, worktreePath) {
7828
- if (resolveWorkspaceRuntimeOrDefault(worktreePath) === "devsy") {
7829
- const result2 = deleteOwnedDevsyWorkspace(devpodId, worktreePath);
7830
- resetWorkspaceRuntimeCaches();
7831
- return result2;
7832
- }
7833
- const result = mutateOwnedDevpodWorkspace("delete", devpodId, worktreePath);
7834
- resetWorkspaceRuntimeCaches();
7835
- return result;
8653
+ function deleteOwnedDevsyWorkspace(devsyId, worktreePath) {
8654
+ return mutateOwnedDevsyWorkspace("delete", devsyId, worktreePath);
7836
8655
  }
7837
- async function startDevpodWorkspace(options) {
7838
- if (resolveWorkspaceRuntimeOrDefault(options.repoPath) === "devsy") {
7839
- try {
7840
- const result = await startDevsyWorkspace({
7841
- repoPath: options.repoPath,
7842
- devsyId: options.devpodId,
7843
- devcontainerPath: options.devcontainerPath,
7844
- recreate: options.recreate,
7845
- quiet: options.quiet,
7846
- workspace: options.workspace,
7847
- inactivityTimeout: readWorkspaceRuntimeConfig().devsyInactivityTimeout
7848
- });
7849
- resetWorkspaceRuntimeCaches();
7850
- return result;
7851
- } catch (error) {
7852
- if (error instanceof DevsyStartPostconditionError) {
7853
- resetWorkspaceRuntimeCaches();
7854
- throw new DevpodStartPostconditionError(error.message);
7855
- }
7856
- throw error;
7857
- }
8656
+ function assertDevsyTarget(devsyId, repoPath) {
8657
+ const workspaces = listDevsyWorkspaces();
8658
+ const existing = selectDevsyWorkspace(workspaces, repoPath);
8659
+ const id = devsyId ?? existing?.id;
8660
+ if (id) {
8661
+ const before = inspectDevsyWorkspaceOwnership(workspaces, id, repoPath);
8662
+ if (before.status === "conflict") throw new Error(before.reason);
7858
8663
  }
7859
- const activity = options.recreate ? "DevPod recreate" : "DevPod start";
7860
- return withMutationLock2(activity, options.repoPath, () => {
7861
- const workspaces = listDevpodWorkspaces(options.repoPath);
7862
- let devpodId = options.devpodId ?? selectDevpodWorkspace(workspaces, options.repoPath)?.id;
7863
- if (devpodId) {
7864
- const before = inspectDevpodWorkspaceOwnership(workspaces, devpodId, options.repoPath);
7865
- if (before.status === "conflict") throw new Error(before.reason);
7866
- if (options.recreate && before.status !== "owned") {
7867
- throw new Error(`Cannot recreate DevPod '${devpodId}' without one exact owner.`);
8664
+ return id;
8665
+ }
8666
+ async function startDevsyWorkspace(options) {
8667
+ const activity = options.recreate ? "Devsy recreate" : "Devsy start";
8668
+ let agent;
8669
+ try {
8670
+ agent = requireReadyDevsyAgent();
8671
+ } catch (error) {
8672
+ if (!(error instanceof DevsyAgentReadinessError)) throw error;
8673
+ const repair = devsyAgentRepairSuggestion(error.inspection);
8674
+ throw new Error(`${error.message}. ${repair}`);
8675
+ }
8676
+ return withMutationLockAsync(activity, options.repoPath, async () => {
8677
+ let devsyId = assertDevsyTarget(options.devsyId, options.repoPath);
8678
+ if (devsyId && options.recreate) {
8679
+ const attached = listDevsyWorkspaces();
8680
+ const ownership = inspectDevsyWorkspaceOwnership(attached, devsyId, options.repoPath);
8681
+ if (ownership.status !== "owned") {
8682
+ throw new Error(`Cannot recreate Devsy workspace '${devsyId}' without one exact owner.`);
7868
8683
  }
7869
- } else if (options.recreate) {
7870
- throw new Error("Cannot recreate a DevPod before its exact id is known.");
7871
8684
  }
7872
- const args = ["up", options.repoPath];
7873
- if (devpodId) args.push("--id", devpodId);
7874
- if (options.devcontainerPath) {
7875
- args.push("--devcontainer-path", options.devcontainerPath);
8685
+ if (!devsyId && options.recreate) {
8686
+ throw new Error("Cannot recreate a Devsy workspace before its exact id is known.");
7876
8687
  }
7877
- args.push("--open-ide=false");
8688
+ const args = ["workspace", "up", options.repoPath];
8689
+ if (devsyId) args.push("--id", devsyId);
8690
+ if (options.devcontainerPath) args.push("--devcontainer", options.devcontainerPath);
8691
+ args.push("--ide-launch", "skip");
7878
8692
  if (options.workspace) {
7879
8693
  args.push(
7880
8694
  "--workspace-env",
@@ -7883,8 +8697,12 @@ async function startDevpodWorkspace(options) {
7883
8697
  `DEVROUTER_WORKSPACE=${options.workspace.token}`
7884
8698
  );
7885
8699
  }
8700
+ if (options.inactivityTimeout) {
8701
+ args.push("--provider-option", `INACTIVITY_TIMEOUT=${options.inactivityTimeout}`);
8702
+ }
7886
8703
  if (options.recreate) args.push("--recreate");
7887
8704
  const env = { ...process.env };
8705
+ env.DEVSY_AGENT_BINARY = agent.binaryPath;
7888
8706
  if (options.workspace) {
7889
8707
  env.WORKSPACE = options.workspace.token;
7890
8708
  env.DEVROUTER_WORKSPACE = options.workspace.token;
@@ -7896,500 +8714,252 @@ async function startDevpodWorkspace(options) {
7896
8714
  delete env.DEVROUTER_GIT_COMMON_DIR;
7897
8715
  delete env.DEVCONTAINER_COMPOSE_OVERLAY;
7898
8716
  }
7899
- const result = (0, import_node_child_process16.spawnSync)("devpod", args, {
7900
- stdio: options.quiet ? ["inherit", 2, "inherit"] : "inherit",
7901
- env
7902
- });
8717
+ const result = await runDevsyUp(args, env, options.quiet ?? false);
7903
8718
  if (result.status !== 0) {
7904
- throw new Error(`devpod up failed for '${devpodId ?? options.repoPath}'.`);
7905
- }
7906
- try {
7907
- const attached = listDevpodWorkspaces(options.repoPath);
7908
- devpodId ??= selectDevpodWorkspace(attached, options.repoPath)?.id;
7909
- if (!devpodId) {
7910
- throw new Error(`DevPod did not attach '${options.repoPath}' after startup.`);
7911
- }
7912
- const ownership = inspectDevpodWorkspaceOwnership(attached, devpodId, options.repoPath);
7913
- if (ownership.status === "conflict") throw new Error(ownership.reason);
7914
- if (ownership.status !== "owned") {
7915
- throw new Error(
7916
- `DevPod did not attach '${options.repoPath}' as '${devpodId}' after startup.`
7917
- );
7918
- }
7919
- resetWorkspaceRuntimeCaches();
7920
- return devpodId;
7921
- } catch (error) {
7922
- const message = error instanceof Error ? error.message : String(error);
7923
- throw new DevpodStartPostconditionError(message);
7924
- }
7925
- });
7926
- }
7927
- var import_node_child_process16, import_node_fs21, import_node_path21, DEVPOD_MUTATION_LOCK_FILE, DEVPOD_MUTATION_WAIT_MS, DevpodStartPostconditionError;
7928
- var init_devpod_mutation = __esm({
7929
- "src/core/devpod-mutation.ts"() {
7930
- "use strict";
7931
- import_node_child_process16 = require("child_process");
7932
- import_node_fs21 = __toESM(require("fs"));
7933
- import_node_path21 = __toESM(require("path"));
7934
- init_devpod_workspaces();
7935
- init_devsy_mutation();
7936
- init_file_lock();
7937
- init_router();
7938
- init_workspace_runtime();
7939
- DEVPOD_MUTATION_LOCK_FILE = import_node_path21.default.join(DEVROUTER_HOME, "devpod-mutation.lock");
7940
- DEVPOD_MUTATION_WAIT_MS = 18e5;
7941
- DevpodStartPostconditionError = class extends Error {
7942
- };
7943
- }
7944
- });
7945
-
7946
- // src/core/workspace-ownership.ts
7947
- function commandError(command, repoPath, stderr) {
7948
- return new Error(
7949
- `${command} failed for '${repoPath}': ${stderr?.trim() || "not a Git repository"}`
7950
- );
7951
- }
7952
- function resolveGitCommonDir(repoPath) {
7953
- const result = (0, import_node_child_process17.spawnSync)("git", ["-C", repoPath, "rev-parse", "--git-common-dir"], {
7954
- encoding: "utf-8",
7955
- env: READ_ONLY_GIT_ENV
7956
- });
7957
- const output2 = result.stdout.trim();
7958
- if (result.status !== 0 || !output2) {
7959
- throw commandError("Could not resolve the Git common directory", repoPath, result.stderr);
7960
- }
7961
- return comparableWorkspacePath(import_node_path22.default.isAbsolute(output2) ? output2 : import_node_path22.default.resolve(repoPath, output2));
7962
- }
7963
- function resolveGitTopLevel(repoPath) {
7964
- const result = (0, import_node_child_process17.spawnSync)("git", ["-C", repoPath, "rev-parse", "--show-toplevel"], {
7965
- encoding: "utf-8",
7966
- env: READ_ONLY_GIT_ENV
7967
- });
7968
- const output2 = result.stdout.trim();
7969
- if (result.status !== 0 || !output2) {
7970
- throw commandError("Could not resolve the Git checkout root", repoPath, result.stderr);
7971
- }
7972
- return comparableWorkspacePath(output2);
7973
- }
7974
- function listGitWorktrees(repoPath) {
7975
- const result = (0, import_node_child_process17.spawnSync)("git", ["-C", repoPath, "worktree", "list", "--porcelain"], {
7976
- encoding: "utf-8",
7977
- env: READ_ONLY_GIT_ENV
7978
- });
7979
- if (result.status !== 0) {
7980
- throw commandError("git worktree list", repoPath, result.stderr);
7981
- }
7982
- const worktrees = [];
7983
- let current = {};
7984
- const finish = () => {
7985
- if (!current.path) return;
7986
- worktrees.push({
7987
- path: comparableWorkspacePath(current.path),
7988
- branch: current.branch,
7989
- locked: current.locked ?? false,
7990
- prunable: current.prunable ?? false
7991
- });
7992
- current = {};
7993
- };
7994
- for (const line of `${result.stdout}
7995
- `.split("\n")) {
7996
- if (line.startsWith("worktree ")) {
7997
- finish();
7998
- current.path = line.slice("worktree ".length).trim();
7999
- } else if (line.startsWith("branch ")) {
8000
- current.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, "");
8001
- } else if (line === "locked" || line.startsWith("locked ")) {
8002
- current.locked = true;
8003
- } else if (line === "prunable" || line.startsWith("prunable ")) {
8004
- current.prunable = true;
8005
- } else if (line === "") {
8006
- finish();
8007
- }
8008
- }
8009
- return worktrees;
8010
- }
8011
- function ownershipDirectory(repoPath) {
8012
- return import_node_path22.default.join(resolveGitCommonDir(repoPath), OWNERSHIP_DIR);
8013
- }
8014
- function validateWorkspace(value, label) {
8015
- if (typeof value !== "string" || wsFromBranch(value) !== value) {
8016
- throw new Error(`invalid workspace ownership ${label}`);
8017
- }
8018
- return value;
8019
- }
8020
- function validateTimestamp(value, label) {
8021
- if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
8022
- throw new Error(`invalid workspace ownership ${label}`);
8023
- }
8024
- return value;
8025
- }
8026
- function validateRecord(value, expectedWorkspace) {
8027
- if (!value || typeof value !== "object" || Array.isArray(value)) {
8028
- throw new Error("invalid workspace ownership record");
8029
- }
8030
- const candidate = value;
8031
- if (candidate.version !== OWNERSHIP_VERSION) {
8032
- throw new Error(`unsupported workspace ownership version '${String(candidate.version)}'`);
8033
- }
8034
- const workspace = validateWorkspace(candidate.workspace, "workspace");
8035
- if (expectedWorkspace && workspace !== expectedWorkspace) {
8036
- throw new Error(
8037
- `workspace ownership file '${expectedWorkspace}' contains identity '${workspace}'`
8038
- );
8039
- }
8040
- if (typeof candidate.worktreePath !== "string" || !import_node_path22.default.isAbsolute(candidate.worktreePath)) {
8041
- throw new Error("invalid workspace ownership worktreePath");
8042
- }
8043
- if (candidate.branch !== null && typeof candidate.branch !== "string") {
8044
- throw new Error("invalid workspace ownership branch");
8045
- }
8046
- const devpodId = validateWorkspace(candidate.devpodId, "devpodId");
8047
- return {
8048
- version: OWNERSHIP_VERSION,
8049
- workspace,
8050
- worktreePath: comparableWorkspacePath(candidate.worktreePath),
8051
- branch: candidate.branch,
8052
- devpodId,
8053
- createdAt: validateTimestamp(candidate.createdAt, "createdAt"),
8054
- updatedAt: validateTimestamp(candidate.updatedAt, "updatedAt")
8055
- };
8056
- }
8057
- function readRecordFile(filePath, expectedWorkspace) {
8058
- let parsed;
8059
- try {
8060
- parsed = JSON.parse(import_node_fs22.default.readFileSync(filePath, "utf-8"));
8061
- } catch (error) {
8062
- if (error instanceof SyntaxError) {
8063
- throw new Error(`invalid workspace ownership JSON at '${filePath}'`);
8064
- }
8065
- throw error;
8066
- }
8067
- return validateRecord(parsed, expectedWorkspace);
8068
- }
8069
- function listWorkspaceOwnership(repoPath) {
8070
- const directory = ownershipDirectory(repoPath);
8071
- return listWorkspaceOwnershipInDirectory(directory);
8072
- }
8073
- function listWorkspaceOwnershipInDirectory(directory) {
8074
- let entries;
8075
- try {
8076
- entries = import_node_fs22.default.readdirSync(directory, { withFileTypes: true });
8077
- } catch (error) {
8078
- if (error.code === "ENOENT") return [];
8079
- throw error;
8080
- }
8081
- return entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).sort((left, right) => left.name.localeCompare(right.name)).map((entry) => {
8082
- const workspace = entry.name.slice(0, -".json".length);
8083
- validateWorkspace(workspace, "filename");
8084
- return readRecordFile(import_node_path22.default.join(directory, entry.name), workspace);
8085
- });
8086
- }
8087
- function writeWorkspaceOwnershipInDirectory(directory, input2) {
8088
- const workspace = validateWorkspace(input2.workspace, "workspace");
8089
- const devpodId = validateWorkspace(input2.devpodId, "devpodId");
8090
- const worktreePath = comparableWorkspacePath(input2.worktreePath);
8091
- const filePath = import_node_path22.default.join(directory, `${workspace}.json`);
8092
- const now = (/* @__PURE__ */ new Date()).toISOString();
8093
- const records = listWorkspaceOwnershipInDirectory(directory);
8094
- const existing = records.find((record2) => record2.workspace === workspace);
8095
- if (existing && !sameWorkspacePath(existing.worktreePath, worktreePath)) {
8096
- throw new Error(
8097
- `Workspace '${workspace}' already belongs to '${existing.worktreePath}', refusing '${worktreePath}'.`
8098
- );
8099
- }
8100
- const pathOwner = records.find(
8101
- (record2) => record2.workspace !== workspace && sameWorkspacePath(record2.worktreePath, worktreePath)
8102
- );
8103
- if (pathOwner) {
8104
- throw new Error(
8105
- `Worktree '${worktreePath}' is already owned by workspace '${pathOwner.workspace}'.`
8106
- );
8107
- }
8108
- if (existing && existing.devpodId !== devpodId) {
8109
- throw new Error(
8110
- `Workspace '${workspace}' already owns DevPod '${existing.devpodId}', refusing '${devpodId}'.`
8111
- );
8112
- }
8113
- const record = {
8114
- version: OWNERSHIP_VERSION,
8115
- workspace,
8116
- worktreePath,
8117
- branch: input2.branch ?? null,
8118
- devpodId,
8119
- createdAt: existing?.createdAt ?? validateTimestamp(now, "createdAt"),
8120
- updatedAt: validateTimestamp(now, "updatedAt")
8121
- };
8122
- writeFileAtomically(filePath, `${JSON.stringify(record, null, 2)}
8123
- `);
8124
- return record;
8125
- }
8126
- function removeWorkspaceOwnershipInDirectory(directory, workspace) {
8127
- const filePath = import_node_path22.default.join(directory, `${validateWorkspace(workspace, "workspace")}.json`);
8128
- try {
8129
- import_node_fs22.default.rmSync(filePath);
8130
- return true;
8131
- } catch (error) {
8132
- if (error.code === "ENOENT") return false;
8133
- throw error;
8134
- }
8135
- }
8136
- function sameOwnershipRecord(left, right) {
8137
- return left.version === right.version && left.workspace === right.workspace && sameWorkspacePath(left.worktreePath, right.worktreePath) && left.branch === right.branch && left.devpodId === right.devpodId && left.createdAt === right.createdAt && left.updatedAt === right.updatedAt;
8719
+ let message = `devsy workspace up failed for '${devsyId ?? options.repoPath}'.`;
8720
+ if (result.error?.message) message += ` ${result.error.message}`;
8721
+ if (AGENT_ACQUISITION_RE.test(result.stderrTail)) {
8722
+ message += ` Devsy rejected the verified agent source. Run: ${DEVSY_AGENT_SETUP_COMMAND}`;
8723
+ }
8724
+ if (failedStartMayHaveAttached(devsyId, options.repoPath)) {
8725
+ throw new DevsyStartPostconditionError(message);
8726
+ }
8727
+ throw new Error(message);
8728
+ }
8729
+ try {
8730
+ const attached = listDevsyWorkspaces();
8731
+ devsyId ??= selectDevsyWorkspace(attached, options.repoPath)?.id;
8732
+ if (!devsyId) {
8733
+ throw new Error(`Devsy did not attach '${options.repoPath}' after startup.`);
8734
+ }
8735
+ const ownership = inspectDevsyWorkspaceOwnership(attached, devsyId, options.repoPath);
8736
+ if (ownership.status === "conflict") throw new Error(ownership.reason);
8737
+ if (ownership.status !== "owned") {
8738
+ throw new Error(
8739
+ `Devsy did not attach '${options.repoPath}' as '${devsyId}' after startup.`
8740
+ );
8741
+ }
8742
+ return devsyId;
8743
+ } catch (error) {
8744
+ const message = error instanceof Error ? error.message : String(error);
8745
+ throw new DevsyStartPostconditionError(message);
8746
+ }
8747
+ });
8138
8748
  }
8139
- function removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected) {
8140
- const filePath = import_node_path22.default.join(
8141
- directory,
8142
- `${validateWorkspace(expected.workspace, "workspace")}.json`
8143
- );
8144
- let current;
8145
- try {
8146
- current = readRecordFile(filePath, expected.workspace);
8147
- } catch (error) {
8148
- if (error.code === "ENOENT") return "absent";
8149
- throw error;
8749
+ var import_node_child_process16, import_node_fs22, import_node_path22, DEVSY_MUTATION_LOCK_FILE, DEVSY_MUTATION_WAIT_MS, DevsyStartPostconditionError, AGENT_ACQUISITION_RE, DEVSY_STDERR_TAIL_BYTES;
8750
+ var init_devsy_mutation = __esm({
8751
+ "src/core/devsy-mutation.ts"() {
8752
+ "use strict";
8753
+ import_node_child_process16 = require("child_process");
8754
+ import_node_fs22 = __toESM(require("fs"));
8755
+ import_node_path22 = __toESM(require("path"));
8756
+ init_devsy_agent();
8757
+ init_devsy_workspaces();
8758
+ init_file_lock();
8759
+ init_managed_devsy_stop();
8760
+ init_router();
8761
+ DEVSY_MUTATION_LOCK_FILE = import_node_path22.default.join(DEVROUTER_HOME, "devsy-mutation.lock");
8762
+ DEVSY_MUTATION_WAIT_MS = 18e5;
8763
+ DevsyStartPostconditionError = class extends Error {
8764
+ };
8765
+ AGENT_ACQUISITION_RE = /inject agent.*agent binary not found/i;
8766
+ DEVSY_STDERR_TAIL_BYTES = 8192;
8150
8767
  }
8151
- if (!sameOwnershipRecord(current, expected)) return "changed";
8152
- import_node_fs22.default.rmSync(filePath);
8153
- return "removed";
8154
- }
8155
- function withWorkspaceOwnershipTransaction(repoPath, operation, options = {}) {
8156
- const directory = ownershipDirectory(repoPath);
8157
- import_node_fs22.default.mkdirSync(directory, { recursive: true });
8768
+ });
8769
+
8770
+ // src/core/devpod-mutation.ts
8771
+ function withMutationLock2(activity, target, operation) {
8772
+ import_node_fs23.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
8158
8773
  return withFileLockSync(
8159
- import_node_path22.default.join(directory, ".lock"),
8774
+ DEVPOD_MUTATION_LOCK_FILE,
8160
8775
  {
8161
- activity: "workspace ownership transaction",
8162
- target: `'${repoPath}'`,
8163
- waitMs: options.waitMs ?? 5e3
8776
+ activity,
8777
+ target: `'${target}'`,
8778
+ waitMs: DEVPOD_MUTATION_WAIT_MS,
8779
+ fair: true,
8780
+ onWait: createStderrWaitReporter(activity, `'${target}'`)
8164
8781
  },
8165
- () => operation({
8166
- list: () => listWorkspaceOwnershipInDirectory(directory),
8167
- write: (input2) => writeWorkspaceOwnershipInDirectory(directory, input2),
8168
- remove: (workspace) => removeWorkspaceOwnershipInDirectory(directory, workspace),
8169
- removeIfMatches: (expected) => removeWorkspaceOwnershipIfMatchesInDirectory(directory, expected)
8170
- })
8782
+ operation
8171
8783
  );
8172
8784
  }
8173
- function providerPathOwner(providerWorkspaces, worktreePath) {
8174
- const owners = providerWorkspaces.filter(
8175
- (workspace) => sameWorkspacePath(workspace.source.localFolder, worktreePath)
8176
- );
8177
- if (owners.length > 1) {
8785
+ function commandFailure3(result) {
8786
+ return [result.error?.message, result.stdout, result.stderr].filter(Boolean).join("\n").trim();
8787
+ }
8788
+ function runDevpodAction(action2, devpodId, force = false) {
8789
+ const args = action2 === "delete" ? [action2, devpodId, ...force ? ["--force"] : [], "--ignore-not-found"] : [action2, devpodId];
8790
+ const result = (0, import_node_child_process17.spawnSync)("devpod", args, { encoding: "utf-8" });
8791
+ if (result.status !== 0) {
8178
8792
  throw new Error(
8179
- `Worktree '${worktreePath}' is registered by multiple workspace runtimes; no identity was claimed.`
8793
+ `devpod ${action2}${force ? " --force" : ""} failed for '${devpodId}': ${commandFailure3(result) || "unknown error"}`
8180
8794
  );
8181
8795
  }
8182
- return owners[0];
8183
- }
8184
- function persistedWorkspaceOwners(repoPath, worktreePath) {
8185
- const owners = /* @__PURE__ */ new Map();
8186
- for (const worktree of listGitWorktrees(repoPath)) {
8187
- if (sameWorkspacePath(worktree.path, worktreePath)) continue;
8188
- let workspace;
8189
- try {
8190
- workspace = readPersistedWorkspace(worktree.path);
8191
- } catch (error) {
8192
- if (!import_node_fs22.default.existsSync(worktree.path)) continue;
8193
- throw error;
8194
- }
8195
- if (!workspace) continue;
8196
- const existing = owners.get(workspace);
8197
- if (existing && !sameWorkspacePath(existing, worktree.path)) {
8198
- throw new Error(`Persisted workspace identity '${workspace}' belongs to multiple worktrees.`);
8199
- }
8200
- owners.set(workspace, worktree.path);
8201
- }
8202
- return owners;
8203
8796
  }
8204
- function claimConflict(workspace, devpodId, worktreePath, records, providerWorkspaces, persistedOwners, exactRecord) {
8205
- const recordOwner = records.find(
8206
- (record) => record !== exactRecord && (record.workspace === workspace || record.devpodId === devpodId)
8207
- );
8208
- if (recordOwner) {
8209
- return `workspace owner record '${recordOwner.workspace}' for '${recordOwner.worktreePath}'`;
8210
- }
8211
- const providerOwner = providerWorkspaces.find(
8212
- (providerWorkspace) => providerWorkspace.id === devpodId && !sameWorkspacePath(providerWorkspace.source.localFolder, worktreePath)
8797
+ function inspectExactOwnership2(devpodId, worktreePath) {
8798
+ const ownership = inspectDevpodWorkspaceOwnership(
8799
+ listDevpodWorkspaces(worktreePath),
8800
+ devpodId,
8801
+ worktreePath
8213
8802
  );
8214
- if (providerOwner) {
8215
- return `workspace runtime identity '${providerOwner.id}' already belongs to '${providerOwner.source.localFolder}'`;
8216
- }
8217
- const persistedOwner = persistedOwners.get(workspace);
8218
- if (persistedOwner) {
8219
- return `persisted checkout metadata for '${persistedOwner}'`;
8220
- }
8221
- return void 0;
8803
+ if (ownership.status === "conflict") throw new Error(ownership.reason);
8804
+ return ownership;
8222
8805
  }
8223
- function claimWorkspaceIdentity(repoPath, input2) {
8224
- const worktreePath = comparableWorkspacePath(repoPath);
8225
- const exactProvider = providerPathOwner(input2.providerWorkspaces, worktreePath);
8226
- return withWorkspaceOwnershipTransaction(repoPath, (transaction) => {
8227
- const records = transaction.list();
8228
- const exactRecords = records.filter(
8229
- (record) => sameWorkspacePath(record.worktreePath, worktreePath)
8230
- );
8231
- if (exactRecords.length > 1) {
8232
- throw new Error(
8233
- `Worktree '${worktreePath}' has multiple workspace owner records; no identity was claimed.`
8234
- );
8806
+ function mutateOwnedDevpodWorkspace(action2, devpodId, worktreePath) {
8807
+ return withMutationLock2(`DevPod ${action2}`, worktreePath, () => {
8808
+ const before = inspectExactOwnership2(devpodId, worktreePath);
8809
+ if (before.status === "absent") return { status: "absent" };
8810
+ runDevpodAction(action2, devpodId);
8811
+ let after = inspectExactOwnership2(devpodId, worktreePath);
8812
+ if (action2 === "stop" && after.status !== "owned") {
8813
+ throw new Error(`DevPod '${devpodId}' no longer owns '${worktreePath}' after provider stop.`);
8235
8814
  }
8236
- const exactRecord = exactRecords[0];
8237
- const persisted = readPersistedWorkspace(worktreePath);
8238
- const persistedOwners = persistedWorkspaceOwners(repoPath, worktreePath);
8239
- if (exactRecord) {
8240
- if (persisted && persisted !== exactRecord.workspace) {
8815
+ if (action2 === "delete" && after.status === "owned") {
8816
+ const runtime = inspectDevpodRuntimeStatus(devpodId, worktreePath);
8817
+ if (runtime !== "not-found") {
8241
8818
  throw new Error(
8242
- `Persisted workspace identity '${persisted}' disagrees with owner record '${exactRecord.workspace}'.`
8819
+ `DevPod '${devpodId}' still owns '${worktreePath}' after provider delete (runtime=${runtime}).`
8243
8820
  );
8244
8821
  }
8245
- if (exactProvider && exactProvider.id !== exactRecord.devpodId) {
8246
- throw new Error(
8247
- `Workspace runtime '${exactProvider.id}' disagrees with owner record '${exactRecord.devpodId}'.`
8248
- );
8822
+ after = inspectExactOwnership2(devpodId, worktreePath);
8823
+ if (after.status === "owned") {
8824
+ runDevpodAction("delete", devpodId, true);
8825
+ after = inspectExactOwnership2(devpodId, worktreePath);
8249
8826
  }
8250
- const conflict2 = claimConflict(
8251
- exactRecord.workspace,
8252
- exactRecord.devpodId,
8253
- worktreePath,
8254
- records,
8255
- input2.providerWorkspaces,
8256
- persistedOwners,
8257
- exactRecord
8258
- );
8259
- if (conflict2) {
8827
+ if (after.status !== "absent") {
8260
8828
  throw new Error(
8261
- `Workspace '${exactRecord.workspace}' conflicts with ${conflict2}; no identity was claimed.`
8829
+ `DevPod '${devpodId}' still owns '${worktreePath}' after forced provider delete.`
8262
8830
  );
8263
8831
  }
8264
- if (!persisted) persistWorkspace(worktreePath, exactRecord.workspace);
8265
- return transaction.write({
8266
- workspace: exactRecord.workspace,
8267
- worktreePath,
8268
- branch: input2.branch ?? null,
8269
- devpodId: exactRecord.devpodId
8270
- });
8271
8832
  }
8272
- if (persisted && exactProvider && persisted !== exactProvider.id) {
8273
- throw new Error(
8274
- `Persisted workspace identity '${persisted}' disagrees with workspace runtime '${exactProvider.id}'.`
8275
- );
8833
+ return { status: "changed" };
8834
+ });
8835
+ }
8836
+ function stopOwnedDevpodWorkspace(devpodId, worktreePath) {
8837
+ try {
8838
+ resetWorkspaceRuntimeCaches();
8839
+ if (resolveWorkspaceRuntimeOrDefault(worktreePath) === "devsy") {
8840
+ return stopOwnedDevsyWorkspace(devpodId, worktreePath);
8276
8841
  }
8277
- let workspace = persisted ?? exactProvider?.id;
8278
- let devpodId = exactProvider?.id ?? persisted;
8279
- if (!workspace || !devpodId) {
8280
- if (input2.unavailableRuntimes.length > 0) {
8281
- throw new Error(
8282
- `Cannot claim a new workspace identity because these runtime registries are unavailable: ${input2.unavailableRuntimes.join(", ")}.`
8283
- );
8284
- }
8285
- const candidate = workspaceIdentityCandidates(input2.source).find(
8286
- (next) => !claimConflict(
8287
- next,
8288
- next,
8289
- worktreePath,
8290
- records,
8291
- input2.providerWorkspaces,
8292
- persistedOwners
8293
- )
8294
- );
8295
- if (!candidate) {
8296
- throw new Error(
8297
- `Could not allocate a collision-safe workspace identity for '${worktreePath}'.`
8298
- );
8842
+ return mutateOwnedDevpodWorkspace("stop", devpodId, worktreePath);
8843
+ } finally {
8844
+ resetWorkspaceRuntimeCaches();
8845
+ }
8846
+ }
8847
+ function deleteOwnedDevpodWorkspace(devpodId, worktreePath) {
8848
+ if (resolveWorkspaceRuntimeOrDefault(worktreePath) === "devsy") {
8849
+ const result2 = deleteOwnedDevsyWorkspace(devpodId, worktreePath);
8850
+ resetWorkspaceRuntimeCaches();
8851
+ return result2;
8852
+ }
8853
+ const result = mutateOwnedDevpodWorkspace("delete", devpodId, worktreePath);
8854
+ resetWorkspaceRuntimeCaches();
8855
+ return result;
8856
+ }
8857
+ async function startDevpodWorkspace(options) {
8858
+ if (resolveWorkspaceRuntimeOrDefault(options.repoPath) === "devsy") {
8859
+ try {
8860
+ const result = await startDevsyWorkspace({
8861
+ repoPath: options.repoPath,
8862
+ devsyId: options.devpodId,
8863
+ devcontainerPath: options.devcontainerPath,
8864
+ recreate: options.recreate,
8865
+ quiet: options.quiet,
8866
+ workspace: options.workspace,
8867
+ inactivityTimeout: readWorkspaceRuntimeConfig().devsyInactivityTimeout
8868
+ });
8869
+ resetWorkspaceRuntimeCaches();
8870
+ return result;
8871
+ } catch (error) {
8872
+ if (error instanceof DevsyStartPostconditionError) {
8873
+ resetWorkspaceRuntimeCaches();
8874
+ throw new DevpodStartPostconditionError(error.message);
8299
8875
  }
8300
- workspace = candidate;
8301
- devpodId = candidate;
8876
+ throw error;
8302
8877
  }
8303
- const conflict = claimConflict(
8304
- workspace,
8305
- devpodId,
8306
- worktreePath,
8307
- records,
8308
- input2.providerWorkspaces,
8309
- persistedOwners
8310
- );
8311
- if (conflict) {
8312
- throw new Error(
8313
- `Workspace '${workspace}' conflicts with ${conflict}; no identity was claimed.`
8878
+ }
8879
+ const activity = options.recreate ? "DevPod recreate" : "DevPod start";
8880
+ return withMutationLock2(activity, options.repoPath, () => {
8881
+ const workspaces = listDevpodWorkspaces(options.repoPath);
8882
+ let devpodId = options.devpodId ?? selectDevpodWorkspace(workspaces, options.repoPath)?.id;
8883
+ if (devpodId) {
8884
+ const before = inspectDevpodWorkspaceOwnership(workspaces, devpodId, options.repoPath);
8885
+ if (before.status === "conflict") throw new Error(before.reason);
8886
+ if (options.recreate && before.status !== "owned") {
8887
+ throw new Error(`Cannot recreate DevPod '${devpodId}' without one exact owner.`);
8888
+ }
8889
+ } else if (options.recreate) {
8890
+ throw new Error("Cannot recreate a DevPod before its exact id is known.");
8891
+ }
8892
+ const args = ["up", options.repoPath];
8893
+ if (devpodId) args.push("--id", devpodId);
8894
+ if (options.devcontainerPath) {
8895
+ args.push("--devcontainer-path", options.devcontainerPath);
8896
+ }
8897
+ args.push("--open-ide=false");
8898
+ if (options.workspace) {
8899
+ args.push(
8900
+ "--workspace-env",
8901
+ `WORKSPACE=${options.workspace.token}`,
8902
+ "--workspace-env",
8903
+ `DEVROUTER_WORKSPACE=${options.workspace.token}`
8314
8904
  );
8315
8905
  }
8316
- const written = transaction.write({
8317
- workspace,
8318
- worktreePath,
8319
- branch: input2.branch ?? null,
8320
- devpodId
8906
+ if (options.recreate) args.push("--recreate");
8907
+ const env = { ...process.env };
8908
+ if (options.workspace) {
8909
+ env.WORKSPACE = options.workspace.token;
8910
+ env.DEVROUTER_WORKSPACE = options.workspace.token;
8911
+ env.DEVROUTER_GIT_COMMON_DIR = options.workspace.gitCommonDir;
8912
+ env.DEVCONTAINER_COMPOSE_OVERLAY = "docker-compose.devrouter.yml";
8913
+ } else {
8914
+ delete env.WORKSPACE;
8915
+ delete env.DEVROUTER_WORKSPACE;
8916
+ delete env.DEVROUTER_GIT_COMMON_DIR;
8917
+ delete env.DEVCONTAINER_COMPOSE_OVERLAY;
8918
+ }
8919
+ const result = (0, import_node_child_process17.spawnSync)("devpod", args, {
8920
+ stdio: options.quiet ? ["inherit", 2, "inherit"] : "inherit",
8921
+ env
8321
8922
  });
8923
+ if (result.status !== 0) {
8924
+ throw new Error(`devpod up failed for '${devpodId ?? options.repoPath}'.`);
8925
+ }
8322
8926
  try {
8323
- persistWorkspace(worktreePath, workspace);
8324
- } catch (error) {
8325
- const cleanup = transaction.removeIfMatches(written);
8326
- if (cleanup !== "removed") {
8327
- const detail = error instanceof Error ? error.message : String(error);
8927
+ const attached = listDevpodWorkspaces(options.repoPath);
8928
+ devpodId ??= selectDevpodWorkspace(attached, options.repoPath)?.id;
8929
+ if (!devpodId) {
8930
+ throw new Error(`DevPod did not attach '${options.repoPath}' after startup.`);
8931
+ }
8932
+ const ownership = inspectDevpodWorkspaceOwnership(attached, devpodId, options.repoPath);
8933
+ if (ownership.status === "conflict") throw new Error(ownership.reason);
8934
+ if (ownership.status !== "owned") {
8328
8935
  throw new Error(
8329
- `Could not persist workspace identity and owner-record rollback was '${cleanup}': ${detail}`
8936
+ `DevPod did not attach '${options.repoPath}' as '${devpodId}' after startup.`
8330
8937
  );
8331
8938
  }
8332
- throw error;
8939
+ resetWorkspaceRuntimeCaches();
8940
+ return devpodId;
8941
+ } catch (error) {
8942
+ const message = error instanceof Error ? error.message : String(error);
8943
+ throw new DevpodStartPostconditionError(message);
8333
8944
  }
8334
- return written;
8335
8945
  });
8336
8946
  }
8337
- function removeWorkspaceOwnership(repoPath, workspace) {
8338
- return withWorkspaceOwnershipTransaction(
8339
- repoPath,
8340
- (transaction) => transaction.remove(workspace)
8341
- );
8342
- }
8343
- function inspectWorkspaceOwnership(record, worktrees, devpods) {
8344
- const worktree = worktrees.find(
8345
- (candidate) => sameWorkspacePath(candidate.path, record.worktreePath)
8346
- );
8347
- const devpodOwnership = devpods ? inspectDevpodWorkspaceOwnership(devpods, record.devpodId, record.worktreePath) : void 0;
8348
- const devpodStatus = devpodOwnership?.status ?? "unknown";
8349
- if (devpodStatus === "conflict") {
8350
- return { ownerStatus: "conflict", devpodStatus, worktree };
8351
- }
8352
- if (worktree?.locked) {
8353
- return { ownerStatus: "locked", devpodStatus, worktree };
8354
- }
8355
- if ((!worktree || worktree.prunable) && import_node_fs22.default.existsSync(record.worktreePath)) {
8356
- return { ownerStatus: "conflict", devpodStatus, worktree };
8357
- }
8358
- if (!worktree || worktree.prunable) {
8359
- return { ownerStatus: "missing", devpodStatus, worktree };
8360
- }
8361
- let persisted;
8362
- try {
8363
- persisted = readPersistedWorkspace(worktree.path);
8364
- } catch {
8365
- return { ownerStatus: "conflict", devpodStatus, worktree };
8366
- }
8367
- return {
8368
- ownerStatus: persisted === record.workspace ? "present" : "conflict",
8369
- devpodStatus,
8370
- worktree
8371
- };
8372
- }
8373
- function listMissingWorkspaceOwnership(repoPath) {
8374
- const worktrees = listGitWorktrees(repoPath);
8375
- return listWorkspaceOwnership(repoPath).filter(
8376
- (record) => inspectWorkspaceOwnership(record, worktrees, void 0).ownerStatus === "missing"
8377
- );
8378
- }
8379
- var import_node_child_process17, import_node_fs22, import_node_path22, READ_ONLY_GIT_ENV, OWNERSHIP_VERSION, OWNERSHIP_DIR;
8380
- var init_workspace_ownership = __esm({
8381
- "src/core/workspace-ownership.ts"() {
8947
+ var import_node_child_process17, import_node_fs23, import_node_path23, DEVPOD_MUTATION_LOCK_FILE, DEVPOD_MUTATION_WAIT_MS, DevpodStartPostconditionError;
8948
+ var init_devpod_mutation = __esm({
8949
+ "src/core/devpod-mutation.ts"() {
8382
8950
  "use strict";
8383
8951
  import_node_child_process17 = require("child_process");
8384
- import_node_fs22 = __toESM(require("fs"));
8385
- import_node_path22 = __toESM(require("path"));
8386
- init_atomic_file();
8952
+ import_node_fs23 = __toESM(require("fs"));
8953
+ import_node_path23 = __toESM(require("path"));
8387
8954
  init_devpod_workspaces();
8955
+ init_devsy_mutation();
8388
8956
  init_file_lock();
8389
- init_workspace();
8390
- READ_ONLY_GIT_ENV = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
8391
- OWNERSHIP_VERSION = 1;
8392
- OWNERSHIP_DIR = import_node_path22.default.join("devrouter", "workspaces");
8957
+ init_router();
8958
+ init_workspace_runtime();
8959
+ DEVPOD_MUTATION_LOCK_FILE = import_node_path23.default.join(DEVROUTER_HOME, "devpod-mutation.lock");
8960
+ DEVPOD_MUTATION_WAIT_MS = 18e5;
8961
+ DevpodStartPostconditionError = class extends Error {
8962
+ };
8393
8963
  }
8394
8964
  });
8395
8965
 
@@ -8403,10 +8973,10 @@ function inRepositoryWorkspaceScope(repoPath, worktreePath, livePaths) {
8403
8973
  if (livePaths.some((candidate) => sameWorkspacePath(candidate, worktreePath))) return true;
8404
8974
  const comparableRepo = comparableWorkspacePath(repoPath);
8405
8975
  const comparableWorktree = comparableWorkspacePath(worktreePath);
8406
- const localRoot = import_node_path23.default.join(comparableRepo, "trees") + import_node_path23.default.sep;
8976
+ const localRoot = import_node_path24.default.join(comparableRepo, "trees") + import_node_path24.default.sep;
8407
8977
  if (comparableWorktree.startsWith(localRoot)) return true;
8408
- const legacyPrefix = `${import_node_path23.default.basename(comparableRepo)}-`;
8409
- return import_node_path23.default.dirname(comparableWorktree) === import_node_path23.default.dirname(comparableRepo) && import_node_path23.default.basename(comparableWorktree).startsWith(legacyPrefix);
8978
+ const legacyPrefix = `${import_node_path24.default.basename(comparableRepo)}-`;
8979
+ return import_node_path24.default.dirname(comparableWorktree) === import_node_path24.default.dirname(comparableRepo) && import_node_path24.default.basename(comparableWorktree).startsWith(legacyPrefix);
8410
8980
  }
8411
8981
  function previewActions(devpodStatus, routeCount, includeRecord) {
8412
8982
  const actions = [
@@ -8658,11 +9228,11 @@ function applyWorkspaceGc(plan) {
8658
9228
  candidates
8659
9229
  };
8660
9230
  }
8661
- var import_node_path23;
9231
+ var import_node_path24;
8662
9232
  var init_workspace_gc = __esm({
8663
9233
  "src/core/workspace-gc.ts"() {
8664
9234
  "use strict";
8665
- import_node_path23 = __toESM(require("path"));
9235
+ import_node_path24 = __toESM(require("path"));
8666
9236
  init_devpod_mutation();
8667
9237
  init_devpod_workspaces();
8668
9238
  init_host_routes();
@@ -8732,11 +9302,11 @@ function inspectPostgresCredentials(repoPath, config) {
8732
9302
  parseErrors.push(`${app.name}: ${composeFile} (${message})`);
8733
9303
  continue;
8734
9304
  }
8735
- if (!import_node_fs23.default.existsSync(absolutePath)) {
9305
+ if (!import_node_fs24.default.existsSync(absolutePath)) {
8736
9306
  continue;
8737
9307
  }
8738
9308
  try {
8739
- const raw = import_node_fs23.default.readFileSync(absolutePath, "utf-8");
9309
+ const raw = import_node_fs24.default.readFileSync(absolutePath, "utf-8");
8740
9310
  const parsed = import_yaml5.default.parse(raw);
8741
9311
  const root = asRecord2(parsed);
8742
9312
  const services = asRecord2(root?.services);
@@ -9015,7 +9585,7 @@ async function buildDoctorReport(options = {}) {
9015
9585
  const config = runtimeConfig.config;
9016
9586
  loadedConfig = config;
9017
9587
  loadedWorkspace = runtimeConfig.workspace;
9018
- const cliVersion = true ? "0.0.51" : "0.0.0-dev";
9588
+ const cliVersion = true ? "0.0.53" : "0.0.0-dev";
9019
9589
  const configVersion = config.devrouter?.version;
9020
9590
  if (configVersion && cliVersion !== "0.0.0-dev" && compareSemver(configVersion, cliVersion) > 0) {
9021
9591
  addCheck(checks, {
@@ -9045,9 +9615,9 @@ async function buildDoctorReport(options = {}) {
9045
9615
  (app) => app.docker.composeFiles.map((filePath) => ({
9046
9616
  app: app.name,
9047
9617
  filePath,
9048
- absolutePath: import_node_path24.default.resolve(repo.path, filePath)
9618
+ absolutePath: import_node_path25.default.resolve(repo.path, filePath)
9049
9619
  }))
9050
- ).filter((entry) => !import_node_fs23.default.existsSync(entry.absolutePath));
9620
+ ).filter((entry) => !import_node_fs24.default.existsSync(entry.absolutePath));
9051
9621
  addCheck(checks, {
9052
9622
  id: "repo.compose-files",
9053
9623
  level: missingComposeFiles.length === 0 ? "ok" : "error",
@@ -9090,8 +9660,8 @@ async function buildDoctorReport(options = {}) {
9090
9660
  const missingHostCwds = config.apps.filter((app) => app.runtime === "host").map((app) => ({
9091
9661
  app: app.name,
9092
9662
  cwd: app.hostRun.cwd,
9093
- absolutePath: import_node_path24.default.resolve(repo.path, app.hostRun.cwd)
9094
- })).filter((entry) => !import_node_fs23.default.existsSync(entry.absolutePath));
9663
+ absolutePath: import_node_path25.default.resolve(repo.path, app.hostRun.cwd)
9664
+ })).filter((entry) => !import_node_fs24.default.existsSync(entry.absolutePath));
9095
9665
  addCheck(checks, {
9096
9666
  id: "repo.host-cwd",
9097
9667
  level: missingHostCwds.length === 0 ? "ok" : "error",
@@ -9235,12 +9805,12 @@ async function buildDoctorReport(options = {}) {
9235
9805
  nextSteps
9236
9806
  };
9237
9807
  }
9238
- var import_node_fs23, import_node_path24, import_yaml5, POSTGRES_DEFAULTS;
9808
+ var import_node_fs24, import_node_path25, import_yaml5, POSTGRES_DEFAULTS;
9239
9809
  var init_doctor = __esm({
9240
9810
  "src/core/doctor.ts"() {
9241
9811
  "use strict";
9242
- import_node_fs23 = __toESM(require("fs"));
9243
- import_node_path24 = __toESM(require("path"));
9812
+ import_node_fs24 = __toESM(require("fs"));
9813
+ import_node_path25 = __toESM(require("path"));
9244
9814
  import_yaml5 = __toESM(require("yaml"));
9245
9815
  init_docker();
9246
9816
  init_host_routes();
@@ -9687,81 +10257,54 @@ var init_http_route_probe = __esm({
9687
10257
  }
9688
10258
  });
9689
10259
 
9690
- // src/core/route-publication.ts
9691
- function routedAppsFromConfig(config) {
9692
- return config.apps.filter((app) => app.kind !== "dependency");
9693
- }
9694
- function configuredProxyAppsFromConfig(config) {
9695
- return routedAppsFromConfig(config).filter(
9696
- (app) => app.runtime === "proxy"
9697
- );
9698
- }
9699
- function proxyAppsFromConfig(config) {
9700
- const routedApps = routedAppsFromConfig(config);
9701
- const proxyApps = configuredProxyAppsFromConfig(config);
9702
- const unsupported = routedApps.filter((app) => app.runtime !== "proxy");
9703
- if (unsupported.length > 0) {
9704
- throw new Error(
9705
- `Environment reconciliation supports proxy runtime apps only; unsupported: ${unsupported.map((app) => `${app.name} (${app.runtime})`).join(", ")}`
9706
- );
9707
- }
9708
- return proxyApps;
10260
+ // src/core/traefik-route-health.ts
10261
+ function sleep(ms) {
10262
+ return new Promise((resolve) => setTimeout(resolve, ms));
9709
10263
  }
9710
- async function ensureRouteInfrastructure(apps, options = {}) {
9711
- ensureRouterFiles();
9712
- await ensureNetwork(DEVNET_NAME);
9713
- const tlsCoverage = await ensureTLSHostsCovered(
9714
- apps.map((app) => app.host),
9715
- options
10264
+ function inspectRouterApi(protocol) {
10265
+ const result = (0, import_node_child_process20.spawnSync)(
10266
+ "curl",
10267
+ [
10268
+ "--silent",
10269
+ "--show-error",
10270
+ "--fail",
10271
+ "--max-time",
10272
+ ROUTER_API_REQUEST_TIMEOUT_SECONDS,
10273
+ `${ROUTER_API_BASE}/${protocol}/routers?per_page=${ROUTER_API_PAGE_SIZE}`
10274
+ ],
10275
+ { encoding: "utf-8" }
9716
10276
  );
9717
- for (const app of apps) {
9718
- if (app.protocol === "tcp") {
9719
- if (!isTLSEnabled()) {
9720
- throw new Error(
9721
- `TCP route '${app.name}' requires TLS. Run: ${tlsSetupCommand(options.repoPath)}`
9722
- );
9723
- }
9724
- activateTcpProtocol(app.tcpProtocol);
9725
- }
9726
- }
9727
- startRouterStack();
9728
- return tlsCoverage;
9729
- }
9730
- async function replacePublishedProxyRoutes(repoPath, config, workspace) {
9731
- const apps = proxyAppsFromConfig(config);
9732
- const tlsCoverage = await ensureRouteInfrastructure(apps, { repoPath });
9733
- const routes = apps.map((app) => {
9734
- const upstream = parseUpstream(app.upstream);
10277
+ if (result.status !== 0) {
9735
10278
  return {
9736
- name: app.name,
9737
- host: app.host,
9738
- protocol: app.protocol,
9739
- tcpProtocol: app.protocol === "tcp" ? app.tcpProtocol : void 0,
9740
- repoPath,
9741
- port: upstream.port,
9742
- mode: "proxy",
9743
- upstreamHost: upstream.upstreamHost,
9744
- workspace
10279
+ ok: false,
10280
+ details: result.stderr?.trim() || `curl exited with status ${result.status ?? "unknown"}`
9745
10281
  };
9746
- });
9747
- replaceHostRoutesForRepo(repoPath, routes);
9748
- return { routes, tlsRefreshed: tlsCoverage.refreshed };
9749
- }
9750
- var init_route_publication = __esm({
9751
- "src/core/route-publication.ts"() {
9752
- "use strict";
9753
- init_docker();
9754
- init_host_routes();
9755
- init_router();
9756
- init_tls();
9757
10282
  }
9758
- });
9759
-
9760
- // src/core/traefik-route-health.ts
9761
- function sleep(ms) {
9762
- return new Promise((resolve) => setTimeout(resolve, ms));
10283
+ let value;
10284
+ try {
10285
+ value = JSON.parse(result.stdout);
10286
+ } catch {
10287
+ return { ok: false, details: `${protocol.toUpperCase()} router API returned invalid JSON` };
10288
+ }
10289
+ if (!Array.isArray(value)) {
10290
+ return { ok: false, details: `${protocol.toUpperCase()} router API returned a non-array` };
10291
+ }
10292
+ return {
10293
+ ok: true,
10294
+ reachedPageLimit: value.length >= ROUTER_API_PAGE_SIZE,
10295
+ names: new Set(
10296
+ value.flatMap((item) => {
10297
+ if (!item || typeof item !== "object") return [];
10298
+ const name = item.name;
10299
+ return typeof name === "string" ? [name] : [];
10300
+ })
10301
+ )
10302
+ };
10303
+ }
10304
+ function isRecord3(value) {
10305
+ return value !== null && typeof value === "object" && !Array.isArray(value);
9763
10306
  }
9764
- function inspectRouterApi(protocol) {
10307
+ function inspectNamedRouterApi(protocol, resource, name) {
9765
10308
  const result = (0, import_node_child_process20.spawnSync)(
9766
10309
  "curl",
9767
10310
  [
@@ -9769,8 +10312,8 @@ function inspectRouterApi(protocol) {
9769
10312
  "--show-error",
9770
10313
  "--fail",
9771
10314
  "--max-time",
9772
- "2",
9773
- `${ROUTER_API_BASE}/${protocol}/routers?per_page=${ROUTER_API_PAGE_SIZE}`
10315
+ ROUTER_API_REQUEST_TIMEOUT_SECONDS,
10316
+ `${ROUTER_API_BASE}/${protocol}/${resource}/${encodeURIComponent(name)}`
9774
10317
  ],
9775
10318
  { encoding: "utf-8" }
9776
10319
  );
@@ -9784,23 +10327,135 @@ function inspectRouterApi(protocol) {
9784
10327
  try {
9785
10328
  value = JSON.parse(result.stdout);
9786
10329
  } catch {
9787
- return { ok: false, details: `${protocol.toUpperCase()} router API returned invalid JSON` };
10330
+ return {
10331
+ ok: false,
10332
+ details: `${protocol.toUpperCase()} ${resource.slice(0, -1)} API returned invalid JSON`
10333
+ };
9788
10334
  }
9789
- if (!Array.isArray(value)) {
9790
- return { ok: false, details: `${protocol.toUpperCase()} router API returned a non-array` };
10335
+ if (!isRecord3(value)) {
10336
+ return {
10337
+ ok: false,
10338
+ details: `${protocol.toUpperCase()} ${resource.slice(0, -1)} API returned a non-object`
10339
+ };
10340
+ }
10341
+ return { ok: true, value };
10342
+ }
10343
+ function renderExpectedRoute(route) {
10344
+ const protocol = route.protocol ?? "http";
10345
+ const routerName = buildHostRouteRouterName(route.repoPath, route.name);
10346
+ const routeState = {
10347
+ id: buildHostRouteId(route.repoPath, route.name),
10348
+ name: route.name,
10349
+ host: route.host,
10350
+ protocol,
10351
+ tcpProtocol: route.tcpProtocol,
10352
+ repoPath: route.repoPath,
10353
+ port: route.port,
10354
+ mode: route.mode,
10355
+ upstreamHost: route.upstreamHost,
10356
+ pid: route.pid,
10357
+ command: route.command,
10358
+ workspace: route.workspace,
10359
+ createdAt: "",
10360
+ updatedAt: ""
10361
+ };
10362
+ const document = buildHostRoutesDocument([routeState], false);
10363
+ const protocolDocument = document[protocol];
10364
+ if (!isRecord3(protocolDocument)) return void 0;
10365
+ const routers = protocolDocument.routers;
10366
+ const services = protocolDocument.services;
10367
+ if (!isRecord3(routers) || !isRecord3(services)) return void 0;
10368
+ const routerKey = routerName.slice(0, routerName.lastIndexOf("@"));
10369
+ const router = routers[routerKey];
10370
+ const service = services[routerKey];
10371
+ if (!isRecord3(router) || !isRecord3(service)) return void 0;
10372
+ const rule = router.rule;
10373
+ const serviceReference = router.service;
10374
+ const loadBalancer = service.loadBalancer;
10375
+ const servers = isRecord3(loadBalancer) ? loadBalancer.servers : void 0;
10376
+ if (typeof rule !== "string" || serviceReference !== routerKey || !isRecord3(loadBalancer) || !Array.isArray(servers) || servers.length !== 1 || !isRecord3(servers[0])) {
10377
+ return void 0;
9791
10378
  }
10379
+ const serverField = protocol === "http" ? "url" : "address";
10380
+ const server = servers[0][serverField];
10381
+ if (typeof server !== "string") return void 0;
10382
+ const provider = routerName.slice(routerName.lastIndexOf("@")).trim();
10383
+ if (!provider.startsWith("@")) return void 0;
9792
10384
  return {
9793
- ok: true,
9794
- reachedPageLimit: value.length >= ROUTER_API_PAGE_SIZE,
9795
- names: new Set(
9796
- value.flatMap((item) => {
9797
- if (!item || typeof item !== "object") return [];
9798
- const name = item.name;
9799
- return typeof name === "string" ? [name] : [];
9800
- })
9801
- )
10385
+ protocol,
10386
+ routerName,
10387
+ serviceKey: routerKey,
10388
+ serviceName: `${routerKey}${provider}`,
10389
+ rule,
10390
+ serverField,
10391
+ server
10392
+ };
10393
+ }
10394
+ function inspectExpectedRouteMatch(route) {
10395
+ const expected = renderExpectedRoute(route);
10396
+ const routeName = buildHostRouteRouterName(route.repoPath, route.name);
10397
+ if (!expected) {
10398
+ return {
10399
+ ok: false,
10400
+ routes: [routeName],
10401
+ details: "host-route renderer returned an incomplete route configuration"
10402
+ };
10403
+ }
10404
+ const routerResult = inspectNamedRouterApi(expected.protocol, "routers", expected.routerName);
10405
+ if (!routerResult.ok) {
10406
+ return { ok: false, routes: [routeName], details: routerResult.details };
10407
+ }
10408
+ 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) {
10409
+ return {
10410
+ ok: false,
10411
+ routes: [routeName],
10412
+ details: "active Traefik router does not match the rendered route"
10413
+ };
10414
+ }
10415
+ const serviceResult = inspectNamedRouterApi(expected.protocol, "services", expected.serviceName);
10416
+ if (!serviceResult.ok) {
10417
+ return { ok: false, routes: [routeName], details: serviceResult.details };
10418
+ }
10419
+ const loadBalancer = serviceResult.value.loadBalancer;
10420
+ const servers = isRecord3(loadBalancer) ? loadBalancer.servers : void 0;
10421
+ const server = Array.isArray(servers) && servers.length === 1 ? servers[0] : void 0;
10422
+ 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) {
10423
+ return {
10424
+ ok: false,
10425
+ routes: [routeName],
10426
+ details: "active Traefik service does not match the rendered route"
10427
+ };
10428
+ }
10429
+ return { ok: true };
10430
+ }
10431
+ function inspectExpectedRouteMatches(routes) {
10432
+ const mismatched = [];
10433
+ const failures = [];
10434
+ for (const route of routes) {
10435
+ const result = inspectExpectedRouteMatch(route);
10436
+ if (!result.ok) {
10437
+ mismatched.push(...result.routes);
10438
+ failures.push(result.details);
10439
+ }
10440
+ }
10441
+ return mismatched.length === 0 ? { ok: true } : {
10442
+ ok: false,
10443
+ routes: mismatched,
10444
+ details: failures.join("; ")
9802
10445
  };
9803
10446
  }
10447
+ async function waitForExpectedRouteMatches(routes, timeoutMs, pollIntervalMs) {
10448
+ const deadline = Date.now() + timeoutMs;
10449
+ let result;
10450
+ do {
10451
+ result = inspectExpectedRouteMatches(routes);
10452
+ if (result.ok) return result;
10453
+ if (Date.now() < deadline) {
10454
+ await sleep(Math.min(pollIntervalMs, Math.max(1, deadline - Date.now())));
10455
+ }
10456
+ } while (Date.now() < deadline);
10457
+ return result;
10458
+ }
9804
10459
  function inspectExpectedRoutes(routes, expectation) {
9805
10460
  const mismatched = [];
9806
10461
  const failures = [];
@@ -9857,7 +10512,11 @@ async function ensureTraefikRouteExpectation(routes, expectation, options) {
9857
10512
  pollIntervalMs
9858
10513
  );
9859
10514
  if (initial.ok) return { restarted: false };
9860
- import_node_fs24.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
10515
+ if (options.allowRestart === false)
10516
+ throw new Error(
10517
+ "Traefik routes did not reload; shared router restart is disabled for this operation."
10518
+ );
10519
+ import_node_fs25.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
9861
10520
  return withFileLock(
9862
10521
  ROUTER_RELOAD_LOCK_FILE,
9863
10522
  {
@@ -9887,21 +10546,33 @@ async function ensureTraefikRouteExpectation(routes, expectation, options) {
9887
10546
  async function ensureTraefikRoutesLoaded(routes, options = {}) {
9888
10547
  return ensureTraefikRouteExpectation(routes, "loaded", options);
9889
10548
  }
10549
+ async function ensureTraefikRoutesMatch(routes, options = {}) {
10550
+ if (routes.length === 0) return;
10551
+ const timeoutMs = options.initialTimeoutMs ?? options.recoveryTimeoutMs ?? DEFAULT_INITIAL_TIMEOUT_MS;
10552
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
10553
+ const result = await waitForExpectedRouteMatches(routes, timeoutMs, pollIntervalMs);
10554
+ if (!result.ok) {
10555
+ throw new Error(
10556
+ `Traefik route configuration did not match rendered routes: ${result.routes.join(", ")} (${result.details})`
10557
+ );
10558
+ }
10559
+ }
9890
10560
  async function ensureTraefikRoutesRemoved(routes, options = {}) {
9891
10561
  return ensureTraefikRouteExpectation(routes, "removed", options);
9892
10562
  }
9893
- var import_node_child_process20, import_node_fs24, import_node_path25, ROUTER_API_BASE, ROUTER_RELOAD_LOCK_FILE, DEFAULT_INITIAL_TIMEOUT_MS, DEFAULT_RECOVERY_TIMEOUT_MS, DEFAULT_POLL_INTERVAL_MS, ROUTER_API_PAGE_SIZE;
10563
+ var import_node_child_process20, import_node_fs25, import_node_path26, ROUTER_API_BASE, ROUTER_RELOAD_LOCK_FILE, ROUTER_API_REQUEST_TIMEOUT_SECONDS, DEFAULT_INITIAL_TIMEOUT_MS, DEFAULT_RECOVERY_TIMEOUT_MS, DEFAULT_POLL_INTERVAL_MS, ROUTER_API_PAGE_SIZE;
9894
10564
  var init_traefik_route_health = __esm({
9895
10565
  "src/core/traefik-route-health.ts"() {
9896
10566
  "use strict";
9897
10567
  import_node_child_process20 = require("child_process");
9898
- import_node_fs24 = __toESM(require("fs"));
9899
- import_node_path25 = __toESM(require("path"));
10568
+ import_node_fs25 = __toESM(require("fs"));
10569
+ import_node_path26 = __toESM(require("path"));
9900
10570
  init_file_lock();
9901
10571
  init_host_routes();
9902
10572
  init_router();
9903
10573
  ROUTER_API_BASE = "http://127.0.0.1:8080/api";
9904
- ROUTER_RELOAD_LOCK_FILE = import_node_path25.default.join(DEVROUTER_HOME, "router-reload.lock");
10574
+ ROUTER_RELOAD_LOCK_FILE = import_node_path26.default.join(DEVROUTER_HOME, "router-reload.lock");
10575
+ ROUTER_API_REQUEST_TIMEOUT_SECONDS = "2";
9905
10576
  DEFAULT_INITIAL_TIMEOUT_MS = 3e3;
9906
10577
  DEFAULT_RECOVERY_TIMEOUT_MS = 1e4;
9907
10578
  DEFAULT_POLL_INTERVAL_MS = 250;
@@ -9912,11 +10583,11 @@ var init_traefik_route_health = __esm({
9912
10583
  // src/core/workspace-ensure.ts
9913
10584
  function assertOverlay(container, repoPath) {
9914
10585
  const workingDir = container.labels["com.docker.compose.project.working_dir"];
9915
- if (!workingDir || !sameWorkspacePath(workingDir, import_node_path26.default.join(repoPath, ".devcontainer"))) {
10586
+ if (!workingDir || !sameWorkspacePath(workingDir, import_node_path27.default.join(repoPath, ".devcontainer"))) {
9916
10587
  throw new Error(`Container '${container.id}' does not belong to the exact worktree.`);
9917
10588
  }
9918
10589
  const configFiles = (container.labels["com.docker.compose.project.config_files"] ?? "").split(",").filter(Boolean);
9919
- const expectedOverlay = import_node_path26.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
10590
+ const expectedOverlay = import_node_path27.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
9920
10591
  if (!configFiles.some((configFile) => sameWorkspacePath(configFile, expectedOverlay))) {
9921
10592
  throw new Error(`Container '${container.id}' was not started with ${DEVCONTAINER_OVERLAY}.`);
9922
10593
  }
@@ -10106,7 +10777,7 @@ function hasExactManagedComposeProject(repoPath, state) {
10106
10777
  }).some((container) => {
10107
10778
  const workingDir = container.labels["com.docker.compose.project.working_dir"];
10108
10779
  return Boolean(
10109
- workingDir && sameWorkspacePath(workingDir, import_node_path26.default.join(repoPath, ".devcontainer"))
10780
+ workingDir && sameWorkspacePath(workingDir, import_node_path27.default.join(repoPath, ".devcontainer"))
10110
10781
  );
10111
10782
  });
10112
10783
  } catch {
@@ -10165,7 +10836,7 @@ function validateWorkspaceContainers(containers, options) {
10165
10836
  if (options.target.kind === "linked") {
10166
10837
  assertOverlay(appContainer, options.repoPath);
10167
10838
  }
10168
- assertReady(appContainer, "Workspace app");
10839
+ if (!options.allowStopped) assertReady(appContainer, "Workspace app");
10169
10840
  if (options.target.kind === "linked") {
10170
10841
  const gitCommonDir = options.target.gitCommonDir;
10171
10842
  const gitMount = appContainer.mounts.find(
@@ -10317,6 +10988,142 @@ function resolveLinkedTarget(repoPath) {
10317
10988
  gitCommonDir: resolveGitCommonDir(repoPath)
10318
10989
  };
10319
10990
  }
10991
+ function resolveRepairTarget(repoPath, linked) {
10992
+ const workspace = linked ? readPersistedWorkspace(repoPath) : void 0;
10993
+ if (linked && !workspace) throw new Error("Repair requires an existing workspace identity.");
10994
+ const state = readManagedRuntimeState(repoPath, workspace);
10995
+ if (state?.status !== "degraded") {
10996
+ throw new Error("Repair requires a persisted degraded managed runtime.");
10997
+ }
10998
+ resetWorkspaceRuntimeCaches();
10999
+ const snapshots = getWorkspaceRegistrySnapshots();
11000
+ if (snapshots.unavailable.length) throw new Error("Repair cannot read every provider registry.");
11001
+ const matches = [...snapshots.devpod ?? [], ...snapshots.devsy ?? []].filter(
11002
+ (entry) => entry.id === state.devpodId || sameWorkspacePath(entry.source.localFolder, repoPath)
11003
+ );
11004
+ if (matches.length !== 1 || matches[0].id !== state.devpodId || !sameWorkspacePath(matches[0].source.localFolder, repoPath)) {
11005
+ throw new Error("Repair requires one exact retained provider registration.");
11006
+ }
11007
+ const selected = snapshots[resolveWorkspaceRuntimeOrDefault(repoPath)] ?? [];
11008
+ if (!selected.some(
11009
+ (entry) => entry.id === state.devpodId && sameWorkspacePath(entry.source.localFolder, repoPath)
11010
+ )) {
11011
+ throw new Error("Repair provider selection does not own the retained workspace.");
11012
+ }
11013
+ if (workspace) {
11014
+ const owner = readWorkspaceOwnership(repoPath, workspace);
11015
+ if (!owner || owner.devpodId !== state.devpodId || !sameWorkspacePath(owner.worktreePath, repoPath)) {
11016
+ throw new Error("Repair requires the existing exact workspace owner record.");
11017
+ }
11018
+ return {
11019
+ kind: "linked",
11020
+ workspace,
11021
+ devpodId: state.devpodId,
11022
+ hadExactDevpod: true,
11023
+ gitCommonDir: resolveGitCommonDir(repoPath)
11024
+ };
11025
+ }
11026
+ return { kind: "primary", devpodId: state.devpodId, hadExactDevpod: true };
11027
+ }
11028
+ function assertRepairBaseline(options) {
11029
+ const { repoPath, target, state, plan, config, profile, processes } = options;
11030
+ const desired = {
11031
+ apps: proxyAppsFromConfig(config).map((app) => app.name),
11032
+ services: plan.desiredProfileServices,
11033
+ processes
11034
+ };
11035
+ if (state.profile !== profile || state.sourceConfigSha256 !== plan.sourceConfigSha256 || state.effectiveConfigSha256 !== plan.effectiveConfigSha256 || Object.keys(desired).some((key) => {
11036
+ const resource = key;
11037
+ return JSON.stringify([...desired[resource]].sort()) !== JSON.stringify([...state.desired[resource]].sort());
11038
+ }) || inspectManagedDevcontainerGeneratedConfig(plan).status !== "valid") {
11039
+ throw new Error(
11040
+ "Repair requires the recorded profile, resource sets, and unchanged managed configuration."
11041
+ );
11042
+ }
11043
+ if (options.routes.some(
11044
+ (route) => route.workspace !== target.workspace || !desired.apps.includes(route.name)
11045
+ )) {
11046
+ throw new Error("Repair refuses unexpected active routes.");
11047
+ }
11048
+ const containers = inspectWorkspaceContainers();
11049
+ const retained = plan.desiredServices.map((service) => {
11050
+ const matches = exactWorkspaceServiceContainers(
11051
+ containers,
11052
+ repoPath,
11053
+ state.composeProject,
11054
+ service,
11055
+ plan.composeFiles
11056
+ );
11057
+ if (matches.length !== 1)
11058
+ throw new Error(`Repair requires one retained container for '${service}'.`);
11059
+ return matches[0];
11060
+ });
11061
+ const primary = retained.find(
11062
+ (c) => c.labels["com.docker.compose.service"] === plan.primaryService
11063
+ );
11064
+ if (!primary) throw new Error("Repair cannot identify the retained primary container.");
11065
+ if (!primary.state.Running && (options.routes.length > 0 || containers.some(
11066
+ (container) => container.labels["com.docker.compose.project"] === state.composeProject && container.state.Running !== false
11067
+ ))) {
11068
+ throw new Error(
11069
+ "Repair of a stopped primary requires all exact project containers stopped and no checkout routes."
11070
+ );
11071
+ }
11072
+ validateWorkspaceContainers(containers, {
11073
+ repoPath,
11074
+ target,
11075
+ upstreamHosts: [],
11076
+ allowStopped: !primary.state.Running
11077
+ });
11078
+ for (const container of containers) {
11079
+ if (container.labels["com.docker.compose.project"] !== state.composeProject) continue;
11080
+ const service = container.labels["com.docker.compose.service"] ?? "";
11081
+ if (typeof container.state.Running !== "boolean" || !hasExactComposeIdentity(container, {
11082
+ repoPath,
11083
+ service,
11084
+ composeProject: state.composeProject,
11085
+ composeFiles: plan.composeFiles
11086
+ }) || container.state.Running && !plan.desiredServices.includes(service)) {
11087
+ throw new Error("Repair refuses foreign or unexpected active Compose resources.");
11088
+ }
11089
+ }
11090
+ const workspacePath = primary.mounts.find(
11091
+ (m) => m.Type === "bind" && sameWorkspacePath(m.Source, repoPath)
11092
+ )?.Destination;
11093
+ if (!workspacePath) throw new Error("Repair cannot prove the primary workspace mount.");
11094
+ for (const name of primary.state.Running ? config.managedRuntime?.processes ?? [] : []) {
11095
+ if (!processes.includes(name) && runManagedProcessAction({
11096
+ container: { id: primary.id, workspacePath },
11097
+ name,
11098
+ action: "status",
11099
+ quiet: true
11100
+ }) !== "stopped") {
11101
+ throw new Error(`Repair refuses unexpected or unproven process '${name}'.`);
11102
+ }
11103
+ }
11104
+ assertManagedContainerConfigUnchanged({
11105
+ plan,
11106
+ containers: retained,
11107
+ workspace: options.workspace
11108
+ });
11109
+ return retained;
11110
+ }
11111
+ function assertRetainedRepairContainers(repoPath, plan, retained) {
11112
+ const current = inspectWorkspaceContainers();
11113
+ for (const previous of retained) {
11114
+ const service = previous.labels["com.docker.compose.service"] ?? "";
11115
+ const matches = exactWorkspaceServiceContainers(
11116
+ current,
11117
+ repoPath,
11118
+ previous.labels["com.docker.compose.project"] ?? "",
11119
+ service,
11120
+ plan.composeFiles
11121
+ );
11122
+ if (matches.length !== 1 || matches[0].id !== previous.id) {
11123
+ throw new Error(`Repair lost retained container identity for '${service}'.`);
11124
+ }
11125
+ }
11126
+ }
10320
11127
  function resolvePrimaryTarget(repoPath) {
10321
11128
  const existingDevpod = selectDevpodWorkspace(listDevpodWorkspaces(repoPath), repoPath);
10322
11129
  return {
@@ -10327,7 +11134,7 @@ function resolvePrimaryTarget(repoPath) {
10327
11134
  }
10328
11135
  function isPrimaryCheckout(repoPath) {
10329
11136
  try {
10330
- return import_node_fs25.default.statSync(import_node_path26.default.join(repoPath, ".git")).isDirectory();
11137
+ return import_node_fs26.default.statSync(import_node_path27.default.join(repoPath, ".git")).isDirectory();
10331
11138
  } catch {
10332
11139
  return false;
10333
11140
  }
@@ -10358,7 +11165,9 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10358
11165
  );
10359
11166
  }
10360
11167
  }
10361
- return withWorkspaceLifecycleLock(repoPath, async () => {
11168
+ const ensureLocked = async () => {
11169
+ let repairMutationStarted = false;
11170
+ let retainedRepairContainers = [];
10362
11171
  let environmentStarted = false;
10363
11172
  let managedPlan;
10364
11173
  let previousManagedState;
@@ -10376,25 +11185,30 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10376
11185
  let firstTransitionBaseline;
10377
11186
  const routeLoadOptions = {
10378
11187
  initialTimeoutMs: Math.min(options.httpTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS, 3e3),
10379
- recoveryTimeoutMs: Math.min(options.httpTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS, 1e4)
11188
+ recoveryTimeoutMs: Math.min(options.httpTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS, 1e4),
11189
+ ...options.repair ? { allowRestart: false } : {}
10380
11190
  };
10381
11191
  try {
10382
- const target = linked ? resolveLinkedTarget(repoPath) : resolvePrimaryTarget(repoPath);
11192
+ const target = options.repair ? resolveRepairTarget(repoPath, linked) : linked ? resolveLinkedTarget(repoPath) : resolvePrimaryTarget(repoPath);
10383
11193
  let devpodId = target.devpodId;
10384
11194
  if (target.kind === "linked") {
10385
- const overlayPath = import_node_path26.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
10386
- if (!import_node_fs25.default.existsSync(overlayPath)) {
11195
+ const overlayPath = import_node_path27.default.join(repoPath, ".devcontainer", DEVCONTAINER_OVERLAY);
11196
+ if (!import_node_fs26.default.existsSync(overlayPath)) {
10387
11197
  throw new Error(`Missing required DevPod compose overlay: ${overlayPath}`);
10388
11198
  }
10389
11199
  }
10390
11200
  const managedPostStart = resolveManagedPostStartPlan(repoPath);
10391
11201
  managedPostStartPlan = managedPostStart;
11202
+ if (options.repair)
11203
+ previousManagedState = readManagedRuntimeState(repoPath, target.workspace);
10392
11204
  const runtime = loadRuntimeConfig(
10393
11205
  repoPath,
10394
11206
  target.kind === "primary" ? "" : target.workspace,
10395
- options.profile
11207
+ options.profile ?? (options.repair ? previousManagedState?.profile : void 0)
10396
11208
  );
10397
11209
  const managedRuntime = runtime.config.managedRuntime;
11210
+ if (options.repair && !managedRuntime)
11211
+ throw new Error("Repair requires managedRuntime configuration.");
10398
11212
  previousRoutes = listHostRouteState().filter(
10399
11213
  (route) => sameWorkspacePath(route.repoPath, repoPath)
10400
11214
  );
@@ -10403,12 +11217,12 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10403
11217
  const desiredProcesses = managedRuntime ? desiredManagedProcesses(managedRuntime, runtime.resolvedProfile) : [];
10404
11218
  if (managedRuntime) {
10405
11219
  previousManagedState = readManagedRuntimeState(repoPath, target.workspace);
10406
- if (previousManagedState && !hasExactManagedComposeProject(repoPath, previousManagedState)) {
11220
+ if (!options.repair && previousManagedState && !hasExactManagedComposeProject(repoPath, previousManagedState)) {
10407
11221
  previousManagedState = void 0;
10408
11222
  }
10409
- if (previousManagedState?.status === "degraded") {
11223
+ if (!options.repair && previousManagedState?.status === "degraded") {
10410
11224
  throw new Error(
10411
- "Managed runtime state is degraded; refusing a new profile transition until drift is repaired."
11225
+ "Managed runtime state is degraded; refusing a new profile transition until drift is repaired. Inspect status, then use ensure --repair for the recorded profile."
10412
11226
  );
10413
11227
  }
10414
11228
  if (previousManagedState && previousManagedState.devpodId !== target.devpodId) {
@@ -10453,7 +11267,7 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10453
11267
  }
10454
11268
  const apps = proxyAppsFromConfig(runtime.config);
10455
11269
  const parsedUpstreams = apps.map((app) => parseUpstream(app.upstream));
10456
- const aliasPrefix = target.kind === "linked" ? target.workspace : wsFromBranch(runtime.config.project?.name ?? import_node_path26.default.basename(repoPath)) ?? "app";
11270
+ const aliasPrefix = target.kind === "linked" ? target.workspace : wsFromBranch(runtime.config.project?.name ?? import_node_path27.default.basename(repoPath)) ?? "app";
10457
11271
  for (const [index, app] of apps.entries()) {
10458
11272
  if (!parsedUpstreams[index].host.startsWith(`${aliasPrefix}-`)) {
10459
11273
  const owner = target.kind === "linked" ? "workspace" : "checkout";
@@ -10462,7 +11276,20 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10462
11276
  );
10463
11277
  }
10464
11278
  }
10465
- if (managedPlan) {
11279
+ if (options.repair && managedPlan && previousManagedState) {
11280
+ retainedRepairContainers = assertRepairBaseline({
11281
+ repoPath,
11282
+ target,
11283
+ state: previousManagedState,
11284
+ plan: managedPlan,
11285
+ config: runtime.config,
11286
+ profile: runtime.profile,
11287
+ processes: desiredProcesses,
11288
+ routes: previousRoutes,
11289
+ workspace: managedWorkspaceEnv
11290
+ });
11291
+ }
11292
+ if (managedPlan && !options.repair) {
10466
11293
  writeManagedDevcontainerConfig(managedPlan);
10467
11294
  managedConfigWritten = true;
10468
11295
  }
@@ -10498,7 +11325,7 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10498
11325
  let recreated = false;
10499
11326
  let container;
10500
11327
  try {
10501
- await startAndProveAttachment();
11328
+ if (!options.repair) await startAndProveAttachment();
10502
11329
  } catch (error) {
10503
11330
  if (managedPlan || !target.hadExactDevpod) {
10504
11331
  throw error;
@@ -10508,7 +11335,45 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10508
11335
  }
10509
11336
  if (!container) {
10510
11337
  try {
10511
- container = await preflight(0);
11338
+ if (options.repair && managedPlan && previousManagedState) {
11339
+ assertRetainedRepairContainers(repoPath, managedPlan, retainedRepairContainers);
11340
+ const primaryService = managedPlan.primaryService;
11341
+ const primary = retainedRepairContainers.find(
11342
+ (entry) => entry.labels["com.docker.compose.service"] === primaryService
11343
+ );
11344
+ if (!primary) throw new Error("Repair lost its retained primary container.");
11345
+ const stopped = retainedRepairContainers.filter((entry) => !entry.state.Running);
11346
+ for (const entry of stopped) {
11347
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(entry.id))
11348
+ throw new Error("Repair found an invalid retained container ID.");
11349
+ }
11350
+ if (stopped.length > 0) {
11351
+ repairMutationStarted = true;
11352
+ transitionPhase = "service-start";
11353
+ const result = (0, import_node_child_process21.spawnSync)("docker", ["start", ...stopped.map((entry) => entry.id)], {
11354
+ encoding: "utf-8",
11355
+ timeout: DEFAULT_READINESS_TIMEOUT_MS,
11356
+ stdio: options.quiet ? ["ignore", 2, "inherit"] : "inherit"
11357
+ });
11358
+ if (result.status !== 0)
11359
+ throw new Error("Could not start retained repair containers.");
11360
+ assertRetainedRepairContainers(repoPath, managedPlan, retainedRepairContainers);
11361
+ }
11362
+ await waitForManagedServices(
11363
+ managedPlan,
11364
+ repoPath,
11365
+ previousManagedState.composeProject,
11366
+ managedPlan.desiredServices,
11367
+ primary.id,
11368
+ options.containerTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS
11369
+ );
11370
+ }
11371
+ container = options.repair ? await waitForContainerPreflight(
11372
+ repoPath,
11373
+ currentTarget(),
11374
+ [],
11375
+ options.containerTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS
11376
+ ) : await preflight(0);
10512
11377
  } catch (error) {
10513
11378
  if (managedPlan || !target.hadExactDevpod) {
10514
11379
  throw error;
@@ -10549,13 +11414,18 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10549
11414
  }
10550
11415
  if (!matches[0]?.state.Running) missingServices.push(service);
10551
11416
  }
10552
- startExactManagedServices({
10553
- plan: managedPlan,
10554
- composeProject: managedComposeProject,
10555
- services: missingServices,
10556
- quiet: options.quiet,
10557
- workspace: managedWorkspaceEnv
10558
- });
11417
+ if (options.repair) {
11418
+ assertRetainedRepairContainers(repoPath, managedPlan, retainedRepairContainers);
11419
+ repairMutationStarted = true;
11420
+ }
11421
+ if (!options.repair)
11422
+ startExactManagedServices({
11423
+ plan: managedPlan,
11424
+ composeProject: managedComposeProject,
11425
+ services: missingServices,
11426
+ quiet: options.quiet,
11427
+ workspace: managedWorkspaceEnv
11428
+ });
10559
11429
  await waitForManagedServices(
10560
11430
  managedPlan,
10561
11431
  repoPath,
@@ -10567,6 +11437,8 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10567
11437
  container = await preflight(options.containerTimeoutMs ?? DEFAULT_READINESS_TIMEOUT_MS);
10568
11438
  managedContainer = container;
10569
11439
  }
11440
+ if (options.repair && managedPlan)
11441
+ assertRetainedRepairContainers(repoPath, managedPlan, retainedRepairContainers);
10570
11442
  transitionPhase = "process-start";
10571
11443
  runManagedPostStart({
10572
11444
  plan: managedPostStart,
@@ -10580,6 +11452,17 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10580
11452
  const desiredProcessSet = new Set(desiredProcesses);
10581
11453
  for (const process2 of processRegistry) {
10582
11454
  if (desiredProcessSet.has(process2)) continue;
11455
+ if (options.repair) {
11456
+ if (runManagedProcessAction({
11457
+ container,
11458
+ name: process2,
11459
+ action: "status",
11460
+ quiet: options.quiet
11461
+ }) !== "stopped") {
11462
+ throw new Error(`Repair found unexpected process '${process2}' after adapter replay.`);
11463
+ }
11464
+ continue;
11465
+ }
10583
11466
  runManagedProcessAction({
10584
11467
  container,
10585
11468
  name: process2,
@@ -10599,7 +11482,8 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10599
11482
  }
10600
11483
  }
10601
11484
  transitionPhase = "service-stop";
10602
- stopDroppedManagedServices(managedPlan, repoPath, managedComposeProject);
11485
+ if (!options.repair)
11486
+ stopDroppedManagedServices(managedPlan, repoPath, managedComposeProject);
10603
11487
  assertDroppedManagedServicesStopped(managedPlan, repoPath, managedComposeProject);
10604
11488
  await waitForManagedServices(
10605
11489
  managedPlan,
@@ -10614,11 +11498,13 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10614
11498
  const publication = await replacePublishedProxyRoutes(
10615
11499
  repoPath,
10616
11500
  runtime.config,
10617
- target.workspace
11501
+ target.workspace,
11502
+ options.repair ? { prepareInfrastructure: false } : void 0
10618
11503
  );
10619
11504
  candidateRoutes = publication.routes;
10620
11505
  candidateRoutesPublished = true;
10621
- await ensureTraefikRoutesLoaded(publication.routes, routeLoadOptions);
11506
+ if (options.repair) await ensureTraefikRoutesMatch(publication.routes, routeLoadOptions);
11507
+ else await ensureTraefikRoutesLoaded(publication.routes, routeLoadOptions);
10622
11508
  await ensureTraefikRoutesRemoved(
10623
11509
  removedRoutesForReplacement(previousRoutes.map(routeInputFromState), publication.routes),
10624
11510
  routeLoadOptions
@@ -10665,8 +11551,9 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10665
11551
  if (!devpodId) {
10666
11552
  throw new Error(`DevPod id for '${repoPath}' was not resolved after startup.`);
10667
11553
  }
11554
+ let candidateState;
10668
11555
  if (managedPlan && managedComposeProject) {
10669
- writeManagedRuntimeState({
11556
+ candidateState = {
10670
11557
  version: 1,
10671
11558
  repoPath,
10672
11559
  ...target.workspace !== void 0 ? { workspace: target.workspace } : {},
@@ -10682,15 +11569,24 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10682
11569
  effectiveConfigSha256: managedPlan.effectiveConfigSha256,
10683
11570
  status: "ready",
10684
11571
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
10685
- });
11572
+ };
11573
+ if (!options.repair) writeManagedRuntimeState(candidateState);
10686
11574
  }
10687
11575
  const managedRuntimeStatus = managedPlan ? collectManagedRuntimeStatus({
10688
11576
  repoPath,
10689
11577
  workspace: target.workspace,
10690
11578
  config: runtime.config,
10691
11579
  profile: runtime.profile,
10692
- resolvedProfile: runtime.resolvedProfile
11580
+ resolvedProfile: runtime.resolvedProfile,
11581
+ ...options.repair ? { candidateState } : {}
10693
11582
  }) : void 0;
11583
+ if (options.repair && candidateState && managedPlan) {
11584
+ assertRetainedRepairContainers(repoPath, managedPlan, retainedRepairContainers);
11585
+ resolveRepairTarget(repoPath, linked);
11586
+ if (managedRuntimeStatus?.status !== "ready")
11587
+ throw new Error("Repaired candidate runtime did not pass final readiness validation.");
11588
+ writeManagedRuntimeState(candidateState);
11589
+ }
10694
11590
  return {
10695
11591
  kind: target.kind,
10696
11592
  repoPath,
@@ -10703,6 +11599,37 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10703
11599
  ...managedRuntimeStatus ? { managedRuntime: managedRuntimeStatus } : {}
10704
11600
  };
10705
11601
  } catch (error) {
11602
+ if (options.repair && !repairMutationStarted) throw error;
11603
+ if (options.repair && repairMutationStarted && previousManagedState && managedPlan) {
11604
+ const failures = [];
11605
+ if (transitionPhase === "route-publication") {
11606
+ try {
11607
+ const currentRoutes = listHostRouteState().filter((route) => sameWorkspacePath(route.repoPath, repoPath)).map(routeInputFromState);
11608
+ const rollbackRoutes = previousRoutes.map(routeInputFromState);
11609
+ replaceHostRoutesForRepo(repoPath, rollbackRoutes);
11610
+ await ensureTraefikRoutesMatch(rollbackRoutes, routeLoadOptions);
11611
+ await ensureTraefikRoutesRemoved(
11612
+ removedRoutesForReplacement(currentRoutes, rollbackRoutes),
11613
+ routeLoadOptions
11614
+ );
11615
+ } catch (rollbackError) {
11616
+ failures.push(
11617
+ `routes: ${rollbackError instanceof Error ? rollbackError.message : String(rollbackError)}`
11618
+ );
11619
+ }
11620
+ }
11621
+ try {
11622
+ markManagedRuntimeDegraded(previousManagedState, transitionPhase);
11623
+ } catch (stateError) {
11624
+ failures.push(
11625
+ `state: ${stateError instanceof Error ? stateError.message : String(stateError)}`
11626
+ );
11627
+ }
11628
+ const original = error instanceof Error ? error.message : String(error);
11629
+ throw new Error(
11630
+ `${original} Repair failed; owned resources may remain running.${failures.length ? ` Recovery errors: ${failures.join("; ")}.` : " State remains degraded."}`
11631
+ );
11632
+ }
10706
11633
  if (managedPlan && managedContainer && managedComposeProject) {
10707
11634
  const rollbackErrors = [];
10708
11635
  const failedPhase = transitionPhase;
@@ -10904,19 +11831,36 @@ async function workspaceEnsure(requestedRepoPath, options = {}) {
10904
11831
  }
10905
11832
  throw error;
10906
11833
  }
11834
+ };
11835
+ return withWorkspaceLifecycleLock(repoPath, async () => {
11836
+ if (!options.repair) return ensureLocked();
11837
+ const runtime = resolveWorkspaceRuntimeOrDefault(repoPath);
11838
+ import_node_fs26.default.mkdirSync(DEVROUTER_HOME, { recursive: true });
11839
+ return withFileLock(
11840
+ import_node_path27.default.join(DEVROUTER_HOME, `${runtime}-mutation.lock`),
11841
+ {
11842
+ activity: "Managed runtime repair",
11843
+ target: repoPath,
11844
+ fair: true,
11845
+ waitMs: 18e5,
11846
+ onWait: createStderrWaitReporter("Managed runtime repair", repoPath)
11847
+ },
11848
+ ensureLocked
11849
+ );
10907
11850
  });
10908
11851
  }
10909
- var import_node_child_process21, import_node_fs25, import_node_path26, DEVCONTAINER_OVERLAY, DEFAULT_READINESS_TIMEOUT_MS, POLL_INTERVAL_MS;
11852
+ var import_node_child_process21, import_node_fs26, import_node_path27, DEVCONTAINER_OVERLAY, DEFAULT_READINESS_TIMEOUT_MS, POLL_INTERVAL_MS;
10910
11853
  var init_workspace_ensure = __esm({
10911
11854
  "src/core/workspace-ensure.ts"() {
10912
11855
  "use strict";
10913
11856
  import_node_child_process21 = require("child_process");
10914
- import_node_fs25 = __toESM(require("fs"));
10915
- import_node_path26 = __toESM(require("path"));
11857
+ import_node_fs26 = __toESM(require("fs"));
11858
+ import_node_path27 = __toESM(require("path"));
10916
11859
  init_devcontainer_profile();
10917
11860
  init_devpod_environment();
10918
11861
  init_devpod_mutation();
10919
11862
  init_devpod_workspaces();
11863
+ init_file_lock();
10920
11864
  init_host_routes();
10921
11865
  init_http_route_probe();
10922
11866
  init_managed_post_start();
@@ -10964,7 +11908,8 @@ async function runEnsureCommand(options) {
10964
11908
  const result = await workspaceEnsure(repoPath, {
10965
11909
  open: options.open,
10966
11910
  quiet: Boolean(options.json),
10967
- profile: options.profile
11911
+ profile: options.profile,
11912
+ ...options.repair ? { repair: true } : {}
10968
11913
  });
10969
11914
  if (options.json) {
10970
11915
  process.stdout.write(`${JSON.stringify(result, null, 2)}
@@ -10999,7 +11944,7 @@ function warnMissingWorkspaceOwnership(repoPath) {
10999
11944
  );
11000
11945
  }
11001
11946
  function defaultWorktreePath(mainRepo, ws) {
11002
- return import_node_path27.default.join(mainRepo, "trees", ws);
11947
+ return import_node_path28.default.join(mainRepo, "trees", ws);
11003
11948
  }
11004
11949
  function assertDefaultWorktreeRootIgnored(mainRepo) {
11005
11950
  const ignored = (0, import_node_child_process22.spawnSync)("git", ["-C", mainRepo, "check-ignore", "-q", "--no-index", "trees/"], {
@@ -11007,12 +11952,12 @@ function assertDefaultWorktreeRootIgnored(mainRepo) {
11007
11952
  });
11008
11953
  if (ignored.status !== 0) {
11009
11954
  throw new Error(
11010
- `Default worktree root '${import_node_path27.default.join(mainRepo, "trees")}' is not ignored. Add 'trees/' to '${import_node_path27.default.join(mainRepo, ".gitignore")}' or use --path.`
11955
+ `Default worktree root '${import_node_path28.default.join(mainRepo, "trees")}' is not ignored. Add 'trees/' to '${import_node_path28.default.join(mainRepo, ".gitignore")}' or use --path.`
11011
11956
  );
11012
11957
  }
11013
11958
  }
11014
11959
  function legacyDefaultWorktreePath(mainRepo, ws) {
11015
- return import_node_path27.default.join(import_node_path27.default.dirname(mainRepo), `${import_node_path27.default.basename(mainRepo)}-${ws}`);
11960
+ return import_node_path28.default.join(import_node_path28.default.dirname(mainRepo), `${import_node_path28.default.basename(mainRepo)}-${ws}`);
11016
11961
  }
11017
11962
  function teardownFallbackPath(mainRepo, workspace) {
11018
11963
  const candidates = [
@@ -11124,7 +12069,7 @@ function assertFullDownPreflight(mainRepo, target) {
11124
12069
  if (sameWorkspacePath(target.worktreePath, mainRepo)) {
11125
12070
  throw new Error("Refusing to remove the primary Git checkout.");
11126
12071
  }
11127
- if (!target.worktree || target.worktree.prunable || !import_node_fs26.default.existsSync(target.worktreePath)) return;
12072
+ if (!target.worktree || target.worktree.prunable || !import_node_fs27.default.existsSync(target.worktreePath)) return;
11128
12073
  if (target.worktree.locked) {
11129
12074
  throw new Error(
11130
12075
  `Worktree '${target.worktreePath}' is locked; unlock it before workspace down.`
@@ -11164,9 +12109,9 @@ async function workspaceUp(branch, opts = {}) {
11164
12109
  (candidate) => defaultWorktreePath(mainRepo, candidate)
11165
12110
  );
11166
12111
  const generatedPath = candidatePaths.find(
11167
- (candidate) => !import_node_fs26.default.existsSync(candidate) && !worktrees.some((worktree) => sameWorkspacePath(worktree.path, candidate))
12112
+ (candidate) => !import_node_fs27.default.existsSync(candidate) && !worktrees.some((worktree) => sameWorkspacePath(worktree.path, candidate))
11168
12113
  );
11169
- const selectedPath = opts.path ? import_node_path27.default.resolve(opts.path) : existingBranch?.path ?? generatedPath;
12114
+ const selectedPath = opts.path ? import_node_path28.default.resolve(opts.path) : existingBranch?.path ?? generatedPath;
11170
12115
  if (!selectedPath) {
11171
12116
  throw new Error(`Could not allocate a collision-safe worktree path for branch '${branch}'.`);
11172
12117
  }
@@ -11175,7 +12120,7 @@ async function workspaceUp(branch, opts = {}) {
11175
12120
  `Branch '${branch}' already uses worktree '${existingBranch.path}', not '${selectedPath}'.`
11176
12121
  );
11177
12122
  }
11178
- if (import_node_fs26.default.existsSync(selectedPath)) {
12123
+ if (import_node_fs27.default.existsSync(selectedPath)) {
11179
12124
  const registered = worktrees.find(
11180
12125
  (worktree) => sameWorkspacePath(worktree.path, selectedPath)
11181
12126
  );
@@ -11269,6 +12214,11 @@ async function mutateWorkspaceRuntime(action2, resolved, worktrees, quiet = fals
11269
12214
  assertDevpodTargetSafe(resolved, worktrees, devpods);
11270
12215
  const devpodId = resolved.record?.devpodId ?? resolved.workspace;
11271
12216
  const mutation = action2 === "stop" ? stopOwnedDevpodWorkspace(devpodId, resolved.worktreePath) : deleteOwnedDevpodWorkspace(devpodId, resolved.worktreePath);
12217
+ if (action2 === "stop" && mutation.status === "absent" && readManagedRuntimeState(resolved.worktreePath, resolved.workspace)) {
12218
+ throw new Error(
12219
+ "Retained managed runtime has no exact provider registration; routes were preserved."
12220
+ );
12221
+ }
11272
12222
  const routes = removeWorkspaceRoutesForWorktree(resolved.workspace, resolved.worktreePath);
11273
12223
  await ensureTraefikRoutesRemoved(routes);
11274
12224
  if (!quiet) {
@@ -11301,7 +12251,7 @@ async function runWorkspaceLifecycle(action2, target, opts = {}) {
11301
12251
  opts.quiet
11302
12252
  );
11303
12253
  if (removeWorktree) {
11304
- if (resolved.worktree && !resolved.worktree.prunable && import_node_fs26.default.existsSync(resolved.worktreePath)) {
12254
+ if (resolved.worktree && !resolved.worktree.prunable && import_node_fs27.default.existsSync(resolved.worktreePath)) {
11305
12255
  const rm = (0, import_node_child_process22.spawnSync)("git", ["-C", mainRepo, "worktree", "remove", resolved.worktreePath], {
11306
12256
  encoding: "utf-8"
11307
12257
  });
@@ -11320,7 +12270,7 @@ async function runWorkspaceLifecycle(action2, target, opts = {}) {
11320
12270
  }
11321
12271
  return result;
11322
12272
  };
11323
- return resolved.worktree && !resolved.worktree.prunable && import_node_fs26.default.existsSync(resolved.worktreePath) ? withWorkspaceLifecycleLock(resolved.worktreePath, operation) : operation();
12273
+ return resolved.worktree && !resolved.worktree.prunable && import_node_fs27.default.existsSync(resolved.worktreePath) ? withWorkspaceLifecycleLock(resolved.worktreePath, operation) : operation();
11324
12274
  }
11325
12275
  async function mutateWorkspaceOwnedPath(action2, worktreePath, opts = {}) {
11326
12276
  const mainRepo = resolveRepoPath(opts.repoPath);
@@ -11355,15 +12305,16 @@ async function workspaceStop(target, opts = {}) {
11355
12305
  async function workspaceDown(target, opts = {}) {
11356
12306
  return runWorkspaceLifecycle("down", target, opts);
11357
12307
  }
11358
- var import_node_child_process22, import_node_fs26, import_node_path27, WORKSPACE_ALLOCATION_LOCK_WAIT_MS;
12308
+ var import_node_child_process22, import_node_fs27, import_node_path28, WORKSPACE_ALLOCATION_LOCK_WAIT_MS;
11359
12309
  var init_workspace_lifecycle = __esm({
11360
12310
  "src/core/workspace-lifecycle.ts"() {
11361
12311
  "use strict";
11362
12312
  import_node_child_process22 = require("child_process");
11363
- import_node_fs26 = __toESM(require("fs"));
11364
- import_node_path27 = __toESM(require("path"));
12313
+ import_node_fs27 = __toESM(require("fs"));
12314
+ import_node_path28 = __toESM(require("path"));
11365
12315
  init_devpod_mutation();
11366
12316
  init_devpod_workspaces();
12317
+ init_managed_runtime_state();
11367
12318
  init_repo_config();
11368
12319
  init_route_state();
11369
12320
  init_traefik_route_health();
@@ -11415,6 +12366,11 @@ async function environmentStop(repoPath, options = {}) {
11415
12366
  return withWorkspaceLifecycleLock(repoPath, async () => {
11416
12367
  const devpod = selectDevpodWorkspace(listDevpodWorkspaces(repoPath), repoPath);
11417
12368
  const mutation = devpod ? options.delete ? deleteOwnedDevpodWorkspace(devpod.id, repoPath) : stopOwnedDevpodWorkspace(devpod.id, repoPath) : { status: "absent" };
12369
+ if (!options.delete && mutation.status === "absent" && readManagedRuntimeState(repoPath, workspace)) {
12370
+ throw new Error(
12371
+ "Retained managed runtime has no exact provider registration; routes were preserved."
12372
+ );
12373
+ }
11418
12374
  const removedRoutes = removeHostRoutesWhere(
11419
12375
  (route) => sameWorkspacePath(route.repoPath, repoPath)
11420
12376
  );
@@ -11436,6 +12392,7 @@ var init_environment_stop = __esm({
11436
12392
  init_devpod_mutation();
11437
12393
  init_devpod_workspaces();
11438
12394
  init_host_routes();
12395
+ init_managed_runtime_state();
11439
12396
  init_traefik_route_health();
11440
12397
  init_workspace();
11441
12398
  init_workspace_lifecycle();
@@ -11933,17 +12890,17 @@ function asRecord3(value) {
11933
12890
  return value;
11934
12891
  }
11935
12892
  function readJson(filePath) {
11936
- if (!import_node_fs27.default.existsSync(filePath)) {
12893
+ if (!import_node_fs28.default.existsSync(filePath)) {
11937
12894
  return void 0;
11938
12895
  }
11939
12896
  try {
11940
- return JSON.parse(import_node_fs27.default.readFileSync(filePath, "utf-8"));
12897
+ return JSON.parse(import_node_fs28.default.readFileSync(filePath, "utf-8"));
11941
12898
  } catch {
11942
12899
  return void 0;
11943
12900
  }
11944
12901
  }
11945
12902
  function relative(repoPath, filePath) {
11946
- return import_node_path28.default.relative(repoPath, filePath) || ".";
12903
+ return import_node_path29.default.relative(repoPath, filePath) || ".";
11947
12904
  }
11948
12905
  function redactEnvAssignments(value) {
11949
12906
  return value.replace(
@@ -11984,7 +12941,7 @@ function inspectPackageManager(repoPath, pkg) {
11984
12941
  ["bun.lock", "bun"]
11985
12942
  ];
11986
12943
  for (const [fileName, name] of lockfiles) {
11987
- if (import_node_fs27.default.existsSync(import_node_path28.default.join(repoPath, fileName))) {
12944
+ if (import_node_fs28.default.existsSync(import_node_path29.default.join(repoPath, fileName))) {
11988
12945
  return { name, source: fileName };
11989
12946
  }
11990
12947
  }
@@ -11999,9 +12956,9 @@ function inspectNode(repoPath, pkg) {
11999
12956
  if (typeof engines?.node === "string") {
12000
12957
  return { version: engines.node, source: "package.json:engines.node" };
12001
12958
  }
12002
- const nvmrc = import_node_path28.default.join(repoPath, ".nvmrc");
12003
- if (import_node_fs27.default.existsSync(nvmrc)) {
12004
- const version = import_node_fs27.default.readFileSync(nvmrc, "utf-8").trim();
12959
+ const nvmrc = import_node_path29.default.join(repoPath, ".nvmrc");
12960
+ if (import_node_fs28.default.existsSync(nvmrc)) {
12961
+ const version = import_node_fs28.default.readFileSync(nvmrc, "utf-8").trim();
12005
12962
  return { version, source: ".nvmrc" };
12006
12963
  }
12007
12964
  return void 0;
@@ -12062,7 +13019,7 @@ function configuredComposeFiles(repoPath) {
12062
13019
  const files = config.apps.filter(
12063
13020
  (app) => app.runtime === "docker"
12064
13021
  ).flatMap((app) => app.docker.composeFiles).filter(
12065
- (fileName) => !import_node_path28.default.isAbsolute(fileName) && !import_node_path28.default.normalize(fileName).startsWith("..")
13022
+ (fileName) => !import_node_path29.default.isAbsolute(fileName) && !import_node_path29.default.normalize(fileName).startsWith("..")
12066
13023
  );
12067
13024
  return Array.from(new Set(files));
12068
13025
  } catch {
@@ -12080,7 +13037,7 @@ function composeFiles(repoPath) {
12080
13037
  ...configuredComposeFiles(repoPath)
12081
13038
  ];
12082
13039
  return Array.from(new Set(candidates)).filter(
12083
- (fileName) => import_node_fs27.default.existsSync(import_node_path28.default.join(repoPath, fileName))
13040
+ (fileName) => import_node_fs28.default.existsSync(import_node_path29.default.join(repoPath, fileName))
12084
13041
  );
12085
13042
  }
12086
13043
  function stringArray2(value) {
@@ -12118,7 +13075,7 @@ function inspectServices(repoPath) {
12118
13075
  const services = [];
12119
13076
  for (const fileName of composeFiles(repoPath)) {
12120
13077
  try {
12121
- const parsed = import_yaml6.default.parse(import_node_fs27.default.readFileSync(import_node_path28.default.join(repoPath, fileName), "utf-8"));
13078
+ const parsed = import_yaml6.default.parse(import_node_fs28.default.readFileSync(import_node_path29.default.join(repoPath, fileName), "utf-8"));
12122
13079
  const serviceMap = asRecord3(asRecord3(parsed)?.services);
12123
13080
  for (const [name, value] of Object.entries(serviceMap ?? {})) {
12124
13081
  const service = asRecord3(value);
@@ -12153,8 +13110,8 @@ function inspectServices(repoPath) {
12153
13110
  return services;
12154
13111
  }
12155
13112
  function inspectEnvFiles(repoPath) {
12156
- const files = import_node_fs27.default.readdirSync(repoPath).filter((fileName) => /^\.env(\.|$)/.test(fileName)).sort().map((fileName) => {
12157
- const content = import_node_fs27.default.readFileSync(import_node_path28.default.join(repoPath, fileName), "utf-8");
13113
+ const files = import_node_fs28.default.readdirSync(repoPath).filter((fileName) => /^\.env(\.|$)/.test(fileName)).sort().map((fileName) => {
13114
+ const content = import_node_fs28.default.readFileSync(import_node_path29.default.join(repoPath, fileName), "utf-8");
12158
13115
  const names = content.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0 && !line.startsWith("#") && line.includes("=")).map((line) => line.split("=")[0]?.trim()).filter((name) => Boolean(name)).sort();
12159
13116
  return { path: fileName, names };
12160
13117
  });
@@ -12168,18 +13125,18 @@ function inspectEnvFiles(repoPath) {
12168
13125
  };
12169
13126
  }
12170
13127
  function inspectDevcontainer(repoPath) {
12171
- const dir = import_node_path28.default.join(repoPath, ".devcontainer");
12172
- if (!import_node_fs27.default.existsSync(dir)) {
13128
+ const dir = import_node_path29.default.join(repoPath, ".devcontainer");
13129
+ if (!import_node_fs28.default.existsSync(dir)) {
12173
13130
  return { exists: false, files: [] };
12174
13131
  }
12175
13132
  return {
12176
13133
  exists: true,
12177
- files: import_node_fs27.default.readdirSync(dir).filter((fileName) => import_node_fs27.default.statSync(import_node_path28.default.join(dir, fileName)).isFile()).sort().map((fileName) => `.devcontainer/${fileName}`)
13134
+ files: import_node_fs28.default.readdirSync(dir).filter((fileName) => import_node_fs28.default.statSync(import_node_path29.default.join(dir, fileName)).isFile()).sort().map((fileName) => `.devcontainer/${fileName}`)
12178
13135
  };
12179
13136
  }
12180
13137
  function inspectDevrouter(repoPath) {
12181
13138
  const configPath = getRepoConfigPath(repoPath);
12182
- if (!import_node_fs27.default.existsSync(configPath)) {
13139
+ if (!import_node_fs28.default.existsSync(configPath)) {
12183
13140
  return {
12184
13141
  exists: false,
12185
13142
  configPath,
@@ -12223,15 +13180,15 @@ function inspectAgentGuidance(repoPath) {
12223
13180
  ["AGENTS.md", "agents"],
12224
13181
  ["CLAUDE.md", "claude"]
12225
13182
  ]) {
12226
- if (import_node_fs27.default.existsSync(import_node_path28.default.join(repoPath, fileName))) {
13183
+ if (import_node_fs28.default.existsSync(import_node_path29.default.join(repoPath, fileName))) {
12227
13184
  results.push({ path: fileName, kind });
12228
13185
  }
12229
13186
  }
12230
- const skillsDir = import_node_path28.default.join(repoPath, ".agents", "skills");
12231
- if (import_node_fs27.default.existsSync(skillsDir)) {
12232
- for (const name of import_node_fs27.default.readdirSync(skillsDir).sort()) {
12233
- const skillPath = import_node_path28.default.join(skillsDir, name, "SKILL.md");
12234
- if (import_node_fs27.default.existsSync(skillPath)) {
13187
+ const skillsDir = import_node_path29.default.join(repoPath, ".agents", "skills");
13188
+ if (import_node_fs28.default.existsSync(skillsDir)) {
13189
+ for (const name of import_node_fs28.default.readdirSync(skillsDir).sort()) {
13190
+ const skillPath = import_node_path29.default.join(skillsDir, name, "SKILL.md");
13191
+ if (import_node_fs28.default.existsSync(skillPath)) {
12235
13192
  results.push({ path: relative(repoPath, skillPath), kind: "skill" });
12236
13193
  }
12237
13194
  }
@@ -12274,7 +13231,7 @@ function buildIssues(report) {
12274
13231
  }
12275
13232
  function inspectRepo(options = {}) {
12276
13233
  const repoPath = resolveRepoPath(options.repo);
12277
- const pkg = readJson(import_node_path28.default.join(repoPath, "package.json"));
13234
+ const pkg = readJson(import_node_path29.default.join(repoPath, "package.json"));
12278
13235
  const scripts = inspectScripts(pkg);
12279
13236
  const reportWithoutIssues = {
12280
13237
  repoPath,
@@ -12293,12 +13250,12 @@ function inspectRepo(options = {}) {
12293
13250
  issues: buildIssues(reportWithoutIssues)
12294
13251
  };
12295
13252
  }
12296
- var import_node_fs27, import_node_path28, import_yaml6;
13253
+ var import_node_fs28, import_node_path29, import_yaml6;
12297
13254
  var init_repo_inspect = __esm({
12298
13255
  "src/core/repo-inspect.ts"() {
12299
13256
  "use strict";
12300
- import_node_fs27 = __toESM(require("fs"));
12301
- import_node_path28 = __toESM(require("path"));
13257
+ import_node_fs28 = __toESM(require("fs"));
13258
+ import_node_path29 = __toESM(require("path"));
12302
13259
  import_yaml6 = __toESM(require("yaml"));
12303
13260
  init_repo_config();
12304
13261
  }
@@ -12403,7 +13360,7 @@ function requiredFileChecks(repoPath) {
12403
13360
  ".devcontainer/docker-compose.yml",
12404
13361
  ".devrouter.yml"
12405
13362
  ];
12406
- const missing = required.filter((fileName) => !import_node_fs28.default.existsSync(import_node_path29.default.join(repoPath, fileName)));
13363
+ const missing = required.filter((fileName) => !import_node_fs29.default.existsSync(import_node_path30.default.join(repoPath, fileName)));
12407
13364
  return {
12408
13365
  id: "repo.devcontainer.verify-files",
12409
13366
  level: missing.length === 0 ? "ok" : "error",
@@ -12622,12 +13579,12 @@ async function verifyDevcontainer(options = {}) {
12622
13579
  nextSteps: collectNextSteps3(checks)
12623
13580
  };
12624
13581
  }
12625
- var import_node_fs28, import_node_path29;
13582
+ var import_node_fs29, import_node_path30;
12626
13583
  var init_devcontainer_verify = __esm({
12627
13584
  "src/core/devcontainer-verify.ts"() {
12628
13585
  "use strict";
12629
- import_node_fs28 = __toESM(require("fs"));
12630
- import_node_path29 = __toESM(require("path"));
13586
+ import_node_fs29 = __toESM(require("fs"));
13587
+ import_node_path30 = __toESM(require("path"));
12631
13588
  init_capabilities();
12632
13589
  init_doctor();
12633
13590
  init_host_routes();
@@ -12924,7 +13881,7 @@ function packageManagerIssues(repo) {
12924
13881
  }
12925
13882
  function plannedFiles(repoPath, version) {
12926
13883
  const repo = inspectRepo({ repo: repoPath });
12927
- const projectName = sanitizeProjectName(import_node_path30.default.basename(repo.repoPath));
13884
+ const projectName = sanitizeProjectName(import_node_path31.default.basename(repo.repoPath));
12928
13885
  const nodeMajor = majorVersion(repo.node?.version, "24");
12929
13886
  const pnpmVersion = repo.packageManager?.name === "pnpm" && repo.packageManager.version ? repo.packageManager.version : DEFAULT_PNPM_VERSION;
12930
13887
  const port = inferPort2(repo);
@@ -12969,8 +13926,8 @@ function plannedFiles(repoPath, version) {
12969
13926
  };
12970
13927
  }
12971
13928
  function classifyFile(repoPath, file) {
12972
- const absolutePath = import_node_path30.default.join(repoPath, file.relativePath);
12973
- if (!import_node_fs29.default.existsSync(absolutePath)) {
13929
+ const absolutePath = import_node_path31.default.join(repoPath, file.relativePath);
13930
+ if (!import_node_fs30.default.existsSync(absolutePath)) {
12974
13931
  return {
12975
13932
  path: file.relativePath,
12976
13933
  action: "create",
@@ -12978,7 +13935,7 @@ function classifyFile(repoPath, file) {
12978
13935
  bytes: Buffer.byteLength(file.content)
12979
13936
  };
12980
13937
  }
12981
- const current = import_node_fs29.default.readFileSync(absolutePath, "utf-8");
13938
+ const current = import_node_fs30.default.readFileSync(absolutePath, "utf-8");
12982
13939
  if (!current.includes(MANAGED_MARKER2)) {
12983
13940
  return {
12984
13941
  path: file.relativePath,
@@ -13035,11 +13992,11 @@ function buildPlan(repoPath, dryRun, version) {
13035
13992
  };
13036
13993
  }
13037
13994
  function writeFile(repoPath, file) {
13038
- const absolutePath = import_node_path30.default.join(repoPath, file.relativePath);
13039
- import_node_fs29.default.mkdirSync(import_node_path30.default.dirname(absolutePath), { recursive: true });
13040
- import_node_fs29.default.writeFileSync(absolutePath, file.content, "utf-8");
13995
+ const absolutePath = import_node_path31.default.join(repoPath, file.relativePath);
13996
+ import_node_fs30.default.mkdirSync(import_node_path31.default.dirname(absolutePath), { recursive: true });
13997
+ import_node_fs30.default.writeFileSync(absolutePath, file.content, "utf-8");
13041
13998
  if (file.executable) {
13042
- import_node_fs29.default.chmodSync(absolutePath, 493);
13999
+ import_node_fs30.default.chmodSync(absolutePath, 493);
13043
14000
  }
13044
14001
  }
13045
14002
  function writeDevcontainer(options = {}) {
@@ -13077,12 +14034,12 @@ function writeDevcontainer(options = {}) {
13077
14034
  nextSteps: postWriteNextSteps(repoPath)
13078
14035
  };
13079
14036
  }
13080
- var import_node_fs29, import_node_path30, MANAGED_MARKER2, DEFAULT_DEVROUTER_VERSION, DEFAULT_PNPM_VERSION, VALID_PACKAGE_VERSION_RE;
14037
+ var import_node_fs30, import_node_path31, MANAGED_MARKER2, DEFAULT_DEVROUTER_VERSION, DEFAULT_PNPM_VERSION, VALID_PACKAGE_VERSION_RE;
13081
14038
  var init_devcontainer_write = __esm({
13082
14039
  "src/core/devcontainer-write.ts"() {
13083
14040
  "use strict";
13084
- import_node_fs29 = __toESM(require("fs"));
13085
- import_node_path30 = __toESM(require("path"));
14041
+ import_node_fs30 = __toESM(require("fs"));
14042
+ import_node_path31 = __toESM(require("path"));
13086
14043
  init_repo_config();
13087
14044
  init_repo_inspect();
13088
14045
  MANAGED_MARKER2 = "devrouter:managed devcontainer";
@@ -13268,11 +14225,11 @@ var init_profile_resolution = __esm({
13268
14225
  function fail(message) {
13269
14226
  throw new Error(message);
13270
14227
  }
13271
- function isRecord2(value) {
14228
+ function isRecord4(value) {
13272
14229
  return typeof value === "object" && value !== null && !Array.isArray(value);
13273
14230
  }
13274
14231
  function requireRecord(value, label) {
13275
- if (!isRecord2(value)) fail(`${label} must be a mapping.`);
14232
+ if (!isRecord4(value)) fail(`${label} must be a mapping.`);
13276
14233
  return value;
13277
14234
  }
13278
14235
  function requireExactKeys(value, allowed, label) {
@@ -13489,13 +14446,13 @@ function buildProfilePlanReport(options) {
13489
14446
  return { ...report, contractPath, bindings };
13490
14447
  }
13491
14448
  function loadContract(repoPath, contractPath) {
13492
- if (import_node_path31.default.isAbsolute(contractPath)) {
14449
+ if (import_node_path32.default.isAbsolute(contractPath)) {
13493
14450
  fail("profile plan contract path must be relative to the repository.");
13494
14451
  }
13495
14452
  const absolutePath = assertPathWithinRepo(contractPath, repoPath, "Profile plan contract");
13496
14453
  let metadata;
13497
14454
  try {
13498
- metadata = import_node_fs30.default.lstatSync(absolutePath);
14455
+ metadata = import_node_fs31.default.lstatSync(absolutePath);
13499
14456
  } catch (error) {
13500
14457
  fail(`could not inspect profile plan contract '${contractPath}': ${error.message}`);
13501
14458
  }
@@ -13505,16 +14462,16 @@ function loadContract(repoPath, contractPath) {
13505
14462
  if (metadata.size > MAX_CONTRACT_BYTES) {
13506
14463
  fail(`profile plan contract exceeds the ${MAX_CONTRACT_BYTES}-byte limit.`);
13507
14464
  }
13508
- const realRepo = import_node_fs30.default.realpathSync(repoPath);
13509
- const realContract = import_node_fs30.default.realpathSync(absolutePath);
14465
+ const realRepo = import_node_fs31.default.realpathSync(repoPath);
14466
+ const realContract = import_node_fs31.default.realpathSync(absolutePath);
13510
14467
  assertPathWithinRepo(realContract, realRepo, "Profile plan contract");
13511
- const source = import_node_fs30.default.readFileSync(absolutePath, "utf-8");
14468
+ const source = import_node_fs31.default.readFileSync(absolutePath, "utf-8");
13512
14469
  if (Buffer.byteLength(source, "utf-8") > MAX_CONTRACT_BYTES) {
13513
14470
  fail(`profile plan contract exceeds the ${MAX_CONTRACT_BYTES}-byte limit.`);
13514
14471
  }
13515
14472
  return {
13516
14473
  contract: parseProfilePlanContract(source),
13517
- contractPath: import_node_path31.default.relative(repoPath, absolutePath).split(import_node_path31.default.sep).join("/")
14474
+ contractPath: import_node_path32.default.relative(repoPath, absolutePath).split(import_node_path32.default.sep).join("/")
13518
14475
  };
13519
14476
  }
13520
14477
  function resolveProfilePlan(options) {
@@ -13528,12 +14485,12 @@ function resolveProfilePlan(options) {
13528
14485
  contractPath: loaded.contractPath
13529
14486
  });
13530
14487
  }
13531
- var import_node_fs30, import_node_path31, import_yaml7, CONTRACT_VERSION, MAX_CONTRACT_BYTES, MAX_APP_MAPPINGS, MAX_BINDING_KEYS, MAX_BINDING_VALUES, MAX_LITERAL_LENGTH, BINDING_KEY;
14488
+ var import_node_fs31, import_node_path32, import_yaml7, CONTRACT_VERSION, MAX_CONTRACT_BYTES, MAX_APP_MAPPINGS, MAX_BINDING_KEYS, MAX_BINDING_VALUES, MAX_LITERAL_LENGTH, BINDING_KEY;
13532
14489
  var init_profile_plan = __esm({
13533
14490
  "src/core/profile-plan.ts"() {
13534
14491
  "use strict";
13535
- import_node_fs30 = __toESM(require("fs"));
13536
- import_node_path31 = __toESM(require("path"));
14492
+ import_node_fs31 = __toESM(require("fs"));
14493
+ import_node_path32 = __toESM(require("path"));
13537
14494
  import_yaml7 = require("yaml");
13538
14495
  init_paths();
13539
14496
  init_profile_resolution();
@@ -13565,7 +14522,7 @@ async function runProfileResolveCommand(options) {
13565
14522
  async function runProfilePlanCommand(options) {
13566
14523
  const report = resolveProfilePlan(options);
13567
14524
  if (options.output) {
13568
- writeFileAtomically(import_node_path32.default.resolve(options.output), `${JSON.stringify(report, null, 2)}
14525
+ writeFileAtomically(import_node_path33.default.resolve(options.output), `${JSON.stringify(report, null, 2)}
13569
14526
  `);
13570
14527
  }
13571
14528
  if (options.json) {
@@ -13601,14 +14558,14 @@ function printProfilePlanSummary(report, output2) {
13601
14558
  process.stdout.write(`Binding ${key}: ${renderValues(report.bindings[key] ?? [])}
13602
14559
  `);
13603
14560
  }
13604
- if (output2) process.stdout.write(`Output: ${import_node_path32.default.resolve(output2)}
14561
+ if (output2) process.stdout.write(`Output: ${import_node_path33.default.resolve(output2)}
13605
14562
  `);
13606
14563
  }
13607
- var import_node_path32;
14564
+ var import_node_path33;
13608
14565
  var init_profile = __esm({
13609
14566
  "src/commands/profile.ts"() {
13610
14567
  "use strict";
13611
- import_node_path32 = __toESM(require("path"));
14568
+ import_node_path33 = __toESM(require("path"));
13612
14569
  init_atomic_file();
13613
14570
  init_output();
13614
14571
  init_profile_plan();
@@ -13898,7 +14855,7 @@ function sanitizeRouterId(value) {
13898
14855
  return value.replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
13899
14856
  }
13900
14857
  function repoHash(repoPath) {
13901
- return (0, import_node_crypto10.createHash)("sha1").update(import_node_path33.default.resolve(repoPath)).digest("hex").slice(0, 12);
14858
+ return (0, import_node_crypto10.createHash)("sha1").update(import_node_path34.default.resolve(repoPath)).digest("hex").slice(0, 12);
13902
14859
  }
13903
14860
  function asDockerApp(app) {
13904
14861
  return app.runtime === "docker";
@@ -13977,11 +14934,11 @@ function prepareDockerOverlay(repoPath, appName, apps, publishTcpPorts = false)
13977
14934
  if (dockerApps.length === 0) {
13978
14935
  throw new Error("No docker apps selected to prepare compose overlay.");
13979
14936
  }
13980
- const cachePath = import_node_path33.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
13981
- import_node_fs31.default.mkdirSync(cachePath, { recursive: true });
13982
- const overlayPath = import_node_path33.default.join(cachePath, "compose.devrouter.yml");
14937
+ const cachePath = import_node_path34.default.join(CACHE_DIR, repoHash(repoPath), sanitizeRouterId(appName));
14938
+ import_node_fs32.default.mkdirSync(cachePath, { recursive: true });
14939
+ const overlayPath = import_node_path34.default.join(cachePath, "compose.devrouter.yml");
13983
14940
  const overlayDocument = buildOverlayDocument(dockerApps, publishTcpPorts);
13984
- import_node_fs31.default.writeFileSync(overlayPath, import_yaml8.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
14941
+ import_node_fs32.default.writeFileSync(overlayPath, import_yaml8.default.stringify(overlayDocument, { lineWidth: 0 }), "utf-8");
13985
14942
  return {
13986
14943
  overlayPath,
13987
14944
  composeFiles: ensureComposeFiles(dockerApps),
@@ -14101,14 +15058,14 @@ function queryMappedPort(repoPath, composeFiles2, overlayPath, service, internal
14101
15058
  const port = Number(match[1]);
14102
15059
  return Number.isInteger(port) && port > 0 ? port : void 0;
14103
15060
  }
14104
- var import_node_child_process27, import_node_crypto10, import_node_fs31, import_node_path33, import_yaml8;
15061
+ var import_node_child_process27, import_node_crypto10, import_node_fs32, import_node_path34, import_yaml8;
14105
15062
  var init_docker_run = __esm({
14106
15063
  "src/core/docker-run.ts"() {
14107
15064
  "use strict";
14108
15065
  import_node_child_process27 = require("child_process");
14109
15066
  import_node_crypto10 = require("crypto");
14110
- import_node_fs31 = __toESM(require("fs"));
14111
- import_node_path33 = __toESM(require("path"));
15067
+ import_node_fs32 = __toESM(require("fs"));
15068
+ import_node_path34 = __toESM(require("path"));
14112
15069
  import_yaml8 = __toESM(require("yaml"));
14113
15070
  init_docker_error_guidance();
14114
15071
  init_paths();
@@ -14930,13 +15887,13 @@ function measureWorktreeConsumption(worktreePath, options) {
14930
15887
  const startedAt = Date.now();
14931
15888
  let rootStat;
14932
15889
  try {
14933
- rootStat = import_node_fs32.default.lstatSync(worktreePath);
15890
+ rootStat = import_node_fs33.default.lstatSync(worktreePath);
14934
15891
  } catch (error) {
14935
15892
  return { status: "unknown", reason: describeError(error, worktreePath) };
14936
15893
  }
14937
15894
  let rootEntries;
14938
15895
  try {
14939
- rootEntries = import_node_fs32.default.readdirSync(worktreePath, { withFileTypes: true });
15896
+ rootEntries = import_node_fs33.default.readdirSync(worktreePath, { withFileTypes: true });
14940
15897
  } catch (error) {
14941
15898
  return { status: "unknown", reason: describeError(error, worktreePath) };
14942
15899
  }
@@ -14962,10 +15919,10 @@ function measureWorktreeConsumption(worktreePath, options) {
14962
15919
  timedOut = true;
14963
15920
  break;
14964
15921
  }
14965
- const entryPath = import_node_path34.default.join(dirPath, entry.name);
15922
+ const entryPath = import_node_path35.default.join(dirPath, entry.name);
14966
15923
  let entryStat;
14967
15924
  try {
14968
- entryStat = import_node_fs32.default.lstatSync(entryPath);
15925
+ entryStat = import_node_fs33.default.lstatSync(entryPath);
14969
15926
  } catch (error) {
14970
15927
  if (error?.code === "ENOENT") continue;
14971
15928
  unreadableReason = describeIncompleteWalk(error);
@@ -14975,7 +15932,7 @@ function measureWorktreeConsumption(worktreePath, options) {
14975
15932
  if (!entryStat.isDirectory()) continue;
14976
15933
  let childEntries;
14977
15934
  try {
14978
- childEntries = import_node_fs32.default.readdirSync(entryPath, { withFileTypes: true });
15935
+ childEntries = import_node_fs33.default.readdirSync(entryPath, { withFileTypes: true });
14979
15936
  } catch (error) {
14980
15937
  unreadableReason = describeIncompleteWalk(error);
14981
15938
  break;
@@ -15039,12 +15996,12 @@ function describeError(error, worktreePath) {
15039
15996
  }
15040
15997
  return `could not stat worktree path '${worktreePath}': ${error?.message ?? String(error)}`;
15041
15998
  }
15042
- var import_node_fs32, import_node_path34, DEFAULT_DEADLINE_MS, BLOCK_SIZE_BYTES;
15999
+ var import_node_fs33, import_node_path35, DEFAULT_DEADLINE_MS, BLOCK_SIZE_BYTES;
15043
16000
  var init_workspace_consumption = __esm({
15044
16001
  "src/core/workspace-consumption.ts"() {
15045
16002
  "use strict";
15046
- import_node_fs32 = __toESM(require("fs"));
15047
- import_node_path34 = __toESM(require("path"));
16003
+ import_node_fs33 = __toESM(require("fs"));
16004
+ import_node_path35 = __toESM(require("path"));
15048
16005
  init_devpod_environment();
15049
16006
  DEFAULT_DEADLINE_MS = 1e4;
15050
16007
  BLOCK_SIZE_BYTES = 512;
@@ -15052,7 +16009,7 @@ var init_workspace_consumption = __esm({
15052
16009
  });
15053
16010
 
15054
16011
  // src/core/workspace-cleanup.ts
15055
- function isRecord3(value) {
16012
+ function isRecord5(value) {
15056
16013
  return value !== null && typeof value === "object" && !Array.isArray(value);
15057
16014
  }
15058
16015
  function isSha(value) {
@@ -15145,7 +16102,7 @@ function evaluateWorkspaceActivity(evidence, cutoff) {
15145
16102
  }
15146
16103
  function readGitSnapshot(worktree, commandRunner) {
15147
16104
  const comparablePath = comparableWorkspacePath(worktree.path);
15148
- if (worktree.prunable || !import_node_fs33.default.existsSync(comparablePath)) {
16105
+ if (worktree.prunable || !import_node_fs34.default.existsSync(comparablePath)) {
15149
16106
  return { worktree, checkout: "missing", head: null, committerDate: null };
15150
16107
  }
15151
16108
  const head = gitOutput(comparablePath, ["rev-parse", "--verify", "HEAD"], commandRunner);
@@ -15196,14 +16153,14 @@ function parseGitHubChanges(value, project, branch) {
15196
16153
  if (!Array.isArray(value)) return void 0;
15197
16154
  const changes = [];
15198
16155
  for (const item of value) {
15199
- if (!isRecord3(item)) return void 0;
15200
- const repository = isRecord3(item.repository) ? item.repository.nameWithOwner : void 0;
15201
- const headRepository = isRecord3(item.headRepository) ? item.headRepository.nameWithOwner : void 0;
16156
+ if (!isRecord5(item)) return void 0;
16157
+ const repository = isRecord5(item.repository) ? item.repository.nameWithOwner : void 0;
16158
+ const headRepository = isRecord5(item.headRepository) ? item.headRepository.nameWithOwner : void 0;
15202
16159
  if (repository !== project || headRepository !== project || item.headRefName !== branch || !isSha(item.headRefOid) || typeof item.baseRefName !== "string") {
15203
16160
  continue;
15204
16161
  }
15205
16162
  const merged = item.state === "MERGED" && validTimestamp(item.mergedAt);
15206
- const mergeCommit = isRecord3(item.mergeCommit) && isSha(item.mergeCommit.oid) ? item.mergeCommit.oid : void 0;
16163
+ const mergeCommit = isRecord5(item.mergeCommit) && isSha(item.mergeCommit.oid) ? item.mergeCommit.oid : void 0;
15207
16164
  const baseSha = isSha(item.baseRefOid) ? item.baseRefOid : void 0;
15208
16165
  changes.push({
15209
16166
  sourceBranch: branch,
@@ -15220,14 +16177,14 @@ function parseGitLabChanges(value, _project, branch) {
15220
16177
  if (!Array.isArray(value)) return void 0;
15221
16178
  const changes = [];
15222
16179
  for (const item of value) {
15223
- if (!isRecord3(item)) return void 0;
16180
+ if (!isRecord5(item)) return void 0;
15224
16181
  const sourceProjectId = item.source_project_id;
15225
16182
  const targetProjectId = item.target_project_id;
15226
16183
  const sameProject = typeof sourceProjectId === "number" && typeof targetProjectId === "number" && sourceProjectId === targetProjectId;
15227
16184
  if (!sameProject || item.source_branch !== branch || !isSha(item.sha) || typeof item.target_branch !== "string") {
15228
16185
  continue;
15229
16186
  }
15230
- const diffRefs = isRecord3(item.diff_refs) ? item.diff_refs : void 0;
16187
+ const diffRefs = isRecord5(item.diff_refs) ? item.diff_refs : void 0;
15231
16188
  const mergeCommitSha = isSha(item.merge_commit_sha) ? item.merge_commit_sha : void 0;
15232
16189
  const baseSha = diffRefs && isSha(diffRefs.base_sha) ? diffRefs.base_sha : void 0;
15233
16190
  changes.push({
@@ -15670,7 +16627,7 @@ function buildWorkspaceCleanupReport(options = {}, dependencies = {}) {
15670
16627
  containers = measureContainersFn(worktreePaths);
15671
16628
  } catch (error) {
15672
16629
  const reason = `container measurement failed: ${describeCause(error)}`;
15673
- containers = new Map(worktreePaths.map((path34) => [path34, unknownContainers(reason)]));
16630
+ containers = new Map(worktreePaths.map((path35) => [path35, unknownContainers(reason)]));
15674
16631
  }
15675
16632
  }
15676
16633
  const rows = records.map((record) => {
@@ -15719,12 +16676,12 @@ function buildWorkspaceCleanupReport(options = {}, dependencies = {}) {
15719
16676
  workspaces: rows
15720
16677
  };
15721
16678
  }
15722
- var import_node_child_process29, import_node_fs33, DEFAULT_INACTIVE_FOR, READ_ONLY_GIT_ENV2, ACTIVITY_SOURCES, defaultCommandRunner;
16679
+ var import_node_child_process29, import_node_fs34, DEFAULT_INACTIVE_FOR, READ_ONLY_GIT_ENV2, ACTIVITY_SOURCES, defaultCommandRunner;
15723
16680
  var init_workspace_cleanup = __esm({
15724
16681
  "src/core/workspace-cleanup.ts"() {
15725
16682
  "use strict";
15726
16683
  import_node_child_process29 = require("child_process");
15727
- import_node_fs33 = __toESM(require("fs"));
16684
+ import_node_fs34 = __toESM(require("fs"));
15728
16685
  init_devpod_workspaces();
15729
16686
  init_host_routes();
15730
16687
  init_repo_config();
@@ -15901,7 +16858,7 @@ var init_version = __esm({
15901
16858
 
15902
16859
  // src/cli.ts
15903
16860
  var import_commander = require("commander");
15904
- var CLI_VERSION = true ? "0.0.51" : "0.0.0-dev";
16861
+ var CLI_VERSION = true ? "0.0.53" : "0.0.0-dev";
15905
16862
  var VERSION_FLAGS = /* @__PURE__ */ new Set(["-V", "--version"]);
15906
16863
  function withErrorHandling(action2) {
15907
16864
  return async (...args) => {
@@ -15956,13 +16913,14 @@ program.command("up").description("Ensure devnet and start shared Traefik (reser
15956
16913
  await runUpCommand2();
15957
16914
  })
15958
16915
  );
15959
- program.command("ensure").description("Start and prove a primary or linked checkout's environment and routes").argument("[path]", "Git checkout path (defaults to current directory)").option("--profile <name>", "Start only this profile's apps/dependencies from .devrouter.yml").option("--open", "Open HTTP routes after readiness succeeds").option("--json", "Output JSON").action(
16916
+ program.command("ensure").description("Start and prove a primary or linked checkout's environment and routes").argument("[path]", "Git checkout path (defaults to current directory)").option("--profile <name>", "Start only this profile's apps/dependencies from .devrouter.yml").option("--repair", "Repair a degraded managed runtime from its recorded profile").option("--open", "Open HTTP routes after readiness succeeds").option("--json", "Output JSON").action(
15960
16917
  withErrorHandling(async (repoPath, _options, command) => {
15961
16918
  const options = command.opts();
15962
16919
  const { runEnsureCommand: runEnsureCommand2 } = await Promise.resolve().then(() => (init_ensure(), ensure_exports));
15963
16920
  await runEnsureCommand2({
15964
16921
  path: repoPath,
15965
16922
  profile: options.profile,
16923
+ repair: options.repair,
15966
16924
  open: Boolean(options.open),
15967
16925
  json: Boolean(options.json)
15968
16926
  });
@@ -16184,7 +17142,7 @@ workspaceCommand2.command("up").description(
16184
17142
  );
16185
17143
  workspaceCommand2.command("ensure").description(
16186
17144
  "Start and prove a primary or linked checkout's workspace runtime, upstreams, and routes"
16187
- ).argument("[path]", "Git checkout path (defaults to current directory)").option("--profile <name>", "Start only this profile's apps/dependencies from .devrouter.yml").option("--open", "Open HTTP routes after readiness succeeds").option("--json", "Output JSON").action(
17145
+ ).argument("[path]", "Git checkout path (defaults to current directory)").option("--profile <name>", "Start only this profile's apps/dependencies from .devrouter.yml").option("--repair", "Repair a degraded managed runtime from its recorded profile").option("--open", "Open HTTP routes after readiness succeeds").option("--json", "Output JSON").action(
16188
17146
  withErrorHandling(
16189
17147
  async (worktreePath, _options, command) => {
16190
17148
  const options = command.opts();
@@ -16192,6 +17150,7 @@ workspaceCommand2.command("ensure").description(
16192
17150
  await runEnsureCommand2({
16193
17151
  path: worktreePath,
16194
17152
  profile: options.profile,
17153
+ repair: options.repair,
16195
17154
  open: Boolean(options.open),
16196
17155
  json: Boolean(options.json)
16197
17156
  });