@markjaquith/agency 2.69.1 → 2.71.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 +29 -16
- package/cli-main.ts +74 -3
- package/package.json +5 -1
- package/pi-extensions/agency.test.ts +117 -0
- package/pi-extensions/agency.ts +141 -0
- package/scripts/install-pi-extension.ts +25 -0
- package/src/cli-parser.ts +11 -0
- package/src/cli.test.ts +40 -2
- package/src/commands/init.test.ts +3 -6
- package/src/commands/integration.test.ts +2 -20
- package/src/commands/integration.ts +3 -5
- package/src/services/IntegrationService.test.ts +24 -156
- package/src/services/IntegrationService.ts +31 -52
- package/src/usage-log.test.ts +94 -0
- package/src/usage-log.ts +143 -0
- package/src/workbase/pi-extension-file.ts +0 -158
|
@@ -18,7 +18,6 @@ interface IntegrationResult {
|
|
|
18
18
|
| "opencode-plugin"
|
|
19
19
|
| "opencode-tui"
|
|
20
20
|
| "opencode-tui-plugin"
|
|
21
|
-
| "pi-extension"
|
|
22
21
|
readonly path: string
|
|
23
22
|
readonly state: string
|
|
24
23
|
readonly diagnostic: string
|
|
@@ -33,7 +32,6 @@ const integrationNames = {
|
|
|
33
32
|
"opencode-plugin": "OpenCode workbase plugin",
|
|
34
33
|
"opencode-tui": "OpenCode TUI config",
|
|
35
34
|
"opencode-tui-plugin": "OpenCode /agency-debug",
|
|
36
|
-
"pi-extension": "Pi workbase extension",
|
|
37
35
|
} as const
|
|
38
36
|
|
|
39
37
|
const logHumanResult = (
|
|
@@ -92,9 +90,9 @@ Usage: agency integration <subcommand>
|
|
|
92
90
|
|
|
93
91
|
Inspect or explicitly synchronize managed agent integration files. OpenCode
|
|
94
92
|
launches load the managed instructions and project config at runtime. Managed
|
|
95
|
-
|
|
96
|
-
skills, and the /agency-debug TUI diagnostic reports OpenCode
|
|
97
|
-
without changing Agency write authority.
|
|
93
|
+
The managed OpenCode extension provides whole-workbase context and
|
|
94
|
+
writable-checkout skills, and the /agency-debug TUI diagnostic reports OpenCode
|
|
95
|
+
integration state without changing Agency write authority.
|
|
98
96
|
|
|
99
97
|
Subcommands:
|
|
100
98
|
status Report file state, access diagnostics, and safe remediation
|
|
@@ -16,10 +16,6 @@ import {
|
|
|
16
16
|
canUpdateManagedWorkbaseOpencodeTuiPlugin,
|
|
17
17
|
managedWorkbaseOpencodeTuiPlugin,
|
|
18
18
|
} from "../workbase/opencode-tui-plugin-file"
|
|
19
|
-
import {
|
|
20
|
-
canUpdateManagedWorkbasePiExtension,
|
|
21
|
-
managedWorkbasePiExtension,
|
|
22
|
-
} from "../workbase/pi-extension-file"
|
|
23
19
|
import { IntegrationService } from "./IntegrationService"
|
|
24
20
|
|
|
25
21
|
const write = async (root: string, path: string, content: string) => {
|
|
@@ -69,7 +65,6 @@ describe("IntegrationService", () => {
|
|
|
69
65
|
"missing",
|
|
70
66
|
"missing",
|
|
71
67
|
"missing",
|
|
72
|
-
"missing",
|
|
73
68
|
])
|
|
74
69
|
expect(await Bun.file(join(root, ".agency/AGENTS.md")).exists()).toBe(false)
|
|
75
70
|
|
|
@@ -86,18 +81,12 @@ describe("IntegrationService", () => {
|
|
|
86
81
|
".opencode/tui/agency-debug.ts",
|
|
87
82
|
managedWorkbaseOpencodeTuiPlugin,
|
|
88
83
|
)
|
|
89
|
-
await write(
|
|
90
|
-
root,
|
|
91
|
-
".pi/extensions/agency-workbase.ts",
|
|
92
|
-
managedWorkbasePiExtension,
|
|
93
|
-
)
|
|
94
84
|
expect((await status(root)).files.map(({ state }) => state)).toEqual([
|
|
95
85
|
"managed",
|
|
96
86
|
"managed",
|
|
97
87
|
"managed",
|
|
98
88
|
"managed",
|
|
99
89
|
"managed",
|
|
100
|
-
"managed",
|
|
101
90
|
])
|
|
102
91
|
})
|
|
103
92
|
|
|
@@ -115,7 +104,6 @@ describe("IntegrationService", () => {
|
|
|
115
104
|
"missing",
|
|
116
105
|
"missing",
|
|
117
106
|
"missing",
|
|
118
|
-
"missing",
|
|
119
107
|
])
|
|
120
108
|
})
|
|
121
109
|
|
|
@@ -220,131 +208,6 @@ describe("IntegrationService", () => {
|
|
|
220
208
|
).toBe(false)
|
|
221
209
|
})
|
|
222
210
|
|
|
223
|
-
test("generates a Pi extension with workbase context and checkout skills", async () => {
|
|
224
|
-
expect(managedWorkbasePiExtension).toContain(
|
|
225
|
-
'import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"',
|
|
226
|
-
)
|
|
227
|
-
expect(managedWorkbasePiExtension).toContain('pi.on("resources_discover"')
|
|
228
|
-
expect(managedWorkbasePiExtension).toContain('pi.on("before_agent_start"')
|
|
229
|
-
expect(managedWorkbasePiExtension).toContain(
|
|
230
|
-
'join(checkout, ".agents", "skills")',
|
|
231
|
-
)
|
|
232
|
-
expect(managedWorkbasePiExtension).toContain(
|
|
233
|
-
'join(checkout, CONFIG_DIR_NAME, "skills")',
|
|
234
|
-
)
|
|
235
|
-
expect(managedWorkbasePiExtension).toContain(
|
|
236
|
-
"The complete Agency workbase is available",
|
|
237
|
-
)
|
|
238
|
-
expect(managedWorkbasePiExtension).toContain(
|
|
239
|
-
"Agency context remains the authority for writes",
|
|
240
|
-
)
|
|
241
|
-
expect(
|
|
242
|
-
canUpdateManagedWorkbasePiExtension(managedWorkbasePiExtension),
|
|
243
|
-
).toBe(true)
|
|
244
|
-
expect(
|
|
245
|
-
canUpdateManagedWorkbasePiExtension(
|
|
246
|
-
managedWorkbasePiExtension.replace("resources_discover", "discover"),
|
|
247
|
-
),
|
|
248
|
-
).toBe(false)
|
|
249
|
-
|
|
250
|
-
const taskDirectory = join(root, "tasks/example")
|
|
251
|
-
const checkoutPath = join(taskDirectory, "code/agency")
|
|
252
|
-
await write(root, ".agency/AGENTS.md", "# Managed Agency instructions\n")
|
|
253
|
-
await write(
|
|
254
|
-
root,
|
|
255
|
-
"tasks/example/TASK.md",
|
|
256
|
-
"---\nrepo: agency\nstatus: working\n---\n",
|
|
257
|
-
)
|
|
258
|
-
await write(
|
|
259
|
-
root,
|
|
260
|
-
"tasks/example/code/agency/.agents/skills/example/SKILL.md",
|
|
261
|
-
"---\nname: example\ndescription: Example.\n---\n",
|
|
262
|
-
)
|
|
263
|
-
await write(
|
|
264
|
-
root,
|
|
265
|
-
"tasks/example/code/agency/.pi/skills/pi-example/SKILL.md",
|
|
266
|
-
"---\nname: pi-example\ndescription: Pi example.\n---\n",
|
|
267
|
-
)
|
|
268
|
-
const path = join(root, ".pi/extensions/agency-workbase.ts")
|
|
269
|
-
await write(
|
|
270
|
-
root,
|
|
271
|
-
".pi/extensions/agency-workbase.ts",
|
|
272
|
-
managedWorkbasePiExtension.replace(
|
|
273
|
-
'import { CONFIG_DIR_NAME, type ExtensionAPI } from "@earendil-works/pi-coding-agent"',
|
|
274
|
-
'const CONFIG_DIR_NAME = ".pi"\ntype ExtensionAPI = any',
|
|
275
|
-
),
|
|
276
|
-
)
|
|
277
|
-
|
|
278
|
-
const contextResponse = JSON.stringify({
|
|
279
|
-
version: 1,
|
|
280
|
-
ok: true,
|
|
281
|
-
result: {
|
|
282
|
-
workbase: { root },
|
|
283
|
-
target: {
|
|
284
|
-
kind: "task",
|
|
285
|
-
taskId: "example",
|
|
286
|
-
path: join(taskDirectory, "TASK.md"),
|
|
287
|
-
},
|
|
288
|
-
authority: {
|
|
289
|
-
mode: "execution",
|
|
290
|
-
writable: { checkoutPath },
|
|
291
|
-
},
|
|
292
|
-
documents: { task: { data: { status: "working" } } },
|
|
293
|
-
validation: { valid: true, warnings: [] },
|
|
294
|
-
},
|
|
295
|
-
})
|
|
296
|
-
const handlers = new Map<string, (...args: any[]) => any>()
|
|
297
|
-
const pi = {
|
|
298
|
-
exec: async () => ({ code: 0, stdout: contextResponse, stderr: "" }),
|
|
299
|
-
on: (name: string, handler: (...args: any[]) => any) => {
|
|
300
|
-
handlers.set(name, handler)
|
|
301
|
-
},
|
|
302
|
-
}
|
|
303
|
-
const previousTarget = process.env.AGENCY_TARGET
|
|
304
|
-
const previousWorkbase = process.env.AGENCY_WORKBASE
|
|
305
|
-
const previousCheckout = process.env.AGENCY_WRITABLE_CHECKOUT
|
|
306
|
-
delete process.env.AGENCY_WORKBASE
|
|
307
|
-
delete process.env.AGENCY_WRITABLE_CHECKOUT
|
|
308
|
-
process.env.AGENCY_TARGET = "execution-unit:task/example"
|
|
309
|
-
try {
|
|
310
|
-
const generated = await import(
|
|
311
|
-
`${pathToFileURL(path).href}?pi-extension=${Date.now()}`
|
|
312
|
-
)
|
|
313
|
-
generated.default(pi)
|
|
314
|
-
const resources = await handlers.get("resources_discover")?.({
|
|
315
|
-
cwd: taskDirectory,
|
|
316
|
-
reason: "startup",
|
|
317
|
-
})
|
|
318
|
-
expect(resources.skillPaths).toEqual([
|
|
319
|
-
join(checkoutPath, ".agents/skills"),
|
|
320
|
-
join(checkoutPath, ".pi/skills"),
|
|
321
|
-
])
|
|
322
|
-
|
|
323
|
-
const prompt = await handlers.get("before_agent_start")?.(
|
|
324
|
-
{ systemPrompt: "Base prompt" },
|
|
325
|
-
{ cwd: taskDirectory },
|
|
326
|
-
)
|
|
327
|
-
expect(prompt.systemPrompt).toContain("# Managed Agency instructions")
|
|
328
|
-
expect(prompt.systemPrompt).toContain(
|
|
329
|
-
`The complete Agency workbase is available at ${root}`,
|
|
330
|
-
)
|
|
331
|
-
expect(prompt.systemPrompt).toContain(
|
|
332
|
-
"active worker for execution-unit:task/example",
|
|
333
|
-
)
|
|
334
|
-
expect(prompt.systemPrompt).toContain(
|
|
335
|
-
`${checkoutPath} as the default implementation directory`,
|
|
336
|
-
)
|
|
337
|
-
} finally {
|
|
338
|
-
if (previousTarget === undefined) delete process.env.AGENCY_TARGET
|
|
339
|
-
else process.env.AGENCY_TARGET = previousTarget
|
|
340
|
-
if (previousWorkbase === undefined) delete process.env.AGENCY_WORKBASE
|
|
341
|
-
else process.env.AGENCY_WORKBASE = previousWorkbase
|
|
342
|
-
if (previousCheckout === undefined)
|
|
343
|
-
delete process.env.AGENCY_WRITABLE_CHECKOUT
|
|
344
|
-
else process.env.AGENCY_WRITABLE_CHECKOUT = previousCheckout
|
|
345
|
-
}
|
|
346
|
-
})
|
|
347
|
-
|
|
348
211
|
const testWorkerIdentity = async ({
|
|
349
212
|
target,
|
|
350
213
|
launchTarget,
|
|
@@ -870,7 +733,6 @@ describe("IntegrationService", () => {
|
|
|
870
733
|
{ name: "opencode-plugin", state: "managed", changed: true },
|
|
871
734
|
{ name: "opencode-tui", state: "managed", changed: true },
|
|
872
735
|
{ name: "opencode-tui-plugin", state: "managed", changed: true },
|
|
873
|
-
{ name: "pi-extension", state: "managed", changed: true },
|
|
874
736
|
])
|
|
875
737
|
expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
|
|
876
738
|
customRootAgents,
|
|
@@ -895,9 +757,6 @@ describe("IntegrationService", () => {
|
|
|
895
757
|
expect(
|
|
896
758
|
await Bun.file(join(root, ".opencode/tui/agency-debug.ts")).text(),
|
|
897
759
|
).toBe(managedWorkbaseOpencodeTuiPlugin)
|
|
898
|
-
expect(
|
|
899
|
-
await Bun.file(join(root, ".pi/extensions/agency-workbase.ts")).text(),
|
|
900
|
-
).toBe(managedWorkbasePiExtension)
|
|
901
760
|
|
|
902
761
|
await unlink(join(root, ".agency/AGENTS.md"))
|
|
903
762
|
const second = await sync(root)
|
|
@@ -962,6 +821,30 @@ describe("IntegrationService", () => {
|
|
|
962
821
|
})
|
|
963
822
|
})
|
|
964
823
|
|
|
824
|
+
test("removes a checksum-valid workbase-local Pi extension", async () => {
|
|
825
|
+
const body = "export default function legacyAgencyExtension() {}\n"
|
|
826
|
+
const checksum = createHash("sha256").update(body).digest("hex")
|
|
827
|
+
const path = join(root, ".pi/extensions/agency-workbase.ts")
|
|
828
|
+
await write(
|
|
829
|
+
root,
|
|
830
|
+
".pi/extensions/agency-workbase.ts",
|
|
831
|
+
`// agency-managed: sha256=${checksum}\n\n${body}`,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
await sync(root)
|
|
835
|
+
expect(await Bun.file(path).exists()).toBe(false)
|
|
836
|
+
expect(await Bun.file(join(root, ".pi")).exists()).toBe(false)
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
test("preserves a customized workbase-local Pi extension", async () => {
|
|
840
|
+
const custom = "export default function customPiExtension() {}\n"
|
|
841
|
+
const path = join(root, ".pi/extensions/agency-workbase.ts")
|
|
842
|
+
await write(root, ".pi/extensions/agency-workbase.ts", custom)
|
|
843
|
+
|
|
844
|
+
await sync(root)
|
|
845
|
+
expect(await Bun.file(path).text()).toBe(custom)
|
|
846
|
+
})
|
|
847
|
+
|
|
965
848
|
test("migrates a checksum-valid plugin from the legacy singular path", async () => {
|
|
966
849
|
const legacyPath = join(
|
|
967
850
|
root,
|
|
@@ -991,21 +874,6 @@ describe("IntegrationService", () => {
|
|
|
991
874
|
expect(await Bun.file(legacyPath).exists()).toBe(false)
|
|
992
875
|
})
|
|
993
876
|
|
|
994
|
-
test("preserves a user-owned Pi extension", async () => {
|
|
995
|
-
const custom = "export default function customPiExtension() {}\n"
|
|
996
|
-
await write(root, ".pi/extensions/agency-workbase.ts", custom)
|
|
997
|
-
|
|
998
|
-
const result = await sync(root)
|
|
999
|
-
expect(result.files[5]).toMatchObject({
|
|
1000
|
-
name: "pi-extension",
|
|
1001
|
-
state: "customized",
|
|
1002
|
-
changed: false,
|
|
1003
|
-
})
|
|
1004
|
-
expect(
|
|
1005
|
-
await Bun.file(join(root, ".pi/extensions/agency-workbase.ts")).text(),
|
|
1006
|
-
).toBe(custom)
|
|
1007
|
-
})
|
|
1008
|
-
|
|
1009
877
|
test("preserves user-owned TUI config and diagnostic plugin", async () => {
|
|
1010
878
|
const customConfig = '{"theme":"custom"}\n'
|
|
1011
879
|
const customPlugin =
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
2
|
import { createHash } from "node:crypto"
|
|
3
|
-
import { join } from "node:path"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
4
|
import { FileSystemService } from "./FileSystemService"
|
|
5
5
|
import { WorkbaseService } from "./WorkbaseService"
|
|
6
6
|
import {
|
|
@@ -23,10 +23,19 @@ import {
|
|
|
23
23
|
canUpdateManagedWorkbaseOpencodeTuiPlugin,
|
|
24
24
|
managedWorkbaseOpencodeTuiPlugin,
|
|
25
25
|
} from "../workbase/opencode-tui-plugin-file"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
|
|
27
|
+
const managedHeaderPattern =
|
|
28
|
+
/^\/\/ agency-managed: sha256=([a-f0-9]{64})\r?\n\r?\n/
|
|
29
|
+
|
|
30
|
+
const canRemoveLegacyPiExtension = (content: string) => {
|
|
31
|
+
const match = content.match(managedHeaderPattern)
|
|
32
|
+
if (!match?.[1]) return false
|
|
33
|
+
return (
|
|
34
|
+
createHash("sha256")
|
|
35
|
+
.update(content.slice(match[0].length))
|
|
36
|
+
.digest("hex") === match[1]
|
|
37
|
+
)
|
|
38
|
+
}
|
|
30
39
|
|
|
31
40
|
type IntegrationFileState = "managed" | "customized" | "missing" | "drifted"
|
|
32
41
|
|
|
@@ -37,7 +46,6 @@ interface IntegrationFileStatus {
|
|
|
37
46
|
| "opencode-plugin"
|
|
38
47
|
| "opencode-tui"
|
|
39
48
|
| "opencode-tui-plugin"
|
|
40
|
-
| "pi-extension"
|
|
41
49
|
readonly path: string
|
|
42
50
|
readonly state: IntegrationFileState
|
|
43
51
|
readonly diagnostic: string
|
|
@@ -136,27 +144,6 @@ const describe = (
|
|
|
136
144
|
"Run 'agency integration sync' to install /agency-debug.",
|
|
137
145
|
}
|
|
138
146
|
}
|
|
139
|
-
if (name === "pi-extension") {
|
|
140
|
-
return state === "managed"
|
|
141
|
-
? {
|
|
142
|
-
diagnostic:
|
|
143
|
-
"Agency's managed Pi extension provides whole-workbase context and exposes writable-checkout skills.",
|
|
144
|
-
remediation: null,
|
|
145
|
-
}
|
|
146
|
-
: state === "customized"
|
|
147
|
-
? {
|
|
148
|
-
diagnostic:
|
|
149
|
-
"A user-owned Pi workbase extension is present and was preserved.",
|
|
150
|
-
remediation: null,
|
|
151
|
-
}
|
|
152
|
-
: {
|
|
153
|
-
diagnostic:
|
|
154
|
-
"The managed Pi workbase extension needs synchronization.",
|
|
155
|
-
remediation:
|
|
156
|
-
"Run 'agency integration sync' to provide workbase context and expose writable-checkout skills in Pi.",
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
147
|
return state === "missing" || state === "drifted"
|
|
161
148
|
? {
|
|
162
149
|
diagnostic: "Managed workbase instructions need synchronization.",
|
|
@@ -215,12 +202,6 @@ const inspect = (root: string) =>
|
|
|
215
202
|
"agency-repository-skills.ts",
|
|
216
203
|
)
|
|
217
204
|
const tuiPluginPath = join(opencodeDirectory, "tui", "agency-debug.ts")
|
|
218
|
-
const piExtensionPath = join(
|
|
219
|
-
root,
|
|
220
|
-
".pi",
|
|
221
|
-
"extensions",
|
|
222
|
-
"agency-workbase.ts",
|
|
223
|
-
)
|
|
224
205
|
const files: IntegrationFileStatus[] = []
|
|
225
206
|
|
|
226
207
|
files.push(
|
|
@@ -325,22 +306,6 @@ const inspect = (root: string) =>
|
|
|
325
306
|
files.push(fileStatus("opencode-tui-plugin", tuiPluginPath, "missing"))
|
|
326
307
|
}
|
|
327
308
|
|
|
328
|
-
if ((yield* fs.readSymlinkTarget(piExtensionPath)) !== null) {
|
|
329
|
-
files.push(fileStatus("pi-extension", piExtensionPath, "customized"))
|
|
330
|
-
} else if (yield* fs.exists(piExtensionPath)) {
|
|
331
|
-
files.push(
|
|
332
|
-
classify(
|
|
333
|
-
"pi-extension",
|
|
334
|
-
piExtensionPath,
|
|
335
|
-
yield* fs.readFile(piExtensionPath),
|
|
336
|
-
managedWorkbasePiExtension,
|
|
337
|
-
canUpdateManagedWorkbasePiExtension,
|
|
338
|
-
),
|
|
339
|
-
)
|
|
340
|
-
} else {
|
|
341
|
-
files.push(fileStatus("pi-extension", piExtensionPath, "missing"))
|
|
342
|
-
}
|
|
343
|
-
|
|
344
309
|
return files
|
|
345
310
|
})
|
|
346
311
|
|
|
@@ -418,6 +383,16 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
|
|
|
418
383
|
yield* canRemoveLegacyOpencodeCommand(root)
|
|
419
384
|
const removeLegacyOpencodePlugin =
|
|
420
385
|
yield* canRemoveLegacyOpencodePlugin(root)
|
|
386
|
+
const legacyPiExtension = join(
|
|
387
|
+
root,
|
|
388
|
+
".pi",
|
|
389
|
+
"extensions",
|
|
390
|
+
"agency-workbase.ts",
|
|
391
|
+
)
|
|
392
|
+
const removeLegacyPiExtension =
|
|
393
|
+
(yield* fs.readSymlinkTarget(legacyPiExtension)) === null &&
|
|
394
|
+
(yield* fs.exists(legacyPiExtension)) &&
|
|
395
|
+
canRemoveLegacyPiExtension(yield* fs.readFile(legacyPiExtension))
|
|
421
396
|
const files: IntegrationSyncFile[] = []
|
|
422
397
|
|
|
423
398
|
for (const status of statuses) {
|
|
@@ -442,9 +417,6 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
|
|
|
442
417
|
status.path,
|
|
443
418
|
managedWorkbaseOpencodeTuiPlugin,
|
|
444
419
|
)
|
|
445
|
-
} else {
|
|
446
|
-
yield* fs.createDirectory(join(root, ".pi", "extensions"))
|
|
447
|
-
yield* fs.writeFile(status.path, managedWorkbasePiExtension)
|
|
448
420
|
}
|
|
449
421
|
}
|
|
450
422
|
files.push({
|
|
@@ -472,6 +444,13 @@ export class IntegrationService extends Effect.Service<IntegrationService>()(
|
|
|
472
444
|
join(root, ".opencode", "command", "agency.md"),
|
|
473
445
|
)
|
|
474
446
|
}
|
|
447
|
+
if (removeLegacyPiExtension) {
|
|
448
|
+
yield* fs.deleteFile(legacyPiExtension)
|
|
449
|
+
yield* fs.deleteDirectoryIfEmpty(dirname(legacyPiExtension))
|
|
450
|
+
yield* fs.deleteDirectoryIfEmpty(
|
|
451
|
+
dirname(dirname(legacyPiExtension)),
|
|
452
|
+
)
|
|
453
|
+
}
|
|
475
454
|
|
|
476
455
|
return { root, files }
|
|
477
456
|
}),
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { rm } from "node:fs/promises"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { cleanupTempDir, createTempDir } from "./test-utils"
|
|
5
|
+
import { exportUsageEvents, recordUsageEvent } from "./usage-log"
|
|
6
|
+
|
|
7
|
+
describe("usage logging", () => {
|
|
8
|
+
const tempDirs: string[] = []
|
|
9
|
+
|
|
10
|
+
afterEach(() => Promise.all(tempDirs.splice(0).map(cleanupTempDir)))
|
|
11
|
+
|
|
12
|
+
test("stores versioned privacy-safe events in session order", async () => {
|
|
13
|
+
const state = await createTempDir()
|
|
14
|
+
tempDirs.push(state)
|
|
15
|
+
const env = {
|
|
16
|
+
XDG_STATE_HOME: state,
|
|
17
|
+
AGENCY_SESSION_ID: "session-1",
|
|
18
|
+
} as NodeJS.ProcessEnv
|
|
19
|
+
for (const commandPath of ["worktree/prepare", "context"]) {
|
|
20
|
+
await recordUsageEvent(
|
|
21
|
+
{
|
|
22
|
+
commandPath,
|
|
23
|
+
flagNames: ["json", "task", "json"],
|
|
24
|
+
durationMs: 12.4,
|
|
25
|
+
outcome: "success",
|
|
26
|
+
exitStatus: 0,
|
|
27
|
+
},
|
|
28
|
+
"1.2.3",
|
|
29
|
+
env,
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
expect(await Bun.file(join(state, "agency/usage.sqlite3")).exists()).toBe(
|
|
34
|
+
true,
|
|
35
|
+
)
|
|
36
|
+
expect(await exportUsageEvents(env)).toEqual([
|
|
37
|
+
expect.objectContaining({
|
|
38
|
+
version: 1,
|
|
39
|
+
sessionId: "session-1",
|
|
40
|
+
sessionSequence: 1,
|
|
41
|
+
agencyVersion: "1.2.3",
|
|
42
|
+
commandPath: "worktree/prepare",
|
|
43
|
+
flagNames: ["json", "task"],
|
|
44
|
+
durationMs: 12,
|
|
45
|
+
outcome: "success",
|
|
46
|
+
exitStatus: 0,
|
|
47
|
+
}),
|
|
48
|
+
expect.objectContaining({
|
|
49
|
+
sessionSequence: 2,
|
|
50
|
+
commandPath: "context",
|
|
51
|
+
}),
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test("supports opt-out and ignores unavailable storage", async () => {
|
|
56
|
+
const state = await createTempDir()
|
|
57
|
+
tempDirs.push(state)
|
|
58
|
+
const disabled = {
|
|
59
|
+
XDG_STATE_HOME: state,
|
|
60
|
+
AGENCY_NO_USAGE_LOG: "true",
|
|
61
|
+
} as NodeJS.ProcessEnv
|
|
62
|
+
await recordUsageEvent(
|
|
63
|
+
{
|
|
64
|
+
commandPath: "status",
|
|
65
|
+
flagNames: [],
|
|
66
|
+
durationMs: 1,
|
|
67
|
+
outcome: "failure",
|
|
68
|
+
exitStatus: 1,
|
|
69
|
+
},
|
|
70
|
+
"1.2.3",
|
|
71
|
+
disabled,
|
|
72
|
+
)
|
|
73
|
+
expect(await Bun.file(join(state, "agency/usage.sqlite3")).exists()).toBe(
|
|
74
|
+
false,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
const blocked = join(state, "blocked")
|
|
78
|
+
await Bun.write(blocked, "not a directory")
|
|
79
|
+
await expect(
|
|
80
|
+
recordUsageEvent(
|
|
81
|
+
{
|
|
82
|
+
commandPath: "status",
|
|
83
|
+
flagNames: [],
|
|
84
|
+
durationMs: 1,
|
|
85
|
+
outcome: "failure",
|
|
86
|
+
exitStatus: 1,
|
|
87
|
+
},
|
|
88
|
+
"1.2.3",
|
|
89
|
+
{ AGENCY_USAGE_DB: join(blocked, "usage.sqlite3") },
|
|
90
|
+
),
|
|
91
|
+
).resolves.toBeUndefined()
|
|
92
|
+
await rm(blocked)
|
|
93
|
+
})
|
|
94
|
+
})
|
package/src/usage-log.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite"
|
|
2
|
+
import { mkdir } from "node:fs/promises"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
|
|
5
|
+
const USAGE_EVENT_VERSION = 1 as const
|
|
6
|
+
const DEFAULT_RETENTION_DAYS = 90
|
|
7
|
+
|
|
8
|
+
export interface UsageEvent {
|
|
9
|
+
readonly commandPath: string
|
|
10
|
+
readonly flagNames: readonly string[]
|
|
11
|
+
readonly durationMs: number
|
|
12
|
+
readonly exitStatus: number
|
|
13
|
+
readonly outcome: "success" | "failure"
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const stateDirectory = (env: NodeJS.ProcessEnv) =>
|
|
17
|
+
env.XDG_STATE_HOME ?? join(env.HOME ?? ".", ".local", "state")
|
|
18
|
+
|
|
19
|
+
const usageDatabasePath = (env: NodeJS.ProcessEnv = process.env) =>
|
|
20
|
+
env.AGENCY_USAGE_DB ?? join(stateDirectory(env), "agency", "usage.sqlite3")
|
|
21
|
+
|
|
22
|
+
const enabled = (env: NodeJS.ProcessEnv) =>
|
|
23
|
+
!["1", "true", "yes"].includes((env.AGENCY_NO_USAGE_LOG ?? "").toLowerCase())
|
|
24
|
+
|
|
25
|
+
const retentionDays = (env: NodeJS.ProcessEnv) => {
|
|
26
|
+
const value = Number.parseInt(env.AGENCY_USAGE_RETENTION_DAYS ?? "", 10)
|
|
27
|
+
return Number.isSafeInteger(value) && value >= 0
|
|
28
|
+
? value
|
|
29
|
+
: DEFAULT_RETENTION_DAYS
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const openDatabase = async (env: NodeJS.ProcessEnv) => {
|
|
33
|
+
const path = usageDatabasePath(env)
|
|
34
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 })
|
|
35
|
+
const database = new Database(path, { create: true, strict: true })
|
|
36
|
+
database.run("PRAGMA journal_mode = WAL")
|
|
37
|
+
database.run("PRAGMA busy_timeout = 1000")
|
|
38
|
+
database.run(`
|
|
39
|
+
CREATE TABLE IF NOT EXISTS usage_events (
|
|
40
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
+
event_version INTEGER NOT NULL,
|
|
42
|
+
session_id TEXT NOT NULL,
|
|
43
|
+
session_sequence INTEGER NOT NULL,
|
|
44
|
+
occurred_at TEXT NOT NULL,
|
|
45
|
+
agency_version TEXT NOT NULL,
|
|
46
|
+
command_path TEXT NOT NULL,
|
|
47
|
+
flag_names TEXT NOT NULL,
|
|
48
|
+
duration_ms INTEGER NOT NULL,
|
|
49
|
+
outcome TEXT NOT NULL,
|
|
50
|
+
exit_status INTEGER NOT NULL
|
|
51
|
+
)
|
|
52
|
+
`)
|
|
53
|
+
database.run(
|
|
54
|
+
"CREATE INDEX IF NOT EXISTS usage_events_session ON usage_events(session_id, session_sequence)",
|
|
55
|
+
)
|
|
56
|
+
database.run(
|
|
57
|
+
"CREATE INDEX IF NOT EXISTS usage_events_command ON usage_events(command_path, occurred_at)",
|
|
58
|
+
)
|
|
59
|
+
return database
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function recordUsageEvent(
|
|
63
|
+
event: UsageEvent,
|
|
64
|
+
agencyVersion: string,
|
|
65
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
if (!enabled(env)) return
|
|
68
|
+
let database: Database | undefined
|
|
69
|
+
try {
|
|
70
|
+
database = await openDatabase(env)
|
|
71
|
+
const sessionId = env.AGENCY_SESSION_ID || `process-${process.pid}`
|
|
72
|
+
database
|
|
73
|
+
.query(`
|
|
74
|
+
INSERT INTO usage_events (
|
|
75
|
+
event_version, session_id, session_sequence, occurred_at,
|
|
76
|
+
agency_version, command_path, flag_names, duration_ms,
|
|
77
|
+
outcome, exit_status
|
|
78
|
+
) VALUES (
|
|
79
|
+
?, ?,
|
|
80
|
+
(SELECT COALESCE(MAX(session_sequence), 0) + 1 FROM usage_events WHERE session_id = ?),
|
|
81
|
+
?, ?, ?, ?, ?, ?, ?
|
|
82
|
+
)
|
|
83
|
+
`)
|
|
84
|
+
.run(
|
|
85
|
+
USAGE_EVENT_VERSION,
|
|
86
|
+
sessionId,
|
|
87
|
+
sessionId,
|
|
88
|
+
new Date().toISOString(),
|
|
89
|
+
agencyVersion,
|
|
90
|
+
event.commandPath,
|
|
91
|
+
JSON.stringify([...new Set(event.flagNames)].sort()),
|
|
92
|
+
Math.max(0, Math.round(event.durationMs)),
|
|
93
|
+
event.outcome,
|
|
94
|
+
event.exitStatus,
|
|
95
|
+
)
|
|
96
|
+
if (Math.random() < 0.01) {
|
|
97
|
+
const days = retentionDays(env)
|
|
98
|
+
database
|
|
99
|
+
.query(
|
|
100
|
+
"DELETE FROM usage_events WHERE occurred_at < datetime('now', ?)",
|
|
101
|
+
)
|
|
102
|
+
.run(`-${days} days`)
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// Usage logging must never affect command behavior.
|
|
106
|
+
} finally {
|
|
107
|
+
database?.close()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function exportUsageEvents(
|
|
112
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
113
|
+
): Promise<readonly Record<string, unknown>[]> {
|
|
114
|
+
if (!enabled(env)) return []
|
|
115
|
+
let database: Database | undefined
|
|
116
|
+
try {
|
|
117
|
+
database = await openDatabase(env)
|
|
118
|
+
const rows = database
|
|
119
|
+
.query(`
|
|
120
|
+
SELECT event_version, session_id, session_sequence, occurred_at,
|
|
121
|
+
agency_version, command_path, flag_names, duration_ms,
|
|
122
|
+
outcome, exit_status
|
|
123
|
+
FROM usage_events ORDER BY occurred_at, id
|
|
124
|
+
`)
|
|
125
|
+
.all() as Record<string, string | number>[]
|
|
126
|
+
return rows.map((row) => ({
|
|
127
|
+
version: row.event_version,
|
|
128
|
+
sessionId: row.session_id,
|
|
129
|
+
sessionSequence: row.session_sequence,
|
|
130
|
+
occurredAt: row.occurred_at,
|
|
131
|
+
agencyVersion: row.agency_version,
|
|
132
|
+
commandPath: row.command_path,
|
|
133
|
+
flagNames: JSON.parse(String(row.flag_names)),
|
|
134
|
+
durationMs: row.duration_ms,
|
|
135
|
+
outcome: row.outcome,
|
|
136
|
+
exitStatus: row.exit_status,
|
|
137
|
+
}))
|
|
138
|
+
} catch {
|
|
139
|
+
return []
|
|
140
|
+
} finally {
|
|
141
|
+
database?.close()
|
|
142
|
+
}
|
|
143
|
+
}
|