@markjaquith/agency 2.63.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 +58 -8
- package/cli-main.ts +2 -1
- package/package.json +1 -1
- package/src/cli-parser.test.ts +4 -1
- package/src/cli-parser.ts +4 -4
- package/src/commands/act.test.ts +95 -0
- package/src/commands/act.ts +89 -8
- package/src/services/DoctorService.ts +9 -0
- package/src/services/IntegrationService.test.ts +70 -21
- package/src/services/WorkbaseService.test.ts +20 -0
- package/src/services/WorkbaseService.ts +16 -0
- package/src/services/WorktreeService.test.ts +192 -0
- package/src/services/WorktreeService.ts +98 -15
- package/src/workbase/opencode-plugin-file.ts +6 -1
- package/src/workbase/schemas.test.ts +19 -0
- package/src/workbase/schemas.ts +1 -0
- package/src/workbase/workspace-command.test.ts +70 -0
- package/src/workbase/workspace-command.ts +63 -0
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`
|
|
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
|
|
|
@@ -517,12 +566,13 @@ pull request, dropping, reopening, or archiving. Cancelling either chooser makes
|
|
|
517
566
|
no changes, and the command refreshes the graph before dispatch so a stale
|
|
518
567
|
selection cannot act on changed work.
|
|
519
568
|
|
|
520
|
-
Use `--epic <id>`, `--task <id>`,
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
569
|
+
Use an existing directory, a positional task ID, `--epic <id>`, `--task <id>`,
|
|
570
|
+
or `--task <id> --phase <id>` to skip work-item selection. For example,
|
|
571
|
+
`agency act .` selects the current task or phase. `--dry-run` still prompts for
|
|
572
|
+
an action but prints the exact Agency command instead of executing it. `--json`
|
|
573
|
+
never prompts or executes; it returns matching targets, status and readiness
|
|
574
|
+
details, document revisions, and each available action's command argv. With no
|
|
575
|
+
selector, JSON includes every active work item.
|
|
526
576
|
|
|
527
577
|
`--auto` is included in generated or executed work commands, and `--draft` is
|
|
528
578
|
included in generated or executed pull request commands.
|
package/cli-main.ts
CHANGED
|
@@ -177,10 +177,11 @@ const VERSION = packageJson.version
|
|
|
177
177
|
// Define commands
|
|
178
178
|
const commands: Record<string, Command> = {
|
|
179
179
|
act: {
|
|
180
|
-
run: async (
|
|
180
|
+
run: async (args: string[], options: Record<string, any>) => {
|
|
181
181
|
if (options.help) return console.log(actHelp)
|
|
182
182
|
await runCommand(
|
|
183
183
|
act({
|
|
184
|
+
directory: args[0],
|
|
184
185
|
auto: options.auto,
|
|
185
186
|
draft: options.draft,
|
|
186
187
|
dryRun: options["dry-run"],
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -31,7 +31,10 @@ describe("strict CLI parsing", () => {
|
|
|
31
31
|
},
|
|
32
32
|
})
|
|
33
33
|
expect(parseCli(["act", "--json"]).values.json).toBe(true)
|
|
34
|
-
|
|
34
|
+
expect(parseCli(["act", "task-id"]).args).toEqual(["task-id"])
|
|
35
|
+
expect(parseCli(["act", "."]).args).toEqual(["."])
|
|
36
|
+
expectUsageError(["act", "one", "two"], "agency act")
|
|
37
|
+
expectUsageError(["act", ".", "--task", "example"], "agency act")
|
|
35
38
|
expectUsageError(["act", "--phase", "build"], "agency act")
|
|
36
39
|
expectUsageError(
|
|
37
40
|
["act", "--epic", "roadmap", "--task", "example"],
|
package/src/cli-parser.ts
CHANGED
|
@@ -148,7 +148,7 @@ const commands = {
|
|
|
148
148
|
},
|
|
149
149
|
act: {
|
|
150
150
|
usage:
|
|
151
|
-
"agency act [--epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]",
|
|
151
|
+
"agency act [<directory-or-task-id> | --epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]",
|
|
152
152
|
options: {
|
|
153
153
|
...outputOptions,
|
|
154
154
|
...entitySelectorOptions,
|
|
@@ -158,9 +158,9 @@ const commands = {
|
|
|
158
158
|
},
|
|
159
159
|
command: {
|
|
160
160
|
usage:
|
|
161
|
-
"agency act [--epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]",
|
|
161
|
+
"agency act [<directory-or-task-id> | --epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]",
|
|
162
162
|
minArgs: 0,
|
|
163
|
-
maxArgs:
|
|
163
|
+
maxArgs: 1,
|
|
164
164
|
options: ["epic", "task", "phase", "dry-run", "json", "auto", "draft"],
|
|
165
165
|
conflicts: [
|
|
166
166
|
["dry-run", "json"],
|
|
@@ -1198,7 +1198,7 @@ function applyEntitySelectors(
|
|
|
1198
1198
|
spec.usage,
|
|
1199
1199
|
)
|
|
1200
1200
|
}
|
|
1201
|
-
if (
|
|
1201
|
+
if (["act", "work", "context"].includes(commandName)) {
|
|
1202
1202
|
if (
|
|
1203
1203
|
positionals.length >
|
|
1204
1204
|
(commandName === "work" && positionals[0] === "prepare" ? 1 : 0)
|
package/src/commands/act.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
3
4
|
import { join } from "node:path"
|
|
4
5
|
import {
|
|
5
6
|
captureLogs,
|
|
@@ -118,6 +119,100 @@ describe("act command", () => {
|
|
|
118
119
|
expect(await readTaskStatus("example")).toBe("open")
|
|
119
120
|
})
|
|
120
121
|
|
|
122
|
+
test("resolves a positional path and task ID without prompting for an item", async () => {
|
|
123
|
+
await createTask("example")
|
|
124
|
+
await mkdir(join(root, "tasks/example/code/agency"), { recursive: true })
|
|
125
|
+
for (const options of [
|
|
126
|
+
{ cwd: join(root, "tasks/example"), directory: "." },
|
|
127
|
+
{ cwd: join(root, "tasks/example"), directory: "code/agency" },
|
|
128
|
+
{ cwd: root, directory: "example" },
|
|
129
|
+
]) {
|
|
130
|
+
const prompts: string[] = []
|
|
131
|
+
await runTestEffect(
|
|
132
|
+
act(
|
|
133
|
+
{ ...options, inputAllowed: true, dryRun: true, silent: true },
|
|
134
|
+
scriptedInteraction(["drop"], (prompt) => prompts.push(prompt)),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
expect(prompts).toEqual(["Act on task example"])
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
test("resolves a positional phase path without prompting for an item", async () => {
|
|
142
|
+
await runTestEffect(
|
|
143
|
+
task({
|
|
144
|
+
subcommand: "create",
|
|
145
|
+
args: ["multi"],
|
|
146
|
+
multiPhase: true,
|
|
147
|
+
cwd: root,
|
|
148
|
+
silent: true,
|
|
149
|
+
}),
|
|
150
|
+
)
|
|
151
|
+
await runTestEffect(
|
|
152
|
+
phase({
|
|
153
|
+
subcommand: "create",
|
|
154
|
+
args: ["multi", "build"],
|
|
155
|
+
repo: "agency",
|
|
156
|
+
branch: "task/multi-build",
|
|
157
|
+
base: "main",
|
|
158
|
+
cwd: root,
|
|
159
|
+
silent: true,
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
const prompts: string[] = []
|
|
163
|
+
await runTestEffect(
|
|
164
|
+
act(
|
|
165
|
+
{
|
|
166
|
+
cwd: join(root, "tasks/multi/phases/build"),
|
|
167
|
+
directory: ".",
|
|
168
|
+
inputAllowed: true,
|
|
169
|
+
dryRun: true,
|
|
170
|
+
silent: true,
|
|
171
|
+
},
|
|
172
|
+
scriptedInteraction(["drop"], (prompt) => prompts.push(prompt)),
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
expect(prompts).toEqual(["Act on phase multi/build"])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test("orders owned items hierarchically and omits items without actions", async () => {
|
|
180
|
+
await runTestEffect(
|
|
181
|
+
task({
|
|
182
|
+
subcommand: "create",
|
|
183
|
+
args: ["multi"],
|
|
184
|
+
multiPhase: true,
|
|
185
|
+
cwd: root,
|
|
186
|
+
silent: true,
|
|
187
|
+
}),
|
|
188
|
+
)
|
|
189
|
+
await runTestEffect(
|
|
190
|
+
phase({
|
|
191
|
+
subcommand: "create",
|
|
192
|
+
args: ["multi", "build"],
|
|
193
|
+
repo: "agency",
|
|
194
|
+
branch: "task/multi-build",
|
|
195
|
+
base: "main",
|
|
196
|
+
cwd: root,
|
|
197
|
+
silent: true,
|
|
198
|
+
}),
|
|
199
|
+
)
|
|
200
|
+
let choices: readonly Choice<unknown>[] = []
|
|
201
|
+
await runTestEffect(
|
|
202
|
+
act(
|
|
203
|
+
{ cwd: root, inputAllowed: true },
|
|
204
|
+
scriptedInteraction([null], (_prompt, offered) => {
|
|
205
|
+
choices = offered
|
|
206
|
+
}),
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
expect(choices.map((choice) => [choice.value, choice.depth])).toEqual([
|
|
211
|
+
["task:multi", 0],
|
|
212
|
+
["phase:multi/build", 1],
|
|
213
|
+
])
|
|
214
|
+
})
|
|
215
|
+
|
|
121
216
|
test("returns structured actions and argv for agents", async () => {
|
|
122
217
|
await createTask("example")
|
|
123
218
|
const logs = await captureLogs(() =>
|
package/src/commands/act.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path"
|
|
2
3
|
import type { GraphNode } from "../graph-schema"
|
|
3
4
|
import { isTerminalStatus } from "../readiness"
|
|
5
|
+
import { FileSystemService } from "../services/FileSystemService"
|
|
4
6
|
import { GraphService } from "../services/GraphService"
|
|
5
7
|
import { WorkbaseService } from "../services/WorkbaseService"
|
|
6
8
|
import type { BaseCommandOptions } from "../utils/command"
|
|
@@ -29,6 +31,7 @@ export interface ActInteraction {
|
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
interface ActOptions extends BaseCommandOptions {
|
|
34
|
+
readonly directory?: string
|
|
32
35
|
readonly auto?: boolean
|
|
33
36
|
readonly draft?: boolean
|
|
34
37
|
readonly dryRun?: boolean
|
|
@@ -49,13 +52,49 @@ const entityDescription = (node: EntityNode) =>
|
|
|
49
52
|
? ` - ${node.data.description}`
|
|
50
53
|
: ""
|
|
51
54
|
|
|
55
|
+
const orderedEntities = (
|
|
56
|
+
nodes: readonly EntityNode[],
|
|
57
|
+
edges: readonly {
|
|
58
|
+
readonly kind: string
|
|
59
|
+
readonly from: string
|
|
60
|
+
readonly to: string
|
|
61
|
+
}[],
|
|
62
|
+
) => {
|
|
63
|
+
const byId = new Map(nodes.map((node) => [node.id, node]))
|
|
64
|
+
const children = new Map<string, EntityNode[]>()
|
|
65
|
+
const owned = new Set<string>()
|
|
66
|
+
for (const edge of edges) {
|
|
67
|
+
if (edge.kind !== "owns") continue
|
|
68
|
+
const parent = byId.get(edge.from)
|
|
69
|
+
const child = byId.get(edge.to)
|
|
70
|
+
if (!parent || !child) continue
|
|
71
|
+
children.set(edge.from, [...(children.get(edge.from) ?? []), child])
|
|
72
|
+
owned.add(child.id)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ordered: { readonly node: EntityNode; readonly depth: number }[] = []
|
|
76
|
+
const append = (node: EntityNode, depth: number) => {
|
|
77
|
+
ordered.push({ node, depth })
|
|
78
|
+
for (const child of children.get(node.id) ?? []) append(child, depth + 1)
|
|
79
|
+
}
|
|
80
|
+
for (const node of nodes) {
|
|
81
|
+
if (!owned.has(node.id)) append(node, 0)
|
|
82
|
+
}
|
|
83
|
+
return ordered
|
|
84
|
+
}
|
|
85
|
+
|
|
52
86
|
const entityChoices = (
|
|
53
87
|
nodes: readonly EntityNode[],
|
|
88
|
+
edges: readonly {
|
|
89
|
+
readonly kind: string
|
|
90
|
+
readonly from: string
|
|
91
|
+
readonly to: string
|
|
92
|
+
}[],
|
|
54
93
|
): readonly Choice<string>[] =>
|
|
55
|
-
nodes.map((node, index) => ({
|
|
94
|
+
orderedEntities(nodes, edges).map(({ node, depth }, index) => ({
|
|
56
95
|
key: String(index),
|
|
57
96
|
label: `[${node.status}] ${node.kind} ${node.key}${entityDescription(node)}`,
|
|
58
|
-
depth
|
|
97
|
+
depth,
|
|
59
98
|
segments: [
|
|
60
99
|
{ text: `[${node.status}] `, color: macchiato.overlay1 },
|
|
61
100
|
{ text: `${node.kind} `, color: macchiato.sapphire },
|
|
@@ -229,6 +268,26 @@ const selectedEntityKey = (options: ActOptions) =>
|
|
|
229
268
|
? `task:${options.taskId}`
|
|
230
269
|
: undefined
|
|
231
270
|
|
|
271
|
+
const pathEntityKey = (
|
|
272
|
+
directory: string | undefined,
|
|
273
|
+
isDirectory: boolean,
|
|
274
|
+
root: string,
|
|
275
|
+
startPath: string,
|
|
276
|
+
) => {
|
|
277
|
+
if (!directory) return undefined
|
|
278
|
+
if (!isDirectory) return `task:${directory}`
|
|
279
|
+
const path = relative(root, startPath)
|
|
280
|
+
const parts =
|
|
281
|
+
!path || isAbsolute(path) || path.startsWith(`..${sep}`)
|
|
282
|
+
? []
|
|
283
|
+
: path.split(sep)
|
|
284
|
+
if (parts[0] === "epics" && parts[1]) return `epic:${parts[1]}`
|
|
285
|
+
if (parts[0] !== "tasks" || !parts[1]) return undefined
|
|
286
|
+
return parts[2] === "phases" && parts[3]
|
|
287
|
+
? `phase:${parts[1]}/${parts[3]}`
|
|
288
|
+
: `task:${parts[1]}`
|
|
289
|
+
}
|
|
290
|
+
|
|
232
291
|
const sameActions = (
|
|
233
292
|
left: readonly Choice<ActAction>[],
|
|
234
293
|
right: readonly Choice<ActAction>[],
|
|
@@ -250,11 +309,19 @@ export const act = (
|
|
|
250
309
|
)
|
|
251
310
|
}
|
|
252
311
|
const cwd = options.cwd ?? process.cwd()
|
|
312
|
+
const fs = yield* FileSystemService
|
|
253
313
|
const workbase = yield* WorkbaseService
|
|
254
314
|
const graphs = yield* GraphService
|
|
255
315
|
const { log } = createLoggers(options)
|
|
256
|
-
const
|
|
257
|
-
|
|
316
|
+
const directoryPath = options.directory
|
|
317
|
+
? resolve(cwd, options.directory)
|
|
318
|
+
: undefined
|
|
319
|
+
const isDirectory = directoryPath
|
|
320
|
+
? yield* fs.isDirectory(directoryPath)
|
|
321
|
+
: false
|
|
322
|
+
const startPath = isDirectory && directoryPath ? directoryPath : cwd
|
|
323
|
+
const { root, config } = yield* workbase.loadConfig(startPath)
|
|
324
|
+
const graph = yield* graphs.get({ cwd: root })
|
|
258
325
|
const nodes = graph.nodes.filter(
|
|
259
326
|
(node): node is EntityNode =>
|
|
260
327
|
node.kind === "epic" || node.kind === "task" || node.kind === "phase",
|
|
@@ -269,7 +336,9 @@ export const act = (
|
|
|
269
336
|
)
|
|
270
337
|
}
|
|
271
338
|
|
|
272
|
-
const requestedKey =
|
|
339
|
+
const requestedKey =
|
|
340
|
+
selectedEntityKey(options) ??
|
|
341
|
+
pathEntityKey(options.directory, isDirectory, root, startPath)
|
|
273
342
|
const matchingNodes = requestedKey
|
|
274
343
|
? nodes.filter((node) => entityKey(node) === requestedKey)
|
|
275
344
|
: nodes
|
|
@@ -293,11 +362,21 @@ export const act = (
|
|
|
293
362
|
return
|
|
294
363
|
}
|
|
295
364
|
|
|
365
|
+
const selectableNodes = nodes.filter(
|
|
366
|
+
(node) => actionChoices(node, graph.nodes).length > 0,
|
|
367
|
+
)
|
|
368
|
+
if (!requestedKey && selectableNodes.length === 0) {
|
|
369
|
+
return yield* Effect.fail(
|
|
370
|
+
new Error(
|
|
371
|
+
"No work items with available actions found in this workbase",
|
|
372
|
+
),
|
|
373
|
+
)
|
|
374
|
+
}
|
|
296
375
|
const selectedKey =
|
|
297
376
|
requestedKey ??
|
|
298
377
|
(yield* interaction.select(
|
|
299
378
|
"Act on",
|
|
300
|
-
entityChoices(
|
|
379
|
+
entityChoices(selectableNodes, graph.edges),
|
|
301
380
|
config.chooserCommand,
|
|
302
381
|
))
|
|
303
382
|
if (selectedKey === null) return
|
|
@@ -424,10 +503,12 @@ export const act = (
|
|
|
424
503
|
})
|
|
425
504
|
|
|
426
505
|
export const help = `
|
|
427
|
-
Usage: agency act [--epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]
|
|
506
|
+
Usage: agency act [<directory-or-task-id> | --epic <id> | --task <id> [--phase <id>]] [--dry-run | --json] [--auto] [--draft]
|
|
428
507
|
|
|
429
508
|
Interactively choose an active work item and a state-aware lifecycle action.
|
|
430
|
-
|
|
509
|
+
An existing positional directory selects its containing epic, task, or phase;
|
|
510
|
+
otherwise the positional value is a task ID. Selectors skip work-item selection.
|
|
511
|
+
--dry-run prints the selected action's exact
|
|
431
512
|
Agency command without executing it. --json lists targets, available actions,
|
|
432
513
|
and command argv without prompting or executing.
|
|
433
514
|
|
|
@@ -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
|
|
@@ -174,6 +174,18 @@ describe("IntegrationService", () => {
|
|
|
174
174
|
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
175
175
|
"Do not invoke agency work for this target",
|
|
176
176
|
)
|
|
177
|
+
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
178
|
+
"as the default implementation directory",
|
|
179
|
+
)
|
|
180
|
+
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
181
|
+
"Set each tool's working directory to that checkout when supported",
|
|
182
|
+
)
|
|
183
|
+
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
184
|
+
"Run Agency lifecycle and context commands from the task or phase directory",
|
|
185
|
+
)
|
|
186
|
+
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
187
|
+
"reference checkouts reported by Agency context are read-only",
|
|
188
|
+
)
|
|
177
189
|
expect(managedWorkbaseOpencodePlugin).toContain(
|
|
178
190
|
".map((path) => `${path}${sep}.`)",
|
|
179
191
|
)
|
|
@@ -187,23 +199,39 @@ describe("IntegrationService", () => {
|
|
|
187
199
|
).toBe(false)
|
|
188
200
|
})
|
|
189
201
|
|
|
190
|
-
|
|
202
|
+
const testWorkerIdentity = async ({
|
|
203
|
+
target,
|
|
204
|
+
launchTarget,
|
|
205
|
+
phase,
|
|
206
|
+
}: {
|
|
207
|
+
readonly target:
|
|
208
|
+
| { readonly kind: "task"; readonly taskId: string }
|
|
209
|
+
| {
|
|
210
|
+
readonly kind: "phase"
|
|
211
|
+
readonly taskId: string
|
|
212
|
+
readonly phaseId: string
|
|
213
|
+
}
|
|
214
|
+
readonly launchTarget: string
|
|
215
|
+
readonly phase?: string
|
|
216
|
+
}) => {
|
|
191
217
|
const path = join(root, "agency-repository-skills.ts")
|
|
218
|
+
const checkoutPath = join(root, "code/agency")
|
|
192
219
|
const contextResponse = JSON.stringify({
|
|
193
220
|
version: 1,
|
|
194
221
|
ok: true,
|
|
195
222
|
result: {
|
|
196
223
|
workbase: { root },
|
|
197
224
|
target: {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
path: join(root, "TASK.md"),
|
|
225
|
+
...target,
|
|
226
|
+
path: join(root, phase ? "PHASE.md" : "TASK.md"),
|
|
201
227
|
},
|
|
202
228
|
authority: {
|
|
203
229
|
mode: "execution",
|
|
204
|
-
writable: { checkoutPath
|
|
230
|
+
writable: { checkoutPath },
|
|
205
231
|
},
|
|
206
|
-
documents:
|
|
232
|
+
documents: phase
|
|
233
|
+
? { phase: { data: { status: "working" } } }
|
|
234
|
+
: { task: { data: { status: "working" } } },
|
|
207
235
|
validation: { valid: true, warnings: [] },
|
|
208
236
|
},
|
|
209
237
|
})
|
|
@@ -225,27 +253,28 @@ describe("IntegrationService", () => {
|
|
|
225
253
|
}) as ReturnType<typeof Bun.spawn>) as typeof Bun.spawn
|
|
226
254
|
try {
|
|
227
255
|
const generated = await import(
|
|
228
|
-
`${pathToFileURL(path).href}?worker-identity`
|
|
256
|
+
`${pathToFileURL(path).href}?worker-identity=${encodeURIComponent(launchTarget)}`
|
|
229
257
|
)
|
|
230
258
|
expect(
|
|
231
259
|
generated.workerLaunchTarget([
|
|
232
260
|
{
|
|
233
261
|
type: "text",
|
|
234
|
-
text:
|
|
262
|
+
text: `Agency worker launch target: ${launchTarget}. Start the task.`,
|
|
235
263
|
},
|
|
236
264
|
]),
|
|
237
|
-
).toBe(
|
|
265
|
+
).toBe(launchTarget)
|
|
238
266
|
expect(generated.workerLaunchTarget(undefined)).toBeUndefined()
|
|
239
267
|
expect(
|
|
240
268
|
generated.contextTarget({
|
|
241
|
-
target
|
|
269
|
+
target,
|
|
242
270
|
authority: { mode: "execution" },
|
|
243
271
|
}),
|
|
244
|
-
).toBe(
|
|
272
|
+
).toBe(launchTarget)
|
|
245
273
|
expect(await generated.agencyContext(root)).toMatchObject({
|
|
246
274
|
root,
|
|
247
|
-
target:
|
|
275
|
+
target: launchTarget,
|
|
248
276
|
task: "example",
|
|
277
|
+
phase,
|
|
249
278
|
})
|
|
250
279
|
const hooks = await generated.default({ directory: root } as never)
|
|
251
280
|
await hooks["chat.message"]!(
|
|
@@ -254,7 +283,7 @@ describe("IntegrationService", () => {
|
|
|
254
283
|
parts: [
|
|
255
284
|
{
|
|
256
285
|
type: "text",
|
|
257
|
-
text:
|
|
286
|
+
text: `Agency worker launch target: ${launchTarget}. Start the task.`,
|
|
258
287
|
},
|
|
259
288
|
],
|
|
260
289
|
} as never,
|
|
@@ -276,11 +305,17 @@ describe("IntegrationService", () => {
|
|
|
276
305
|
{ sessionID: "worker-session" } as never,
|
|
277
306
|
system,
|
|
278
307
|
)
|
|
279
|
-
expect(system.system).
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
308
|
+
expect(system.system).toHaveLength(1)
|
|
309
|
+
expect(system.system[0]).toContain(`active worker for ${launchTarget}`)
|
|
310
|
+
expect(system.system[0]).toContain(
|
|
311
|
+
`${checkoutPath} as the default implementation directory`,
|
|
312
|
+
)
|
|
313
|
+
expect(system.system[0]).toContain(
|
|
314
|
+
"Run Agency lifecycle and context commands from the task or phase directory",
|
|
315
|
+
)
|
|
316
|
+
expect(system.system[0]).toContain(
|
|
317
|
+
"reference checkouts reported by Agency context are read-only",
|
|
318
|
+
)
|
|
284
319
|
const mismatchedSystem = { system: [] as string[] }
|
|
285
320
|
await hooks["experimental.chat.system.transform"]!(
|
|
286
321
|
{ sessionID: "mismatched-session" } as never,
|
|
@@ -292,15 +327,29 @@ describe("IntegrationService", () => {
|
|
|
292
327
|
await hooks["shell.env"]!({ sessionID: "worker-session" } as never, shell)
|
|
293
328
|
expect(shell.env).toMatchObject({
|
|
294
329
|
AGENCY_SESSION_ID: "worker-session",
|
|
295
|
-
AGENCY_TARGET:
|
|
330
|
+
AGENCY_TARGET: launchTarget,
|
|
296
331
|
AGENCY_WORKBASE: root,
|
|
297
332
|
AGENCY_TASK_ID: "example",
|
|
298
|
-
AGENCY_WRITABLE_CHECKOUT:
|
|
333
|
+
AGENCY_WRITABLE_CHECKOUT: checkoutPath,
|
|
299
334
|
})
|
|
335
|
+
if (phase) expect(shell.env.AGENCY_PHASE_ID).toBe(phase)
|
|
300
336
|
} finally {
|
|
301
337
|
Bun.spawn = originalSpawn
|
|
302
338
|
}
|
|
303
|
-
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
test("binds validated task worker identity to an OpenCode session", () =>
|
|
342
|
+
testWorkerIdentity({
|
|
343
|
+
target: { kind: "task", taskId: "example" },
|
|
344
|
+
launchTarget: "execution-unit:task/example",
|
|
345
|
+
}))
|
|
346
|
+
|
|
347
|
+
test("binds validated phase worker identity to an OpenCode session", () =>
|
|
348
|
+
testWorkerIdentity({
|
|
349
|
+
target: { kind: "phase", taskId: "example", phaseId: "build" },
|
|
350
|
+
launchTarget: "execution-unit:phase/example/build",
|
|
351
|
+
phase: "build",
|
|
352
|
+
}))
|
|
304
353
|
|
|
305
354
|
test("registers a TUI-only /agency-debug diagnostic", async () => {
|
|
306
355
|
const config = JSON.parse(managedBody(managedWorkbaseOpencodeTui))
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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*
|
|
1449
|
-
.
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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
|
|
@@ -164,7 +164,12 @@ const plugin: Plugin = async ({ directory }) => {
|
|
|
164
164
|
const context = workerSessions.get(sessionID)
|
|
165
165
|
if (!context?.target) return
|
|
166
166
|
output.system.push(
|
|
167
|
-
|
|
167
|
+
[
|
|
168
|
+
\`Agency verified this OpenCode session as the active worker for \${context.target}. Perform the assigned work directly. Do not invoke agency work for this target or launch a replacement worker.\`,
|
|
169
|
+
context.checkout
|
|
170
|
+
? \`OpenCode remains rooted in the task or phase directory for Agency instructions and context. Treat \${context.checkout} as the default implementation directory: use it for source reads, edits, repository status, builds, tests, formatting, and other repository-local commands. Set each tool's working directory to that checkout when supported; otherwise use absolute paths. Run Agency lifecycle and context commands from the task or phase directory. Any reference checkouts reported by Agency context are read-only.\`
|
|
171
|
+
: undefined,
|
|
172
|
+
].filter(Boolean).join(" "),
|
|
168
173
|
)
|
|
169
174
|
},
|
|
170
175
|
"shell.env": async ({ sessionID }, output) => {
|
|
@@ -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)({
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -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
|
+
})
|