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