acdev 1.0.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/src/store.js ADDED
@@ -0,0 +1,135 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { dataDir } from './paths.js';
5
+
6
+ const IN_FLIGHT_STATUSES = new Set([
7
+ 'syncing',
8
+ 'preparing_worktree',
9
+ 'running',
10
+ 'applying_feedback',
11
+ ]);
12
+
13
+ export class Store {
14
+ /** @param {string} repoRoot */
15
+ constructor(repoRoot) {
16
+ this.repoRoot = repoRoot;
17
+ this.statePath = path.join(dataDir(repoRoot), 'state.json');
18
+ this._ensureStateFile();
19
+ this.state = this._read();
20
+ }
21
+
22
+ _ensureStateFile() {
23
+ const dir = path.dirname(this.statePath);
24
+ if (!fs.existsSync(dir)) {
25
+ fs.mkdirSync(dir, { recursive: true });
26
+ }
27
+ if (!fs.existsSync(this.statePath)) {
28
+ fs.writeFileSync(this.statePath, JSON.stringify({ jobs: [] }, null, 2) + '\n', 'utf8');
29
+ }
30
+ }
31
+
32
+ _read() {
33
+ return JSON.parse(fs.readFileSync(this.statePath, 'utf8'));
34
+ }
35
+
36
+ _write() {
37
+ fs.writeFileSync(this.statePath, JSON.stringify(this.state, null, 2) + '\n', 'utf8');
38
+ }
39
+
40
+ /** @returns {Job[]} */
41
+ getJobs() {
42
+ return [...this.state.jobs].sort(
43
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
44
+ );
45
+ }
46
+
47
+ /** @param {string} id @returns {Job | undefined} */
48
+ getJob(id) {
49
+ return this.state.jobs.find((j) => j.id === id);
50
+ }
51
+
52
+ /**
53
+ * @param {{
54
+ * issueUrl: string,
55
+ * issueNumber?: number,
56
+ * ticketSource?: 'github' | 'jira',
57
+ * jiraKey?: string,
58
+ * }} data
59
+ * @returns {Job}
60
+ */
61
+ addJob(data) {
62
+ const now = new Date().toISOString();
63
+ const ticketSource = data.ticketSource === 'jira' ? 'jira' : 'github';
64
+ /** @type {Job} */
65
+ const job = {
66
+ id: randomUUID(),
67
+ issueUrl: data.issueUrl,
68
+ issueNumber: data.issueNumber,
69
+ ticketSource,
70
+ ...(data.jiraKey ? { jiraKey: data.jiraKey } : {}),
71
+ status: 'queued',
72
+ createdAt: now,
73
+ updatedAt: now,
74
+ logs: [],
75
+ };
76
+ this.state.jobs.push(job);
77
+ this._write();
78
+ return job;
79
+ }
80
+
81
+ /**
82
+ * @param {string} id
83
+ * @param {Partial<Job>} patch
84
+ * @returns {Job}
85
+ */
86
+ updateJob(id, patch) {
87
+ const idx = this.state.jobs.findIndex((j) => j.id === id);
88
+ if (idx === -1) {
89
+ throw new Error(`Job not found: ${id}`);
90
+ }
91
+ const updated = {
92
+ ...this.state.jobs[idx],
93
+ ...patch,
94
+ updatedAt: new Date().toISOString(),
95
+ };
96
+ this.state.jobs[idx] = updated;
97
+ this._write();
98
+ return updated;
99
+ }
100
+
101
+ /**
102
+ * Remove a job from the store entirely (allows re-enqueue after dedupe).
103
+ * @param {string} id
104
+ * @returns {boolean} true if a job was removed
105
+ */
106
+ deleteJob(id) {
107
+ const idx = this.state.jobs.findIndex((j) => j.id === id);
108
+ if (idx === -1) return false;
109
+ this.state.jobs.splice(idx, 1);
110
+ this._write();
111
+ return true;
112
+ }
113
+
114
+ /** Mark in-flight jobs as failed after a server restart. */
115
+ reconcileStaleJobs() {
116
+ let changed = false;
117
+ for (const job of this.state.jobs) {
118
+ if (IN_FLIGHT_STATUSES.has(job.status)) {
119
+ job.status = 'failed';
120
+ job.error =
121
+ 'Server restarted while this job was in progress. Re-queue this issue to retry.';
122
+ job.updatedAt = new Date().toISOString();
123
+ job.logs.push({
124
+ ts: new Date().toISOString(),
125
+ type: 'error',
126
+ payload: job.error,
127
+ });
128
+ changed = true;
129
+ }
130
+ }
131
+ if (changed) {
132
+ this._write();
133
+ }
134
+ }
135
+ }
package/src/urls.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Split issue URL input on commas and/or newlines.
3
+ * Accepts a string, an array of strings, or nested mixes.
4
+ * @param {unknown} input
5
+ * @returns {string[]}
6
+ */
7
+ export function splitIssueUrls(input) {
8
+ if (Array.isArray(input)) {
9
+ return input.flatMap((item) => splitIssueUrls(item));
10
+ }
11
+ if (typeof input !== 'string') return [];
12
+ return input
13
+ .split(/[\n,]+/)
14
+ .map((u) => u.trim())
15
+ .filter(Boolean);
16
+ }
package/src/usage.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Normalize Claude Agent SDK result cost/usage into a first-class Job.usage shape.
3
+ *
4
+ * SDK result messages use snake_case on `usage` and camelCase on `modelUsage`.
5
+ *
6
+ * @typedef {object} JobUsage
7
+ * @property {number} [totalCostUsd]
8
+ * @property {number} [inputTokens]
9
+ * @property {number} [outputTokens]
10
+ * @property {number} [cacheReadInputTokens]
11
+ * @property {number} [cacheCreationInputTokens]
12
+ * @property {number} [numTurns]
13
+ * @property {number} [durationMs]
14
+ * @property {Record<string, object>} [modelUsage]
15
+ */
16
+
17
+ /**
18
+ * @param {unknown} n
19
+ * @returns {number | undefined}
20
+ */
21
+ function asFiniteNumber(n) {
22
+ if (typeof n !== 'number' || !Number.isFinite(n)) return undefined;
23
+ return n;
24
+ }
25
+
26
+ /**
27
+ * Extract usage from an SDK `result` message (success or error subtypes).
28
+ * @param {object | null | undefined} message
29
+ * @returns {JobUsage | null}
30
+ */
31
+ export function extractUsageFromResult(message) {
32
+ if (!message || message.type !== 'result') return null;
33
+
34
+ const rawUsage = message.usage && typeof message.usage === 'object' ? message.usage : {};
35
+ const totalCostUsd = asFiniteNumber(message.total_cost_usd);
36
+ const inputTokens = asFiniteNumber(rawUsage.input_tokens);
37
+ const outputTokens = asFiniteNumber(rawUsage.output_tokens);
38
+ const cacheReadInputTokens = asFiniteNumber(rawUsage.cache_read_input_tokens);
39
+ const cacheCreationInputTokens = asFiniteNumber(rawUsage.cache_creation_input_tokens);
40
+ const numTurns = asFiniteNumber(message.num_turns);
41
+ const durationMs = asFiniteNumber(message.duration_ms);
42
+
43
+ let modelUsage;
44
+ if (message.modelUsage && typeof message.modelUsage === 'object') {
45
+ modelUsage = message.modelUsage;
46
+ }
47
+
48
+ const hasSignal =
49
+ totalCostUsd !== undefined ||
50
+ inputTokens !== undefined ||
51
+ outputTokens !== undefined ||
52
+ cacheReadInputTokens !== undefined ||
53
+ cacheCreationInputTokens !== undefined ||
54
+ modelUsage !== undefined;
55
+
56
+ if (!hasSignal) return null;
57
+
58
+ /** @type {JobUsage} */
59
+ const out = {};
60
+ if (totalCostUsd !== undefined) out.totalCostUsd = totalCostUsd;
61
+ if (inputTokens !== undefined) out.inputTokens = inputTokens;
62
+ if (outputTokens !== undefined) out.outputTokens = outputTokens;
63
+ if (cacheReadInputTokens !== undefined) out.cacheReadInputTokens = cacheReadInputTokens;
64
+ if (cacheCreationInputTokens !== undefined) {
65
+ out.cacheCreationInputTokens = cacheCreationInputTokens;
66
+ }
67
+ if (numTurns !== undefined) out.numTurns = numTurns;
68
+ if (durationMs !== undefined) out.durationMs = durationMs;
69
+ if (modelUsage !== undefined) out.modelUsage = modelUsage;
70
+ return out;
71
+ }
72
+
73
+ /**
74
+ * Scan job logs for the last `agent_event` whose payload is a `result` message
75
+ * that carries usage/cost fields. Used to backfill older jobs.
76
+ *
77
+ * Results that appear before the most recent `retry queued` status event are
78
+ * ignored so a prior run's cost does not leak onto a new attempt.
79
+ * @param {Array<{ type?: string, payload?: object }> | null | undefined} logs
80
+ * @returns {JobUsage | null}
81
+ */
82
+ export function usageFromLogs(logs) {
83
+ if (!Array.isArray(logs)) return null;
84
+ let start = 0;
85
+ for (let i = 0; i < logs.length; i++) {
86
+ const ev = logs[i];
87
+ if (ev?.type === 'status' && ev.payload === 'retry queued') {
88
+ start = i + 1;
89
+ }
90
+ }
91
+ for (let i = logs.length - 1; i >= start; i--) {
92
+ const ev = logs[i];
93
+ if (ev?.type !== 'agent_event') continue;
94
+ const usage = extractUsageFromResult(ev.payload);
95
+ if (usage) return usage;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ /**
101
+ * Ensure a job object exposes `usage` when it can be derived from logs.
102
+ * Does not mutate the stored job.
103
+ *
104
+ * Skips log backfill for queued / in-flight jobs so a retry does not keep
105
+ * showing the previous run's cost until the new run finishes.
106
+ * @param {object} job
107
+ * @returns {object}
108
+ */
109
+ export function withJobUsage(job) {
110
+ if (!job || typeof job !== 'object') return job;
111
+ if (job.usage) return job;
112
+ const live = new Set([
113
+ 'queued',
114
+ 'syncing',
115
+ 'preparing_worktree',
116
+ 'running',
117
+ 'applying_feedback',
118
+ ]);
119
+ if (live.has(job.status)) return job;
120
+ const usage = usageFromLogs(job.logs);
121
+ return usage ? { ...job, usage } : job;
122
+ }