@markjaquith/agency 2.27.0 → 2.28.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/cli.ts +20 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +1 -0
- package/src/cli-parser.ts +10 -0
- package/src/cli.test.ts +9 -0
- package/src/commands/doctor.test.ts +156 -0
- package/src/commands/doctor.ts +47 -0
- package/src/commands/read-only.test.ts +2 -0
- package/src/services/DoctorService.ts +419 -0
- package/src/test-utils.ts +2 -0
package/cli.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { pr, help as prHelp } from "./src/commands/pr"
|
|
|
9
9
|
import { work, workPrepare, help as workHelp } from "./src/commands/work"
|
|
10
10
|
import { worktree, help as worktreeHelp } from "./src/commands/worktree"
|
|
11
11
|
import { status, help as statusHelp } from "./src/commands/status"
|
|
12
|
+
import { doctor, help as doctorHelp } from "./src/commands/doctor"
|
|
12
13
|
import { validate, help as validateHelp } from "./src/commands/validate"
|
|
13
14
|
import { context, help as contextHelp } from "./src/commands/context"
|
|
14
15
|
import { graph, help as graphHelp } from "./src/commands/graph"
|
|
@@ -41,6 +42,7 @@ import { ClaimService } from "./src/services/ClaimService"
|
|
|
41
42
|
import { SyncService } from "./src/services/SyncService"
|
|
42
43
|
import { ReadinessService } from "./src/services/ReadinessService"
|
|
43
44
|
import { GraphMutationService } from "./src/services/GraphMutationService"
|
|
45
|
+
import { DoctorService } from "./src/services/DoctorService"
|
|
44
46
|
import {
|
|
45
47
|
claimCommand,
|
|
46
48
|
claimHelp,
|
|
@@ -72,6 +74,7 @@ const CliLayer = Layer.mergeAll(
|
|
|
72
74
|
SyncService.Default,
|
|
73
75
|
ReadinessService.Default,
|
|
74
76
|
GraphMutationService.Default,
|
|
77
|
+
DoctorService.Default,
|
|
75
78
|
)
|
|
76
79
|
|
|
77
80
|
/**
|
|
@@ -524,6 +527,22 @@ const commands: Record<string, Command> = {
|
|
|
524
527
|
)
|
|
525
528
|
},
|
|
526
529
|
},
|
|
530
|
+
doctor: {
|
|
531
|
+
run: async (_args: string[], options: Record<string, any>) => {
|
|
532
|
+
if (options.help) {
|
|
533
|
+
console.log(doctorHelp)
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
await runCommand(
|
|
537
|
+
doctor({
|
|
538
|
+
silent: options.silent,
|
|
539
|
+
verbose: options.verbose,
|
|
540
|
+
json: options.json,
|
|
541
|
+
cwd: options.cwd,
|
|
542
|
+
}),
|
|
543
|
+
)
|
|
544
|
+
},
|
|
545
|
+
},
|
|
527
546
|
validate: {
|
|
528
547
|
run: async (args: string[], options: Record<string, any>) => {
|
|
529
548
|
if (options.help) {
|
|
@@ -632,6 +651,7 @@ Commands:
|
|
|
632
651
|
pr create Create a pull request for an execution unit
|
|
633
652
|
repo <subcommand> Manage workbase repositories
|
|
634
653
|
status Show status for the current workbase
|
|
654
|
+
doctor Diagnose workbase health and integrations
|
|
635
655
|
validate [path] Validate a workbase
|
|
636
656
|
context [target] Return complete target context
|
|
637
657
|
graph Export the complete workbase graph
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -123,6 +123,7 @@ describe("strict CLI parsing", () => {
|
|
|
123
123
|
|
|
124
124
|
test("parses addressable resource maintenance commands", () => {
|
|
125
125
|
for (const args of [
|
|
126
|
+
["doctor", "--json"],
|
|
126
127
|
["repo", "show", "agency", "--json"],
|
|
127
128
|
["repo", "fetch", "agency"],
|
|
128
129
|
["repo", "remove", "agency"],
|
package/src/cli-parser.ts
CHANGED
|
@@ -804,6 +804,16 @@ const commands = {
|
|
|
804
804
|
conflicts: viewConflicts,
|
|
805
805
|
},
|
|
806
806
|
},
|
|
807
|
+
doctor: {
|
|
808
|
+
usage: "agency doctor [--json]",
|
|
809
|
+
options: outputOptions,
|
|
810
|
+
command: {
|
|
811
|
+
usage: "agency doctor [--json]",
|
|
812
|
+
minArgs: 0,
|
|
813
|
+
maxArgs: 0,
|
|
814
|
+
options: ["json"],
|
|
815
|
+
},
|
|
816
|
+
},
|
|
807
817
|
validate: {
|
|
808
818
|
usage: "agency validate [path] [--json] [--no-input]",
|
|
809
819
|
options: {
|
package/src/cli.test.ts
CHANGED
|
@@ -935,6 +935,15 @@ status: open
|
|
|
935
935
|
valid: true,
|
|
936
936
|
issues: [],
|
|
937
937
|
})
|
|
938
|
+
const doctor = parseJson(await runCli(["doctor", "--json"], root))
|
|
939
|
+
expect(doctor).toMatchObject({
|
|
940
|
+
version: 1,
|
|
941
|
+
root: workbaseRoot,
|
|
942
|
+
checks: expect.arrayContaining([
|
|
943
|
+
expect.objectContaining({ id: "tool.git", status: "pass" }),
|
|
944
|
+
expect.objectContaining({ id: "workbase.validation", status: "pass" }),
|
|
945
|
+
]),
|
|
946
|
+
})
|
|
938
947
|
|
|
939
948
|
const validation = parseJson(
|
|
940
949
|
await runCli(["validate", root, "--json"], parent),
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
import { chmod, mkdir } from "node:fs/promises"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import {
|
|
6
|
+
captureLogs,
|
|
7
|
+
cleanupTempDir,
|
|
8
|
+
createTempDir,
|
|
9
|
+
runTestEffect,
|
|
10
|
+
} from "../test-utils"
|
|
11
|
+
import { doctor } from "./doctor"
|
|
12
|
+
|
|
13
|
+
const write = async (root: string, path: string, content: string) => {
|
|
14
|
+
const fullPath = join(root, path)
|
|
15
|
+
await mkdir(dirname(fullPath), { recursive: true })
|
|
16
|
+
await Bun.write(fullPath, content)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const managedAgents = (body: string) =>
|
|
20
|
+
`<!-- agency-managed: sha256=${createHash("sha256").update(body).digest("hex")} -->\n\n${body}`
|
|
21
|
+
|
|
22
|
+
describe("doctor command", () => {
|
|
23
|
+
let root: string
|
|
24
|
+
let repository: string
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
root = await createTempDir()
|
|
28
|
+
repository = join(root, "source")
|
|
29
|
+
await mkdir(repository)
|
|
30
|
+
await Bun.$`git init -q -b main ${repository}`
|
|
31
|
+
await Bun.$`git -C ${repository} config user.email test@example.com`
|
|
32
|
+
await Bun.$`git -C ${repository} config user.name Test`
|
|
33
|
+
await write(repository, "README.md", "test\n")
|
|
34
|
+
await Bun.$`git -C ${repository} add README.md`
|
|
35
|
+
await Bun.$`git -C ${repository} commit -q -m initial`
|
|
36
|
+
await Bun.$`git -C ${repository} remote add origin https://example.com/agency.git`
|
|
37
|
+
await write(
|
|
38
|
+
root,
|
|
39
|
+
"agency.json",
|
|
40
|
+
JSON.stringify({
|
|
41
|
+
version: 2,
|
|
42
|
+
runners: { missing: { command: ["definitely-not-installed"] } },
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
await mkdir(join(root, "repos"), { recursive: true })
|
|
46
|
+
await Bun.$`ln -s ${repository} ${join(root, "repos/agency")}`
|
|
47
|
+
await write(
|
|
48
|
+
root,
|
|
49
|
+
"tasks/example/TASK.md",
|
|
50
|
+
`---
|
|
51
|
+
ticketUrl: null
|
|
52
|
+
repo: agency
|
|
53
|
+
branch: feat/example
|
|
54
|
+
base: main
|
|
55
|
+
pr: null
|
|
56
|
+
status: open
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
# Example
|
|
60
|
+
`,
|
|
61
|
+
)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
afterEach(async () => cleanupTempDir(root))
|
|
65
|
+
|
|
66
|
+
test("returns stable checks, severities, and remediation in JSON", async () => {
|
|
67
|
+
const logs = await captureLogs(() =>
|
|
68
|
+
runTestEffect(doctor({ cwd: root, json: true })),
|
|
69
|
+
)
|
|
70
|
+
const report = JSON.parse(logs[0]!)
|
|
71
|
+
|
|
72
|
+
expect(report).toMatchObject({
|
|
73
|
+
version: 1,
|
|
74
|
+
root,
|
|
75
|
+
healthy: false,
|
|
76
|
+
})
|
|
77
|
+
expect(report.checks).toEqual(
|
|
78
|
+
expect.arrayContaining([
|
|
79
|
+
expect.objectContaining({
|
|
80
|
+
id: "tool.git",
|
|
81
|
+
level: "error",
|
|
82
|
+
status: "pass",
|
|
83
|
+
}),
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
id: "integration.runner.missing",
|
|
86
|
+
level: "error",
|
|
87
|
+
status: "fail",
|
|
88
|
+
}),
|
|
89
|
+
expect.objectContaining({
|
|
90
|
+
id: "ref.agency.main",
|
|
91
|
+
status: "pass",
|
|
92
|
+
}),
|
|
93
|
+
expect.objectContaining({
|
|
94
|
+
id: "worktree.task.example",
|
|
95
|
+
status: "pass",
|
|
96
|
+
}),
|
|
97
|
+
]),
|
|
98
|
+
)
|
|
99
|
+
for (const check of report.checks) {
|
|
100
|
+
if (check.status === "fail") expect(check.remediation).toBeTruthy()
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test("reports repository, ref, remote, and managed-file failures", async () => {
|
|
105
|
+
await Bun.$`git -C ${repository} remote remove origin`
|
|
106
|
+
await Bun.$`git -C ${repository} branch -m other`
|
|
107
|
+
await write(root, "AGENTS.md", managedAgents("old\n"))
|
|
108
|
+
|
|
109
|
+
const logs = await captureLogs(() =>
|
|
110
|
+
runTestEffect(doctor({ cwd: root, json: true })),
|
|
111
|
+
)
|
|
112
|
+
const checks = JSON.parse(logs[0]!).checks
|
|
113
|
+
|
|
114
|
+
expect(checks).toEqual(
|
|
115
|
+
expect.arrayContaining([
|
|
116
|
+
expect.objectContaining({
|
|
117
|
+
id: "repository.agency.remote",
|
|
118
|
+
status: "fail",
|
|
119
|
+
}),
|
|
120
|
+
expect.objectContaining({
|
|
121
|
+
id: "ref.agency.main",
|
|
122
|
+
status: "fail",
|
|
123
|
+
}),
|
|
124
|
+
expect.objectContaining({
|
|
125
|
+
id: "integration.file.agents",
|
|
126
|
+
status: "fail",
|
|
127
|
+
}),
|
|
128
|
+
]),
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("is safe when the workbase is read-only", async () => {
|
|
133
|
+
for (const path of [
|
|
134
|
+
join(root, "agency.json"),
|
|
135
|
+
join(root, "tasks/example/TASK.md"),
|
|
136
|
+
]) {
|
|
137
|
+
await chmod(path, 0o444)
|
|
138
|
+
}
|
|
139
|
+
await chmod(join(root, "tasks/example"), 0o555)
|
|
140
|
+
await chmod(join(root, "tasks"), 0o555)
|
|
141
|
+
await chmod(root, 0o555)
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
await runTestEffect(doctor({ cwd: root, silent: true }))
|
|
145
|
+
} finally {
|
|
146
|
+
await chmod(root, 0o755)
|
|
147
|
+
await chmod(join(root, "tasks"), 0o755)
|
|
148
|
+
await chmod(join(root, "tasks/example"), 0o755)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
|
|
152
|
+
expect(
|
|
153
|
+
await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
|
|
154
|
+
).toBe(false)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { DoctorService } from "../services/DoctorService"
|
|
3
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
+
import { createLoggers } from "../utils/effect"
|
|
5
|
+
|
|
6
|
+
interface DoctorOptions extends BaseCommandOptions {
|
|
7
|
+
readonly json?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const doctor = (options: DoctorOptions = {}) =>
|
|
11
|
+
Effect.gen(function* () {
|
|
12
|
+
const service = yield* DoctorService
|
|
13
|
+
const { log } = createLoggers(options)
|
|
14
|
+
const report = yield* service.inspect(options.cwd ?? process.cwd())
|
|
15
|
+
|
|
16
|
+
if (options.json) {
|
|
17
|
+
log(JSON.stringify(report, null, 2))
|
|
18
|
+
return
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const check of report.checks) {
|
|
22
|
+
const marker =
|
|
23
|
+
check.status === "pass"
|
|
24
|
+
? "PASS"
|
|
25
|
+
: check.level === "error"
|
|
26
|
+
? "ERROR"
|
|
27
|
+
: check.level === "warning"
|
|
28
|
+
? "WARN"
|
|
29
|
+
: "OPTIONAL"
|
|
30
|
+
log(`${marker}\t${check.id}\t${check.message}`)
|
|
31
|
+
if (check.remediation) log(` Remediation: ${check.remediation}`)
|
|
32
|
+
}
|
|
33
|
+
log("")
|
|
34
|
+
log(
|
|
35
|
+
`${report.healthy ? "Healthy" : "Unhealthy"}: ${report.summary.errors} error(s), ${report.summary.warnings} warning(s), ${report.summary.optional} unavailable optional capability(s)`,
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
export const help = `
|
|
40
|
+
Usage: agency doctor [options]
|
|
41
|
+
|
|
42
|
+
Diagnose tools, integrations, repositories, refs, worktrees, permissions, and
|
|
43
|
+
managed-file drift without changing the workbase.
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
--json Output the health report as JSON
|
|
47
|
+
`
|
|
@@ -14,6 +14,7 @@ import { validate } from "./validate"
|
|
|
14
14
|
import { context } from "./context"
|
|
15
15
|
import { graph } from "./graph"
|
|
16
16
|
import { next } from "./next"
|
|
17
|
+
import { doctor } from "./doctor"
|
|
17
18
|
|
|
18
19
|
const write = async (root: string, path: string, content: string) => {
|
|
19
20
|
const fullPath = join(root, path)
|
|
@@ -147,6 +148,7 @@ status: open
|
|
|
147
148
|
)
|
|
148
149
|
await runTestEffect(graph({ cwd: root, silent: true }))
|
|
149
150
|
await runTestEffect(next({ cwd: root, silent: true }))
|
|
151
|
+
await runTestEffect(doctor({ cwd: root, silent: true }))
|
|
150
152
|
|
|
151
153
|
expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
|
|
152
154
|
expect(
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { Effect, Either } from "effect"
|
|
2
|
+
import { constants } from "node:fs"
|
|
3
|
+
import { access } from "node:fs/promises"
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path"
|
|
5
|
+
import { EpicService } from "./EpicService"
|
|
6
|
+
import { FileSystemService } from "./FileSystemService"
|
|
7
|
+
import { IntegrationService } from "./IntegrationService"
|
|
8
|
+
import { PhaseService } from "./PhaseService"
|
|
9
|
+
import { RepositoryService } from "./RepositoryService"
|
|
10
|
+
import { TaskService } from "./TaskService"
|
|
11
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
12
|
+
import { WorktreeService } from "./WorktreeService"
|
|
13
|
+
|
|
14
|
+
type DoctorCheckLevel = "error" | "warning" | "optional"
|
|
15
|
+
|
|
16
|
+
interface DoctorCheck {
|
|
17
|
+
readonly id: string
|
|
18
|
+
readonly category:
|
|
19
|
+
| "tool"
|
|
20
|
+
| "integration"
|
|
21
|
+
| "workbase"
|
|
22
|
+
| "repository"
|
|
23
|
+
| "ref"
|
|
24
|
+
| "worktree"
|
|
25
|
+
| "permission"
|
|
26
|
+
readonly level: DoctorCheckLevel
|
|
27
|
+
readonly status: "pass" | "fail"
|
|
28
|
+
readonly message: string
|
|
29
|
+
readonly remediation: string | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface DoctorReport {
|
|
33
|
+
readonly version: 1
|
|
34
|
+
readonly root: string
|
|
35
|
+
readonly healthy: boolean
|
|
36
|
+
readonly summary: {
|
|
37
|
+
readonly passed: number
|
|
38
|
+
readonly errors: number
|
|
39
|
+
readonly warnings: number
|
|
40
|
+
readonly optional: number
|
|
41
|
+
}
|
|
42
|
+
readonly checks: readonly DoctorCheck[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const executableAvailable = (executable: string, root: string) =>
|
|
46
|
+
Effect.tryPromise({
|
|
47
|
+
try: async () => {
|
|
48
|
+
if (executable.includes("/")) {
|
|
49
|
+
const path = isAbsolute(executable)
|
|
50
|
+
? executable
|
|
51
|
+
: resolve(root, executable)
|
|
52
|
+
await access(path, constants.X_OK)
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
return Bun.which(executable) !== null
|
|
56
|
+
},
|
|
57
|
+
catch: () => false,
|
|
58
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(false)))
|
|
59
|
+
|
|
60
|
+
const permissionAvailable = (path: string, mode: number) =>
|
|
61
|
+
Effect.tryPromise({
|
|
62
|
+
try: () => access(path, mode).then(() => true),
|
|
63
|
+
catch: () => false,
|
|
64
|
+
}).pipe(Effect.catchAll(() => Effect.succeed(false)))
|
|
65
|
+
|
|
66
|
+
const messageOf = (error: unknown) =>
|
|
67
|
+
error instanceof Error
|
|
68
|
+
? error.message
|
|
69
|
+
: typeof error === "object" &&
|
|
70
|
+
error !== null &&
|
|
71
|
+
"message" in error &&
|
|
72
|
+
typeof error.message === "string"
|
|
73
|
+
? error.message
|
|
74
|
+
: String(error)
|
|
75
|
+
|
|
76
|
+
export class DoctorService extends Effect.Service<DoctorService>()(
|
|
77
|
+
"DoctorService",
|
|
78
|
+
{
|
|
79
|
+
sync: () => ({
|
|
80
|
+
inspect: (startPath: string = process.cwd()) =>
|
|
81
|
+
Effect.gen(function* () {
|
|
82
|
+
const epics = yield* EpicService
|
|
83
|
+
const fs = yield* FileSystemService
|
|
84
|
+
const integrations = yield* IntegrationService
|
|
85
|
+
const phases = yield* PhaseService
|
|
86
|
+
const repositories = yield* RepositoryService
|
|
87
|
+
const tasks = yield* TaskService
|
|
88
|
+
const workbases = yield* WorkbaseService
|
|
89
|
+
const worktrees = yield* WorktreeService
|
|
90
|
+
const { root, config } = yield* workbases.loadConfig(startPath)
|
|
91
|
+
const checks: DoctorCheck[] = []
|
|
92
|
+
const add = (
|
|
93
|
+
check: Omit<DoctorCheck, "remediation"> & {
|
|
94
|
+
readonly remediation?: string
|
|
95
|
+
},
|
|
96
|
+
) =>
|
|
97
|
+
checks.push({
|
|
98
|
+
...check,
|
|
99
|
+
remediation:
|
|
100
|
+
check.status === "fail"
|
|
101
|
+
? (check.remediation ?? "Remediation is unknown.")
|
|
102
|
+
: null,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const tool = function* (
|
|
106
|
+
id: string,
|
|
107
|
+
executable: string,
|
|
108
|
+
level: DoctorCheckLevel,
|
|
109
|
+
label: string,
|
|
110
|
+
) {
|
|
111
|
+
const available = yield* executableAvailable(executable, root)
|
|
112
|
+
add({
|
|
113
|
+
id,
|
|
114
|
+
category: id.startsWith("tool.") ? "tool" : "integration",
|
|
115
|
+
level,
|
|
116
|
+
status: available ? "pass" : "fail",
|
|
117
|
+
message: available
|
|
118
|
+
? `${label} executable '${executable}' is available`
|
|
119
|
+
: `${label} executable '${executable}' is unavailable`,
|
|
120
|
+
remediation:
|
|
121
|
+
level === "optional"
|
|
122
|
+
? `Install '${executable}' to enable ${label.toLowerCase()}, or leave it unavailable if unused.`
|
|
123
|
+
: `Install '${executable}' and ensure it is executable on PATH.`,
|
|
124
|
+
})
|
|
125
|
+
return available
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const gitAvailable = yield* tool("tool.git", "git", "error", "Git")
|
|
129
|
+
yield* tool(
|
|
130
|
+
"capability.runner.opencode",
|
|
131
|
+
"opencode",
|
|
132
|
+
"optional",
|
|
133
|
+
"OpenCode runner",
|
|
134
|
+
)
|
|
135
|
+
yield* tool(
|
|
136
|
+
"capability.runner.claude",
|
|
137
|
+
"claude",
|
|
138
|
+
"optional",
|
|
139
|
+
"Claude runner",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const configuredCommands: readonly (readonly [
|
|
143
|
+
string,
|
|
144
|
+
readonly string[],
|
|
145
|
+
string,
|
|
146
|
+
])[] = [
|
|
147
|
+
...(config.chooserCommand
|
|
148
|
+
? [
|
|
149
|
+
[
|
|
150
|
+
"integration.chooser",
|
|
151
|
+
config.chooserCommand,
|
|
152
|
+
"Chooser",
|
|
153
|
+
] as const,
|
|
154
|
+
]
|
|
155
|
+
: []),
|
|
156
|
+
...(config.worktreeCreateCommand
|
|
157
|
+
? [
|
|
158
|
+
[
|
|
159
|
+
"integration.worktree-create",
|
|
160
|
+
config.worktreeCreateCommand,
|
|
161
|
+
"Worktree creator",
|
|
162
|
+
] as const,
|
|
163
|
+
]
|
|
164
|
+
: []),
|
|
165
|
+
...Object.entries(config.runners ?? {}).map(
|
|
166
|
+
([name, runner]) =>
|
|
167
|
+
[
|
|
168
|
+
`integration.runner.${name}`,
|
|
169
|
+
runner.command,
|
|
170
|
+
`Configured runner '${name}'`,
|
|
171
|
+
] as const,
|
|
172
|
+
),
|
|
173
|
+
...Object.entries(config.runners ?? {}).flatMap(([name, runner]) =>
|
|
174
|
+
runner.resumeCommand
|
|
175
|
+
? [
|
|
176
|
+
[
|
|
177
|
+
`integration.runner.${name}.resume`,
|
|
178
|
+
runner.resumeCommand,
|
|
179
|
+
`Configured runner '${name}' resume`,
|
|
180
|
+
] as const,
|
|
181
|
+
]
|
|
182
|
+
: [],
|
|
183
|
+
),
|
|
184
|
+
...(config.delivery
|
|
185
|
+
? [
|
|
186
|
+
[
|
|
187
|
+
`integration.delivery.${config.delivery.provider}`,
|
|
188
|
+
config.delivery.createCommand,
|
|
189
|
+
`Delivery provider '${config.delivery.provider}'`,
|
|
190
|
+
] as const,
|
|
191
|
+
[
|
|
192
|
+
`integration.delivery.${config.delivery.provider}.query`,
|
|
193
|
+
config.delivery.queryCommand,
|
|
194
|
+
`Delivery provider '${config.delivery.provider}' query`,
|
|
195
|
+
] as const,
|
|
196
|
+
]
|
|
197
|
+
: []),
|
|
198
|
+
]
|
|
199
|
+
for (const [id, command, label] of configuredCommands) {
|
|
200
|
+
yield* tool(id, command[0]!, "error", label)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const validation = yield* workbases.validate(root)
|
|
204
|
+
add({
|
|
205
|
+
id: "workbase.validation",
|
|
206
|
+
category: "workbase",
|
|
207
|
+
level: "error",
|
|
208
|
+
status: validation.valid ? "pass" : "fail",
|
|
209
|
+
message: validation.valid
|
|
210
|
+
? "Workbase documents and relationships are valid"
|
|
211
|
+
: `Workbase validation found ${validation.issues.length} issue(s): ${validation.issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ")}`,
|
|
212
|
+
remediation:
|
|
213
|
+
"Run 'agency validate' and correct every reported issue.",
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
for (const [id, mode, level, label, remediation] of [
|
|
217
|
+
[
|
|
218
|
+
"permission.workbase.read",
|
|
219
|
+
constants.R_OK,
|
|
220
|
+
"error",
|
|
221
|
+
"readable",
|
|
222
|
+
`Grant the current user read access to ${root}.`,
|
|
223
|
+
],
|
|
224
|
+
[
|
|
225
|
+
"permission.workbase.write",
|
|
226
|
+
constants.W_OK,
|
|
227
|
+
"warning",
|
|
228
|
+
"writable",
|
|
229
|
+
`Grant the current user write access to ${root} before running mutation commands.`,
|
|
230
|
+
],
|
|
231
|
+
] as const) {
|
|
232
|
+
const available = yield* permissionAvailable(root, mode)
|
|
233
|
+
add({
|
|
234
|
+
id,
|
|
235
|
+
category: "permission",
|
|
236
|
+
level,
|
|
237
|
+
status: available ? "pass" : "fail",
|
|
238
|
+
message: `Workbase root is ${available ? "" : "not "}${label}`,
|
|
239
|
+
remediation,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const integrationStatus = yield* integrations.status(root)
|
|
244
|
+
for (const file of integrationStatus.files) {
|
|
245
|
+
const failed = file.state === "missing" || file.state === "drifted"
|
|
246
|
+
add({
|
|
247
|
+
id: `integration.file.${file.name}`,
|
|
248
|
+
category: "integration",
|
|
249
|
+
level: file.state === "customized" ? "optional" : "warning",
|
|
250
|
+
status: failed ? "fail" : "pass",
|
|
251
|
+
message: `${file.name} integration file is ${file.state}: ${file.path}`,
|
|
252
|
+
remediation:
|
|
253
|
+
"Run 'agency integration sync' to restore managed content.",
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const refs = new Map<string, Set<string>>()
|
|
258
|
+
const declareRef = (repo: string, ref: string) => {
|
|
259
|
+
const values = refs.get(repo) ?? new Set<string>()
|
|
260
|
+
values.add(ref)
|
|
261
|
+
refs.set(repo, values)
|
|
262
|
+
}
|
|
263
|
+
if (validation.valid) {
|
|
264
|
+
for (const epic of yield* epics.list(root)) {
|
|
265
|
+
for (const reference of epic.data.repos)
|
|
266
|
+
declareRef(reference.repo, reference.ref)
|
|
267
|
+
}
|
|
268
|
+
for (const task of yield* tasks.list(root)) {
|
|
269
|
+
if ("repo" in task.data) {
|
|
270
|
+
declareRef(task.data.repo, task.data.base)
|
|
271
|
+
for (const reference of task.data.repos ?? [])
|
|
272
|
+
declareRef(reference.repo, reference.ref)
|
|
273
|
+
} else {
|
|
274
|
+
for (const phase of yield* phases.list(task.id, root)) {
|
|
275
|
+
declareRef(phase.data.repo, phase.data.base)
|
|
276
|
+
for (const reference of phase.data.repos ?? [])
|
|
277
|
+
declareRef(reference.repo, reference.ref)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const repositoryList = gitAvailable
|
|
284
|
+
? yield* repositories.list(root)
|
|
285
|
+
: []
|
|
286
|
+
if (!gitAvailable) {
|
|
287
|
+
add({
|
|
288
|
+
id: "repository.inspection",
|
|
289
|
+
category: "repository",
|
|
290
|
+
level: "warning",
|
|
291
|
+
status: "fail",
|
|
292
|
+
message:
|
|
293
|
+
"Repository, ref, remote, and worktree checks were skipped because Git is unavailable",
|
|
294
|
+
remediation: "Install 'git' and rerun 'agency doctor'.",
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
for (const repository of repositoryList) {
|
|
298
|
+
const verified = yield* fs.runCommand(
|
|
299
|
+
["git", "-C", repository.path, "rev-parse", "--git-dir"],
|
|
300
|
+
{ captureOutput: true },
|
|
301
|
+
)
|
|
302
|
+
const repositoryValid = verified.exitCode === 0
|
|
303
|
+
add({
|
|
304
|
+
id: `repository.${repository.alias}.valid`,
|
|
305
|
+
category: "repository",
|
|
306
|
+
level: "error",
|
|
307
|
+
status: repositoryValid ? "pass" : "fail",
|
|
308
|
+
message: repositoryValid
|
|
309
|
+
? `Repository '${repository.alias}' is a valid Git repository`
|
|
310
|
+
: `Repository '${repository.alias}' is not a valid Git repository`,
|
|
311
|
+
remediation: `Run 'agency repo verify ${repository.alias}', then repair or relink the repository.`,
|
|
312
|
+
})
|
|
313
|
+
add({
|
|
314
|
+
id: `repository.${repository.alias}.remote`,
|
|
315
|
+
category: "repository",
|
|
316
|
+
level: "warning",
|
|
317
|
+
status: repository.remote ? "pass" : "fail",
|
|
318
|
+
message: repository.remote
|
|
319
|
+
? `Repository '${repository.alias}' origin is ${repository.remote}`
|
|
320
|
+
: `Repository '${repository.alias}' has no origin remote`,
|
|
321
|
+
remediation: `Run 'agency repo remote ${repository.alias} <url>' to configure origin.`,
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
for (const ref of [...(refs.get(repository.alias) ?? [])].sort()) {
|
|
325
|
+
const local = yield* fs.runCommand(
|
|
326
|
+
[
|
|
327
|
+
"git",
|
|
328
|
+
"-C",
|
|
329
|
+
repository.path,
|
|
330
|
+
"rev-parse",
|
|
331
|
+
"--verify",
|
|
332
|
+
`${ref}^{commit}`,
|
|
333
|
+
],
|
|
334
|
+
{ captureOutput: true },
|
|
335
|
+
)
|
|
336
|
+
const remote =
|
|
337
|
+
local.exitCode === 0
|
|
338
|
+
? local
|
|
339
|
+
: yield* fs.runCommand(
|
|
340
|
+
[
|
|
341
|
+
"git",
|
|
342
|
+
"-C",
|
|
343
|
+
repository.path,
|
|
344
|
+
"rev-parse",
|
|
345
|
+
"--verify",
|
|
346
|
+
`origin/${ref}^{commit}`,
|
|
347
|
+
],
|
|
348
|
+
{ captureOutput: true },
|
|
349
|
+
)
|
|
350
|
+
const found = local.exitCode === 0 || remote.exitCode === 0
|
|
351
|
+
add({
|
|
352
|
+
id: `ref.${repository.alias}.${ref}`,
|
|
353
|
+
category: "ref",
|
|
354
|
+
level: "error",
|
|
355
|
+
status: found ? "pass" : "fail",
|
|
356
|
+
message: `Declared ref '${ref}' for '${repository.alias}' is ${found ? "available" : "missing"}`,
|
|
357
|
+
remediation: `Run 'agency repo fetch ${repository.alias}' and verify that ref '${ref}' exists on origin.`,
|
|
358
|
+
})
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (validation.valid && gitAvailable) {
|
|
363
|
+
const inspected = yield* Effect.either(worktrees.list(root))
|
|
364
|
+
if (Either.isLeft(inspected)) {
|
|
365
|
+
add({
|
|
366
|
+
id: "worktree.inspection",
|
|
367
|
+
category: "worktree",
|
|
368
|
+
level: "warning",
|
|
369
|
+
status: "fail",
|
|
370
|
+
message: `Worktree inspection failed: ${messageOf(inspected.left)}`,
|
|
371
|
+
remediation:
|
|
372
|
+
"Remediation is unknown; inspect repositories with 'agency worktree list --json'.",
|
|
373
|
+
})
|
|
374
|
+
} else {
|
|
375
|
+
for (const inspection of inspected.right) {
|
|
376
|
+
const target =
|
|
377
|
+
inspection.owner.kind === "phase"
|
|
378
|
+
? `${inspection.owner.taskId}.${inspection.owner.phaseId}`
|
|
379
|
+
: inspection.owner.taskId
|
|
380
|
+
add({
|
|
381
|
+
id: `worktree.${inspection.owner.kind}.${target}`,
|
|
382
|
+
category: "worktree",
|
|
383
|
+
level: "error",
|
|
384
|
+
status: inspection.conflicts.length === 0 ? "pass" : "fail",
|
|
385
|
+
message:
|
|
386
|
+
inspection.conflicts.length === 0
|
|
387
|
+
? `Worktree registrations for '${target}' are consistent`
|
|
388
|
+
: inspection.conflicts
|
|
389
|
+
.map((conflict) => conflict.message)
|
|
390
|
+
.join("; "),
|
|
391
|
+
remediation: `Run 'agency worktree inspect ${inspection.owner.taskId}${inspection.owner.phaseId ? ` ${inspection.owner.phaseId}` : ""}', then use 'agency worktree repair' if appropriate.`,
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const summary = {
|
|
398
|
+
passed: checks.filter((check) => check.status === "pass").length,
|
|
399
|
+
errors: checks.filter(
|
|
400
|
+
(check) => check.status === "fail" && check.level === "error",
|
|
401
|
+
).length,
|
|
402
|
+
warnings: checks.filter(
|
|
403
|
+
(check) => check.status === "fail" && check.level === "warning",
|
|
404
|
+
).length,
|
|
405
|
+
optional: checks.filter(
|
|
406
|
+
(check) => check.status === "fail" && check.level === "optional",
|
|
407
|
+
).length,
|
|
408
|
+
}
|
|
409
|
+
return {
|
|
410
|
+
version: 1,
|
|
411
|
+
root,
|
|
412
|
+
healthy: summary.errors === 0,
|
|
413
|
+
summary,
|
|
414
|
+
checks,
|
|
415
|
+
} satisfies DoctorReport
|
|
416
|
+
}),
|
|
417
|
+
}),
|
|
418
|
+
},
|
|
419
|
+
) {}
|
package/src/test-utils.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { ClaimService } from "./services/ClaimService"
|
|
|
19
19
|
import { SyncService } from "./services/SyncService"
|
|
20
20
|
import { ReadinessService } from "./services/ReadinessService"
|
|
21
21
|
import { GraphMutationService } from "./services/GraphMutationService"
|
|
22
|
+
import { DoctorService } from "./services/DoctorService"
|
|
22
23
|
|
|
23
24
|
export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
|
|
24
25
|
|
|
@@ -42,6 +43,7 @@ const TestLayer = Layer.mergeAll(
|
|
|
42
43
|
SyncService.Default,
|
|
43
44
|
ReadinessService.Default,
|
|
44
45
|
GraphMutationService.Default,
|
|
46
|
+
DoctorService.Default,
|
|
45
47
|
)
|
|
46
48
|
|
|
47
49
|
export async function runTestEffect<A, E>(
|