@markjaquith/agency 3.2.2 → 3.2.4

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.
Files changed (48) hide show
  1. package/README.md +30 -62
  2. package/cli-main.ts +20 -83
  3. package/fixtures/protocol/orchestration-recipes.json +9 -34
  4. package/package.json +1 -4
  5. package/schemas/agency-graph-v1.schema.json +2 -31
  6. package/src/cli-parser.test.ts +34 -132
  7. package/src/cli-parser.ts +45 -102
  8. package/src/cli.test.ts +60 -117
  9. package/src/commands/act.ts +2 -6
  10. package/src/commands/sync.ts +3 -3
  11. package/src/commands/validate.ts +3 -1
  12. package/src/commands/work.test.ts +70 -8
  13. package/src/commands/work.ts +15 -8
  14. package/src/graph-schema.ts +0 -2
  15. package/src/protocol.test.ts +0 -25
  16. package/src/protocol.ts +0 -11
  17. package/src/readiness.test.ts +2 -2
  18. package/src/services/ArchiveBulkService.test.ts +0 -88
  19. package/src/services/ArchiveService.ts +0 -32
  20. package/src/services/GraphMutationService.ts +0 -26
  21. package/src/services/GraphService.test.ts +1 -1
  22. package/src/services/IntegrationService.test.ts +7 -4
  23. package/src/services/LifecycleTransaction.ts +5 -1
  24. package/src/services/PhaseService.ts +1 -8
  25. package/src/services/ReadinessService.test.ts +0 -34
  26. package/src/services/ReadinessService.ts +1 -20
  27. package/src/services/ReviewService.test.ts +0 -64
  28. package/src/services/ReviewService.ts +0 -5
  29. package/src/services/SyncService.test.ts +75 -88
  30. package/src/services/SyncService.ts +52 -123
  31. package/src/services/TaskPhaseService.test.ts +13 -1
  32. package/src/services/TaskService.ts +1 -7
  33. package/src/services/WorkbaseService.ts +0 -3
  34. package/src/services/WorktreeService.test.ts +192 -1
  35. package/src/services/WorktreeService.ts +169 -34
  36. package/src/test-utils.ts +0 -2
  37. package/src/usage-log.test.ts +89 -5
  38. package/src/usage-log.ts +139 -31
  39. package/src/workbase/AGENTS.md +9 -15
  40. package/src/workbase/agent-command.test.ts +1 -3
  41. package/src/workbase/agent-command.ts +1 -6
  42. package/src/workbase/document-revision.ts +0 -4
  43. package/src/workbase/opencode-plugin-file.ts +1 -0
  44. package/src/workbase/schemas.test.ts +12 -25
  45. package/src/workbase/schemas.ts +0 -16
  46. package/src/commands/claim.ts +0 -122
  47. package/src/services/ClaimService.test.ts +0 -415
  48. package/src/services/ClaimService.ts +0 -608
@@ -182,6 +182,14 @@ const formatCommand = (args: readonly string[]) =>
182
182
  )
183
183
  .join(" ")
184
184
 
185
+ const describeError = (error: unknown) =>
186
+ typeof error === "object" &&
187
+ error !== null &&
188
+ "message" in error &&
189
+ typeof error.message === "string"
190
+ ? error.message
191
+ : String(error)
192
+
185
193
  const runPostCheckoutHook = (options: {
186
194
  readonly command: readonly string[] | undefined
187
195
  readonly variables: CheckoutCommandVariables
@@ -1871,6 +1879,10 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1871
1879
  }
1872
1880
  | { review: { repo: string; commit: string } }
1873
1881
  let codePath: string
1882
+ let executionState: {
1883
+ readonly status: string
1884
+ readonly claim?: { readonly state: string }
1885
+ }
1874
1886
  if ("phases" in task.data) {
1875
1887
  if (!phaseId) {
1876
1888
  return yield* new WorktreeError({
@@ -1880,6 +1892,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1880
1892
  const phase =
1881
1893
  options.phase ?? (yield* phases.show(taskId, phaseId, root))
1882
1894
  execution = phase.data
1895
+ executionState = phase.data
1883
1896
  codePath = join(dirname(phase.path), "code")
1884
1897
  } else {
1885
1898
  if (phaseId) {
@@ -1888,8 +1901,25 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1888
1901
  })
1889
1902
  }
1890
1903
  execution = task.data
1904
+ executionState = task.data
1891
1905
  codePath = join(dirname(task.path), "code")
1892
1906
  }
1907
+ const ownerLabel = phaseId
1908
+ ? `Phase '${taskId}/${phaseId}'`
1909
+ : `Task '${taskId}'`
1910
+ if (executionState.claim?.state === "active") {
1911
+ return yield* new WorktreeError({
1912
+ message: `${ownerLabel} has an active claim; release or finish it before removing its worktrees`,
1913
+ })
1914
+ }
1915
+ if (
1916
+ executionState.status === "working" ||
1917
+ executionState.status === "delegated"
1918
+ ) {
1919
+ return yield* new WorktreeError({
1920
+ message: `${ownerLabel} has active '${executionState.status}' ownership; reopen it before removing its worktrees`,
1921
+ })
1922
+ }
1893
1923
 
1894
1924
  const codeDirectoryExists = yield* fs.isDirectory(codePath)
1895
1925
  const removalPlans: {
@@ -1931,6 +1961,11 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1931
1961
  const alias = checkout.repo
1932
1962
  const repositoryPath = join(root, "repos", alias)
1933
1963
  const checkoutPath = join(codePath, alias)
1964
+ if ((yield* fs.readSymlinkTarget(checkoutPath)) !== null) {
1965
+ return yield* new WorktreeError({
1966
+ message: `Cannot remove ${codePath}; expected checkout ${checkoutPath} is a symbolic link`,
1967
+ })
1968
+ }
1934
1969
  if (
1935
1970
  (yield* fs.exists(checkoutPath)) &&
1936
1971
  !(yield* fs.isDirectory(checkoutPath))
@@ -2024,7 +2059,12 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2024
2059
  ["git", "-C", checkoutPath, "status", "--porcelain"],
2025
2060
  { captureOutput: true },
2026
2061
  )
2027
- if (status.exitCode !== 0 || status.stdout.trim()) {
2062
+ if (status.exitCode !== 0) {
2063
+ return yield* new WorktreeError({
2064
+ message: `Failed to remove worktree for '${alias}': checkout cleanliness could not be verified: ${status.stderr.trim() || `git status exited with code ${status.exitCode}`}`,
2065
+ })
2066
+ }
2067
+ if (status.stdout.trim()) {
2028
2068
  return yield* new WorktreeError({
2029
2069
  message: `Failed to remove worktree for '${alias}': checkout has uncommitted changes`,
2030
2070
  })
@@ -2039,6 +2079,25 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2039
2079
  head: registered.head,
2040
2080
  branch: registered.branch?.replace(/^refs\/heads\//, ""),
2041
2081
  })
2082
+ if (!checkoutExists) {
2083
+ const unrelatedStale: string[] = []
2084
+ for (const worktree of parseWorktreeList(listed.stdout)) {
2085
+ const worktreePath = (yield* fs.exists(worktree.path))
2086
+ ? yield* fs.realPath(worktree.path)
2087
+ : resolve(worktree.path)
2088
+ if (
2089
+ worktreePath !== registered.path &&
2090
+ !(yield* fs.exists(worktree.path))
2091
+ ) {
2092
+ unrelatedStale.push(worktree.path)
2093
+ }
2094
+ }
2095
+ if (unrelatedStale.length > 0) {
2096
+ return yield* new WorktreeError({
2097
+ message: `Cannot remove stale registration ${registered.path} because pruning would also remove unrelated stale registrations: ${unrelatedStale.join(", ")}`,
2098
+ })
2099
+ }
2100
+ }
2042
2101
  }
2043
2102
  for (const plan of removalPlans) {
2044
2103
  if (!plan.checkoutExists || !plan.head) continue
@@ -2056,6 +2115,38 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2056
2115
  }
2057
2116
 
2058
2117
  const completed: typeof removalPlans = []
2118
+ const planState = (plan: (typeof removalPlans)[number]) =>
2119
+ Effect.gen(function* () {
2120
+ const checkoutExists = yield* fs.isDirectory(plan.checkoutPath)
2121
+ const listed = yield* fs.runCommand(
2122
+ [
2123
+ "git",
2124
+ "-C",
2125
+ plan.repositoryPath,
2126
+ "worktree",
2127
+ "list",
2128
+ "--porcelain",
2129
+ "-z",
2130
+ ],
2131
+ { captureOutput: true },
2132
+ )
2133
+ if (listed.exitCode !== 0) {
2134
+ return yield* new WorktreeError({
2135
+ message: `Failed to inspect worktrees for '${plan.alias}' during recovery: ${listed.stderr.trim() || `git worktree list exited with code ${listed.exitCode}`}`,
2136
+ })
2137
+ }
2138
+ let registered = false
2139
+ for (const worktree of parseWorktreeList(listed.stdout)) {
2140
+ const worktreePath = (yield* fs.exists(worktree.path))
2141
+ ? yield* fs.realPath(worktree.path)
2142
+ : resolve(worktree.path)
2143
+ if (worktreePath === plan.registeredPath) {
2144
+ registered = true
2145
+ break
2146
+ }
2147
+ }
2148
+ return { checkoutExists, registered }
2149
+ })
2059
2150
  const removed = yield* Effect.gen(function* () {
2060
2151
  for (const plan of removalPlans) {
2061
2152
  const command = plan.checkoutExists
@@ -2080,8 +2171,18 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2080
2171
  captureOutput: true,
2081
2172
  })
2082
2173
  if (result.exitCode !== 0) {
2174
+ const state = yield* planState(plan).pipe(
2175
+ Effect.catchAll(() => Effect.succeed(undefined)),
2176
+ )
2177
+ if (
2178
+ !state ||
2179
+ state.checkoutExists !== plan.checkoutExists ||
2180
+ !state.registered
2181
+ ) {
2182
+ completed.push(plan)
2183
+ }
2083
2184
  return yield* new WorktreeError({
2084
- message: `Failed to remove worktree for '${plan.alias}': ${result.stderr}`,
2185
+ message: `Failed to remove worktree for '${plan.alias}': ${result.stderr.trim() || `git exited with code ${result.exitCode}`}`,
2085
2186
  })
2086
2187
  }
2087
2188
  completed.push(plan)
@@ -2099,43 +2200,77 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2099
2200
  for (const plan of [...completed].reverse()) {
2100
2201
  if (!plan.checkoutExists) {
2101
2202
  manualRecovery.push(
2102
- `Re-run worktree repair for stale registration ${plan.registeredPath}`,
2203
+ `Restore stale registration ${plan.registeredPath} if it is still needed`,
2103
2204
  )
2104
2205
  continue
2105
2206
  }
2106
- yield* fs.createDirectory(dirname(plan.checkoutPath))
2107
- const command = plan.branch
2108
- ? [
2109
- "git",
2110
- "-C",
2111
- plan.repositoryPath,
2112
- "worktree",
2113
- "add",
2114
- plan.checkoutPath,
2115
- plan.branch,
2116
- ]
2117
- : [
2118
- "git",
2119
- "-C",
2120
- plan.repositoryPath,
2121
- "worktree",
2122
- "add",
2123
- "--detach",
2124
- plan.checkoutPath,
2125
- plan.head!,
2126
- ]
2127
- const restored = yield* fs.runCommand(command, {
2128
- captureOutput: true,
2129
- })
2130
- if (restored.exitCode === 0)
2131
- rolledBack.push(plan.checkoutPath)
2132
- else manualRecovery.push(`Restore ${plan.checkoutPath}`)
2207
+ const recovery = Effect.gen(function* () {
2208
+ const state = yield* planState(plan)
2209
+ if (state.checkoutExists && state.registered) {
2210
+ rolledBack.push(plan.checkoutPath)
2211
+ return
2212
+ }
2213
+ if (state.checkoutExists || state.registered) {
2214
+ manualRecovery.push(
2215
+ `Restore ${plan.checkoutPath}; checkout path ${state.checkoutExists ? "exists" : "is missing"} and registration ${state.registered ? "exists" : "is missing"}`,
2216
+ )
2217
+ return
2218
+ }
2219
+ yield* fs.createDirectory(dirname(plan.checkoutPath))
2220
+ const command = plan.branch
2221
+ ? [
2222
+ "git",
2223
+ "-C",
2224
+ plan.repositoryPath,
2225
+ "worktree",
2226
+ "add",
2227
+ plan.checkoutPath,
2228
+ plan.branch,
2229
+ ]
2230
+ : [
2231
+ "git",
2232
+ "-C",
2233
+ plan.repositoryPath,
2234
+ "worktree",
2235
+ "add",
2236
+ "--detach",
2237
+ plan.checkoutPath,
2238
+ plan.head!,
2239
+ ]
2240
+ const restored = yield* fs.runCommand(command, {
2241
+ captureOutput: true,
2242
+ })
2243
+ if (restored.exitCode === 0) {
2244
+ rolledBack.push(plan.checkoutPath)
2245
+ } else {
2246
+ manualRecovery.push(
2247
+ `Restore ${plan.checkoutPath}: ${restored.stderr.trim() || `git exited with code ${restored.exitCode}`}`,
2248
+ )
2249
+ }
2250
+ }).pipe(
2251
+ Effect.catchAll((recoveryCause) =>
2252
+ Effect.sync(() => {
2253
+ manualRecovery.push(
2254
+ `Restore ${plan.checkoutPath}: ${describeError(recoveryCause)}`,
2255
+ )
2256
+ }),
2257
+ ),
2258
+ )
2259
+ yield* recovery
2133
2260
  }
2261
+ const causeMessage = describeError(cause)
2134
2262
  return yield* new WorktreeError({
2135
- message: manualRecovery.length
2136
- ? "Worktree removal failed and requires manual recovery"
2137
- : "Worktree removal failed; removed worktrees were restored",
2138
- completed: completed.map((plan) => plan.checkoutPath),
2263
+ message:
2264
+ completed.length === 0
2265
+ ? `${causeMessage}; no worktrees were removed`
2266
+ : manualRecovery.length
2267
+ ? `${causeMessage}. Worktree removal requires manual recovery`
2268
+ : `${causeMessage}. Removed worktrees were restored`,
2269
+ completed: completed.map((plan) =>
2270
+ plan.checkoutExists
2271
+ ? plan.checkoutPath
2272
+ : plan.registeredPath,
2273
+ ),
2139
2274
  rolledBack,
2140
2275
  manualRecovery,
2141
2276
  cause,
package/src/test-utils.ts CHANGED
@@ -16,7 +16,6 @@ import { ArchiveService } from "./services/ArchiveService"
16
16
  import { IntegrationService } from "./services/IntegrationService"
17
17
  import { ContextService } from "./services/ContextService"
18
18
  import { GraphService } from "./services/GraphService"
19
- import { ClaimService } from "./services/ClaimService"
20
19
  import { SyncService } from "./services/SyncService"
21
20
  import { ReadinessService } from "./services/ReadinessService"
22
21
  import { GraphMutationService } from "./services/GraphMutationService"
@@ -48,7 +47,6 @@ const TestLayer = Layer.mergeAll(
48
47
  IntegrationService.Default,
49
48
  ContextService.Default,
50
49
  GraphService.Default,
51
- ClaimService.Default,
52
50
  SyncService.Default,
53
51
  ReadinessService.Default,
54
52
  GraphMutationService.Default,
@@ -1,20 +1,36 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test"
2
+ import { Database } from "bun:sqlite"
2
3
  import { rm } from "node:fs/promises"
3
4
  import { join } from "node:path"
4
5
  import { cleanupTempDir, createTempDir } from "./test-utils"
5
- import { exportUsageEvents, recordUsageEvent } from "./usage-log"
6
+ import {
7
+ exportUsageEvents,
8
+ recordUsageEvent,
9
+ usageOutcomeCode,
10
+ } from "./usage-log"
6
11
 
7
12
  describe("usage logging", () => {
8
13
  const tempDirs: string[] = []
9
14
 
10
15
  afterEach(() => Promise.all(tempDirs.splice(0).map(cleanupTempDir)))
11
16
 
17
+ test("maps only reviewed failure categories", () => {
18
+ expect(usageOutcomeCode({ _tag: "WorkbaseNotFoundError" })).toBe(
19
+ "WORKBASE_NOT_FOUND",
20
+ )
21
+ expect(usageOutcomeCode({ _tag: "private-customer-id" })).toBe(
22
+ "COMMAND_FAILED",
23
+ )
24
+ })
25
+
12
26
  test("stores versioned privacy-safe events in session order", async () => {
13
27
  const state = await createTempDir()
14
28
  tempDirs.push(state)
15
29
  const env = {
16
30
  XDG_STATE_HOME: state,
17
31
  AGENCY_SESSION_ID: "session-1",
32
+ AGENCY_INVOCATION_SOURCE: "automation",
33
+ AGENCY_USAGE_TEST: "1",
18
34
  } as NodeJS.ProcessEnv
19
35
  for (const commandPath of ["worktree/prepare", "context"]) {
20
36
  await recordUsageEvent(
@@ -23,6 +39,7 @@ describe("usage logging", () => {
23
39
  flagNames: ["json", "task", "json"],
24
40
  durationMs: 12.4,
25
41
  outcome: "success",
42
+ outcomeCode: "SUCCESS",
26
43
  exitStatus: 0,
27
44
  ...(commandPath === "context"
28
45
  ? {
@@ -40,26 +57,31 @@ describe("usage logging", () => {
40
57
  expect(await Bun.file(join(state, "agency/usage.sqlite3")).exists()).toBe(
41
58
  true,
42
59
  )
43
- expect(await exportUsageEvents(env)).toEqual([
60
+ const events = await exportUsageEvents(env)
61
+ expect(events).toEqual([
44
62
  expect.objectContaining({
45
63
  version: 2,
46
- sessionId: "session-1",
47
- sessionSequence: 1,
64
+ journeyId: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
65
+ journeySequence: 1,
66
+ invocationSource: "automation",
67
+ isTest: true,
48
68
  agencyVersion: "1.2.3",
49
69
  commandPath: "worktree/prepare",
50
70
  flagNames: ["json", "task"],
51
71
  durationMs: 12,
52
72
  outcome: "success",
73
+ outcomeCode: "SUCCESS",
53
74
  exitStatus: 0,
54
75
  }),
55
76
  expect.objectContaining({
56
- sessionSequence: 2,
77
+ journeySequence: 2,
57
78
  commandPath: "context",
58
79
  vcs: "git",
59
80
  terminalStage: "publish",
60
81
  category: "success",
61
82
  }),
62
83
  ])
84
+ expect(JSON.stringify(events)).not.toContain("session-1")
63
85
  })
64
86
 
65
87
  test("supports opt-out and ignores unavailable storage", async () => {
@@ -75,6 +97,7 @@ describe("usage logging", () => {
75
97
  flagNames: [],
76
98
  durationMs: 1,
77
99
  outcome: "failure",
100
+ outcomeCode: "COMMAND_FAILED",
78
101
  exitStatus: 1,
79
102
  },
80
103
  "1.2.3",
@@ -93,6 +116,7 @@ describe("usage logging", () => {
93
116
  flagNames: [],
94
117
  durationMs: 1,
95
118
  outcome: "failure",
119
+ outcomeCode: "COMMAND_FAILED",
96
120
  exitStatus: 1,
97
121
  },
98
122
  "1.2.3",
@@ -101,4 +125,64 @@ describe("usage logging", () => {
101
125
  ).resolves.toBeUndefined()
102
126
  await rm(blocked)
103
127
  })
128
+
129
+ test("prunes expired events on every database access", async () => {
130
+ const state = await createTempDir()
131
+ tempDirs.push(state)
132
+ const env = {
133
+ XDG_STATE_HOME: state,
134
+ AGENCY_USAGE_RETENTION_DAYS: "30",
135
+ } as NodeJS.ProcessEnv
136
+ await recordUsageEvent(
137
+ {
138
+ commandPath: "status",
139
+ flagNames: [],
140
+ durationMs: 1,
141
+ outcome: "success",
142
+ outcomeCode: "SUCCESS",
143
+ exitStatus: 0,
144
+ },
145
+ "1.2.3",
146
+ env,
147
+ )
148
+ const database = new Database(join(state, "agency/usage.sqlite3"))
149
+ database.run(
150
+ "UPDATE usage_events SET occurred_at = datetime('now', '-31 days')",
151
+ )
152
+ database.close()
153
+
154
+ expect(await exportUsageEvents(env)).toEqual([])
155
+ })
156
+
157
+ test("removes legacy events that may contain positional values", async () => {
158
+ const state = await createTempDir()
159
+ tempDirs.push(state)
160
+ const path = join(state, "usage.sqlite3")
161
+ const database = new Database(path, { create: true })
162
+ database.run(
163
+ "CREATE TABLE usage_events (id INTEGER PRIMARY KEY, command_path TEXT NOT NULL)",
164
+ )
165
+ database.run("INSERT INTO usage_events (command_path) VALUES (?)", [
166
+ "task/private-customer-id",
167
+ ])
168
+ database.close()
169
+ const env = { AGENCY_USAGE_DB: path } as NodeJS.ProcessEnv
170
+
171
+ await recordUsageEvent(
172
+ {
173
+ commandPath: "task/show",
174
+ flagNames: [],
175
+ durationMs: 1,
176
+ outcome: "success",
177
+ outcomeCode: "SUCCESS",
178
+ exitStatus: 0,
179
+ },
180
+ "1.2.3",
181
+ env,
182
+ )
183
+
184
+ expect(await exportUsageEvents(env)).toEqual([
185
+ expect.objectContaining({ commandPath: "task/show", version: 2 }),
186
+ ])
187
+ })
104
188
  })