@markjaquith/agency 2.64.0 → 2.65.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/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.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -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,
@@ -1157,7 +1161,7 @@ const materializeJj = (options: {
1157
1161
  const created: {
1158
1162
  repositoryPath: string
1159
1163
  workspacePath: string
1160
- workspaceName: string
1164
+ workspaceName: string | null
1161
1165
  }[] = []
1162
1166
  const resumePath = jjResumePath(options.taskPath, options.phasePath)
1163
1167
  const resume = yield* readJjResumeState(resumePath)
@@ -1227,9 +1231,14 @@ const materializeJj = (options: {
1227
1231
  const canonicalPath = exists
1228
1232
  ? yield* fs.realPath(workspacePath)
1229
1233
  : resolve(workspacePath)
1230
- const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
1234
+ const registeredWorkspaces =
1235
+ yield* backend.listWorkspaces(repositoryPath)
1236
+ const registered = registeredWorkspaces.find(
1231
1237
  (workspace) => workspace.path === canonicalPath,
1232
1238
  )
1239
+ const registeredByName = registeredWorkspaces.find(
1240
+ (workspace) => workspace.name === workspaceName,
1241
+ )
1233
1242
  if (exists && !registered && resumeCheckout) {
1234
1243
  const residual = yield* inspectJjResidual(
1235
1244
  options.root,
@@ -1256,6 +1265,11 @@ const materializeJj = (options: {
1256
1265
  message: `Workspace registry contains a missing checkout at ${workspacePath}`,
1257
1266
  })
1258
1267
  }
1268
+ if (!exists && registeredByName) {
1269
+ return yield* new WorktreeError({
1270
+ message: `Jj workspace name '${workspaceName}' is already registered at ${registeredByName.path}`,
1271
+ })
1272
+ }
1259
1273
  if (exists && registered) {
1260
1274
  const actualCommit = resumeCheckout
1261
1275
  ? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
@@ -1313,7 +1327,7 @@ const materializeJj = (options: {
1313
1327
  })
1314
1328
  }
1315
1329
 
1316
- const command = [
1330
+ const defaultCommand = [
1317
1331
  "jj",
1318
1332
  "-R",
1319
1333
  repositoryPath,
@@ -1325,6 +1339,36 @@ const materializeJj = (options: {
1325
1339
  revision,
1326
1340
  workspacePath,
1327
1341
  ]
1342
+ const workspaceVariables = {
1343
+ repo: repositoryPath,
1344
+ workspace: workspacePath,
1345
+ name: workspaceName,
1346
+ revision,
1347
+ kind:
1348
+ "branch" in checkout
1349
+ ? ("writable" as const)
1350
+ : ("reference" as const),
1351
+ requestedRef: requestedRevision,
1352
+ }
1353
+ let command = defaultCommand
1354
+ let commandEnvironment: Record<string, string> | undefined
1355
+ if (options.config.workspaceCreateCommand && !resumeCheckout) {
1356
+ try {
1357
+ command = expandWorkspaceCreateCommand(
1358
+ options.config.workspaceCreateCommand,
1359
+ workspaceVariables,
1360
+ )
1361
+ commandEnvironment = workspaceCommandEnvironment(workspaceVariables)
1362
+ } catch (cause) {
1363
+ return yield* new WorktreeError({
1364
+ message:
1365
+ cause instanceof Error
1366
+ ? cause.message
1367
+ : "Invalid workspaceCreateCommand",
1368
+ cause,
1369
+ })
1370
+ }
1371
+ }
1328
1372
  operations.push({
1329
1373
  action: "create-workspace",
1330
1374
  repo: checkout.repo,
@@ -1353,6 +1397,38 @@ const materializeJj = (options: {
1353
1397
  workspaceName,
1354
1398
  commitId: resumeCheckout.commitId,
1355
1399
  })
1400
+ } else if (options.config.workspaceCreateCommand) {
1401
+ verboseLog(`Running workspace command: ${formatCommand(command)}`)
1402
+ const result = yield* fs.runCommand(command, {
1403
+ cwd: repositoryPath,
1404
+ captureOutput: true,
1405
+ forwardOutput: forwardCommandOutput,
1406
+ env: commandEnvironment,
1407
+ })
1408
+ const createdPath = yield* fs.isDirectory(workspacePath)
1409
+ const canonicalCreatedPath = createdPath
1410
+ ? yield* fs.realPath(workspacePath)
1411
+ : resolve(workspacePath)
1412
+ const registeredAfterCommand = (yield* backend.listWorkspaces(
1413
+ repositoryPath,
1414
+ )).find((workspace) => workspace.path === canonicalCreatedPath)
1415
+ if (createdPath || registeredAfterCommand) {
1416
+ created.push({
1417
+ repositoryPath,
1418
+ workspacePath,
1419
+ workspaceName: registeredAfterCommand?.name ?? null,
1420
+ })
1421
+ }
1422
+ if (result.exitCode !== 0) {
1423
+ return yield* new WorktreeError({
1424
+ message: `Failed to create jj workspace for '${checkout.repo}': ${result.stderr.trim() || result.stdout.trim()}`,
1425
+ })
1426
+ }
1427
+ if (!createdPath) {
1428
+ return yield* new WorktreeError({
1429
+ message: `Workspace command did not create ${workspacePath}`,
1430
+ })
1431
+ }
1356
1432
  } else {
1357
1433
  yield* backend.createWorkspace({
1358
1434
  repositoryPath,
@@ -1362,7 +1438,9 @@ const materializeJj = (options: {
1362
1438
  ...("branch" in checkout ? { branch: checkout.branch } : {}),
1363
1439
  })
1364
1440
  }
1365
- created.push({ repositoryPath, workspacePath, workspaceName })
1441
+ if (!options.config.workspaceCreateCommand || resumeCheckout) {
1442
+ created.push({ repositoryPath, workspacePath, workspaceName })
1443
+ }
1366
1444
  const canonicalWorkspacePath = yield* fs.realPath(workspacePath)
1367
1445
  const registeredAfterCreate = (yield* backend.listWorkspaces(
1368
1446
  repositoryPath,
@@ -1370,7 +1448,10 @@ const materializeJj = (options: {
1370
1448
  const head = resumeCheckout
1371
1449
  ? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
1372
1450
  : yield* backend.workspaceHead(workspacePath)
1373
- if (!registeredAfterCreate || head !== revision) {
1451
+ if (
1452
+ registeredAfterCreate?.name !== workspaceName ||
1453
+ head !== revision
1454
+ ) {
1374
1455
  return yield* new WorktreeError({
1375
1456
  message: `Created jj workspace for '${checkout.repo}' failed validation`,
1376
1457
  })
@@ -1445,16 +1526,18 @@ const materializeJj = (options: {
1445
1526
  const rolledBack: string[] = []
1446
1527
  const manualRecovery: string[] = []
1447
1528
  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
- )
1529
+ const removed = yield* (
1530
+ workspace.workspaceName
1531
+ ? backend.removeWorkspace({
1532
+ repositoryPath: workspace.repositoryPath,
1533
+ workspacePath: workspace.workspacePath,
1534
+ workspaceName: workspace.workspaceName,
1535
+ })
1536
+ : fs.deleteDirectory(workspace.workspacePath)
1537
+ ).pipe(
1538
+ Effect.as(true),
1539
+ Effect.catchAll(() => Effect.succeed(false)),
1540
+ )
1458
1541
  if (removed)
1459
1542
  rolledBack.push(`create-workspace ${workspace.workspaceName}`)
1460
1543
  else
@@ -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
+ })