@markjaquith/agency 3.2.0 → 3.2.2
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 +10 -2
- package/cli-main.ts +38 -0
- package/package.json +1 -1
- package/src/commands/context.test.ts +56 -0
- package/src/commands/push.test.ts +4 -2
- package/src/commands/push.ts +2 -0
- package/src/protocol.test.ts +24 -0
- package/src/protocol.ts +56 -4
- package/src/services/ContextService.ts +157 -200
- package/src/services/FileSystemService.ts +2 -0
- package/src/services/PushService.test.ts +110 -4
- package/src/services/PushService.ts +435 -85
- package/src/usage-log.test.ts +11 -1
- package/src/usage-log.ts +22 -4
- package/src/utils/process.test.ts +10 -0
- package/src/utils/process.ts +59 -6
package/src/usage-log.test.ts
CHANGED
|
@@ -24,6 +24,13 @@ describe("usage logging", () => {
|
|
|
24
24
|
durationMs: 12.4,
|
|
25
25
|
outcome: "success",
|
|
26
26
|
exitStatus: 0,
|
|
27
|
+
...(commandPath === "context"
|
|
28
|
+
? {
|
|
29
|
+
vcs: "git" as const,
|
|
30
|
+
terminalStage: "publish",
|
|
31
|
+
category: "success",
|
|
32
|
+
}
|
|
33
|
+
: {}),
|
|
27
34
|
},
|
|
28
35
|
"1.2.3",
|
|
29
36
|
env,
|
|
@@ -35,7 +42,7 @@ describe("usage logging", () => {
|
|
|
35
42
|
)
|
|
36
43
|
expect(await exportUsageEvents(env)).toEqual([
|
|
37
44
|
expect.objectContaining({
|
|
38
|
-
version:
|
|
45
|
+
version: 2,
|
|
39
46
|
sessionId: "session-1",
|
|
40
47
|
sessionSequence: 1,
|
|
41
48
|
agencyVersion: "1.2.3",
|
|
@@ -48,6 +55,9 @@ describe("usage logging", () => {
|
|
|
48
55
|
expect.objectContaining({
|
|
49
56
|
sessionSequence: 2,
|
|
50
57
|
commandPath: "context",
|
|
58
|
+
vcs: "git",
|
|
59
|
+
terminalStage: "publish",
|
|
60
|
+
category: "success",
|
|
51
61
|
}),
|
|
52
62
|
])
|
|
53
63
|
})
|
package/src/usage-log.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite"
|
|
|
2
2
|
import { mkdir } from "node:fs/promises"
|
|
3
3
|
import { dirname, join } from "node:path"
|
|
4
4
|
|
|
5
|
-
const USAGE_EVENT_VERSION =
|
|
5
|
+
const USAGE_EVENT_VERSION = 2 as const
|
|
6
6
|
const DEFAULT_RETENTION_DAYS = 90
|
|
7
7
|
|
|
8
8
|
export interface UsageEvent {
|
|
@@ -11,6 +11,9 @@ export interface UsageEvent {
|
|
|
11
11
|
readonly durationMs: number
|
|
12
12
|
readonly exitStatus: number
|
|
13
13
|
readonly outcome: "success" | "failure"
|
|
14
|
+
readonly vcs?: "git"
|
|
15
|
+
readonly terminalStage?: string
|
|
16
|
+
readonly category?: string
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
const stateDirectory = (env: NodeJS.ProcessEnv) =>
|
|
@@ -50,6 +53,13 @@ const openDatabase = async (env: NodeJS.ProcessEnv) => {
|
|
|
50
53
|
exit_status INTEGER NOT NULL
|
|
51
54
|
)
|
|
52
55
|
`)
|
|
56
|
+
for (const column of ["vcs", "terminal_stage", "category"]) {
|
|
57
|
+
try {
|
|
58
|
+
database.run(`ALTER TABLE usage_events ADD COLUMN ${column} TEXT`)
|
|
59
|
+
} catch {
|
|
60
|
+
// Existing databases already have migrated columns.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
53
63
|
database.run(
|
|
54
64
|
"CREATE INDEX IF NOT EXISTS usage_events_session ON usage_events(session_id, session_sequence)",
|
|
55
65
|
)
|
|
@@ -74,11 +84,11 @@ export async function recordUsageEvent(
|
|
|
74
84
|
INSERT INTO usage_events (
|
|
75
85
|
event_version, session_id, session_sequence, occurred_at,
|
|
76
86
|
agency_version, command_path, flag_names, duration_ms,
|
|
77
|
-
outcome, exit_status
|
|
87
|
+
outcome, exit_status, vcs, terminal_stage, category
|
|
78
88
|
) VALUES (
|
|
79
89
|
?, ?,
|
|
80
90
|
(SELECT COALESCE(MAX(session_sequence), 0) + 1 FROM usage_events WHERE session_id = ?),
|
|
81
|
-
?, ?, ?, ?, ?, ?, ?
|
|
91
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
82
92
|
)
|
|
83
93
|
`)
|
|
84
94
|
.run(
|
|
@@ -92,6 +102,9 @@ export async function recordUsageEvent(
|
|
|
92
102
|
Math.max(0, Math.round(event.durationMs)),
|
|
93
103
|
event.outcome,
|
|
94
104
|
event.exitStatus,
|
|
105
|
+
event.vcs ?? null,
|
|
106
|
+
event.terminalStage ?? null,
|
|
107
|
+
event.category ?? null,
|
|
95
108
|
)
|
|
96
109
|
if (Math.random() < 0.01) {
|
|
97
110
|
const days = retentionDays(env)
|
|
@@ -119,7 +132,7 @@ export async function exportUsageEvents(
|
|
|
119
132
|
.query(`
|
|
120
133
|
SELECT event_version, session_id, session_sequence, occurred_at,
|
|
121
134
|
agency_version, command_path, flag_names, duration_ms,
|
|
122
|
-
outcome, exit_status
|
|
135
|
+
outcome, exit_status, vcs, terminal_stage, category
|
|
123
136
|
FROM usage_events ORDER BY occurred_at, id
|
|
124
137
|
`)
|
|
125
138
|
.all() as Record<string, string | number>[]
|
|
@@ -134,6 +147,11 @@ export async function exportUsageEvents(
|
|
|
134
147
|
durationMs: row.duration_ms,
|
|
135
148
|
outcome: row.outcome,
|
|
136
149
|
exitStatus: row.exit_status,
|
|
150
|
+
...(row.vcs == null ? {} : { vcs: row.vcs }),
|
|
151
|
+
...(row.terminal_stage == null
|
|
152
|
+
? {}
|
|
153
|
+
: { terminalStage: row.terminal_stage }),
|
|
154
|
+
...(row.category == null ? {} : { category: row.category }),
|
|
137
155
|
}))
|
|
138
156
|
} catch {
|
|
139
157
|
return []
|
|
@@ -64,4 +64,14 @@ describe("spawnProcess", () => {
|
|
|
64
64
|
expect(result.stderr).toContain("err:0:")
|
|
65
65
|
expect(result.stderr).toContain(`err:${lineCount - 1}:`)
|
|
66
66
|
})
|
|
67
|
+
|
|
68
|
+
test("terminates timed-out process groups", async () => {
|
|
69
|
+
const startedAt = performance.now()
|
|
70
|
+
await expect(
|
|
71
|
+
Effect.runPromise(
|
|
72
|
+
spawnProcess(["sh", "-c", "sleep 30 & wait"], { timeoutMs: 25 }),
|
|
73
|
+
),
|
|
74
|
+
).rejects.toThrow("Process timed out")
|
|
75
|
+
expect(performance.now() - startedAt).toBeLessThan(1_000)
|
|
76
|
+
})
|
|
67
77
|
})
|
package/src/utils/process.ts
CHANGED
|
@@ -18,6 +18,7 @@ interface SpawnOptions {
|
|
|
18
18
|
readonly stdout?: "pipe" | "inherit" | "tee"
|
|
19
19
|
readonly stderr?: "pipe" | "inherit" | "tee"
|
|
20
20
|
readonly env?: Record<string, string>
|
|
21
|
+
readonly timeoutMs?: number
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const readOutput = async (
|
|
@@ -45,8 +46,14 @@ class ProcessError extends Data.TaggedError("ProcessError")<{
|
|
|
45
46
|
command: string
|
|
46
47
|
exitCode: number
|
|
47
48
|
stderr: string
|
|
49
|
+
timedOut?: boolean
|
|
50
|
+
timeoutMs?: number
|
|
51
|
+
elapsedMs?: number
|
|
48
52
|
}> {
|
|
49
53
|
override get message(): string {
|
|
54
|
+
if (this.timedOut) {
|
|
55
|
+
return `Process timed out after ${this.elapsedMs ?? this.timeoutMs} ms: ${this.command}${this.stderr ? `\n${this.stderr}` : ""}`
|
|
56
|
+
}
|
|
50
57
|
return (
|
|
51
58
|
this.stderr ||
|
|
52
59
|
`Process failed with exit code ${this.exitCode}: ${this.command}`
|
|
@@ -65,12 +72,14 @@ export const spawnProcess = (
|
|
|
65
72
|
): Effect.Effect<ProcessResult, ProcessError> =>
|
|
66
73
|
Effect.tryPromise({
|
|
67
74
|
try: async () => {
|
|
75
|
+
const startedAt = performance.now()
|
|
68
76
|
const proc = Bun.spawn([...args], {
|
|
69
77
|
cwd: options?.cwd ?? process.cwd(),
|
|
70
78
|
stdin: options?.stdin ?? "pipe",
|
|
71
79
|
stdout: options?.stdout === "inherit" ? "inherit" : "pipe",
|
|
72
80
|
stderr: options?.stderr === "inherit" ? "inherit" : "pipe",
|
|
73
81
|
env: options?.env ? { ...process.env, ...options.env } : process.env,
|
|
82
|
+
detached: options?.timeoutMs !== undefined,
|
|
74
83
|
})
|
|
75
84
|
// Start draining stdout/stderr immediately so verbose subprocesses
|
|
76
85
|
// cannot block on filled pipe buffers before they exit.
|
|
@@ -89,11 +98,53 @@ export const spawnProcess = (
|
|
|
89
98
|
options?.stderr === "tee" ? process.stderr : undefined,
|
|
90
99
|
)
|
|
91
100
|
|
|
101
|
+
let timedOut = false
|
|
102
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
103
|
+
const exited =
|
|
104
|
+
options?.timeoutMs === undefined
|
|
105
|
+
? proc.exited
|
|
106
|
+
: Promise.race([
|
|
107
|
+
proc.exited,
|
|
108
|
+
new Promise<number>((resolve) => {
|
|
109
|
+
timer = setTimeout(async () => {
|
|
110
|
+
timedOut = true
|
|
111
|
+
try {
|
|
112
|
+
process.kill(-proc.pid, "SIGTERM")
|
|
113
|
+
} catch {
|
|
114
|
+
proc.kill("SIGTERM")
|
|
115
|
+
}
|
|
116
|
+
const stopped = await Promise.race([
|
|
117
|
+
proc.exited.then(() => true),
|
|
118
|
+
Bun.sleep(250).then(() => false),
|
|
119
|
+
])
|
|
120
|
+
if (!stopped) {
|
|
121
|
+
try {
|
|
122
|
+
process.kill(-proc.pid, "SIGKILL")
|
|
123
|
+
} catch {
|
|
124
|
+
proc.kill("SIGKILL")
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
resolve(await proc.exited)
|
|
128
|
+
}, options.timeoutMs)
|
|
129
|
+
}),
|
|
130
|
+
])
|
|
92
131
|
const [exitCode, stdout, stderr] = await Promise.all([
|
|
93
|
-
|
|
132
|
+
exited,
|
|
94
133
|
stdoutPromise,
|
|
95
134
|
stderrPromise,
|
|
96
135
|
])
|
|
136
|
+
if (timer) clearTimeout(timer)
|
|
137
|
+
if (timedOut) {
|
|
138
|
+
throw new ProcessError({
|
|
139
|
+
command: args.join(" "),
|
|
140
|
+
exitCode:
|
|
141
|
+
typeof exitCode === "number" ? exitCode : (proc.exitCode ?? -1),
|
|
142
|
+
stderr: stderr.trim(),
|
|
143
|
+
timedOut: true,
|
|
144
|
+
timeoutMs: options?.timeoutMs,
|
|
145
|
+
elapsedMs: Math.round(performance.now() - startedAt),
|
|
146
|
+
})
|
|
147
|
+
}
|
|
97
148
|
|
|
98
149
|
return {
|
|
99
150
|
stdout: stdout.trim(),
|
|
@@ -103,9 +154,11 @@ export const spawnProcess = (
|
|
|
103
154
|
}
|
|
104
155
|
},
|
|
105
156
|
catch: (error) =>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
157
|
+
error instanceof ProcessError
|
|
158
|
+
? error
|
|
159
|
+
: new ProcessError({
|
|
160
|
+
command: args.join(" "),
|
|
161
|
+
exitCode: -1,
|
|
162
|
+
stderr: error instanceof Error ? error.message : String(error),
|
|
163
|
+
}),
|
|
111
164
|
})
|