@markjaquith/agency 2.64.0 → 2.65.1

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/README.md CHANGED
@@ -246,6 +246,55 @@ Supplemental read-only repositories remain detached Git worktrees at their
246
246
  declared refs so they do not acquire writable branches. Jj workbases always use
247
247
  jj workspaces and ignore this Git-specific customization.
248
248
 
249
+ ### Custom Jj Workspace Command
250
+
251
+ Jj workbases normally create managed workspaces with `jj workspace add`. Set
252
+ `workspaceCreateCommand` to an argv template when another tool should create or
253
+ adopt a prepared workspace directly at Agency's destination:
254
+
255
+ ```json
256
+ {
257
+ "version": 2,
258
+ "vcs": "jj",
259
+ "workspaceCreateCommand": [
260
+ "my-prewarm-tool",
261
+ "adopt",
262
+ "--repo",
263
+ "{repo}",
264
+ "--destination",
265
+ "{workspace}",
266
+ "--name",
267
+ "{name}",
268
+ "--revision",
269
+ "{revision}"
270
+ ]
271
+ }
272
+ ```
273
+
274
+ Available placeholders are:
275
+
276
+ - `{repo}`: absolute repository alias path under `repos/`
277
+ - `{workspace}`: absolute managed workspace path Agency requires
278
+ - `{name}`: unique jj workspace name Agency requires
279
+ - `{revision}`: exact commit the new working copy must be based on
280
+ - `{kind}`: `writable` or `reference`
281
+ - `{requestedRef}`: configured branch, reference, or review commit
282
+
283
+ `{repo}`, `{workspace}`, `{name}`, and `{revision}` are required. Agency invokes
284
+ the command directly without a shell and sets matching `AGENCY_REPO`,
285
+ `AGENCY_WORKSPACE`, `AGENCY_WORKSPACE_NAME`, `AGENCY_REVISION`,
286
+ `AGENCY_CHECKOUT_KIND`, and `AGENCY_REQUESTED_REF` environment variables. The
287
+ command applies to each new jj checkout and must leave `{workspace}` registered
288
+ under `{name}` with its working-copy parent at `{revision}`. This lets a prewarm
289
+ tool move or adopt a prepared workspace without first paying for Agency's normal
290
+ full checkout.
291
+
292
+ Agency validates the registration, name, path, and revision before running any
293
+ `postCheckoutCommand`. A failed command or validation removes a partially
294
+ created workspace when possible and otherwise reports manual recovery. Resume
295
+ restoration continues to use Agency's built-in exact-target recovery path.
296
+ Git workbases ignore this jj-specific customization.
297
+
249
298
  ### Post-checkout Commands
250
299
 
251
300
  Each repository declaration may provide a VCS-neutral `postCheckoutCommand` argv
@@ -267,8 +316,8 @@ shell, with the new checkout as its working directory:
267
316
  The hook runs for each newly created managed checkout, including writable and
268
317
  reference checkouts, after Git worktree or jj workspace creation has completed
269
318
  and Agency has validated the checkout. It does not run for a reused checkout or
270
- for inspection-only commands. A custom `worktreeCreateCommand` completes and is
271
- validated before this hook runs.
319
+ for inspection-only commands. A custom `worktreeCreateCommand` or
320
+ `workspaceCreateCommand` completes and is validated before this hook runs.
272
321
 
273
322
  Available placeholders and matching environment variables are:
274
323
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.64.0",
3
+ "version": "2.65.1",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -17,6 +17,13 @@ const git = (args: readonly string[], cwd?: string) => {
17
17
  }
18
18
  }
19
19
 
20
+ const jj = (args: readonly string[], cwd?: string) => {
21
+ const result = Bun.spawnSync(["jj", ...args], { cwd })
22
+ if (result.exitCode !== 0) {
23
+ throw new Error(new TextDecoder().decode(result.stderr))
24
+ }
25
+ }
26
+
20
27
  describe("ArchiveService bulk task archive", () => {
21
28
  let root: string
22
29
  let source: string
@@ -436,6 +443,98 @@ claim:
436
443
  ).toBe(true)
437
444
  })
438
445
 
446
+ test("archives a jj working-copy commit preserved by its task bookmark", async () => {
447
+ if (!Bun.which("jj")) return
448
+ const repository = join(root, "repos/agency")
449
+ await rm(repository, { recursive: true, force: true })
450
+ git(["clone", source, repository])
451
+ jj(["git", "init", "--colocate", repository])
452
+ await Bun.write(
453
+ join(root, "agency.json"),
454
+ JSON.stringify({ version: 2, vcs: "jj" }),
455
+ )
456
+ await createTask("jj-bookmarked")
457
+ await dropTask("jj-bookmarked")
458
+ const workspace = await runTestEffect(
459
+ WorktreeService.pipe(
460
+ Effect.flatMap((service) =>
461
+ service.materialize("jj-bookmarked", undefined, root),
462
+ ),
463
+ ),
464
+ )
465
+ await Bun.write(join(workspace.writablePath!, "preserved.txt"), "keep\n")
466
+ jj(
467
+ ["bookmark", "set", "task/jj-bookmarked", "-r", "@"],
468
+ workspace.writablePath!,
469
+ )
470
+
471
+ const preview = await archiveTasks(true)
472
+ expect(preview.tasks[0]).toMatchObject({
473
+ id: "jj-bookmarked",
474
+ disposition: "planned",
475
+ removedWorktrees: [workspace.writablePath!],
476
+ })
477
+ expect(
478
+ await Bun.file(join(workspace.writablePath!, "preserved.txt")).text(),
479
+ ).toBe("keep\n")
480
+
481
+ const result = await archiveTasks()
482
+ expect(result.tasks[0]).toMatchObject({
483
+ id: "jj-bookmarked",
484
+ disposition: "archived",
485
+ })
486
+ expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
487
+ const preserved = Bun.spawnSync([
488
+ "jj",
489
+ "-R",
490
+ repository,
491
+ "file",
492
+ "show",
493
+ "-r",
494
+ "task/jj-bookmarked",
495
+ 'root:"preserved.txt"',
496
+ ])
497
+ if (preserved.exitCode !== 0) {
498
+ throw new Error(preserved.stderr.toString())
499
+ }
500
+ expect(preserved.stdout.toString()).toBe("keep\n")
501
+ })
502
+
503
+ test("archives a forgotten jj workspace preserved by its task bookmark", async () => {
504
+ if (!Bun.which("jj")) return
505
+ const repository = join(root, "repos/agency")
506
+ await rm(repository, { recursive: true, force: true })
507
+ git(["clone", source, repository])
508
+ jj(["git", "init", "--colocate", repository])
509
+ await Bun.write(
510
+ join(root, "agency.json"),
511
+ JSON.stringify({ version: 2, vcs: "jj" }),
512
+ )
513
+ await createTask("jj-stale")
514
+ await dropTask("jj-stale")
515
+ const workspace = await runTestEffect(
516
+ WorktreeService.pipe(
517
+ Effect.flatMap((service) =>
518
+ service.materialize("jj-stale", undefined, root),
519
+ ),
520
+ ),
521
+ )
522
+ await Bun.write(join(workspace.writablePath!, "preserved.txt"), "keep\n")
523
+ jj(["bookmark", "set", "task/jj-stale", "-r", "@"], workspace.writablePath!)
524
+ jj(["-R", repository, "workspace", "forget", "agency-jj-stale-task-agency"])
525
+
526
+ const result = await archiveTasks(true)
527
+
528
+ expect(result.tasks[0]).toMatchObject({
529
+ id: "jj-stale",
530
+ disposition: "planned",
531
+ removedWorktrees: [workspace.writablePath!],
532
+ })
533
+ expect(
534
+ await Bun.file(join(workspace.writablePath!, "preserved.txt")).text(),
535
+ ).toBe("keep\n")
536
+ })
537
+
439
538
  test("rolls back the entire cohort when application fails", async () => {
440
539
  await runTestEffect(
441
540
  EpicService.pipe(
@@ -173,6 +173,15 @@ export class DoctorService extends Effect.Service<DoctorService>()(
173
173
  ] as const,
174
174
  ]
175
175
  : []),
176
+ ...(config.workspaceCreateCommand
177
+ ? [
178
+ [
179
+ "integration.workspace-create",
180
+ config.workspaceCreateCommand,
181
+ "Workspace creator",
182
+ ] as const,
183
+ ]
184
+ : []),
176
185
  ...Object.entries(config.repositories ?? {}).flatMap(
177
186
  ([alias, repository]) =>
178
187
  repository.postCheckoutCommand
@@ -371,6 +371,26 @@ status: done
371
371
  ).rejects.toThrow("{worktree}")
372
372
  })
373
373
 
374
+ test("rejects an invalid workspace command template", async () => {
375
+ await write(
376
+ root,
377
+ "agency.json",
378
+ JSON.stringify({
379
+ version: 2,
380
+ vcs: "jj",
381
+ workspaceCreateCommand: ["tool", "{repo}", "{workspace}", "{name}"],
382
+ }),
383
+ )
384
+
385
+ await expect(
386
+ runTestEffect(
387
+ WorkbaseService.pipe(
388
+ Effect.flatMap((service) => service.discover(root)),
389
+ ),
390
+ ),
391
+ ).rejects.toThrow("{revision}")
392
+ })
393
+
374
394
  test("rejects an unknown post-checkout command placeholder", async () => {
375
395
  await write(
376
396
  root,
@@ -20,6 +20,7 @@ import {
20
20
  type WorkbaseRegistration,
21
21
  } from "../workbase/schemas"
22
22
  import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
23
+ import { validateWorkspaceCreateCommand } from "../workbase/workspace-command"
23
24
  import { validatePostCheckoutCommand } from "../workbase/checkout-command"
24
25
  import { validateRunners } from "../workbase/runner-command"
25
26
  import { findDependencyCycles } from "../workbase/dependency-graph"
@@ -275,6 +276,21 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
275
276
  })
276
277
  }
277
278
  }
279
+ if (decoded.value.workspaceCreateCommand) {
280
+ try {
281
+ validateWorkspaceCreateCommand(
282
+ decoded.value.workspaceCreateCommand,
283
+ )
284
+ } catch (cause) {
285
+ return yield* new WorkbaseConfigError({
286
+ path: configPath,
287
+ message:
288
+ cause instanceof Error
289
+ ? cause.message
290
+ : "Invalid workspaceCreateCommand",
291
+ })
292
+ }
293
+ }
278
294
  for (const [alias, repository] of Object.entries(
279
295
  decoded.value.repositories ?? {},
280
296
  )) {
@@ -319,6 +319,198 @@ describe("WorktreeService", () => {
319
319
  expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
320
320
  })
321
321
 
322
+ test("uses a custom jj workspace creator before the post-checkout hook", async () => {
323
+ if (!Bun.which("jj")) return
324
+ const repository = join(root, "repos/agency")
325
+ await rm(repository, { recursive: true, force: true })
326
+ await git(["clone", source, repository])
327
+ await jj(["git", "init", "--colocate", repository])
328
+ await Bun.write(
329
+ join(root, "agency.json"),
330
+ JSON.stringify({
331
+ version: 2,
332
+ vcs: "jj",
333
+ workspaceCreateCommand: [
334
+ "sh",
335
+ "-c",
336
+ 'jj -R "$1" workspace add --name "$3" -r "$4" "$2" && printf "%s\\n%s\\n" "$AGENCY_CHECKOUT_KIND" "$AGENCY_REQUESTED_REF" > "$2/creator-finished"',
337
+ "workspace-creator",
338
+ "{repo}",
339
+ "{workspace}",
340
+ "{name}",
341
+ "{revision}",
342
+ "{kind}",
343
+ "{requestedRef}",
344
+ ],
345
+ repositories: {
346
+ agency: {
347
+ remote: "https://example.com/agency.git",
348
+ postCheckoutCommand: [
349
+ "sh",
350
+ "-c",
351
+ 'test -f creator-finished && printf "post-checkout" > hook-finished',
352
+ ],
353
+ },
354
+ },
355
+ }),
356
+ )
357
+ await runTestEffect(
358
+ TaskService.pipe(
359
+ Effect.flatMap((service) =>
360
+ service.create(
361
+ {
362
+ id: "jj-custom",
363
+ ticketUrl: null,
364
+ repo: "agency",
365
+ branch: "task/jj-custom",
366
+ base: "main",
367
+ },
368
+ root,
369
+ ),
370
+ ),
371
+ ),
372
+ )
373
+
374
+ const workspace = await runTestEffect(
375
+ WorktreeService.pipe(
376
+ Effect.flatMap((service) =>
377
+ service.materialize("jj-custom", undefined, root),
378
+ ),
379
+ ),
380
+ )
381
+ expect(
382
+ await Bun.file(join(workspace.writablePath!, "creator-finished")).text(),
383
+ ).toBe("writable\ntask/jj-custom\n")
384
+ expect(
385
+ await Bun.file(join(workspace.writablePath!, "hook-finished")).text(),
386
+ ).toBe("post-checkout")
387
+ expect(workspace.operations[0]).toMatchObject({
388
+ action: "create-workspace",
389
+ command: expect.arrayContaining(["writable", "task/jj-custom"]),
390
+ status: "completed",
391
+ })
392
+ })
393
+
394
+ test("rolls back a jj workspace partially created by a custom command", async () => {
395
+ if (!Bun.which("jj")) return
396
+ const repository = join(root, "repos/agency")
397
+ await rm(repository, { recursive: true, force: true })
398
+ await git(["clone", source, repository])
399
+ await jj(["git", "init", "--colocate", repository])
400
+ await Bun.write(
401
+ join(root, "agency.json"),
402
+ JSON.stringify({
403
+ version: 2,
404
+ vcs: "jj",
405
+ workspaceCreateCommand: [
406
+ "sh",
407
+ "-c",
408
+ 'jj -R "$1" workspace add --name "$3" -r "$4" "$2" && echo adoption-failed >&2; exit 7',
409
+ "workspace-creator",
410
+ "{repo}",
411
+ "{workspace}",
412
+ "{name}",
413
+ "{revision}",
414
+ ],
415
+ }),
416
+ )
417
+ await runTestEffect(
418
+ TaskService.pipe(
419
+ Effect.flatMap((service) =>
420
+ service.create(
421
+ {
422
+ id: "jj-custom-failure",
423
+ ticketUrl: null,
424
+ repo: "agency",
425
+ branch: "task/jj-custom-failure",
426
+ base: "main",
427
+ },
428
+ root,
429
+ ),
430
+ ),
431
+ ),
432
+ )
433
+ const workspacePath = join(root, "tasks/jj-custom-failure/code/agency")
434
+
435
+ await expect(
436
+ runTestEffect(
437
+ WorktreeService.pipe(
438
+ Effect.flatMap((service) =>
439
+ service.materialize("jj-custom-failure", undefined, root),
440
+ ),
441
+ ),
442
+ ),
443
+ ).rejects.toThrow("adoption-failed")
444
+ expect(await Bun.file(workspacePath).exists()).toBe(false)
445
+ expect(
446
+ await jjOutput(["workspace", "list", "-T", 'name ++ "\\n"'], repository),
447
+ ).not.toContain("agency-jj-custom-failure-task-agency")
448
+ })
449
+
450
+ test("rejects and rolls back a custom jj workspace at the wrong revision", async () => {
451
+ if (!Bun.which("jj")) return
452
+ const repository = join(root, "repos/agency")
453
+ await rm(repository, { recursive: true, force: true })
454
+ await git(["clone", source, repository])
455
+ await jj(["git", "init", "--colocate", repository])
456
+ const hookMarker = join(root, "wrong-revision-hook")
457
+ await Bun.write(
458
+ join(root, "agency.json"),
459
+ JSON.stringify({
460
+ version: 2,
461
+ vcs: "jj",
462
+ workspaceCreateCommand: [
463
+ "sh",
464
+ "-c",
465
+ 'jj -R "$1" workspace add --name "$3" -r "root()" "$2"',
466
+ "workspace-creator",
467
+ "{repo}",
468
+ "{workspace}",
469
+ "{name}",
470
+ "{revision}",
471
+ ],
472
+ repositories: {
473
+ agency: {
474
+ remote: "https://example.com/agency.git",
475
+ postCheckoutCommand: ["sh", "-c", 'touch "$1"', "hook", hookMarker],
476
+ },
477
+ },
478
+ }),
479
+ )
480
+ await runTestEffect(
481
+ TaskService.pipe(
482
+ Effect.flatMap((service) =>
483
+ service.create(
484
+ {
485
+ id: "jj-custom-wrong-revision",
486
+ ticketUrl: null,
487
+ repo: "agency",
488
+ branch: "task/jj-custom-wrong-revision",
489
+ base: "main",
490
+ },
491
+ root,
492
+ ),
493
+ ),
494
+ ),
495
+ )
496
+ const workspacePath = join(
497
+ root,
498
+ "tasks/jj-custom-wrong-revision/code/agency",
499
+ )
500
+
501
+ await expect(
502
+ runTestEffect(
503
+ WorktreeService.pipe(
504
+ Effect.flatMap((service) =>
505
+ service.materialize("jj-custom-wrong-revision", undefined, root),
506
+ ),
507
+ ),
508
+ ),
509
+ ).rejects.toThrow("failed validation")
510
+ expect(await Bun.file(workspacePath).exists()).toBe(false)
511
+ expect(await Bun.file(hookMarker).exists()).toBe(false)
512
+ })
513
+
322
514
  test("suspends and resumes the exact jj working-copy target", async () => {
323
515
  if (!Bun.which("jj")) return
324
516
  const repository = join(root, "repos/agency")
@@ -8,6 +8,10 @@ import {
8
8
  expandWorktreeCreateCommand,
9
9
  worktreeCommandEnvironment,
10
10
  } from "../workbase/worktree-command"
11
+ import {
12
+ expandWorkspaceCreateCommand,
13
+ workspaceCommandEnvironment,
14
+ } from "../workbase/workspace-command"
11
15
  import {
12
16
  expandPostCheckoutCommand,
13
17
  postCheckoutCommandEnvironment,
@@ -470,7 +474,9 @@ const inspectExecution = (
470
474
  : yield* backend.workspaceHead(checkoutPath)
471
475
  : null
472
476
  const dirty =
473
- exists && atPath ? yield* backend.workspaceDirty(checkoutPath) : null
477
+ exists && atPath
478
+ ? yield* backend.workspaceDirty(checkoutPath)
479
+ : (atPath?.dirty ?? null)
474
480
  const expectedCommit =
475
481
  "branch" in checkout && context?.skipWritableRevisionResolution
476
482
  ? actualCommit
@@ -1157,7 +1163,7 @@ const materializeJj = (options: {
1157
1163
  const created: {
1158
1164
  repositoryPath: string
1159
1165
  workspacePath: string
1160
- workspaceName: string
1166
+ workspaceName: string | null
1161
1167
  }[] = []
1162
1168
  const resumePath = jjResumePath(options.taskPath, options.phasePath)
1163
1169
  const resume = yield* readJjResumeState(resumePath)
@@ -1227,9 +1233,14 @@ const materializeJj = (options: {
1227
1233
  const canonicalPath = exists
1228
1234
  ? yield* fs.realPath(workspacePath)
1229
1235
  : resolve(workspacePath)
1230
- const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
1236
+ const registeredWorkspaces =
1237
+ yield* backend.listWorkspaces(repositoryPath)
1238
+ const registered = registeredWorkspaces.find(
1231
1239
  (workspace) => workspace.path === canonicalPath,
1232
1240
  )
1241
+ const registeredByName = registeredWorkspaces.find(
1242
+ (workspace) => workspace.name === workspaceName,
1243
+ )
1233
1244
  if (exists && !registered && resumeCheckout) {
1234
1245
  const residual = yield* inspectJjResidual(
1235
1246
  options.root,
@@ -1256,6 +1267,11 @@ const materializeJj = (options: {
1256
1267
  message: `Workspace registry contains a missing checkout at ${workspacePath}`,
1257
1268
  })
1258
1269
  }
1270
+ if (!exists && registeredByName) {
1271
+ return yield* new WorktreeError({
1272
+ message: `Jj workspace name '${workspaceName}' is already registered at ${registeredByName.path}`,
1273
+ })
1274
+ }
1259
1275
  if (exists && registered) {
1260
1276
  const actualCommit = resumeCheckout
1261
1277
  ? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
@@ -1313,7 +1329,7 @@ const materializeJj = (options: {
1313
1329
  })
1314
1330
  }
1315
1331
 
1316
- const command = [
1332
+ const defaultCommand = [
1317
1333
  "jj",
1318
1334
  "-R",
1319
1335
  repositoryPath,
@@ -1325,6 +1341,36 @@ const materializeJj = (options: {
1325
1341
  revision,
1326
1342
  workspacePath,
1327
1343
  ]
1344
+ const workspaceVariables = {
1345
+ repo: repositoryPath,
1346
+ workspace: workspacePath,
1347
+ name: workspaceName,
1348
+ revision,
1349
+ kind:
1350
+ "branch" in checkout
1351
+ ? ("writable" as const)
1352
+ : ("reference" as const),
1353
+ requestedRef: requestedRevision,
1354
+ }
1355
+ let command = defaultCommand
1356
+ let commandEnvironment: Record<string, string> | undefined
1357
+ if (options.config.workspaceCreateCommand && !resumeCheckout) {
1358
+ try {
1359
+ command = expandWorkspaceCreateCommand(
1360
+ options.config.workspaceCreateCommand,
1361
+ workspaceVariables,
1362
+ )
1363
+ commandEnvironment = workspaceCommandEnvironment(workspaceVariables)
1364
+ } catch (cause) {
1365
+ return yield* new WorktreeError({
1366
+ message:
1367
+ cause instanceof Error
1368
+ ? cause.message
1369
+ : "Invalid workspaceCreateCommand",
1370
+ cause,
1371
+ })
1372
+ }
1373
+ }
1328
1374
  operations.push({
1329
1375
  action: "create-workspace",
1330
1376
  repo: checkout.repo,
@@ -1353,6 +1399,38 @@ const materializeJj = (options: {
1353
1399
  workspaceName,
1354
1400
  commitId: resumeCheckout.commitId,
1355
1401
  })
1402
+ } else if (options.config.workspaceCreateCommand) {
1403
+ verboseLog(`Running workspace command: ${formatCommand(command)}`)
1404
+ const result = yield* fs.runCommand(command, {
1405
+ cwd: repositoryPath,
1406
+ captureOutput: true,
1407
+ forwardOutput: forwardCommandOutput,
1408
+ env: commandEnvironment,
1409
+ })
1410
+ const createdPath = yield* fs.isDirectory(workspacePath)
1411
+ const canonicalCreatedPath = createdPath
1412
+ ? yield* fs.realPath(workspacePath)
1413
+ : resolve(workspacePath)
1414
+ const registeredAfterCommand = (yield* backend.listWorkspaces(
1415
+ repositoryPath,
1416
+ )).find((workspace) => workspace.path === canonicalCreatedPath)
1417
+ if (createdPath || registeredAfterCommand) {
1418
+ created.push({
1419
+ repositoryPath,
1420
+ workspacePath,
1421
+ workspaceName: registeredAfterCommand?.name ?? null,
1422
+ })
1423
+ }
1424
+ if (result.exitCode !== 0) {
1425
+ return yield* new WorktreeError({
1426
+ message: `Failed to create jj workspace for '${checkout.repo}': ${result.stderr.trim() || result.stdout.trim()}`,
1427
+ })
1428
+ }
1429
+ if (!createdPath) {
1430
+ return yield* new WorktreeError({
1431
+ message: `Workspace command did not create ${workspacePath}`,
1432
+ })
1433
+ }
1356
1434
  } else {
1357
1435
  yield* backend.createWorkspace({
1358
1436
  repositoryPath,
@@ -1362,7 +1440,9 @@ const materializeJj = (options: {
1362
1440
  ...("branch" in checkout ? { branch: checkout.branch } : {}),
1363
1441
  })
1364
1442
  }
1365
- created.push({ repositoryPath, workspacePath, workspaceName })
1443
+ if (!options.config.workspaceCreateCommand || resumeCheckout) {
1444
+ created.push({ repositoryPath, workspacePath, workspaceName })
1445
+ }
1366
1446
  const canonicalWorkspacePath = yield* fs.realPath(workspacePath)
1367
1447
  const registeredAfterCreate = (yield* backend.listWorkspaces(
1368
1448
  repositoryPath,
@@ -1370,7 +1450,10 @@ const materializeJj = (options: {
1370
1450
  const head = resumeCheckout
1371
1451
  ? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
1372
1452
  : yield* backend.workspaceHead(workspacePath)
1373
- if (!registeredAfterCreate || head !== revision) {
1453
+ if (
1454
+ registeredAfterCreate?.name !== workspaceName ||
1455
+ head !== revision
1456
+ ) {
1374
1457
  return yield* new WorktreeError({
1375
1458
  message: `Created jj workspace for '${checkout.repo}' failed validation`,
1376
1459
  })
@@ -1445,16 +1528,18 @@ const materializeJj = (options: {
1445
1528
  const rolledBack: string[] = []
1446
1529
  const manualRecovery: string[] = []
1447
1530
  for (const workspace of [...created].reverse()) {
1448
- const removed = yield* backend
1449
- .removeWorkspace({
1450
- repositoryPath: workspace.repositoryPath,
1451
- workspacePath: workspace.workspacePath,
1452
- workspaceName: workspace.workspaceName,
1453
- })
1454
- .pipe(
1455
- Effect.as(true),
1456
- Effect.catchAll(() => Effect.succeed(false)),
1457
- )
1531
+ const removed = yield* (
1532
+ workspace.workspaceName
1533
+ ? backend.removeWorkspace({
1534
+ repositoryPath: workspace.repositoryPath,
1535
+ workspacePath: workspace.workspacePath,
1536
+ workspaceName: workspace.workspaceName,
1537
+ })
1538
+ : fs.deleteDirectory(workspace.workspacePath)
1539
+ ).pipe(
1540
+ Effect.as(true),
1541
+ Effect.catchAll(() => Effect.succeed(false)),
1542
+ )
1458
1543
  if (removed)
1459
1544
  rolledBack.push(`create-workspace ${workspace.workspaceName}`)
1460
1545
  else
@@ -1576,10 +1661,26 @@ const removeJj = (
1576
1661
  bookmark: string
1577
1662
  }[] = []
1578
1663
  for (const checkout of inspection.checkouts) {
1664
+ const repositoryPath = join(root, "repos", checkout.repo)
1665
+ const registered = checkout.registeredPath
1666
+ ? (yield* backend.listWorkspaces(repositoryPath)).find(
1667
+ (workspace) => workspace.path === checkout.registeredPath,
1668
+ )
1669
+ : undefined
1579
1670
  if (checkout.dirty === true && options.persistResume === false) {
1580
- return yield* new WorktreeError({
1581
- message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
1582
- })
1671
+ const preservedByBookmark =
1672
+ checkout.kind === "writable" &&
1673
+ registered?.commit !== null &&
1674
+ registered?.commit !== undefined &&
1675
+ (yield* backend.resolveRevision(
1676
+ repositoryPath,
1677
+ checkout.requestedRef,
1678
+ )) === registered.commit
1679
+ if (!preservedByBookmark) {
1680
+ return yield* new WorktreeError({
1681
+ message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
1682
+ })
1683
+ }
1583
1684
  }
1584
1685
  if (
1585
1686
  checkout.exists &&
@@ -1590,7 +1691,6 @@ const removeJj = (
1590
1691
  message: `Failed to remove workspace for '${checkout.repo}': checkout cleanliness could not be verified`,
1591
1692
  })
1592
1693
  }
1593
- const repositoryPath = join(root, "repos", checkout.repo)
1594
1694
  if (!checkout.registeredPath) {
1595
1695
  const recoveryRevision = recoverable.get(checkout.path)
1596
1696
  if (!recoveryRevision) continue
@@ -1613,9 +1713,6 @@ const removeJj = (
1613
1713
  })
1614
1714
  continue
1615
1715
  }
1616
- const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
1617
- (workspace) => workspace.path === checkout.registeredPath,
1618
- )
1619
1716
  if (!registered?.name || !checkout.actualCommit || !registered.commit) {
1620
1717
  return yield* new WorktreeError({
1621
1718
  message: `Cannot identify jj workspace at ${checkout.registeredPath}`,
@@ -257,6 +257,25 @@ describe("repository post-checkout configuration", () => {
257
257
  })
258
258
  })
259
259
 
260
+ describe("workspace creation configuration", () => {
261
+ test("accepts a jj workspace argv command", () => {
262
+ const config = Schema.decodeUnknownSync(WorkbaseConfig)({
263
+ version: 2,
264
+ vcs: "jj",
265
+ workspaceCreateCommand: [
266
+ "prewarm",
267
+ "adopt",
268
+ "{repo}",
269
+ "{workspace}",
270
+ "{name}",
271
+ "{revision}",
272
+ ],
273
+ })
274
+
275
+ expect(config.workspaceCreateCommand?.[0]).toBe("prewarm")
276
+ })
277
+ })
278
+
260
279
  describe("runner configuration", () => {
261
280
  test("accepts named argv commands with resume commands and environment", () => {
262
281
  const config = Schema.decodeUnknownSync(WorkbaseConfig)({
@@ -109,6 +109,7 @@ export const WorkbaseConfig = Schema.Struct({
109
109
  ),
110
110
  chooserCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
111
111
  worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
112
+ workspaceCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
112
113
  runners: Schema.optional(
113
114
  Schema.Record({
114
115
  key: EntityId,
@@ -0,0 +1,70 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import {
3
+ expandWorkspaceCreateCommand,
4
+ workspaceCommandEnvironment,
5
+ } from "./workspace-command"
6
+
7
+ const variables = {
8
+ repo: "/work/repos/app",
9
+ workspace: "/work/tasks/example/code/app",
10
+ name: "agency-example-task-app",
11
+ revision: "0123456789abcdef",
12
+ kind: "writable" as const,
13
+ requestedRef: "task/example",
14
+ }
15
+
16
+ describe("workspace command templates", () => {
17
+ test("expands argv placeholders without shell interpolation", () => {
18
+ expect(
19
+ expandWorkspaceCreateCommand(
20
+ [
21
+ "tool",
22
+ "--repo={repo}",
23
+ "--workspace={workspace}",
24
+ "--name={name}",
25
+ "--revision={revision}",
26
+ "{kind}",
27
+ "{requestedRef}",
28
+ ],
29
+ variables,
30
+ ),
31
+ ).toEqual([
32
+ "tool",
33
+ "--repo=/work/repos/app",
34
+ "--workspace=/work/tasks/example/code/app",
35
+ "--name=agency-example-task-app",
36
+ "--revision=0123456789abcdef",
37
+ "writable",
38
+ "task/example",
39
+ ])
40
+ })
41
+
42
+ test("requires creation identity placeholders", () => {
43
+ expect(() =>
44
+ expandWorkspaceCreateCommand(
45
+ ["tool", "{repo}", "{workspace}", "{name}"],
46
+ variables,
47
+ ),
48
+ ).toThrow("{revision}")
49
+ })
50
+
51
+ test("rejects unknown placeholders", () => {
52
+ expect(() =>
53
+ expandWorkspaceCreateCommand(
54
+ ["tool", "{repo}", "{workspace}", "{name}", "{revision}", "{base}"],
55
+ variables,
56
+ ),
57
+ ).toThrow("{base}")
58
+ })
59
+
60
+ test("provides equivalent environment variables", () => {
61
+ expect(workspaceCommandEnvironment(variables)).toEqual({
62
+ AGENCY_REPO: variables.repo,
63
+ AGENCY_WORKSPACE: variables.workspace,
64
+ AGENCY_WORKSPACE_NAME: variables.name,
65
+ AGENCY_REVISION: variables.revision,
66
+ AGENCY_CHECKOUT_KIND: variables.kind,
67
+ AGENCY_REQUESTED_REF: variables.requestedRef,
68
+ })
69
+ })
70
+ })
@@ -0,0 +1,63 @@
1
+ interface WorkspaceCommandVariables {
2
+ readonly repo: string
3
+ readonly workspace: string
4
+ readonly name: string
5
+ readonly revision: string
6
+ readonly kind: "writable" | "reference"
7
+ readonly requestedRef: string
8
+ }
9
+
10
+ const REQUIRED_PLACEHOLDERS = ["repo", "workspace", "name", "revision"] as const
11
+ const PLACEHOLDERS = new Set([
12
+ "repo",
13
+ "workspace",
14
+ "name",
15
+ "revision",
16
+ "kind",
17
+ "requestedRef",
18
+ ])
19
+
20
+ export const validateWorkspaceCreateCommand = (command: readonly string[]) => {
21
+ const template = command.join("\u0000")
22
+ for (const placeholder of REQUIRED_PLACEHOLDERS) {
23
+ if (!template.includes(`{${placeholder}}`)) {
24
+ throw new Error(
25
+ `workspaceCreateCommand must include the {${placeholder}} placeholder`,
26
+ )
27
+ }
28
+ }
29
+ for (const argument of command) {
30
+ for (const match of argument.matchAll(/\{([^{}]+)\}/g)) {
31
+ const placeholder = match[1]!
32
+ if (!PLACEHOLDERS.has(placeholder)) {
33
+ throw new Error(
34
+ `Unknown workspaceCreateCommand placeholder: {${placeholder}}`,
35
+ )
36
+ }
37
+ }
38
+ }
39
+ }
40
+
41
+ export const expandWorkspaceCreateCommand = (
42
+ command: readonly string[],
43
+ variables: WorkspaceCommandVariables,
44
+ ): string[] => {
45
+ validateWorkspaceCreateCommand(command)
46
+
47
+ return command.map((argument) =>
48
+ argument.replaceAll(/\{([^{}]+)\}/g, (match, placeholder: string) => {
49
+ return variables[placeholder as keyof WorkspaceCommandVariables] ?? match
50
+ }),
51
+ )
52
+ }
53
+
54
+ export const workspaceCommandEnvironment = (
55
+ variables: WorkspaceCommandVariables,
56
+ ): Record<string, string> => ({
57
+ AGENCY_REPO: variables.repo,
58
+ AGENCY_WORKSPACE: variables.workspace,
59
+ AGENCY_WORKSPACE_NAME: variables.name,
60
+ AGENCY_REVISION: variables.revision,
61
+ AGENCY_CHECKOUT_KIND: variables.kind,
62
+ AGENCY_REQUESTED_REF: variables.requestedRef,
63
+ })