@blogic-cz/agent-tools 0.4.2 → 0.5.1
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 +68 -2
- package/package.json +3 -2
- package/schemas/agent-tools.schema.json +24 -1
- package/src/audit-tool/index.ts +102 -0
- package/src/az-tool/index.ts +6 -1
- package/src/config/index.ts +1 -0
- package/src/config/loader.ts +6 -0
- package/src/config/types.ts +7 -1
- package/src/db-tool/index.ts +6 -1
- package/src/gh-tool/index.ts +9 -2
- package/src/gh-tool/workflow.ts +88 -35
- package/src/k8s-tool/index.ts +6 -1
- package/src/logs-tool/index.ts +6 -1
- package/src/session-tool/index.ts +6 -2
- package/src/shared/audit.ts +324 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @blogic-cz/agent-tools
|
|
2
2
|
|
|
3
|
-
Safe CLI wrappers for AI coding agents.
|
|
3
|
+
Safe CLI wrappers for AI coding agents. 7 tools for GitHub, databases, Kubernetes, Azure DevOps, logs, OpenCode sessions, and audit history — with JSON5 config and a credential guard that blocks agents from touching secrets.
|
|
4
4
|
|
|
5
5
|
## Why
|
|
6
6
|
|
|
@@ -47,7 +47,7 @@ Follow these steps to set up agent-tools in the consumer project.
|
|
|
47
47
|
|
|
48
48
|
**Step 2 — Configure**
|
|
49
49
|
|
|
50
|
-
Ask the user which tools they need (gh, db, k8s, az, logs, session). Create `agent-tools.json5` in the project root with **only the selected tools configured**. See [`examples/agent-tools.json5`](./examples/agent-tools.json5) for the full config reference with all options documented.
|
|
50
|
+
Ask the user which tools they need (gh, db, k8s, az, logs, session, audit). Create `agent-tools.json5` in the project root with **only the selected tools configured**. See [`examples/agent-tools.json5`](./examples/agent-tools.json5) for the full config reference with all options documented.
|
|
51
51
|
|
|
52
52
|
Minimal starting config:
|
|
53
53
|
|
|
@@ -124,6 +124,10 @@ bun run agent-tools/example-tool/index.ts ping
|
|
|
124
124
|
{
|
|
125
125
|
$schema: "https://raw.githubusercontent.com/blogic-cz/agent-tools/main/schemas/agent-tools.schema.json",
|
|
126
126
|
defaultEnvironment: "test", // optional: any string (e.g. "local", "test", "prod")
|
|
127
|
+
audit: {
|
|
128
|
+
retentionDays: 90,
|
|
129
|
+
dbPath: "~/.agent-tools/audit.sqlite",
|
|
130
|
+
},
|
|
127
131
|
kubernetes: {
|
|
128
132
|
default: {
|
|
129
133
|
clusterId: "your-cluster-id",
|
|
@@ -145,6 +149,7 @@ bun run agent-tools/example-tool/index.ts ping
|
|
|
145
149
|
bunx agent-tools-gh pr status
|
|
146
150
|
bunx agent-tools-k8s kubectl --env test --cmd "get pods"
|
|
147
151
|
bunx agent-tools-logs list --env local
|
|
152
|
+
bunx agent-tools-audit list --limit 20
|
|
148
153
|
```
|
|
149
154
|
|
|
150
155
|
```bash
|
|
@@ -158,6 +163,7 @@ Optionally, add script aliases to your `package.json` for shorter invocation:
|
|
|
158
163
|
{
|
|
159
164
|
"scripts": {
|
|
160
165
|
"gh-tool": "agent-tools-gh",
|
|
166
|
+
"audit-tool": "agent-tools-audit",
|
|
161
167
|
"k8s-tool": "agent-tools-k8s",
|
|
162
168
|
"db-tool": "agent-tools-db",
|
|
163
169
|
"logs-tool": "agent-tools-logs",
|
|
@@ -181,6 +187,7 @@ export default { handleToolExecuteBefore };
|
|
|
181
187
|
| Binary | Description |
|
|
182
188
|
| --------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
183
189
|
| `agent-tools-gh` | GitHub CLI wrapper — PR management, issues, workflows, composite commands (`review-triage`, `reply-and-resolve`) |
|
|
190
|
+
| `agent-tools-audit` | Audit trail browser — inspect recent tool invocations and purge old entries |
|
|
184
191
|
| `agent-tools-db` | Database query tool — SQL execution, schema introspection |
|
|
185
192
|
| `agent-tools-k8s` | Kubernetes tool — kubectl wrapper + structured commands (`pods`, `logs`, `describe`, `exec`, `top`) |
|
|
186
193
|
| `agent-tools-az` | Azure DevOps tool — pipelines, builds, repos |
|
|
@@ -189,6 +196,65 @@ export default { handleToolExecuteBefore };
|
|
|
189
196
|
|
|
190
197
|
All tools support `--help` for full usage documentation.
|
|
191
198
|
|
|
199
|
+
`agent-tools-audit` reads the same SQLite file the wrappers write to. By default that file lives at `~/.agent-tools/audit.sqlite`, and you can override both path and retention per repo with the global `audit` config section.
|
|
200
|
+
|
|
201
|
+
## Audit Logging
|
|
202
|
+
|
|
203
|
+
Every tool invocation is automatically recorded to a local SQLite database — zero configuration required. The audit trail captures which tool ran, what arguments it received, how long it took, whether it succeeded, and which project directory it was called from.
|
|
204
|
+
|
|
205
|
+
### How it works
|
|
206
|
+
|
|
207
|
+
Each CLI wrapper (`gh`, `k8s`, `db`, `az`, `logs`, `session`, `audit`) writes a row to `~/.agent-tools/audit.sqlite` on every execution. Logging is fire-and-forget — if the database is unavailable or write fails, the tool continues normally. Audit never blocks or slows down your workflow.
|
|
208
|
+
|
|
209
|
+
Entries older than `retentionDays` (default: 90) are automatically purged on each write.
|
|
210
|
+
|
|
211
|
+
### Browsing the audit trail
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
# Recent 20 entries (default)
|
|
215
|
+
bunx agent-tools-audit list
|
|
216
|
+
|
|
217
|
+
# Last 50 entries, JSON format
|
|
218
|
+
bunx agent-tools-audit list --limit 50 --format json
|
|
219
|
+
|
|
220
|
+
# Filter by tool
|
|
221
|
+
bunx agent-tools-audit list --tool gh
|
|
222
|
+
|
|
223
|
+
# Filter by project directory
|
|
224
|
+
bunx agent-tools-audit list --project /Users/me/my-repo
|
|
225
|
+
|
|
226
|
+
# Purge entries older than 30 days
|
|
227
|
+
bunx agent-tools-audit purge --days 30
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Audit Configuration
|
|
231
|
+
|
|
232
|
+
Both the database path and retention period are configurable in `agent-tools.json5`:
|
|
233
|
+
|
|
234
|
+
```json5
|
|
235
|
+
{
|
|
236
|
+
audit: {
|
|
237
|
+
retentionDays: 90, // days before auto-purge (default: 90)
|
|
238
|
+
dbPath: "~/.agent-tools/audit.sqlite", // database file location
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
All settings are optional — audit works out of the box with sensible defaults.
|
|
244
|
+
|
|
245
|
+
### What gets recorded
|
|
246
|
+
|
|
247
|
+
| Column | Description |
|
|
248
|
+
| ----------- | ------------------------------------------------------ |
|
|
249
|
+
| `ts` | ISO 8601 timestamp |
|
|
250
|
+
| `tool` | Tool name (`gh`, `k8s`, `db`, `az`, `logs`, `session`) |
|
|
251
|
+
| `project` | Working directory (`process.cwd()`) |
|
|
252
|
+
| `args` | Command-line arguments (JSON array) |
|
|
253
|
+
| `duration` | Execution time in milliseconds |
|
|
254
|
+
| `success` | `1` (success) or `0` (failure) |
|
|
255
|
+
| `error` | Error message if failed, `null` otherwise |
|
|
256
|
+
| `exit_code` | Process exit code |
|
|
257
|
+
|
|
192
258
|
## Configuration
|
|
193
259
|
|
|
194
260
|
Config is loaded from `agent-tools.json5` (or `agent-tools.json`) by walking up from the current working directory. Missing config = zero-config mode (works for `gh-tool`; others require config).
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blogic-cz/agent-tools",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, and
|
|
3
|
+
"version": "0.5.1",
|
|
4
|
+
"description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
7
7
|
"ai",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"url": "https://github.com/blogic-cz/agent-tools.git"
|
|
20
20
|
},
|
|
21
21
|
"bin": {
|
|
22
|
+
"agent-tools-audit": "./src/audit-tool/index.ts",
|
|
22
23
|
"agent-tools-az": "./src/az-tool/index.ts",
|
|
23
24
|
"agent-tools-db": "./src/db-tool/index.ts",
|
|
24
25
|
"agent-tools-gh": "./src/gh-tool/index.ts",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
3
|
"$id": "https://raw.githubusercontent.com/blogic-cz/agent-tools/main/schemas/agent-tools.schema.json",
|
|
4
4
|
"title": "Agent Tools Configuration",
|
|
5
|
-
"description": "Root configuration for the agent-tools package. Tool-specific sections (azure, kubernetes, database, logs) are maps of named profiles keyed by profile name. Tools choose a profile via --profile <name> (default key is 'default', single-entry maps can be auto-selected). session and credentialGuard are global sections.",
|
|
5
|
+
"description": "Root configuration for the agent-tools package. Tool-specific sections (azure, kubernetes, database, logs) are maps of named profiles keyed by profile name. Tools choose a profile via --profile <name> (default key is 'default', single-entry maps can be auto-selected). session, audit, and credentialGuard are global sections.",
|
|
6
6
|
"type": "object",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"properties": {
|
|
@@ -46,6 +46,10 @@
|
|
|
46
46
|
"description": "Global session configuration shared across profiles.",
|
|
47
47
|
"$ref": "#/definitions/SessionConfig"
|
|
48
48
|
},
|
|
49
|
+
"audit": {
|
|
50
|
+
"description": "Global audit logging configuration shared across profiles.",
|
|
51
|
+
"$ref": "#/definitions/AuditConfig"
|
|
52
|
+
},
|
|
49
53
|
"credentialGuard": {
|
|
50
54
|
"description": "Global credential guard configuration merged with built-in defaults.",
|
|
51
55
|
"$ref": "#/definitions/CredentialGuardConfig"
|
|
@@ -199,6 +203,21 @@
|
|
|
199
203
|
},
|
|
200
204
|
"required": ["storagePath"]
|
|
201
205
|
},
|
|
206
|
+
"AuditConfig": {
|
|
207
|
+
"description": "Global audit configuration.",
|
|
208
|
+
"type": "object",
|
|
209
|
+
"additionalProperties": false,
|
|
210
|
+
"properties": {
|
|
211
|
+
"retentionDays": {
|
|
212
|
+
"description": "How many days of audit history to keep before purge on open.",
|
|
213
|
+
"type": "number"
|
|
214
|
+
},
|
|
215
|
+
"dbPath": {
|
|
216
|
+
"description": "Filesystem path for the audit SQLite database.",
|
|
217
|
+
"type": "string"
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
},
|
|
202
221
|
"CliToolOverride": {
|
|
203
222
|
"description": "CLI override entry that blocks a tool and recommends a safer wrapper.",
|
|
204
223
|
"type": "object",
|
|
@@ -311,6 +330,10 @@
|
|
|
311
330
|
"session": {
|
|
312
331
|
"storagePath": "~/.local/share/opencode/storage"
|
|
313
332
|
},
|
|
333
|
+
"audit": {
|
|
334
|
+
"retentionDays": 90,
|
|
335
|
+
"dbPath": "~/.agent-tools/audit.sqlite"
|
|
336
|
+
},
|
|
314
337
|
"credentialGuard": {
|
|
315
338
|
"additionalBlockedPaths": ["private/"],
|
|
316
339
|
"additionalAllowedPaths": ["apps/web-app/.env.test"],
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
3
|
+
import { BunRuntime, BunServices } from "@effect/platform-bun";
|
|
4
|
+
import { Console, Effect, Layer, Option } from "effect";
|
|
5
|
+
|
|
6
|
+
import { formatOption, formatOutput, renderCauseToStderr, VERSION } from "#shared";
|
|
7
|
+
import { AuditService, AuditServiceLayer, withAudit } from "#shared/audit";
|
|
8
|
+
|
|
9
|
+
type AuditToolResult<T> = {
|
|
10
|
+
success: boolean;
|
|
11
|
+
executionTimeMs: number;
|
|
12
|
+
data?: T;
|
|
13
|
+
error?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const commonFlags = {
|
|
17
|
+
format: formatOption,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const listCommand = Command.make(
|
|
21
|
+
"list",
|
|
22
|
+
{
|
|
23
|
+
...commonFlags,
|
|
24
|
+
limit: Flag.integer("limit").pipe(
|
|
25
|
+
Flag.withDescription("Maximum number of recent audit entries to return"),
|
|
26
|
+
Flag.withDefault(20),
|
|
27
|
+
),
|
|
28
|
+
project: Flag.optional(Flag.string("project")).pipe(
|
|
29
|
+
Flag.withDescription("Filter entries by exact working directory path"),
|
|
30
|
+
),
|
|
31
|
+
tool: Flag.optional(Flag.string("tool")).pipe(
|
|
32
|
+
Flag.withDescription("Filter entries by tool name (gh, k8s, db, az, logs, session, audit)"),
|
|
33
|
+
),
|
|
34
|
+
},
|
|
35
|
+
({ format, limit, project, tool }) =>
|
|
36
|
+
Effect.gen(function* () {
|
|
37
|
+
const startTime = Date.now();
|
|
38
|
+
const audit = yield* AuditService;
|
|
39
|
+
const entries = yield* audit.listRecent(limit);
|
|
40
|
+
const toolFilteredEntries = Option.match(tool, {
|
|
41
|
+
onNone: () => entries,
|
|
42
|
+
onSome: (value) => entries.filter((entry) => entry.tool === value),
|
|
43
|
+
});
|
|
44
|
+
const filteredEntries = Option.match(project, {
|
|
45
|
+
onNone: () => toolFilteredEntries,
|
|
46
|
+
onSome: (value) => toolFilteredEntries.filter((entry) => entry.project === value),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const result: AuditToolResult<typeof filteredEntries> = {
|
|
50
|
+
success: true,
|
|
51
|
+
executionTimeMs: Date.now() - startTime,
|
|
52
|
+
data: filteredEntries,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
yield* Console.log(formatOutput(result, format));
|
|
56
|
+
}),
|
|
57
|
+
).pipe(Command.withDescription("List recent audit log entries"));
|
|
58
|
+
|
|
59
|
+
const purgeCommand = Command.make(
|
|
60
|
+
"purge",
|
|
61
|
+
{
|
|
62
|
+
...commonFlags,
|
|
63
|
+
days: Flag.integer("days").pipe(
|
|
64
|
+
Flag.withDescription("Delete audit entries older than this many days"),
|
|
65
|
+
Flag.withDefault(90),
|
|
66
|
+
),
|
|
67
|
+
},
|
|
68
|
+
({ days, format }) =>
|
|
69
|
+
Effect.gen(function* () {
|
|
70
|
+
const startTime = Date.now();
|
|
71
|
+
const audit = yield* AuditService;
|
|
72
|
+
const deleted = yield* audit.purgeOlderThanDays(days);
|
|
73
|
+
|
|
74
|
+
const result: AuditToolResult<{ deleted: number; days: number }> = {
|
|
75
|
+
success: true,
|
|
76
|
+
executionTimeMs: Date.now() - startTime,
|
|
77
|
+
data: { deleted, days },
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
yield* Console.log(formatOutput(result, format));
|
|
81
|
+
}),
|
|
82
|
+
).pipe(Command.withDescription("Delete old audit log entries"));
|
|
83
|
+
|
|
84
|
+
const mainCommand = Command.make("audit-tool", {}).pipe(
|
|
85
|
+
Command.withDescription("Audit log inspection and maintenance for agent-tools"),
|
|
86
|
+
Command.withSubcommands([listCommand, purgeCommand]),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const cli = Command.run(mainCommand, {
|
|
90
|
+
version: VERSION,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const MainLayer = AuditServiceLayer.pipe(Layer.provideMerge(BunServices.layer));
|
|
94
|
+
|
|
95
|
+
const program = withAudit("audit", cli).pipe(
|
|
96
|
+
Effect.provide(MainLayer),
|
|
97
|
+
Effect.tapCause(renderCauseToStderr),
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
BunRuntime.runMain(program, {
|
|
101
|
+
disableErrorReporting: true,
|
|
102
|
+
});
|
package/src/az-tool/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { BunRuntime, BunServices } from "@effect/platform-bun";
|
|
|
4
4
|
import { Console, Effect, Layer, Option } from "effect";
|
|
5
5
|
|
|
6
6
|
import { formatAny, formatOption, formatOutput, renderCauseToStderr, VERSION } from "#shared";
|
|
7
|
+
import { AuditServiceLayer, withAudit } from "#shared/audit";
|
|
7
8
|
import {
|
|
8
9
|
findFailedJobs,
|
|
9
10
|
getBuildJobSummary,
|
|
@@ -196,9 +197,13 @@ const cli = Command.run(mainCommand, {
|
|
|
196
197
|
const MainLayer = AzServiceLayer.pipe(
|
|
197
198
|
Layer.provideMerge(ConfigServiceLayer),
|
|
198
199
|
Layer.provideMerge(BunServices.layer),
|
|
200
|
+
Layer.provideMerge(AuditServiceLayer),
|
|
199
201
|
);
|
|
200
202
|
|
|
201
|
-
const program =
|
|
203
|
+
const program = withAudit("az", cli).pipe(
|
|
204
|
+
Effect.provide(MainLayer),
|
|
205
|
+
Effect.tapCause(renderCauseToStderr),
|
|
206
|
+
);
|
|
202
207
|
|
|
203
208
|
BunRuntime.runMain(program, {
|
|
204
209
|
disableErrorReporting: true,
|
package/src/config/index.ts
CHANGED
package/src/config/loader.ts
CHANGED
|
@@ -54,6 +54,11 @@ const LogsConfigSchema = Schema.Struct({
|
|
|
54
54
|
remotePath: Schema.String,
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
const AuditConfigSchema = Schema.Struct({
|
|
58
|
+
retentionDays: Schema.optionalKey(Schema.Number),
|
|
59
|
+
dbPath: Schema.optionalKey(Schema.String),
|
|
60
|
+
});
|
|
61
|
+
|
|
57
62
|
const AgentToolsConfigSchema = Schema.Struct({
|
|
58
63
|
$schema: Schema.optionalKey(Schema.String),
|
|
59
64
|
azure: Schema.optionalKey(Schema.Record(Schema.String, AzureConfigSchema)),
|
|
@@ -65,6 +70,7 @@ const AgentToolsConfigSchema = Schema.Struct({
|
|
|
65
70
|
storagePath: Schema.String,
|
|
66
71
|
}),
|
|
67
72
|
),
|
|
73
|
+
audit: Schema.optionalKey(AuditConfigSchema),
|
|
68
74
|
credentialGuard: Schema.optionalKey(CredentialGuardConfigSchema),
|
|
69
75
|
defaultEnvironment: Schema.optionalKey(Schema.String),
|
|
70
76
|
});
|
package/src/config/types.ts
CHANGED
|
@@ -56,6 +56,11 @@ export type CredentialGuardConfig = {
|
|
|
56
56
|
additionalDangerousBashPatterns?: string[];
|
|
57
57
|
};
|
|
58
58
|
|
|
59
|
+
export type AuditConfig = {
|
|
60
|
+
retentionDays?: number;
|
|
61
|
+
dbPath?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
59
64
|
/**
|
|
60
65
|
* Root agent-tools configuration.
|
|
61
66
|
*
|
|
@@ -63,7 +68,7 @@ export type CredentialGuardConfig = {
|
|
|
63
68
|
* of named profiles. Tools select a profile via the --profile <name> flag (default = "default" key).
|
|
64
69
|
* If only one profile exists, it is used automatically.
|
|
65
70
|
*
|
|
66
|
-
* session and
|
|
71
|
+
* session, credentialGuard, and audit are global - not per-profile.
|
|
67
72
|
*/
|
|
68
73
|
export type AgentToolsConfig = {
|
|
69
74
|
$schema?: string;
|
|
@@ -79,6 +84,7 @@ export type AgentToolsConfig = {
|
|
|
79
84
|
session?: {
|
|
80
85
|
storagePath: string;
|
|
81
86
|
};
|
|
87
|
+
audit?: AuditConfig;
|
|
82
88
|
/** Global credential guard config (merged with built-in defaults, not per-profile) */
|
|
83
89
|
credentialGuard?: CredentialGuardConfig;
|
|
84
90
|
/** Optional default environment name (local|test|prod) used by tools when no --env flag is provided */
|
package/src/db-tool/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { Console, Effect, Layer, Option } from "effect";
|
|
|
6
6
|
import type { SchemaMode } from "./types";
|
|
7
7
|
|
|
8
8
|
import { formatOption, formatOutput, renderCauseToStderr, VERSION } from "#shared";
|
|
9
|
+
import { AuditServiceLayer, withAudit } from "#shared/audit";
|
|
9
10
|
import { ConfigService, ConfigServiceLayer, getDefaultEnvironment } from "#config";
|
|
10
11
|
import { makeDbConfigLayer } from "./config-service";
|
|
11
12
|
import { DbConnectionError } from "./errors";
|
|
@@ -121,9 +122,13 @@ const MainLayer = DbService.layer.pipe(
|
|
|
121
122
|
Layer.provide(dbConfigLayer),
|
|
122
123
|
Layer.provideMerge(ConfigServiceLayer),
|
|
123
124
|
Layer.provideMerge(BunServices.layer),
|
|
125
|
+
Layer.provideMerge(AuditServiceLayer),
|
|
124
126
|
);
|
|
125
127
|
|
|
126
|
-
const program =
|
|
128
|
+
const program = withAudit("db", cli).pipe(
|
|
129
|
+
Effect.provide(MainLayer),
|
|
130
|
+
Effect.tapCause(renderCauseToStderr),
|
|
131
|
+
);
|
|
127
132
|
|
|
128
133
|
BunRuntime.runMain(program, {
|
|
129
134
|
disableErrorReporting: true,
|
package/src/gh-tool/index.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { BunRuntime, BunServices } from "@effect/platform-bun";
|
|
|
4
4
|
import { Effect, Layer } from "effect";
|
|
5
5
|
|
|
6
6
|
import { renderCauseToStderr, VERSION } from "#shared";
|
|
7
|
+
import { AuditServiceLayer, withAudit } from "#shared/audit";
|
|
7
8
|
import {
|
|
8
9
|
issueCloseCommand,
|
|
9
10
|
issueCommentCommand,
|
|
@@ -162,9 +163,15 @@ const cli = Command.run(mainCommand, {
|
|
|
162
163
|
version: VERSION,
|
|
163
164
|
});
|
|
164
165
|
|
|
165
|
-
const MainLayer = GitHubService.layer.pipe(
|
|
166
|
+
const MainLayer = GitHubService.layer.pipe(
|
|
167
|
+
Layer.provideMerge(BunServices.layer),
|
|
168
|
+
Layer.provideMerge(AuditServiceLayer),
|
|
169
|
+
);
|
|
166
170
|
|
|
167
|
-
const program =
|
|
171
|
+
const program = withAudit("gh", cli).pipe(
|
|
172
|
+
Effect.provide(MainLayer),
|
|
173
|
+
Effect.tapCause(renderCauseToStderr),
|
|
174
|
+
);
|
|
168
175
|
|
|
169
176
|
BunRuntime.runMain(program, {
|
|
170
177
|
disableErrorReporting: true,
|
package/src/gh-tool/workflow.ts
CHANGED
|
@@ -48,6 +48,10 @@ type LogEntry = {
|
|
|
48
48
|
message: string;
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
const repoOption = Flag.string("repo").pipe(
|
|
52
|
+
Flag.withDescription("Target repository (owner/name). Defaults to current repo"),
|
|
53
|
+
Flag.optional,
|
|
54
|
+
);
|
|
51
55
|
// ---------------------------------------------------------------------------
|
|
52
56
|
// Internal handlers
|
|
53
57
|
// ---------------------------------------------------------------------------
|
|
@@ -89,26 +93,34 @@ const listRuns = Effect.fn("workflow.listRuns")(function* (opts: {
|
|
|
89
93
|
return yield* gh.runGhJson<WorkflowRun[]>(args);
|
|
90
94
|
});
|
|
91
95
|
|
|
92
|
-
const viewRun = Effect.fn("workflow.viewRun")(function* (runId: number) {
|
|
96
|
+
const viewRun = Effect.fn("workflow.viewRun")(function* (runId: number, repo: string | null) {
|
|
93
97
|
const gh = yield* GitHubService;
|
|
94
98
|
|
|
95
|
-
const
|
|
99
|
+
const args = [
|
|
96
100
|
"run",
|
|
97
101
|
"view",
|
|
98
102
|
String(runId),
|
|
99
103
|
"--json",
|
|
100
104
|
"databaseId,displayTitle,status,conclusion,headBranch,createdAt,event,url,workflowName,jobs",
|
|
101
|
-
]
|
|
105
|
+
];
|
|
106
|
+
if (repo !== null) {
|
|
107
|
+
args.push("--repo", repo);
|
|
108
|
+
}
|
|
102
109
|
|
|
103
|
-
return
|
|
110
|
+
return yield* gh.runGhJson<WorkflowRunDetail>(args);
|
|
104
111
|
});
|
|
105
112
|
|
|
106
|
-
const listJobs = Effect.fn("workflow.listJobs")(function* (runId: number) {
|
|
113
|
+
const listJobs = Effect.fn("workflow.listJobs")(function* (runId: number, repo: string | null) {
|
|
107
114
|
const gh = yield* GitHubService;
|
|
108
115
|
|
|
116
|
+
const args = ["run", "view", String(runId), "--json", "jobs"];
|
|
117
|
+
if (repo !== null) {
|
|
118
|
+
args.push("--repo", repo);
|
|
119
|
+
}
|
|
120
|
+
|
|
109
121
|
const run = yield* gh.runGhJson<{
|
|
110
122
|
jobs: WorkflowJob[];
|
|
111
|
-
}>(
|
|
123
|
+
}>(args);
|
|
112
124
|
|
|
113
125
|
return run.jobs;
|
|
114
126
|
});
|
|
@@ -117,10 +129,15 @@ const fetchLogs = Effect.fn("workflow.fetchLogs")(function* (
|
|
|
117
129
|
runId: number,
|
|
118
130
|
failedOnly: boolean,
|
|
119
131
|
jobId: number | null = null,
|
|
132
|
+
repo: string | null = null,
|
|
120
133
|
) {
|
|
121
134
|
const gh = yield* GitHubService;
|
|
122
135
|
const args = ["run", "view", String(runId)];
|
|
123
136
|
|
|
137
|
+
if (repo !== null) {
|
|
138
|
+
args.push("--repo", repo);
|
|
139
|
+
}
|
|
140
|
+
|
|
124
141
|
if (jobId !== null) {
|
|
125
142
|
args.push("--log", "--job", String(jobId));
|
|
126
143
|
} else if (failedOnly) {
|
|
@@ -164,10 +181,15 @@ const rerunWorkflow = Effect.fn("workflow.rerunWorkflow")(function* (
|
|
|
164
181
|
};
|
|
165
182
|
});
|
|
166
183
|
|
|
167
|
-
const cancelRun = Effect.fn("workflow.cancelRun")(function* (runId: number) {
|
|
184
|
+
const cancelRun = Effect.fn("workflow.cancelRun")(function* (runId: number, repo: string | null) {
|
|
168
185
|
const gh = yield* GitHubService;
|
|
169
186
|
|
|
170
|
-
|
|
187
|
+
const args = ["run", "cancel", String(runId)];
|
|
188
|
+
if (repo !== null) {
|
|
189
|
+
args.push("--repo", repo);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
yield* gh.runGh(args);
|
|
171
193
|
|
|
172
194
|
return {
|
|
173
195
|
cancelled: true as const,
|
|
@@ -176,10 +198,15 @@ const cancelRun = Effect.fn("workflow.cancelRun")(function* (runId: number) {
|
|
|
176
198
|
};
|
|
177
199
|
});
|
|
178
200
|
|
|
179
|
-
const watchRun = Effect.fn("workflow.watchRun")(function* (runId: number) {
|
|
201
|
+
const watchRun = Effect.fn("workflow.watchRun")(function* (runId: number, repo: string | null) {
|
|
180
202
|
const gh = yield* GitHubService;
|
|
181
203
|
|
|
182
|
-
const
|
|
204
|
+
const watchArgs = ["run", "watch", String(runId), "--exit-status"];
|
|
205
|
+
if (repo !== null) {
|
|
206
|
+
watchArgs.push("--repo", repo);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const result = yield* gh.runGh(watchArgs).pipe(
|
|
183
210
|
Effect.catchTag("GitHubCommandError", (error) => {
|
|
184
211
|
// exit-status returns non-zero if run failed, but we still want the output
|
|
185
212
|
if (error.exitCode > 0 && error.stderr === "") {
|
|
@@ -193,7 +220,7 @@ const watchRun = Effect.fn("workflow.watchRun")(function* (runId: number) {
|
|
|
193
220
|
}),
|
|
194
221
|
);
|
|
195
222
|
|
|
196
|
-
const finalState = yield* viewRun(runId);
|
|
223
|
+
const finalState = yield* viewRun(runId, repo);
|
|
197
224
|
|
|
198
225
|
return {
|
|
199
226
|
runId,
|
|
@@ -268,8 +295,12 @@ export function formatLogEntries(entries: LogEntry[]): string {
|
|
|
268
295
|
// Job-level log handlers
|
|
269
296
|
// ---------------------------------------------------------------------------
|
|
270
297
|
|
|
271
|
-
const resolveJobId = Effect.fn("workflow.resolveJobId")(function* (
|
|
272
|
-
|
|
298
|
+
const resolveJobId = Effect.fn("workflow.resolveJobId")(function* (
|
|
299
|
+
runId: number,
|
|
300
|
+
jobName: string,
|
|
301
|
+
repo: string | null,
|
|
302
|
+
) {
|
|
303
|
+
const jobs = yield* listJobs(runId, repo);
|
|
273
304
|
|
|
274
305
|
// Exact match first
|
|
275
306
|
const exact = jobs.find((j) => j.name === jobName);
|
|
@@ -305,8 +336,9 @@ const filterFailedStepEntries = Effect.fn("workflow.filterFailedStepEntries")(fu
|
|
|
305
336
|
runId: number,
|
|
306
337
|
jobId: number,
|
|
307
338
|
entries: LogEntry[],
|
|
339
|
+
repo: string | null,
|
|
308
340
|
) {
|
|
309
|
-
const jobs = yield* listJobs(runId);
|
|
341
|
+
const jobs = yield* listJobs(runId, repo);
|
|
310
342
|
const job = jobs.find((j) => j.databaseId === jobId);
|
|
311
343
|
if (!job) return entries;
|
|
312
344
|
|
|
@@ -324,25 +356,39 @@ const fetchJobLogs = Effect.fn("workflow.fetchJobLogs")(function* (opts: {
|
|
|
324
356
|
job: string;
|
|
325
357
|
failedStepsOnly: boolean;
|
|
326
358
|
format: string;
|
|
359
|
+
repo: string | null;
|
|
327
360
|
}) {
|
|
328
361
|
const gh = yield* GitHubService;
|
|
329
|
-
const { owner, name: repo } = yield* gh.getRepoInfo();
|
|
330
362
|
|
|
331
|
-
|
|
363
|
+
let owner: string;
|
|
364
|
+
let repoName: string;
|
|
365
|
+
if (opts.repo !== null) {
|
|
366
|
+
const parts = opts.repo.split("/");
|
|
367
|
+
owner = parts[0];
|
|
368
|
+
repoName = parts[1];
|
|
369
|
+
} else {
|
|
370
|
+
const info = yield* gh.getRepoInfo();
|
|
371
|
+
owner = info.owner;
|
|
372
|
+
repoName = info.name;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const jobId = yield* resolveJobId(opts.runId, opts.job, opts.repo);
|
|
332
376
|
|
|
333
377
|
// Fetch raw logs via API (follows 302 redirect automatically)
|
|
334
|
-
const raw = yield* gh
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
378
|
+
const raw = yield* gh
|
|
379
|
+
.runGh(["api", `repos/${owner}/${repoName}/actions/jobs/${jobId}/logs`])
|
|
380
|
+
.pipe(
|
|
381
|
+
Effect.map((r) => r.stdout),
|
|
382
|
+
Effect.catchTag("GitHubCommandError", () => {
|
|
383
|
+
// Fallback: use gh run view --log --job
|
|
384
|
+
return fetchLogs(opts.runId, false, jobId, opts.repo).pipe(Effect.map((r) => r.log));
|
|
385
|
+
}),
|
|
386
|
+
);
|
|
341
387
|
|
|
342
388
|
let entries = parseRawJobLogs(raw);
|
|
343
389
|
|
|
344
390
|
if (opts.failedStepsOnly) {
|
|
345
|
-
entries = yield* filterFailedStepEntries(opts.runId, jobId, entries);
|
|
391
|
+
entries = yield* filterFailedStepEntries(opts.runId, jobId, entries, opts.repo);
|
|
346
392
|
}
|
|
347
393
|
|
|
348
394
|
if (opts.format === "json") {
|
|
@@ -420,11 +466,12 @@ export const workflowViewCommand = Command.make(
|
|
|
420
466
|
"view",
|
|
421
467
|
{
|
|
422
468
|
format: formatOption,
|
|
469
|
+
repo: repoOption,
|
|
423
470
|
run: Flag.integer("run").pipe(Flag.withDescription("Workflow run ID")),
|
|
424
471
|
},
|
|
425
|
-
({ format, run }) =>
|
|
472
|
+
({ format, repo, run }) =>
|
|
426
473
|
Effect.gen(function* () {
|
|
427
|
-
const detail = yield* viewRun(run);
|
|
474
|
+
const detail = yield* viewRun(run, Option.getOrNull(repo));
|
|
428
475
|
yield* logFormatted(detail, format);
|
|
429
476
|
}),
|
|
430
477
|
).pipe(Command.withDescription("View workflow run details including jobs and steps"));
|
|
@@ -433,11 +480,12 @@ export const workflowJobsCommand = Command.make(
|
|
|
433
480
|
"jobs",
|
|
434
481
|
{
|
|
435
482
|
format: formatOption,
|
|
483
|
+
repo: repoOption,
|
|
436
484
|
run: Flag.integer("run").pipe(Flag.withDescription("Workflow run ID")),
|
|
437
485
|
},
|
|
438
|
-
({ format, run }) =>
|
|
486
|
+
({ format, repo, run }) =>
|
|
439
487
|
Effect.gen(function* () {
|
|
440
|
-
const jobs = yield* listJobs(run);
|
|
488
|
+
const jobs = yield* listJobs(run, Option.getOrNull(repo));
|
|
441
489
|
yield* logFormatted(jobs, format);
|
|
442
490
|
}),
|
|
443
491
|
).pipe(Command.withDescription("List jobs and their steps for a workflow run"));
|
|
@@ -450,11 +498,12 @@ export const workflowLogsCommand = Command.make(
|
|
|
450
498
|
Flag.withDefault(true),
|
|
451
499
|
),
|
|
452
500
|
format: formatOption,
|
|
501
|
+
repo: repoOption,
|
|
453
502
|
run: Flag.integer("run").pipe(Flag.withDescription("Workflow run ID")),
|
|
454
503
|
},
|
|
455
|
-
({ failedOnly, format, run }) =>
|
|
504
|
+
({ failedOnly, format, repo, run }) =>
|
|
456
505
|
Effect.gen(function* () {
|
|
457
|
-
const logs = yield* fetchLogs(run, failedOnly);
|
|
506
|
+
const logs = yield* fetchLogs(run, failedOnly, null, Option.getOrNull(repo));
|
|
458
507
|
|
|
459
508
|
if (format === "toon" || format === "json") {
|
|
460
509
|
yield* logFormatted(logs, format);
|
|
@@ -489,11 +538,12 @@ export const workflowCancelCommand = Command.make(
|
|
|
489
538
|
"cancel",
|
|
490
539
|
{
|
|
491
540
|
format: formatOption,
|
|
541
|
+
repo: repoOption,
|
|
492
542
|
run: Flag.integer("run").pipe(Flag.withDescription("Workflow run ID to cancel")),
|
|
493
543
|
},
|
|
494
|
-
({ format, run }) =>
|
|
544
|
+
({ format, repo, run }) =>
|
|
495
545
|
Effect.gen(function* () {
|
|
496
|
-
const result = yield* cancelRun(run);
|
|
546
|
+
const result = yield* cancelRun(run, Option.getOrNull(repo));
|
|
497
547
|
yield* logFormatted(result, format);
|
|
498
548
|
}),
|
|
499
549
|
).pipe(Command.withDescription("Cancel an in-progress workflow run"));
|
|
@@ -502,11 +552,12 @@ export const workflowWatchCommand = Command.make(
|
|
|
502
552
|
"watch",
|
|
503
553
|
{
|
|
504
554
|
format: formatOption,
|
|
555
|
+
repo: repoOption,
|
|
505
556
|
run: Flag.integer("run").pipe(Flag.withDescription("Workflow run ID to watch")),
|
|
506
557
|
},
|
|
507
|
-
({ format, run }) =>
|
|
558
|
+
({ format, repo, run }) =>
|
|
508
559
|
Effect.gen(function* () {
|
|
509
|
-
const result = yield* watchRun(run);
|
|
560
|
+
const result = yield* watchRun(run, Option.getOrNull(repo));
|
|
510
561
|
yield* logFormatted(result, format);
|
|
511
562
|
}),
|
|
512
563
|
).pipe(Command.withDescription("Watch a workflow run until it completes, then show final status"));
|
|
@@ -522,15 +573,17 @@ export const workflowJobLogsCommand = Command.make(
|
|
|
522
573
|
job: Flag.string("job").pipe(
|
|
523
574
|
Flag.withDescription("Job name to fetch logs for (exact or partial match)"),
|
|
524
575
|
),
|
|
576
|
+
repo: repoOption,
|
|
525
577
|
run: Flag.integer("run").pipe(Flag.withDescription("Workflow run ID")),
|
|
526
578
|
},
|
|
527
|
-
({ failedStepsOnly, format, job, run }) =>
|
|
579
|
+
({ failedStepsOnly, format, job, repo, run }) =>
|
|
528
580
|
Effect.gen(function* () {
|
|
529
581
|
const result = yield* fetchJobLogs({
|
|
530
582
|
runId: run,
|
|
531
583
|
job,
|
|
532
584
|
failedStepsOnly,
|
|
533
585
|
format,
|
|
586
|
+
repo: Option.getOrNull(repo),
|
|
534
587
|
});
|
|
535
588
|
|
|
536
589
|
if ("formatted" in result) {
|
package/src/k8s-tool/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { Console, Effect, Layer, Option } from "effect";
|
|
|
6
6
|
import type { CommandResult } from "./types";
|
|
7
7
|
|
|
8
8
|
import { formatOption, formatOutput, renderCauseToStderr, VERSION } from "#shared";
|
|
9
|
+
import { AuditServiceLayer, withAudit } from "#shared/audit";
|
|
9
10
|
import { K8sService, K8sServiceLayer } from "./service";
|
|
10
11
|
import { ConfigService, ConfigServiceLayer, getDefaultEnvironment, getToolConfig } from "#config";
|
|
11
12
|
import type { K8sConfig } from "#config";
|
|
@@ -401,9 +402,13 @@ const cli = Command.run(mainCommand, {
|
|
|
401
402
|
const MainLayer = K8sServiceLayer.pipe(
|
|
402
403
|
Layer.provideMerge(ConfigServiceLayer),
|
|
403
404
|
Layer.provideMerge(BunServices.layer),
|
|
405
|
+
Layer.provideMerge(AuditServiceLayer),
|
|
404
406
|
);
|
|
405
407
|
|
|
406
|
-
const program =
|
|
408
|
+
const program = withAudit("k8s", cli).pipe(
|
|
409
|
+
Effect.provide(MainLayer),
|
|
410
|
+
Effect.tapCause(renderCauseToStderr),
|
|
411
|
+
);
|
|
407
412
|
|
|
408
413
|
BunRuntime.runMain(program, {
|
|
409
414
|
disableErrorReporting: true,
|
package/src/logs-tool/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ import { Console, Effect, Layer, Option, Result } from "effect";
|
|
|
21
21
|
import type { Environment, LogResult, ReadOptions } from "./types";
|
|
22
22
|
|
|
23
23
|
import { formatOption, formatOutput, renderCauseToStderr, VERSION } from "#shared";
|
|
24
|
+
import { AuditServiceLayer, withAudit } from "#shared/audit";
|
|
24
25
|
import { ConfigService, ConfigServiceLayer, getDefaultEnvironment } from "#config";
|
|
25
26
|
import { LogsConfigError, LogsNotFoundError, LogsReadError, LogsTimeoutError } from "./errors";
|
|
26
27
|
import { LogsService, LogsServiceLayer } from "./service";
|
|
@@ -229,9 +230,13 @@ export const run = Command.runWith(mainCommand, {
|
|
|
229
230
|
const MainLayer = LogsServiceLayer.pipe(
|
|
230
231
|
Layer.provideMerge(ConfigServiceLayer),
|
|
231
232
|
Layer.provideMerge(BunServices.layer),
|
|
233
|
+
Layer.provideMerge(AuditServiceLayer),
|
|
232
234
|
);
|
|
233
235
|
|
|
234
|
-
const program =
|
|
236
|
+
const program = withAudit("logs", cli).pipe(
|
|
237
|
+
Effect.provide(MainLayer),
|
|
238
|
+
Effect.tapCause(renderCauseToStderr),
|
|
239
|
+
);
|
|
235
240
|
|
|
236
241
|
BunRuntime.runMain(program, {
|
|
237
242
|
disableErrorReporting: true,
|
|
@@ -14,6 +14,7 @@ import { Console, Effect, Layer, Result } from "effect";
|
|
|
14
14
|
import type { MessageSummary, SessionResult } from "./types";
|
|
15
15
|
|
|
16
16
|
import { formatOption, formatOutput, VERSION } from "#shared";
|
|
17
|
+
import { AuditServiceLayer, withAudit } from "#shared/audit";
|
|
17
18
|
import { ResolvedPaths, ResolvedPathsLayer } from "./config";
|
|
18
19
|
import { SessionStorageNotFoundError } from "./errors";
|
|
19
20
|
import { formatDate, SessionService, SessionServiceLayer, truncate } from "./service";
|
|
@@ -261,9 +262,12 @@ export const run = Command.runWith(mainCommand, {
|
|
|
261
262
|
version: VERSION,
|
|
262
263
|
});
|
|
263
264
|
|
|
264
|
-
const MainLayer = AppLayer.pipe(
|
|
265
|
+
const MainLayer = AppLayer.pipe(
|
|
266
|
+
Layer.provideMerge(BunServices.layer),
|
|
267
|
+
Layer.provideMerge(AuditServiceLayer),
|
|
268
|
+
);
|
|
265
269
|
|
|
266
|
-
const program = cli.pipe(Effect.provide(MainLayer));
|
|
270
|
+
const program = withAudit("session", cli).pipe(Effect.provide(MainLayer));
|
|
267
271
|
|
|
268
272
|
BunRuntime.runMain(program, {
|
|
269
273
|
disableErrorReporting: true,
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { Database, constants } from "bun:sqlite";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { Cause, Effect, Layer, ServiceMap } from "effect";
|
|
7
|
+
|
|
8
|
+
import { loadConfig } from "#config";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_AUDIT_RETENTION_DAYS = 90;
|
|
11
|
+
const IN_MEMORY_DB_PATH = ":memory:";
|
|
12
|
+
|
|
13
|
+
export type AuditLogEntry = {
|
|
14
|
+
id: number;
|
|
15
|
+
ts: string;
|
|
16
|
+
tool: string;
|
|
17
|
+
project: string;
|
|
18
|
+
args: string;
|
|
19
|
+
duration: number;
|
|
20
|
+
success: boolean;
|
|
21
|
+
error: string | null;
|
|
22
|
+
exitCode: number | null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type AuditRecord = {
|
|
26
|
+
tool: string;
|
|
27
|
+
project: string;
|
|
28
|
+
args: string;
|
|
29
|
+
duration: number;
|
|
30
|
+
success: boolean;
|
|
31
|
+
error?: string;
|
|
32
|
+
exitCode?: number | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type AuditServiceShape = {
|
|
36
|
+
readonly dbPath: string;
|
|
37
|
+
readonly record: (entry: AuditRecord) => Effect.Effect<void, never, never>;
|
|
38
|
+
readonly listRecent: (limit?: number) => Effect.Effect<readonly AuditLogEntry[], never, never>;
|
|
39
|
+
readonly purgeOlderThanDays: (days: number) => Effect.Effect<number, never, never>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type AuditServiceOptions = {
|
|
43
|
+
readonly dbPath?: string;
|
|
44
|
+
readonly retentionDays?: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type ResolvedAuditOptions = {
|
|
48
|
+
dbPath: string;
|
|
49
|
+
retentionDays: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
type AuditRow = {
|
|
53
|
+
id: number;
|
|
54
|
+
ts: string;
|
|
55
|
+
tool: string;
|
|
56
|
+
project: string;
|
|
57
|
+
args: string;
|
|
58
|
+
duration: number;
|
|
59
|
+
success: number;
|
|
60
|
+
error: string | null;
|
|
61
|
+
exit_code: number | null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type TableInfoRow = {
|
|
65
|
+
name: string;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export class AuditService extends ServiceMap.Service<AuditService, AuditServiceShape>()(
|
|
69
|
+
"@agent-tools/AuditService",
|
|
70
|
+
) {}
|
|
71
|
+
|
|
72
|
+
export const resolveAuditDbPath = (): string => join(homedir(), ".agent-tools", "audit.sqlite");
|
|
73
|
+
|
|
74
|
+
const expandHomePath = (path: string): string =>
|
|
75
|
+
path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
|
|
76
|
+
|
|
77
|
+
const isInMemoryDatabase = (dbPath: string): boolean =>
|
|
78
|
+
dbPath === "" || dbPath === IN_MEMORY_DB_PATH;
|
|
79
|
+
|
|
80
|
+
const toAuditEntry = (row: AuditRow): AuditLogEntry => ({
|
|
81
|
+
id: row.id,
|
|
82
|
+
ts: row.ts,
|
|
83
|
+
tool: row.tool,
|
|
84
|
+
project: row.project,
|
|
85
|
+
args: row.args,
|
|
86
|
+
duration: row.duration,
|
|
87
|
+
success: row.success === 1,
|
|
88
|
+
error: row.error,
|
|
89
|
+
exitCode: row.exit_code,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const safeToolName = (tool: string): string => tool.trim() || "unknown";
|
|
93
|
+
|
|
94
|
+
const deriveToolNameFromArgv = (): string => {
|
|
95
|
+
const executablePath = process.argv[1];
|
|
96
|
+
if (!executablePath) {
|
|
97
|
+
return "unknown";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const fileName = basename(executablePath, ".ts");
|
|
101
|
+
return fileName.replace(/^agent-tools-/, "").replace(/-tool$/, "");
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const formatUnknownError = (error: unknown): string => {
|
|
105
|
+
if (error instanceof Error) {
|
|
106
|
+
return error.message;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (typeof error === "object" && error !== null) {
|
|
110
|
+
const message = Reflect.get(error, "message");
|
|
111
|
+
if (typeof message === "string") {
|
|
112
|
+
return message;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return String(error);
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const formatCause = (cause: Cause.Cause<unknown>): string => {
|
|
120
|
+
const firstFailure = cause.reasons.find(Cause.isFailReason);
|
|
121
|
+
if (firstFailure !== undefined) {
|
|
122
|
+
return formatUnknownError(firstFailure.error);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const firstDefect = cause.reasons.find(Cause.isDieReason);
|
|
126
|
+
if (firstDefect !== undefined) {
|
|
127
|
+
return formatUnknownError(firstDefect.defect);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (Cause.hasInterruptsOnly(cause)) {
|
|
131
|
+
return "Interrupted";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return "Unknown error";
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const extractExitCode = (cause: Cause.Cause<unknown>): number | null => {
|
|
138
|
+
const firstFailure = cause.reasons.find(Cause.isFailReason);
|
|
139
|
+
if (
|
|
140
|
+
firstFailure === undefined ||
|
|
141
|
+
typeof firstFailure.error !== "object" ||
|
|
142
|
+
firstFailure.error === null
|
|
143
|
+
) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const exitCode = Reflect.get(firstFailure.error, "exitCode");
|
|
148
|
+
return typeof exitCode === "number" ? exitCode : null;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const createTableSql = `
|
|
152
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
153
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
154
|
+
ts TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
155
|
+
tool TEXT NOT NULL,
|
|
156
|
+
project TEXT NOT NULL,
|
|
157
|
+
args TEXT NOT NULL,
|
|
158
|
+
duration INTEGER NOT NULL,
|
|
159
|
+
success INTEGER NOT NULL,
|
|
160
|
+
error TEXT,
|
|
161
|
+
exit_code INTEGER
|
|
162
|
+
) STRICT
|
|
163
|
+
`;
|
|
164
|
+
|
|
165
|
+
const initializeDatabase = (db: Database, retentionDays: number): void => {
|
|
166
|
+
db.run("PRAGMA journal_mode=WAL");
|
|
167
|
+
db.run("PRAGMA synchronous=NORMAL");
|
|
168
|
+
db.run("PRAGMA busy_timeout=5000");
|
|
169
|
+
db.run(createTableSql);
|
|
170
|
+
const columns = db.query<TableInfoRow, []>("PRAGMA table_info(audit_log)").all();
|
|
171
|
+
if (!columns.some((column) => column.name === "project")) {
|
|
172
|
+
db.run("ALTER TABLE audit_log ADD COLUMN project TEXT");
|
|
173
|
+
}
|
|
174
|
+
db.run("UPDATE audit_log SET project = '' WHERE project IS NULL");
|
|
175
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts DESC)");
|
|
176
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_audit_log_tool_ts ON audit_log(tool, ts DESC)");
|
|
177
|
+
db.run("CREATE INDEX IF NOT EXISTS idx_audit_log_project_ts ON audit_log(project, ts DESC)");
|
|
178
|
+
db.run("DELETE FROM audit_log WHERE ts < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)", [
|
|
179
|
+
`-${retentionDays} days`,
|
|
180
|
+
]);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const openDatabase = (dbPath: string, retentionDays: number): Database => {
|
|
184
|
+
if (!isInMemoryDatabase(dbPath)) {
|
|
185
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const db = new Database(dbPath, { create: true, strict: true });
|
|
189
|
+
initializeDatabase(db, retentionDays);
|
|
190
|
+
|
|
191
|
+
if (!isInMemoryDatabase(dbPath)) {
|
|
192
|
+
try {
|
|
193
|
+
db.fileControl(constants.SQLITE_FCNTL_PERSIST_WAL, 0);
|
|
194
|
+
} catch {
|
|
195
|
+
return db;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return db;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const resolveAuditOptions = (options: AuditServiceOptions): Effect.Effect<ResolvedAuditOptions> =>
|
|
203
|
+
Effect.tryPromise({
|
|
204
|
+
try: () => loadConfig(),
|
|
205
|
+
catch: () => undefined,
|
|
206
|
+
}).pipe(
|
|
207
|
+
Effect.orElseSucceed(() => undefined),
|
|
208
|
+
Effect.map((config) => ({
|
|
209
|
+
dbPath: expandHomePath(options.dbPath ?? config?.audit?.dbPath ?? resolveAuditDbPath()),
|
|
210
|
+
retentionDays:
|
|
211
|
+
options.retentionDays ?? config?.audit?.retentionDays ?? DEFAULT_AUDIT_RETENTION_DAYS,
|
|
212
|
+
})),
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
const createAuditService = (dbPath: string, db: Database | null): AuditServiceShape => ({
|
|
216
|
+
dbPath,
|
|
217
|
+
record: (entry) =>
|
|
218
|
+
db === null
|
|
219
|
+
? Effect.void
|
|
220
|
+
: Effect.sync(() => {
|
|
221
|
+
db.run(
|
|
222
|
+
"INSERT INTO audit_log (tool, project, args, duration, success, error, exit_code) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
223
|
+
[
|
|
224
|
+
safeToolName(entry.tool),
|
|
225
|
+
entry.project,
|
|
226
|
+
entry.args,
|
|
227
|
+
entry.duration,
|
|
228
|
+
entry.success ? 1 : 0,
|
|
229
|
+
entry.error ?? null,
|
|
230
|
+
entry.exitCode ?? null,
|
|
231
|
+
],
|
|
232
|
+
);
|
|
233
|
+
}),
|
|
234
|
+
listRecent: (limit = 20) =>
|
|
235
|
+
db === null
|
|
236
|
+
? Effect.succeed([])
|
|
237
|
+
: Effect.sync(() => {
|
|
238
|
+
const rows = db
|
|
239
|
+
.query<AuditRow, [number]>(
|
|
240
|
+
"SELECT id, ts, tool, project, args, duration, success, error, exit_code FROM audit_log ORDER BY id DESC LIMIT ?",
|
|
241
|
+
)
|
|
242
|
+
.all(limit);
|
|
243
|
+
return rows.map(toAuditEntry);
|
|
244
|
+
}),
|
|
245
|
+
purgeOlderThanDays: (days) =>
|
|
246
|
+
db === null
|
|
247
|
+
? Effect.succeed(0)
|
|
248
|
+
: Effect.sync(() => {
|
|
249
|
+
const result = db.run(
|
|
250
|
+
"DELETE FROM audit_log WHERE ts < strftime('%Y-%m-%dT%H:%M:%fZ', 'now', ?)",
|
|
251
|
+
[`-${days} days`],
|
|
252
|
+
);
|
|
253
|
+
return result.changes;
|
|
254
|
+
}),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
export const makeAuditServiceLayer = (options: AuditServiceOptions = {}) => {
|
|
258
|
+
return Layer.effect(
|
|
259
|
+
AuditService,
|
|
260
|
+
Effect.flatMap(resolveAuditOptions(options), ({ dbPath, retentionDays }) =>
|
|
261
|
+
Effect.acquireRelease(
|
|
262
|
+
Effect.sync(() => {
|
|
263
|
+
try {
|
|
264
|
+
return openDatabase(dbPath, retentionDays);
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
}),
|
|
269
|
+
(db) =>
|
|
270
|
+
db === null
|
|
271
|
+
? Effect.void
|
|
272
|
+
: Effect.sync(() => {
|
|
273
|
+
db.close(false);
|
|
274
|
+
}).pipe(Effect.ignore),
|
|
275
|
+
).pipe(Effect.map((db) => createAuditService(dbPath, db))),
|
|
276
|
+
),
|
|
277
|
+
);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
export const AuditServiceLayer = makeAuditServiceLayer();
|
|
281
|
+
|
|
282
|
+
const safelyRecord = (entry: AuditRecord) =>
|
|
283
|
+
Effect.gen(function* () {
|
|
284
|
+
const audit = yield* AuditService;
|
|
285
|
+
yield* audit.record(entry);
|
|
286
|
+
}).pipe(Effect.ignore);
|
|
287
|
+
|
|
288
|
+
export const withAudit = <A, E, R>(
|
|
289
|
+
toolName: string,
|
|
290
|
+
program: Effect.Effect<A, E, R>,
|
|
291
|
+
): Effect.Effect<A, E, R | AuditService> =>
|
|
292
|
+
Effect.suspend(() => {
|
|
293
|
+
const startedAt = Date.now();
|
|
294
|
+
const project = process.cwd();
|
|
295
|
+
const args = process.argv.slice(2).join(" ");
|
|
296
|
+
const tool = safeToolName(toolName || deriveToolNameFromArgv());
|
|
297
|
+
|
|
298
|
+
return Effect.matchCauseEffect(program, {
|
|
299
|
+
onFailure: (cause) =>
|
|
300
|
+
Effect.flatMap(
|
|
301
|
+
safelyRecord({
|
|
302
|
+
tool,
|
|
303
|
+
project,
|
|
304
|
+
args,
|
|
305
|
+
duration: Date.now() - startedAt,
|
|
306
|
+
success: false,
|
|
307
|
+
error: formatCause(cause),
|
|
308
|
+
exitCode: extractExitCode(cause),
|
|
309
|
+
}),
|
|
310
|
+
() => Effect.failCause(cause),
|
|
311
|
+
),
|
|
312
|
+
onSuccess: (value) =>
|
|
313
|
+
Effect.flatMap(
|
|
314
|
+
safelyRecord({
|
|
315
|
+
tool,
|
|
316
|
+
project,
|
|
317
|
+
args,
|
|
318
|
+
duration: Date.now() - startedAt,
|
|
319
|
+
success: true,
|
|
320
|
+
}),
|
|
321
|
+
() => Effect.succeed(value),
|
|
322
|
+
),
|
|
323
|
+
});
|
|
324
|
+
});
|