@markjaquith/agency 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/pr.ts +1 -0
- package/src/commands/work.test.ts +12 -2
- package/src/commands/work.ts +6 -1
- package/src/services/FileSystemService.ts +7 -2
- package/src/services/PullRequestService.ts +3 -0
- package/src/services/WorktreeService.test.ts +60 -1
- package/src/services/WorktreeService.ts +20 -0
- package/src/utils/process.test.ts +38 -1
- package/src/utils/process.ts +30 -6
package/package.json
CHANGED
package/src/commands/pr.ts
CHANGED
|
@@ -52,9 +52,18 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
52
52
|
args: readonly string[]
|
|
53
53
|
cwd: string
|
|
54
54
|
}> = []
|
|
55
|
+
const materializeOptions: Array<
|
|
56
|
+
Parameters<WorktreeService["materialize"]>[3]
|
|
57
|
+
> = []
|
|
55
58
|
const worktrees = {
|
|
56
|
-
materialize: (
|
|
59
|
+
materialize: (
|
|
60
|
+
_taskId: string,
|
|
61
|
+
_phaseId?: string,
|
|
62
|
+
_root?: string,
|
|
63
|
+
commandOptions?: Parameters<WorktreeService["materialize"]>[3],
|
|
64
|
+
) => {
|
|
57
65
|
events.push("materialize")
|
|
66
|
+
materializeOptions.push(commandOptions)
|
|
58
67
|
return options.materializeError
|
|
59
68
|
? Effect.fail(options.materializeError)
|
|
60
69
|
: Effect.succeed(options.workspace ?? singlePhaseWorkspace)
|
|
@@ -125,7 +134,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
125
134
|
) as Effect.Effect<void, unknown, never>,
|
|
126
135
|
)
|
|
127
136
|
|
|
128
|
-
return { events, probes, launches, run }
|
|
137
|
+
return { events, probes, launches, materializeOptions, run }
|
|
129
138
|
}
|
|
130
139
|
|
|
131
140
|
describe("work command", () => {
|
|
@@ -375,6 +384,7 @@ describe("work command", () => {
|
|
|
375
384
|
expect(verboseLogs).toEqual([
|
|
376
385
|
"Launching opencode in /workbase/tasks/example/code/agency",
|
|
377
386
|
])
|
|
387
|
+
expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
|
|
378
388
|
|
|
379
389
|
const silentHarness = createHarness()
|
|
380
390
|
const silentLogs = await captureLogs(() =>
|
package/src/commands/work.ts
CHANGED
|
@@ -155,7 +155,12 @@ export const work = (
|
|
|
155
155
|
} else {
|
|
156
156
|
const taskId = target.taskId
|
|
157
157
|
const phaseId = target.kind === "phase" ? target.phaseId : undefined
|
|
158
|
-
const workspace = yield* worktrees.materialize(
|
|
158
|
+
const workspace = yield* worktrees.materialize(
|
|
159
|
+
taskId,
|
|
160
|
+
phaseId,
|
|
161
|
+
root,
|
|
162
|
+
options,
|
|
163
|
+
)
|
|
159
164
|
prompt = workspace.phasePath
|
|
160
165
|
? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
|
|
161
166
|
: `Start the task. Read ${workspace.taskPath}.`
|
|
@@ -180,6 +180,7 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
|
|
|
180
180
|
options?: {
|
|
181
181
|
readonly cwd?: string
|
|
182
182
|
readonly captureOutput?: boolean
|
|
183
|
+
readonly forwardOutput?: boolean
|
|
183
184
|
readonly env?: Record<string, string>
|
|
184
185
|
},
|
|
185
186
|
) =>
|
|
@@ -187,8 +188,12 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
|
|
|
187
188
|
spawnProcess(args, {
|
|
188
189
|
cwd: options?.cwd,
|
|
189
190
|
stdin: "pipe",
|
|
190
|
-
stdout: options?.
|
|
191
|
-
|
|
191
|
+
stdout: options?.forwardOutput
|
|
192
|
+
? "tee"
|
|
193
|
+
: options?.captureOutput
|
|
194
|
+
? "pipe"
|
|
195
|
+
: "inherit",
|
|
196
|
+
stderr: options?.forwardOutput ? "tee" : "pipe",
|
|
192
197
|
env: options?.env,
|
|
193
198
|
}),
|
|
194
199
|
Effect.mapError(
|
|
@@ -2,6 +2,7 @@ import { Data, Effect } from "effect"
|
|
|
2
2
|
import { FileSystemService } from "./FileSystemService"
|
|
3
3
|
import { WorkbaseService } from "./WorkbaseService"
|
|
4
4
|
import { WorktreeService } from "./WorktreeService"
|
|
5
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
5
6
|
import { TaskService } from "./TaskService"
|
|
6
7
|
import { PhaseService } from "./PhaseService"
|
|
7
8
|
import {
|
|
@@ -59,6 +60,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
59
60
|
phaseId?: string,
|
|
60
61
|
draft = false,
|
|
61
62
|
startPath: string = process.cwd(),
|
|
63
|
+
options: BaseCommandOptions = {},
|
|
62
64
|
) =>
|
|
63
65
|
Effect.gen(function* () {
|
|
64
66
|
const service = yield* PullRequestService
|
|
@@ -70,6 +72,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
70
72
|
taskId,
|
|
71
73
|
phaseId,
|
|
72
74
|
startPath,
|
|
75
|
+
options,
|
|
73
76
|
)
|
|
74
77
|
const task = yield* tasks.show(taskId, workspace.root)
|
|
75
78
|
const execution =
|
|
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
|
2
2
|
import { Effect } from "effect"
|
|
3
3
|
import { mkdir } from "node:fs/promises"
|
|
4
4
|
import { join } from "node:path"
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
captureLogs,
|
|
7
|
+
cleanupTempDir,
|
|
8
|
+
createTempDir,
|
|
9
|
+
runTestEffect,
|
|
10
|
+
} from "../test-utils"
|
|
6
11
|
import { TaskService } from "./TaskService"
|
|
7
12
|
import { PhaseService } from "./PhaseService"
|
|
8
13
|
import { WorktreeService } from "./WorktreeService"
|
|
@@ -127,6 +132,60 @@ describe("WorktreeService", () => {
|
|
|
127
132
|
).toBe("example\n")
|
|
128
133
|
})
|
|
129
134
|
|
|
135
|
+
test("logs the expanded configured command in verbose mode", async () => {
|
|
136
|
+
await Bun.write(
|
|
137
|
+
join(root, "agency.json"),
|
|
138
|
+
JSON.stringify({
|
|
139
|
+
version: 2,
|
|
140
|
+
worktreeCreateCommand: [
|
|
141
|
+
"sh",
|
|
142
|
+
"-c",
|
|
143
|
+
'git -C "$1" worktree add -b "$3" "$2" "$4" >/dev/null 2>&1',
|
|
144
|
+
"agency-worktree",
|
|
145
|
+
"{repo}",
|
|
146
|
+
"{worktree}",
|
|
147
|
+
"{branch}",
|
|
148
|
+
"{base}",
|
|
149
|
+
],
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
await runTestEffect(
|
|
153
|
+
TaskService.pipe(
|
|
154
|
+
Effect.flatMap((service) =>
|
|
155
|
+
service.create(
|
|
156
|
+
{
|
|
157
|
+
id: "verbose-command",
|
|
158
|
+
ticketUrl: "https://example.com/task",
|
|
159
|
+
repo: "agency",
|
|
160
|
+
branch: "task/verbose-command",
|
|
161
|
+
base: "main",
|
|
162
|
+
},
|
|
163
|
+
root,
|
|
164
|
+
),
|
|
165
|
+
),
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
const logs = await captureLogs(() =>
|
|
170
|
+
runTestEffect(
|
|
171
|
+
WorktreeService.pipe(
|
|
172
|
+
Effect.flatMap((service) =>
|
|
173
|
+
service.materialize("verbose-command", undefined, root, {
|
|
174
|
+
verbose: true,
|
|
175
|
+
}),
|
|
176
|
+
),
|
|
177
|
+
),
|
|
178
|
+
),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
expect(logs).toHaveLength(1)
|
|
182
|
+
expect(logs[0]).toStartWith("Running worktree command: sh -c ")
|
|
183
|
+
expect(logs[0]).toContain(join(root, "repos", "agency"))
|
|
184
|
+
expect(logs[0]).toContain(
|
|
185
|
+
join(root, "tasks", "verbose-command", "code", "agency"),
|
|
186
|
+
)
|
|
187
|
+
})
|
|
188
|
+
|
|
130
189
|
test("selects phases and rejects missing or unexpected phase IDs", async () => {
|
|
131
190
|
await runTestEffect(
|
|
132
191
|
TaskService.pipe(
|
|
@@ -9,6 +9,8 @@ import {
|
|
|
9
9
|
worktreeCommandEnvironment,
|
|
10
10
|
} from "../workbase/worktree-command"
|
|
11
11
|
import type { RepositoryReference } from "../workbase/schemas"
|
|
12
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
13
|
+
import { createLoggers } from "../utils/effect"
|
|
12
14
|
|
|
13
15
|
class WorktreeError extends Data.TaggedError("WorktreeError")<{
|
|
14
16
|
readonly message: string
|
|
@@ -49,6 +51,15 @@ const parseWorktreeList = (output: string): readonly GitWorktree[] => {
|
|
|
49
51
|
return worktrees
|
|
50
52
|
}
|
|
51
53
|
|
|
54
|
+
const formatCommand = (args: readonly string[]) =>
|
|
55
|
+
args
|
|
56
|
+
.map((argument) =>
|
|
57
|
+
/^[A-Za-z0-9_./:=+@%-]+$/.test(argument)
|
|
58
|
+
? argument
|
|
59
|
+
: `'${argument.replaceAll("'", `'\\''`)}'`,
|
|
60
|
+
)
|
|
61
|
+
.join(" ")
|
|
62
|
+
|
|
52
63
|
export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
53
64
|
"WorktreeService",
|
|
54
65
|
{
|
|
@@ -57,12 +68,16 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
57
68
|
taskId: string,
|
|
58
69
|
phaseId?: string,
|
|
59
70
|
startPath: string = process.cwd(),
|
|
71
|
+
options: BaseCommandOptions = {},
|
|
60
72
|
) =>
|
|
61
73
|
Effect.gen(function* () {
|
|
62
74
|
const fs = yield* FileSystemService
|
|
63
75
|
const workbase = yield* WorkbaseService
|
|
64
76
|
const tasks = yield* TaskService
|
|
65
77
|
const phases = yield* PhaseService
|
|
78
|
+
const { verboseLog } = createLoggers(options)
|
|
79
|
+
const forwardCommandOutput =
|
|
80
|
+
options.verbose === true && !options.silent && !options.json
|
|
66
81
|
const { root, config } = yield* workbase.loadConfig(startPath)
|
|
67
82
|
const report = yield* workbase.validate(root)
|
|
68
83
|
const ownershipIssue = report.issues.find((issue) =>
|
|
@@ -258,9 +273,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
258
273
|
]
|
|
259
274
|
}
|
|
260
275
|
|
|
276
|
+
if (config.worktreeCreateCommand) {
|
|
277
|
+
verboseLog(`Running worktree command: ${formatCommand(args)}`)
|
|
278
|
+
}
|
|
261
279
|
const result = yield* fs.runCommand(args, {
|
|
262
280
|
cwd: repositoryPath,
|
|
263
281
|
captureOutput: true,
|
|
282
|
+
forwardOutput:
|
|
283
|
+
config.worktreeCreateCommand && forwardCommandOutput,
|
|
264
284
|
env,
|
|
265
285
|
})
|
|
266
286
|
if (result.exitCode !== 0) {
|
|
@@ -1,8 +1,45 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test"
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
3
|
import { spawnProcess } from "./process"
|
|
4
4
|
|
|
5
5
|
describe("spawnProcess", () => {
|
|
6
|
+
test("forwards and captures output in tee mode", async () => {
|
|
7
|
+
const forwardedStdout: Uint8Array[] = []
|
|
8
|
+
const forwardedStderr: Uint8Array[] = []
|
|
9
|
+
const stdout = spyOn(process.stdout, "write").mockImplementation(((
|
|
10
|
+
chunk: Uint8Array,
|
|
11
|
+
) => {
|
|
12
|
+
forwardedStdout.push(chunk)
|
|
13
|
+
return true
|
|
14
|
+
}) as never)
|
|
15
|
+
const stderr = spyOn(process.stderr, "write").mockImplementation(((
|
|
16
|
+
chunk: Uint8Array,
|
|
17
|
+
) => {
|
|
18
|
+
forwardedStderr.push(chunk)
|
|
19
|
+
return true
|
|
20
|
+
}) as never)
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const result = await Effect.runPromise(
|
|
24
|
+
spawnProcess(
|
|
25
|
+
["sh", "-c", "printf 'standard output'; printf 'standard error' >&2"],
|
|
26
|
+
{ stdout: "tee", stderr: "tee" },
|
|
27
|
+
),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
expect(result).toEqual({
|
|
31
|
+
stdout: "standard output",
|
|
32
|
+
stderr: "standard error",
|
|
33
|
+
exitCode: 0,
|
|
34
|
+
})
|
|
35
|
+
expect(Buffer.concat(forwardedStdout).toString()).toBe("standard output")
|
|
36
|
+
expect(Buffer.concat(forwardedStderr).toString()).toBe("standard error")
|
|
37
|
+
} finally {
|
|
38
|
+
stdout.mockRestore()
|
|
39
|
+
stderr.mockRestore()
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
|
|
6
43
|
test("captures large stdout and stderr without hanging", async () => {
|
|
7
44
|
const line = "x".repeat(4096)
|
|
8
45
|
const script = [
|
package/src/utils/process.ts
CHANGED
|
@@ -15,11 +15,29 @@ interface ProcessResult {
|
|
|
15
15
|
interface SpawnOptions {
|
|
16
16
|
readonly cwd?: string
|
|
17
17
|
readonly stdin?: "pipe" | "inherit"
|
|
18
|
-
readonly stdout?: "pipe" | "inherit"
|
|
19
|
-
readonly stderr?: "pipe" | "inherit"
|
|
18
|
+
readonly stdout?: "pipe" | "inherit" | "tee"
|
|
19
|
+
readonly stderr?: "pipe" | "inherit" | "tee"
|
|
20
20
|
readonly env?: Record<string, string>
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
const readOutput = async (
|
|
24
|
+
stream: ReadableStream<Uint8Array> | null | undefined,
|
|
25
|
+
target?: { write(chunk: Uint8Array): unknown },
|
|
26
|
+
) => {
|
|
27
|
+
if (!stream) return ""
|
|
28
|
+
|
|
29
|
+
const reader = stream.getReader()
|
|
30
|
+
const decoder = new TextDecoder()
|
|
31
|
+
let output = ""
|
|
32
|
+
while (true) {
|
|
33
|
+
const { done, value } = await reader.read()
|
|
34
|
+
if (done) break
|
|
35
|
+
target?.write(value)
|
|
36
|
+
output += decoder.decode(value, { stream: true })
|
|
37
|
+
}
|
|
38
|
+
return output + decoder.decode()
|
|
39
|
+
}
|
|
40
|
+
|
|
23
41
|
/**
|
|
24
42
|
* Generic error for process execution failures
|
|
25
43
|
*/
|
|
@@ -50,8 +68,8 @@ export const spawnProcess = (
|
|
|
50
68
|
const proc = Bun.spawn([...args], {
|
|
51
69
|
cwd: options?.cwd ?? process.cwd(),
|
|
52
70
|
stdin: options?.stdin ?? "pipe",
|
|
53
|
-
stdout: options?.stdout
|
|
54
|
-
stderr: options?.stderr
|
|
71
|
+
stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
|
|
72
|
+
stderr: options?.stderr === "inherit" ? "inherit" : "pipe",
|
|
55
73
|
env: options?.env ? { ...process.env, ...options.env } : process.env,
|
|
56
74
|
})
|
|
57
75
|
// Start draining stdout/stderr immediately so verbose subprocesses
|
|
@@ -59,11 +77,17 @@ export const spawnProcess = (
|
|
|
59
77
|
const stdoutPromise =
|
|
60
78
|
options?.stdout === "inherit"
|
|
61
79
|
? Promise.resolve("")
|
|
62
|
-
:
|
|
80
|
+
: readOutput(
|
|
81
|
+
proc.stdout,
|
|
82
|
+
options?.stdout === "tee" ? process.stdout : undefined,
|
|
83
|
+
)
|
|
63
84
|
const stderrPromise =
|
|
64
85
|
options?.stderr === "inherit"
|
|
65
86
|
? Promise.resolve("")
|
|
66
|
-
:
|
|
87
|
+
: readOutput(
|
|
88
|
+
proc.stderr,
|
|
89
|
+
options?.stderr === "tee" ? process.stderr : undefined,
|
|
90
|
+
)
|
|
67
91
|
|
|
68
92
|
const [exitCode, stdout, stderr] = await Promise.all([
|
|
69
93
|
proc.exited,
|