@smithers-orchestrator/vcs 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@smithers-orchestrator/vcs",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "VCS discovery and jj workspace operations for Smithers",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -22,13 +22,20 @@
22
22
  "dependencies": {
23
23
  "@effect/platform": "^0.96.0",
24
24
  "effect": "^3.21.1",
25
- "@smithers-orchestrator/observability": "0.21.0"
25
+ "@smithers-orchestrator/observability": "0.23.0"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@effect/platform-bun": "^0.89.0",
29
29
  "@types/bun": "latest",
30
30
  "typescript": "~5.9.3"
31
31
  },
32
+ "optionalDependencies": {
33
+ "@smithers-orchestrator/jj-darwin-arm64": "0.23.0",
34
+ "@smithers-orchestrator/jj-darwin-x64": "0.23.0",
35
+ "@smithers-orchestrator/jj-linux-x64": "0.23.0",
36
+ "@smithers-orchestrator/jj-win32-x64": "0.23.0",
37
+ "@smithers-orchestrator/jj-linux-arm64": "0.23.0"
38
+ },
32
39
  "scripts": {
33
40
  "build": "tsup --dts-only",
34
41
  "test": "bun test tests",
@@ -0,0 +1,11 @@
1
+ /**
2
+ * A resolved VCS executable plus where Smithers found it.
3
+ *
4
+ * - `env`: an explicit override (e.g. `SMITHERS_JJ_PATH`)
5
+ * - `bundled`: a binary shipped inside a `@smithers-orchestrator/jj-<platform>` package
6
+ * - `path`: the bare command name, left for the OS to resolve against `PATH`
7
+ */
8
+ export type ResolvedBinary = {
9
+ path: string;
10
+ source: "env" | "bundled" | "path";
11
+ };
@@ -0,0 +1,16 @@
1
+ export type WorkspaceSnapshot = {
2
+ /**
3
+ * Working-copy commit id at this snapshot. Advances on every snapshot, so it
4
+ * addresses an individual working-copy state (unlike `changeId`, which is
5
+ * stable across edits to the same working copy).
6
+ */
7
+ commitId: string;
8
+ /** Change id of `@`. Stable across edits to one working copy; grouping metadata only. */
9
+ changeId: string;
10
+ /**
11
+ * jj operation id for this snapshot. The durable restore handle: the commit id
12
+ * of an abandoned working-copy commit can be garbage-collected, while the
13
+ * operation log is retained under the configured gc policy.
14
+ */
15
+ operationId: string;
16
+ };
package/src/index.d.ts CHANGED
@@ -116,4 +116,81 @@ type WorkspaceAddOptions = WorkspaceAddOptions$1;
116
116
  type WorkspaceInfo = WorkspaceInfo$1;
117
117
  type WorkspaceResult = WorkspaceResult$1;
118
118
 
119
- export { findVcsRoot, getJjPointer, isJjRepo, revertToJjPointer, runJj, workspaceAdd, workspaceClose, workspaceList };
119
+ /**
120
+ * A resolved VCS executable plus where Smithers found it.
121
+ *
122
+ * - `env`: an explicit override (e.g. `SMITHERS_JJ_PATH`)
123
+ * - `bundled`: a binary shipped inside a `@smithers-orchestrator/jj-<platform>` package
124
+ * - `path`: the bare command name, left for the OS to resolve against `PATH`
125
+ */
126
+ type ResolvedBinary$3 = {
127
+ path: string;
128
+ source: "env" | "bundled" | "path";
129
+ };
130
+
131
+ /** @typedef {import("./ResolvedBinary.js").ResolvedBinary} ResolvedBinary */
132
+ /**
133
+ * Resolve the `git` executable Smithers should spawn.
134
+ *
135
+ * Order of preference:
136
+ * 1. `SMITHERS_GIT_PATH` — an explicit override pointing at a real file.
137
+ * 2. The bare `"git"`, left for the OS to resolve against `PATH`.
138
+ *
139
+ * Git is never bundled (only jj is); this mirrors {@link resolveJjBinary} so the
140
+ * override and the tooling preflight share one source of truth for where git is.
141
+ *
142
+ * @returns {ResolvedBinary}
143
+ */
144
+ declare function resolveGitBinary(): ResolvedBinary$2;
145
+ type ResolvedBinary$2 = ResolvedBinary$3;
146
+
147
+ /**
148
+ * Resolve the `jj` executable Smithers should spawn.
149
+ *
150
+ * Order of preference:
151
+ * 1. `SMITHERS_JJ_PATH` — an explicit override pointing at a real file.
152
+ * 2. A binary bundled via `@smithers-orchestrator/jj-<platform>`.
153
+ * 3. The bare `"jj"`, left for the OS to resolve against `PATH`.
154
+ *
155
+ * Always returns a spawnable command. When jj is genuinely absent the bare
156
+ * `"jj"` simply fails to spawn, which `runJj` already normalizes to exit code
157
+ * 127, so callers keep their soft-failure behavior.
158
+ *
159
+ * @returns {ResolvedBinary}
160
+ */
161
+ declare function resolveJjBinary(): ResolvedBinary$1;
162
+ type ResolvedBinary$1 = ResolvedBinary$3;
163
+
164
+ /**
165
+ * Probe whether a usable `jj` and/or `git` exists for the current host, using
166
+ * the override → bundled → PATH resolution for jj and override → PATH for git.
167
+ *
168
+ * Synchronous and best-effort: used by `smithers doctor` and run preflights to
169
+ * tell the user — before a run fails deep in worktree creation — that no VCS
170
+ * tooling is installed, and which knob (bundled package, PATH install, or
171
+ * `SMITHERS_JJ_PATH`) would fix it.
172
+ *
173
+ * @returns {VcsToolingStatus}
174
+ */
175
+ declare function vcsToolingStatus(): VcsToolingStatus;
176
+ type ResolvedBinary = ResolvedBinary$3;
177
+ /**
178
+ * Whether a usable `jj` and/or `git` exists for the current host. Each field is
179
+ * the resolved binary when `<bin> --version` runs, or null when it does not.
180
+ */
181
+ type VcsToolingStatus = {
182
+ /**
183
+ * a usable jj (override, bundled, or PATH), else null
184
+ */
185
+ jj: ResolvedBinary | null;
186
+ /**
187
+ * a usable git (override or PATH), else null
188
+ */
189
+ git: ResolvedBinary | null;
190
+ /**
191
+ * true when at least one of jj or git is usable
192
+ */
193
+ ok: boolean;
194
+ };
195
+
196
+ export { type VcsToolingStatus, findVcsRoot, getJjPointer, isJjRepo, resolveGitBinary, resolveJjBinary, revertToJjPointer, runJj, vcsToolingStatus, workspaceAdd, workspaceClose, workspaceList };
package/src/index.js CHANGED
@@ -1,8 +1,13 @@
1
1
  export * from "./find-root.js";
2
2
  export * from "./jj.js";
3
+ export * from "./resolveGitBinary.js";
4
+ export * from "./resolveJjBinary.js";
5
+ export * from "./ResolvedBinary.js";
6
+ export * from "./vcsToolingStatus.js";
3
7
  export * from "./JjRevertResult.js";
4
8
  export * from "./RunJjOptions.js";
5
9
  export * from "./RunJjResult.js";
6
10
  export * from "./WorkspaceAddOptions.js";
7
11
  export * from "./WorkspaceInfo.js";
8
12
  export * from "./WorkspaceResult.js";
13
+ export * from "./WorkspaceSnapshot.js";
package/src/jj.js CHANGED
@@ -5,13 +5,16 @@
5
5
  /** @typedef {import("./WorkspaceAddOptions.js").WorkspaceAddOptions} WorkspaceAddOptions */
6
6
  /** @typedef {import("./WorkspaceInfo.js").WorkspaceInfo} WorkspaceInfo */
7
7
  /** @typedef {import("./WorkspaceResult.js").WorkspaceResult} WorkspaceResult */
8
+ /** @typedef {import("./WorkspaceSnapshot.js").WorkspaceSnapshot} WorkspaceSnapshot */
8
9
  // @smithers-type-exports-end
9
10
 
10
11
  import * as Command from "@effect/platform/Command";
11
12
  import { Duration, Effect, Fiber, Metric, Stream } from "effect";
12
13
  import { vcsDuration } from "@smithers-orchestrator/observability/metrics";
14
+ import { resolveJjBinary } from "./resolveJjBinary.js";
13
15
 
14
16
  const JJ_POINTER_TIMEOUT_MS = 1_500;
17
+ const WORKSPACE_SNAPSHOT_TIMEOUT_MS = 1_500;
15
18
  /**
16
19
  * @param {Stream.Stream<Uint8Array, unknown, never>} stream
17
20
  * @returns {Effect.Effect<string, unknown, never>}
@@ -29,7 +32,7 @@ function collectUtf8(stream) {
29
32
  * @returns {Effect.Effect<RunJjResult, never, import("@effect/platform/CommandExecutor").CommandExecutor>}
30
33
  */
31
34
  export function runJj(args, opts = {}) {
32
- let command = Command.make("jj", ...args);
35
+ let command = Command.make(resolveJjBinary().path, ...args);
33
36
  if (opts.cwd) {
34
37
  command = Command.workingDirectory(command, opts.cwd);
35
38
  }
@@ -88,6 +91,55 @@ export function getJjPointer(cwd) {
88
91
  return out ? out : null;
89
92
  }), Effect.annotateLogs({ cwd: cwd ?? "" }), Effect.withLogSpan("vcs:jj-pointer"));
90
93
  }
94
+ /**
95
+ * Wrap a snapshot jj call with a bounded timeout so a slow or hung jj cannot
96
+ * block the agent. On timeout it returns a sentinel non-zero result, which the
97
+ * caller treats as a durability gap rather than a value.
98
+ *
99
+ * @param {Effect.Effect<RunJjResult, never, import("@effect/platform/CommandExecutor").CommandExecutor>} effect
100
+ * @param {string} label
101
+ * @returns {Effect.Effect<RunJjResult, never, import("@effect/platform/CommandExecutor").CommandExecutor>}
102
+ */
103
+ function withSnapshotTimeout(effect, label) {
104
+ return effect.pipe(Effect.timeoutTo({
105
+ duration: Duration.millis(WORKSPACE_SNAPSHOT_TIMEOUT_MS),
106
+ onSuccess: (res) => res,
107
+ onTimeout: () => ({
108
+ code: 124,
109
+ stdout: "",
110
+ stderr: `${label} timed out after ${WORKSPACE_SNAPSHOT_TIMEOUT_MS}ms`,
111
+ }),
112
+ }));
113
+ }
114
+ /**
115
+ * Capture the current working-copy state as a restorable handle.
116
+ *
117
+ * Step 1 (`jj log -r @`) forces exactly one working-copy snapshot and returns the
118
+ * resulting `commit_id` and `change_id`. Step 2 reads the latest operation id
119
+ * WITHOUT taking a second snapshot (`--ignore-working-copy`), so both ids describe
120
+ * the same snapshot from step 1. Returns null on any failure or timeout (a
121
+ * durability gap the caller records); it never throws into the agent path.
122
+ *
123
+ * @param {string} [cwd]
124
+ * @returns {Effect.Effect<WorkspaceSnapshot | null, never, import("@effect/platform/CommandExecutor").CommandExecutor>}
125
+ */
126
+ export function captureWorkspaceSnapshot(cwd) {
127
+ return Effect.gen(function* () {
128
+ const logRes = yield* withSnapshotTimeout(runJj(["log", "-r", "@", "--no-graph", "-T", 'commit_id ++ "\\n" ++ change_id'], { cwd }), "jj snapshot log");
129
+ if (logRes.code !== 0)
130
+ return null;
131
+ const [commitId, changeId] = logRes.stdout.split("\n").map((part) => part.trim());
132
+ if (!commitId)
133
+ return null;
134
+ const opRes = yield* withSnapshotTimeout(runJj(["--ignore-working-copy", "operation", "log", "--no-graph", "--limit", "1", "-T", "self.id()"], { cwd }), "jj snapshot op");
135
+ if (opRes.code !== 0)
136
+ return null;
137
+ const operationId = opRes.stdout.trim();
138
+ if (!operationId)
139
+ return null;
140
+ return { commitId, changeId: changeId ?? "", operationId };
141
+ }).pipe(Effect.annotateLogs({ cwd: cwd ?? "" }), Effect.withLogSpan("vcs:jj-snapshot"));
142
+ }
91
143
  /**
92
144
  * Restore the working copy to a previously recorded jujutsu `change_id`.
93
145
  * Used by the engine to revert attempts within the correct repo/worktree (via `cwd`).
@@ -147,7 +199,11 @@ export function workspaceAdd(name, path, opts = {}) {
147
199
  fs.mkdirSync(parentDir, { recursive: true });
148
200
  }
149
201
  }
150
- catch { }
202
+ catch (error) {
203
+ // Best-effort cleanup: do not abort workspace creation if it fails,
204
+ // but log a warning so cleanup failures are diagnosable rather than silent.
205
+ yield* Effect.logWarning(`jj workspace pre-create cleanup failed at ${path}: ${error instanceof Error ? error.message : String(error)}`);
206
+ }
151
207
  let lastErr = "";
152
208
  for (const args of attempts) {
153
209
  const res = yield* runJj(args, { cwd: opts.cwd });
@@ -0,0 +1,21 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ /** @typedef {import("./ResolvedBinary.js").ResolvedBinary} ResolvedBinary */
4
+
5
+ /**
6
+ * Resolve the `git` executable Smithers should spawn.
7
+ *
8
+ * Order of preference:
9
+ * 1. `SMITHERS_GIT_PATH` — an explicit override pointing at a real file.
10
+ * 2. The bare `"git"`, left for the OS to resolve against `PATH`.
11
+ *
12
+ * Git is never bundled (only jj is); this mirrors {@link resolveJjBinary} so the
13
+ * override and the tooling preflight share one source of truth for where git is.
14
+ *
15
+ * @returns {ResolvedBinary}
16
+ */
17
+ export function resolveGitBinary() {
18
+ const override = process.env.SMITHERS_GIT_PATH;
19
+ if (override && existsSync(override)) return { path: override, source: "env" };
20
+ return { path: "git", source: "path" };
21
+ }
@@ -0,0 +1,69 @@
1
+ import { existsSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { join } from "node:path";
4
+
5
+ /** @typedef {import("./ResolvedBinary.js").ResolvedBinary} ResolvedBinary */
6
+
7
+ const require = createRequire(import.meta.url);
8
+
9
+ /**
10
+ * `${process.platform}-${process.arch}` → the npm package that vendors a `jj`
11
+ * binary for that target. Each package carries `os`/`cpu` fields so a package
12
+ * manager only installs the one matching the host, and exposes the binary at
13
+ * `bin/jj` (`bin/jj.exe` on Windows).
14
+ *
15
+ * Kept in sync with the platform packages under `packages/jj-binaries/` and the
16
+ * `optionalDependencies` of `@smithers-orchestrator/vcs`.
17
+ *
18
+ * @type {Record<string, string>}
19
+ */
20
+ const BUNDLED_PACKAGES = {
21
+ "darwin-arm64": "@smithers-orchestrator/jj-darwin-arm64",
22
+ "darwin-x64": "@smithers-orchestrator/jj-darwin-x64",
23
+ "linux-arm64": "@smithers-orchestrator/jj-linux-arm64",
24
+ "linux-x64": "@smithers-orchestrator/jj-linux-x64",
25
+ "win32-x64": "@smithers-orchestrator/jj-win32-x64",
26
+ };
27
+
28
+ /**
29
+ * Locate the bundled `jj` binary for the current host, or null when no platform
30
+ * package is installed (unsupported target, `--no-optional` install, or not yet
31
+ * published). Resolution goes through the package's `package.json` so it works
32
+ * regardless of hoisting layout.
33
+ *
34
+ * @returns {string | null}
35
+ */
36
+ function bundledJjPath() {
37
+ const pkg = BUNDLED_PACKAGES[`${process.platform}-${process.arch}`];
38
+ if (!pkg) return null;
39
+ const binary = process.platform === "win32" ? "jj.exe" : "jj";
40
+ try {
41
+ const manifest = require.resolve(`${pkg}/package.json`);
42
+ const candidate = join(manifest, "..", "bin", binary);
43
+ return existsSync(candidate) ? candidate : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Resolve the `jj` executable Smithers should spawn.
51
+ *
52
+ * Order of preference:
53
+ * 1. `SMITHERS_JJ_PATH` — an explicit override pointing at a real file.
54
+ * 2. A binary bundled via `@smithers-orchestrator/jj-<platform>`.
55
+ * 3. The bare `"jj"`, left for the OS to resolve against `PATH`.
56
+ *
57
+ * Always returns a spawnable command. When jj is genuinely absent the bare
58
+ * `"jj"` simply fails to spawn, which `runJj` already normalizes to exit code
59
+ * 127, so callers keep their soft-failure behavior.
60
+ *
61
+ * @returns {ResolvedBinary}
62
+ */
63
+ export function resolveJjBinary() {
64
+ const override = process.env.SMITHERS_JJ_PATH;
65
+ if (override && existsSync(override)) return { path: override, source: "env" };
66
+ const bundled = bundledJjPath();
67
+ if (bundled) return { path: bundled, source: "bundled" };
68
+ return { path: "jj", source: "path" };
69
+ }
@@ -0,0 +1,55 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { resolveJjBinary } from "./resolveJjBinary.js";
3
+ import { resolveGitBinary } from "./resolveGitBinary.js";
4
+
5
+ /** @typedef {import("./ResolvedBinary.js").ResolvedBinary} ResolvedBinary */
6
+
7
+ /**
8
+ * Whether a usable `jj` and/or `git` exists for the current host. Each field is
9
+ * the resolved binary when `<bin> --version` runs, or null when it does not.
10
+ *
11
+ * @typedef {object} VcsToolingStatus
12
+ * @property {ResolvedBinary | null} jj a usable jj (override, bundled, or PATH), else null
13
+ * @property {ResolvedBinary | null} git a usable git (override or PATH), else null
14
+ * @property {boolean} ok true when at least one of jj or git is usable
15
+ */
16
+
17
+ const VERSION_PROBE_TIMEOUT_MS = 2_000;
18
+
19
+ /**
20
+ * Whether `<bin> --version` exits 0. Best-effort: a missing binary, a non-zero
21
+ * exit, or a spawn error all read as "not usable".
22
+ *
23
+ * @param {ResolvedBinary} bin
24
+ * @returns {boolean}
25
+ */
26
+ function runsVersion(bin) {
27
+ try {
28
+ const res = spawnSync(bin.path, ["--version"], {
29
+ stdio: "ignore",
30
+ timeout: VERSION_PROBE_TIMEOUT_MS,
31
+ });
32
+ return res.status === 0;
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Probe whether a usable `jj` and/or `git` exists for the current host, using
40
+ * the override → bundled → PATH resolution for jj and override → PATH for git.
41
+ *
42
+ * Synchronous and best-effort: used by `smithers doctor` and run preflights to
43
+ * tell the user — before a run fails deep in worktree creation — that no VCS
44
+ * tooling is installed, and which knob (bundled package, PATH install, or
45
+ * `SMITHERS_JJ_PATH`) would fix it.
46
+ *
47
+ * @returns {VcsToolingStatus}
48
+ */
49
+ export function vcsToolingStatus() {
50
+ const jjBin = resolveJjBinary();
51
+ const gitBin = resolveGitBinary();
52
+ const jj = runsVersion(jjBin) ? jjBin : null;
53
+ const git = runsVersion(gitBin) ? gitBin : null;
54
+ return { jj, git, ok: Boolean(jj || git) };
55
+ }