@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.
@@ -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: 1,
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 = 1 as const
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
  })
@@ -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
- proc.exited,
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
- new ProcessError({
107
- command: args.join(" "),
108
- exitCode: -1,
109
- stderr: error instanceof Error ? error.message : String(error),
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
  })