@markjaquith/agency 2.24.0 → 2.26.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 +14 -2
- package/cli.ts +10 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +10 -1
- package/src/cli-parser.test.ts +26 -0
- package/src/cli-parser.ts +57 -2
- package/src/commands/archive.test.ts +22 -0
- package/src/commands/archive.ts +3 -1
- package/src/commands/repo.test.ts +44 -0
- package/src/commands/repo.ts +107 -1
- package/src/commands/workbase.test.ts +47 -0
- package/src/commands/workbase.ts +52 -1
- package/src/services/ArchiveService.test.ts +59 -0
- package/src/services/ArchiveService.ts +282 -113
- package/src/services/LifecycleTransaction.test.ts +107 -0
- package/src/services/LifecycleTransaction.ts +302 -0
- package/src/services/PhaseService.ts +156 -48
- package/src/services/RepositoryService.test.ts +129 -1
- package/src/services/RepositoryService.ts +181 -0
- package/src/services/TaskPhaseService.test.ts +47 -1
- package/src/services/TaskService.ts +28 -4
- package/src/services/WorkbaseService.test.ts +46 -0
- package/src/services/WorkbaseService.ts +94 -25
- package/src/services/WorktreeLock.ts +60 -0
- package/src/services/WorktreeService.test.ts +174 -1
- package/src/services/WorktreeService.ts +972 -523
|
@@ -2,6 +2,7 @@ import { Schema, TreeFormatter } from "@effect/schema"
|
|
|
2
2
|
import { Data, Effect, Either } from "effect"
|
|
3
3
|
import { join, resolve } from "node:path"
|
|
4
4
|
import { FileSystemService } from "./FileSystemService"
|
|
5
|
+
import { GraphService } from "./GraphService"
|
|
5
6
|
import { WorkbaseService } from "./WorkbaseService"
|
|
6
7
|
import { RepositoryAlias } from "../workbase/schemas"
|
|
7
8
|
|
|
@@ -18,6 +19,11 @@ interface RepositoryInfo {
|
|
|
18
19
|
readonly target: string | null
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
interface RepositoryVerification extends RepositoryInfo {
|
|
23
|
+
readonly valid: boolean
|
|
24
|
+
readonly issues: readonly string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
const validateAlias = (alias: string) => {
|
|
22
28
|
const result = Schema.decodeUnknownEither(RepositoryAlias)(alias)
|
|
23
29
|
return Either.isLeft(result)
|
|
@@ -29,6 +35,65 @@ const validateAlias = (alias: string) => {
|
|
|
29
35
|
: Effect.succeed(result.right)
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
const find = (alias: string, startPath: string) =>
|
|
39
|
+
Effect.gen(function* () {
|
|
40
|
+
const service = yield* RepositoryService
|
|
41
|
+
const validAlias = yield* validateAlias(alias)
|
|
42
|
+
const repositories = yield* service.list(startPath)
|
|
43
|
+
const repository = repositories.find((item) => item.alias === validAlias)
|
|
44
|
+
if (!repository) {
|
|
45
|
+
return yield* new RepositoryError({
|
|
46
|
+
message: `Unknown repository alias '${validAlias}'`,
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
return repository
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const removalBlockers = (repository: RepositoryInfo, startPath: string) =>
|
|
53
|
+
Effect.gen(function* () {
|
|
54
|
+
const fs = yield* FileSystemService
|
|
55
|
+
const graph = yield* GraphService
|
|
56
|
+
const report = yield* graph.get({ cwd: startPath })
|
|
57
|
+
const repositoryId = `repository:${repository.alias}`
|
|
58
|
+
const references = report.edges
|
|
59
|
+
.filter(
|
|
60
|
+
(edge) =>
|
|
61
|
+
edge.to === repositoryId &&
|
|
62
|
+
(edge.kind === "writes" || edge.kind === "references"),
|
|
63
|
+
)
|
|
64
|
+
.map((edge) => edge.from)
|
|
65
|
+
.sort()
|
|
66
|
+
const worktrees: string[] = []
|
|
67
|
+
if (repository.kind !== "symlink") {
|
|
68
|
+
const result = yield* fs.runCommand(
|
|
69
|
+
["git", "-C", repository.path, "worktree", "list", "--porcelain"],
|
|
70
|
+
{ captureOutput: true },
|
|
71
|
+
)
|
|
72
|
+
if (result.exitCode === 0) {
|
|
73
|
+
for (const block of result.stdout.trim().split(/\n\n+/)) {
|
|
74
|
+
if (!block || /(^|\n)bare(\n|$)/.test(block)) continue
|
|
75
|
+
const path = block.match(/^worktree (.+)$/m)?.[1]
|
|
76
|
+
if (path) worktrees.push(path)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { references, worktrees }
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const assertRemovable = (repository: RepositoryInfo, startPath: string) =>
|
|
84
|
+
Effect.gen(function* () {
|
|
85
|
+
const blockers = yield* removalBlockers(repository, startPath)
|
|
86
|
+
const details = [
|
|
87
|
+
...blockers.references.map((item) => `active reference ${item}`),
|
|
88
|
+
...blockers.worktrees.map((item) => `linked worktree ${item}`),
|
|
89
|
+
]
|
|
90
|
+
if (details.length > 0) {
|
|
91
|
+
return yield* new RepositoryError({
|
|
92
|
+
message: `Repository alias '${repository.alias}' is in use and cannot be removed or renamed:\n${details.map((item) => `- ${item}`).join("\n")}`,
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
|
|
32
97
|
export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
33
98
|
"RepositoryService",
|
|
34
99
|
{
|
|
@@ -154,6 +219,122 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
|
|
|
154
219
|
|
|
155
220
|
return repositories
|
|
156
221
|
}),
|
|
222
|
+
|
|
223
|
+
show: (alias: string, startPath: string = process.cwd()) =>
|
|
224
|
+
find(alias, startPath),
|
|
225
|
+
|
|
226
|
+
fetch: (alias: string, startPath: string = process.cwd()) =>
|
|
227
|
+
Effect.gen(function* () {
|
|
228
|
+
const fs = yield* FileSystemService
|
|
229
|
+
const repository = yield* find(alias, startPath)
|
|
230
|
+
const result = yield* fs.runCommand(
|
|
231
|
+
["git", "-C", repository.path, "fetch", "--prune"],
|
|
232
|
+
{ captureOutput: true },
|
|
233
|
+
)
|
|
234
|
+
if (result.exitCode !== 0) {
|
|
235
|
+
return yield* new RepositoryError({
|
|
236
|
+
message: `Failed to fetch repository '${alias}': ${result.stderr.trim()}`,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
return repository
|
|
240
|
+
}),
|
|
241
|
+
|
|
242
|
+
remove: (alias: string, startPath: string = process.cwd()) =>
|
|
243
|
+
Effect.gen(function* () {
|
|
244
|
+
const fs = yield* FileSystemService
|
|
245
|
+
const repository = yield* find(alias, startPath)
|
|
246
|
+
yield* assertRemovable(repository, startPath)
|
|
247
|
+
yield* fs.deleteDirectory(repository.path)
|
|
248
|
+
return repository
|
|
249
|
+
}),
|
|
250
|
+
|
|
251
|
+
unlink: (alias: string, startPath: string = process.cwd()) =>
|
|
252
|
+
Effect.gen(function* () {
|
|
253
|
+
const service = yield* RepositoryService
|
|
254
|
+
const repository = yield* find(alias, startPath)
|
|
255
|
+
if (repository.kind !== "symlink") {
|
|
256
|
+
return yield* new RepositoryError({
|
|
257
|
+
message: `Repository alias '${alias}' is not a link; use 'agency repo remove ${alias}'`,
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
return yield* service.remove(alias, startPath)
|
|
261
|
+
}),
|
|
262
|
+
|
|
263
|
+
rename: (
|
|
264
|
+
alias: string,
|
|
265
|
+
newAlias: string,
|
|
266
|
+
startPath: string = process.cwd(),
|
|
267
|
+
) =>
|
|
268
|
+
Effect.gen(function* () {
|
|
269
|
+
const fs = yield* FileSystemService
|
|
270
|
+
const workbase = yield* WorkbaseService
|
|
271
|
+
const repository = yield* find(alias, startPath)
|
|
272
|
+
const validNewAlias = yield* validateAlias(newAlias)
|
|
273
|
+
const root = yield* workbase.discover(startPath)
|
|
274
|
+
const destination = join(root, "repos", validNewAlias)
|
|
275
|
+
if (yield* fs.exists(destination)) {
|
|
276
|
+
return yield* new RepositoryError({
|
|
277
|
+
message: `Repository alias '${validNewAlias}' already exists`,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
yield* assertRemovable(repository, startPath)
|
|
281
|
+
yield* fs.moveDirectory(repository.path, destination)
|
|
282
|
+
return yield* find(validNewAlias, startPath)
|
|
283
|
+
}),
|
|
284
|
+
|
|
285
|
+
remote: (
|
|
286
|
+
alias: string,
|
|
287
|
+
remote: string | undefined,
|
|
288
|
+
startPath: string = process.cwd(),
|
|
289
|
+
) =>
|
|
290
|
+
Effect.gen(function* () {
|
|
291
|
+
const fs = yield* FileSystemService
|
|
292
|
+
const repository = yield* find(alias, startPath)
|
|
293
|
+
if (remote === undefined) return repository
|
|
294
|
+
if (!remote.trim()) {
|
|
295
|
+
return yield* new RepositoryError({
|
|
296
|
+
message: "Repository remote is required",
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
const hasOrigin = repository.remote !== null
|
|
300
|
+
const result = yield* fs.runCommand(
|
|
301
|
+
[
|
|
302
|
+
"git",
|
|
303
|
+
"-C",
|
|
304
|
+
repository.path,
|
|
305
|
+
"remote",
|
|
306
|
+
hasOrigin ? "set-url" : "add",
|
|
307
|
+
"origin",
|
|
308
|
+
remote,
|
|
309
|
+
],
|
|
310
|
+
{ captureOutput: true },
|
|
311
|
+
)
|
|
312
|
+
if (result.exitCode !== 0) {
|
|
313
|
+
return yield* new RepositoryError({
|
|
314
|
+
message: `Failed to update remote for repository '${alias}': ${result.stderr.trim()}`,
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
return yield* find(alias, startPath)
|
|
318
|
+
}),
|
|
319
|
+
|
|
320
|
+
verify: (alias: string, startPath: string = process.cwd()) =>
|
|
321
|
+
Effect.gen(function* () {
|
|
322
|
+
const fs = yield* FileSystemService
|
|
323
|
+
const repository = yield* find(alias, startPath)
|
|
324
|
+
const issues: string[] = []
|
|
325
|
+
const git = yield* fs.runCommand(
|
|
326
|
+
["git", "-C", repository.path, "rev-parse", "--git-dir"],
|
|
327
|
+
{ captureOutput: true },
|
|
328
|
+
)
|
|
329
|
+
if (git.exitCode !== 0) issues.push("Path is not a Git repository")
|
|
330
|
+
if (repository.remote === null)
|
|
331
|
+
issues.push("Origin remote is not configured")
|
|
332
|
+
return {
|
|
333
|
+
...repository,
|
|
334
|
+
valid: issues.length === 0,
|
|
335
|
+
issues,
|
|
336
|
+
} satisfies RepositoryVerification
|
|
337
|
+
}),
|
|
157
338
|
}),
|
|
158
339
|
},
|
|
159
340
|
) {}
|
|
@@ -1,6 +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
|
+
import { mkdir, rm } from "node:fs/promises"
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
6
|
import { EpicService } from "./EpicService"
|
|
@@ -60,6 +60,52 @@ describe("task and phase services", () => {
|
|
|
60
60
|
expect(epic.data.tasks).toEqual([{ id: "task-one" }])
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
+
test("does not create a task when its parent update cannot start", async () => {
|
|
64
|
+
await runTestEffect(
|
|
65
|
+
EpicService.pipe(
|
|
66
|
+
Effect.flatMap((service) =>
|
|
67
|
+
service.create(
|
|
68
|
+
"locked",
|
|
69
|
+
"https://example.com/epic",
|
|
70
|
+
[{ repo: "agency", ref: "main" }],
|
|
71
|
+
root,
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
const lock = join(root, ".agency-graph-mutation.lock")
|
|
77
|
+
await Bun.write(lock, "held")
|
|
78
|
+
await expect(
|
|
79
|
+
runTestEffect(
|
|
80
|
+
TaskService.pipe(
|
|
81
|
+
Effect.flatMap((service) =>
|
|
82
|
+
service.create(
|
|
83
|
+
{
|
|
84
|
+
id: "not-created",
|
|
85
|
+
ticketUrl: null,
|
|
86
|
+
epic: "locked",
|
|
87
|
+
repo: "agency",
|
|
88
|
+
branch: "task/not-created",
|
|
89
|
+
base: "main",
|
|
90
|
+
},
|
|
91
|
+
root,
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
),
|
|
96
|
+
).rejects.toThrow("Another graph mutation is in progress")
|
|
97
|
+
await rm(lock)
|
|
98
|
+
expect(
|
|
99
|
+
await Bun.file(join(root, "tasks/not-created/TASK.md")).exists(),
|
|
100
|
+
).toBe(false)
|
|
101
|
+
const epic = await runTestEffect(
|
|
102
|
+
EpicService.pipe(
|
|
103
|
+
Effect.flatMap((service) => service.show("locked", root)),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
expect(epic.data.tasks).toEqual([])
|
|
107
|
+
})
|
|
108
|
+
|
|
63
109
|
test("creates and sequences phases on a multi-phase task", async () => {
|
|
64
110
|
await runTestEffect(
|
|
65
111
|
TaskService.pipe(
|
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
import { canTransitionStatus } from "../readiness"
|
|
19
19
|
import { documentRevision } from "../workbase/document-revision"
|
|
20
20
|
import { archivedTaskDirectory } from "../workbase/archive"
|
|
21
|
+
import {
|
|
22
|
+
documentWriteStep,
|
|
23
|
+
runLifecycleTransaction,
|
|
24
|
+
} from "./LifecycleTransaction"
|
|
21
25
|
|
|
22
26
|
class TaskError extends Data.TaggedError("TaskError")<{
|
|
23
27
|
readonly message: string
|
|
@@ -129,6 +133,12 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
129
133
|
...(data.repos ?? []).map((reference) => reference.repo),
|
|
130
134
|
]
|
|
131
135
|
: []
|
|
136
|
+
if (new Set(referencedRepos).size !== referencedRepos.length) {
|
|
137
|
+
return yield* new TaskError({
|
|
138
|
+
message:
|
|
139
|
+
"Repository references must be unique and cannot include the writable repository",
|
|
140
|
+
})
|
|
141
|
+
}
|
|
132
142
|
for (const alias of referencedRepos) {
|
|
133
143
|
if (!(yield* fs.exists(join(root, "repos", alias)))) {
|
|
134
144
|
return yield* new TaskError({
|
|
@@ -140,6 +150,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
140
150
|
let parentEpic: EpicRecord | undefined
|
|
141
151
|
if (input.epic) {
|
|
142
152
|
parentEpic = yield* epics.show(input.epic, root)
|
|
153
|
+
if (parentEpic.data.tasks.some((task) => task.id === id)) {
|
|
154
|
+
return yield* new TaskError({
|
|
155
|
+
message: `Epic '${input.epic}' already lists task '${id}'`,
|
|
156
|
+
})
|
|
157
|
+
}
|
|
143
158
|
}
|
|
144
159
|
|
|
145
160
|
const title = id
|
|
@@ -150,9 +165,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
150
165
|
data,
|
|
151
166
|
`# ${title}\n\nDescribe the task outcome.`,
|
|
152
167
|
)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
168
|
+
const writes: {
|
|
169
|
+
path: string
|
|
170
|
+
content: string
|
|
171
|
+
create?: boolean
|
|
172
|
+
}[] = [{ path, content, create: true }]
|
|
156
173
|
if (input.epic && parentEpic) {
|
|
157
174
|
const parsed = yield* parseFrontmatter(
|
|
158
175
|
parentEpic.content,
|
|
@@ -163,8 +180,15 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
163
180
|
tasks: [...parentEpic.data.tasks, { id }],
|
|
164
181
|
}
|
|
165
182
|
const updated = formatMarkdownDocument(epicData, parsed.body)
|
|
166
|
-
|
|
183
|
+
writes.push({ path: parentEpic.path, content: updated })
|
|
167
184
|
}
|
|
185
|
+
yield* runLifecycleTransaction({
|
|
186
|
+
root,
|
|
187
|
+
preconditions: parentEpic
|
|
188
|
+
? [{ path: parentEpic.path, revision: parentEpic.revision }]
|
|
189
|
+
: [],
|
|
190
|
+
steps: [documentWriteStep(root, writes)],
|
|
191
|
+
})
|
|
168
192
|
|
|
169
193
|
return {
|
|
170
194
|
id,
|
|
@@ -111,6 +111,52 @@ describe("WorkbaseService", () => {
|
|
|
111
111
|
expect(result.defaultWorkbase).toEqual(registered)
|
|
112
112
|
})
|
|
113
113
|
|
|
114
|
+
test("shows, names, clears names, and defaults by path", async () => {
|
|
115
|
+
const workbaseRoot = join(root, "workbase")
|
|
116
|
+
const configDirectory = join(root, "config")
|
|
117
|
+
await write(workbaseRoot, "agency.json", '{"version":2}\n')
|
|
118
|
+
const registered = await runTestEffect(
|
|
119
|
+
WorkbaseService.pipe(
|
|
120
|
+
Effect.flatMap((service) =>
|
|
121
|
+
service.register(workbaseRoot, configDirectory),
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
const result = await runTestEffect(
|
|
127
|
+
WorkbaseService.pipe(
|
|
128
|
+
Effect.flatMap((service) =>
|
|
129
|
+
Effect.gen(function* () {
|
|
130
|
+
const named = yield* service.nameRegistered(
|
|
131
|
+
workbaseRoot,
|
|
132
|
+
"primary",
|
|
133
|
+
configDirectory,
|
|
134
|
+
)
|
|
135
|
+
const shown = yield* service.showRegistered(
|
|
136
|
+
"primary",
|
|
137
|
+
configDirectory,
|
|
138
|
+
)
|
|
139
|
+
const defaultWorkbase = yield* service.setDefault(
|
|
140
|
+
workbaseRoot,
|
|
141
|
+
configDirectory,
|
|
142
|
+
)
|
|
143
|
+
const unnamed = yield* service.nameRegistered(
|
|
144
|
+
registered.id,
|
|
145
|
+
null,
|
|
146
|
+
configDirectory,
|
|
147
|
+
)
|
|
148
|
+
return { named, shown, defaultWorkbase, unnamed }
|
|
149
|
+
}),
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
expect(result.named.name).toBe("primary")
|
|
155
|
+
expect(result.shown).toEqual(result.named)
|
|
156
|
+
expect(result.defaultWorkbase?.id).toBe(registered.id)
|
|
157
|
+
expect(result.unnamed).toEqual({ id: registered.id, path: registered.path })
|
|
158
|
+
})
|
|
159
|
+
|
|
114
160
|
test("rejects names that collide with stable IDs", async () => {
|
|
115
161
|
const firstRoot = join(root, "first")
|
|
116
162
|
const secondRoot = join(root, "second")
|
|
@@ -146,6 +146,31 @@ const writeRegistry = (
|
|
|
146
146
|
yield* fs.writeJSON(path, registry)
|
|
147
147
|
})
|
|
148
148
|
|
|
149
|
+
const findRegistration = (
|
|
150
|
+
selector: string,
|
|
151
|
+
configDirectory?: string,
|
|
152
|
+
basePath: string = process.cwd(),
|
|
153
|
+
) =>
|
|
154
|
+
Effect.gen(function* () {
|
|
155
|
+
const fs = yield* FileSystemService
|
|
156
|
+
const { path, registry } = yield* readRegistry(configDirectory)
|
|
157
|
+
const candidatePath = resolve(basePath, selector)
|
|
158
|
+
const canonicalCandidate = (yield* fs.exists(candidatePath))
|
|
159
|
+
? yield* fs.realPath(candidatePath)
|
|
160
|
+
: candidatePath
|
|
161
|
+
const entry =
|
|
162
|
+
registry.workbases.find((item) => item.id === selector) ??
|
|
163
|
+
registry.workbases.find((item) => item.name === selector) ??
|
|
164
|
+
registry.workbases.find((item) => item.path === canonicalCandidate)
|
|
165
|
+
if (!entry) {
|
|
166
|
+
return yield* new WorkbaseRegistryError({
|
|
167
|
+
path,
|
|
168
|
+
message: `Unknown workbase selector '${selector}'`,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
return { path, registry, entry }
|
|
172
|
+
})
|
|
173
|
+
|
|
149
174
|
export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
150
175
|
"WorkbaseService",
|
|
151
176
|
{
|
|
@@ -401,22 +426,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
401
426
|
basePath: string = process.cwd(),
|
|
402
427
|
) =>
|
|
403
428
|
Effect.gen(function* () {
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
: candidatePath
|
|
410
|
-
const entry =
|
|
411
|
-
registry.workbases.find((item) => item.id === selector) ??
|
|
412
|
-
registry.workbases.find((item) => item.name === selector) ??
|
|
413
|
-
registry.workbases.find((item) => item.path === canonicalCandidate)
|
|
414
|
-
if (!entry) {
|
|
415
|
-
return yield* new WorkbaseRegistryError({
|
|
416
|
-
path,
|
|
417
|
-
message: `Unknown workbase selector '${selector}'`,
|
|
418
|
-
})
|
|
419
|
-
}
|
|
429
|
+
const { path, registry, entry } = yield* findRegistration(
|
|
430
|
+
selector,
|
|
431
|
+
configDirectory,
|
|
432
|
+
basePath,
|
|
433
|
+
)
|
|
420
434
|
const workbases = registry.workbases.filter(
|
|
421
435
|
(item) => item.id !== entry.id,
|
|
422
436
|
)
|
|
@@ -431,6 +445,61 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
431
445
|
return entry
|
|
432
446
|
}),
|
|
433
447
|
|
|
448
|
+
showRegistered: (
|
|
449
|
+
selector: string,
|
|
450
|
+
configDirectory?: string,
|
|
451
|
+
basePath: string = process.cwd(),
|
|
452
|
+
) =>
|
|
453
|
+
findRegistration(selector, configDirectory, basePath).pipe(
|
|
454
|
+
Effect.map(({ entry }) => entry),
|
|
455
|
+
),
|
|
456
|
+
|
|
457
|
+
nameRegistered: (
|
|
458
|
+
selector: string,
|
|
459
|
+
name: string | null,
|
|
460
|
+
configDirectory?: string,
|
|
461
|
+
basePath: string = process.cwd(),
|
|
462
|
+
) =>
|
|
463
|
+
Effect.gen(function* () {
|
|
464
|
+
const { path, registry, entry } = yield* findRegistration(
|
|
465
|
+
selector,
|
|
466
|
+
configDirectory,
|
|
467
|
+
basePath,
|
|
468
|
+
)
|
|
469
|
+
if (name !== null) {
|
|
470
|
+
const decodedName = decode(EntityId, name)
|
|
471
|
+
if (!decodedName.success) {
|
|
472
|
+
return yield* new WorkbaseRegistryError({
|
|
473
|
+
path,
|
|
474
|
+
message: `Invalid workbase name '${name}': names must contain only letters, numbers, dots, underscores, and hyphens`,
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
const collision = registry.workbases.find(
|
|
478
|
+
(item) =>
|
|
479
|
+
item.id !== entry.id &&
|
|
480
|
+
(item.id === name || item.name === name),
|
|
481
|
+
)
|
|
482
|
+
if (collision) {
|
|
483
|
+
return yield* new WorkbaseRegistryError({
|
|
484
|
+
path,
|
|
485
|
+
message: `Workbase name '${name}' is already registered for ${collision.path}`,
|
|
486
|
+
})
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const updated = {
|
|
490
|
+
id: entry.id,
|
|
491
|
+
...(name === null ? {} : { name }),
|
|
492
|
+
path: entry.path,
|
|
493
|
+
}
|
|
494
|
+
yield* writeRegistry(path, {
|
|
495
|
+
...registry,
|
|
496
|
+
workbases: registry.workbases.map((item) =>
|
|
497
|
+
item.id === entry.id ? updated : item,
|
|
498
|
+
),
|
|
499
|
+
})
|
|
500
|
+
return updated
|
|
501
|
+
}),
|
|
502
|
+
|
|
434
503
|
pruneRegistered: (configDirectory?: string) =>
|
|
435
504
|
Effect.gen(function* () {
|
|
436
505
|
const fs = yield* FileSystemService
|
|
@@ -455,7 +524,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
455
524
|
return removed
|
|
456
525
|
}),
|
|
457
526
|
|
|
458
|
-
setDefault: (
|
|
527
|
+
setDefault: (
|
|
528
|
+
selector: string | null,
|
|
529
|
+
configDirectory?: string,
|
|
530
|
+
basePath: string = process.cwd(),
|
|
531
|
+
) =>
|
|
459
532
|
Effect.gen(function* () {
|
|
460
533
|
const { path, registry } = yield* readRegistry(configDirectory)
|
|
461
534
|
if (selector === null) {
|
|
@@ -465,15 +538,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
465
538
|
})
|
|
466
539
|
return null
|
|
467
540
|
}
|
|
468
|
-
const entry =
|
|
469
|
-
|
|
541
|
+
const { entry } = yield* findRegistration(
|
|
542
|
+
selector,
|
|
543
|
+
configDirectory,
|
|
544
|
+
basePath,
|
|
470
545
|
)
|
|
471
|
-
if (!entry) {
|
|
472
|
-
return yield* new WorkbaseRegistryError({
|
|
473
|
-
path,
|
|
474
|
-
message: `Unknown registered workbase selector '${selector}'`,
|
|
475
|
-
})
|
|
476
|
-
}
|
|
477
546
|
yield* writeRegistry(path, {
|
|
478
547
|
version: 2,
|
|
479
548
|
workbases: registry.workbases,
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import { open, rm } from "node:fs/promises"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
|
|
5
|
+
class WorktreeLockError extends Data.TaggedError("WorktreeLockError")<{
|
|
6
|
+
readonly message: string
|
|
7
|
+
readonly cause?: unknown
|
|
8
|
+
}> {}
|
|
9
|
+
|
|
10
|
+
export interface WorktreeLockTarget {
|
|
11
|
+
readonly taskId: string
|
|
12
|
+
readonly phaseId?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const withWorktreeLock = <A, E, R>(
|
|
16
|
+
root: string,
|
|
17
|
+
target: WorktreeLockTarget,
|
|
18
|
+
effect: Effect.Effect<A, E, R>,
|
|
19
|
+
): Effect.Effect<A, E | WorktreeLockError, R> => {
|
|
20
|
+
const key = Buffer.from(
|
|
21
|
+
`${target.taskId}:${target.phaseId ?? "task"}`,
|
|
22
|
+
).toString("hex")
|
|
23
|
+
const lockPath = join(root, `.agency-worktree-${key}.lock`)
|
|
24
|
+
return Effect.acquireUseRelease(
|
|
25
|
+
Effect.tryPromise({
|
|
26
|
+
try: () => open(lockPath, "wx"),
|
|
27
|
+
catch: (cause) =>
|
|
28
|
+
new WorktreeLockError({
|
|
29
|
+
message: `Another worktree operation is in progress for '${target.taskId}${target.phaseId ? `/${target.phaseId}` : ""}'`,
|
|
30
|
+
cause,
|
|
31
|
+
}),
|
|
32
|
+
}),
|
|
33
|
+
() => effect,
|
|
34
|
+
(lock) =>
|
|
35
|
+
Effect.promise(async () => {
|
|
36
|
+
await lock.close().catch(() => undefined)
|
|
37
|
+
await rm(lockPath, { force: true }).catch(() => undefined)
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const withWorktreeLocks = <A, E, R>(
|
|
43
|
+
root: string,
|
|
44
|
+
targets: readonly WorktreeLockTarget[],
|
|
45
|
+
effect: Effect.Effect<A, E, R>,
|
|
46
|
+
): Effect.Effect<A, E | WorktreeLockError, R> => {
|
|
47
|
+
const unique = new Map(
|
|
48
|
+
targets.map((target) => [
|
|
49
|
+
`${target.taskId}:${target.phaseId ?? "task"}`,
|
|
50
|
+
target,
|
|
51
|
+
]),
|
|
52
|
+
)
|
|
53
|
+
let current: Effect.Effect<A, E | WorktreeLockError, R> = effect
|
|
54
|
+
for (const [, target] of [...unique.entries()]
|
|
55
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
56
|
+
.reverse()) {
|
|
57
|
+
current = withWorktreeLock(root, target, current)
|
|
58
|
+
}
|
|
59
|
+
return current
|
|
60
|
+
}
|