opencode-feature-factory 0.2.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/LICENSE +21 -0
- package/README.md +877 -0
- package/assets/agent/backend-builder.md +73 -0
- package/assets/agent/codebase-researcher.md +56 -0
- package/assets/agent/design-interpreter.md +37 -0
- package/assets/agent/frontend-builder.md +74 -0
- package/assets/agent/implementation-validator.md +73 -0
- package/assets/agent/security-reviewer.md +60 -0
- package/assets/agent/spec-writer.md +94 -0
- package/assets/agent/story-reader.md +38 -0
- package/assets/agent/story-writer.md +39 -0
- package/assets/agent/test-verifier.md +73 -0
- package/assets/agent/work-decomposer.md +102 -0
- package/assets/agent/work-reviewer.md +77 -0
- package/assets/command/feature.md +68 -0
- package/assets/skills/feature/SCHEMA.md +990 -0
- package/assets/skills/feature/SKILL.md +620 -0
- package/dist/tui.js +3641 -0
- package/package.json +65 -0
- package/src/cleanup-sweep-command.js +75 -0
- package/src/cleanup-sweep-eligibility.js +581 -0
- package/src/cleanup-sweep-output.js +139 -0
- package/src/cleanup-sweep-report.js +548 -0
- package/src/cleanup-sweep.js +546 -0
- package/src/cli-output.js +251 -0
- package/src/cli.js +1152 -0
- package/src/config.js +231 -0
- package/src/cost-attribution.js +327 -0
- package/src/cost-report.js +185 -0
- package/src/detached-log-supervisor.js +178 -0
- package/src/doctor.js +598 -0
- package/src/env-snapshot.js +211 -0
- package/src/factory-diagnostics.js +429 -0
- package/src/factory-paths.js +40 -0
- package/src/factory.js +4769 -0
- package/src/feature-command-payload.js +378 -0
- package/src/git.js +110 -0
- package/src/github.js +252 -0
- package/src/hardening/atomic-write.js +954 -0
- package/src/hardening/line-output.js +139 -0
- package/src/hardening/output-policy.js +365 -0
- package/src/hardening/process-verification.js +542 -0
- package/src/hardening/sensitive-data.js +341 -0
- package/src/hardening/terminal-encoding.js +224 -0
- package/src/plugin.js +246 -0
- package/src/post-pr-ci.js +754 -0
- package/src/process-evidence.js +1139 -0
- package/src/refs.js +144 -0
- package/src/run-state.js +2411 -0
- package/src/steering-conflicts.js +77 -0
- package/src/telemetry.js +419 -0
- package/src/tui-data.js +499 -0
- package/src/tui-rendering.js +148 -0
- package/src/utils.js +35 -0
- package/src/validate.js +1655 -0
- package/src/worktrees.js +56 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, isAbsolute, win32 } from "node:path";
|
|
3
|
+
import { parse, printParseErrorCode } from "jsonc-parser";
|
|
4
|
+
|
|
5
|
+
const JSONC_PARSE_OPTIONS = {
|
|
6
|
+
allowEmptyContent: true,
|
|
7
|
+
allowTrailingComma: true,
|
|
8
|
+
disallowComments: false,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const POST_PR_CI_DEFAULTS = Object.freeze({
|
|
12
|
+
enabled: false,
|
|
13
|
+
wait_ms: 3_600_000,
|
|
14
|
+
initial_poll_ms: 30_000,
|
|
15
|
+
max_poll_ms: 120_000,
|
|
16
|
+
check_start_grace_ms: 300_000,
|
|
17
|
+
max_transient_errors: 12,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const POST_PR_PLUGIN_KEYS = new Set(["enabled", "waitMinutes", "initialPollSeconds", "maxPollSeconds", "checkStartGraceSeconds", "maxTransientErrors"]);
|
|
21
|
+
const POST_PR_DRIVER_KEYS = new Set(["enabled", "wait_ms", "initial_poll_ms", "max_poll_ms", "check_start_grace_ms", "max_transient_errors"]);
|
|
22
|
+
|
|
23
|
+
/** Strictly normalize the canonical plugin postPrCi object to persisted units. */
|
|
24
|
+
export function normalizePostPrCiConfig(value, { label = "postPrCi", partial = false } = {}) {
|
|
25
|
+
if (value === undefined || value === null) return partial ? {} : { ...POST_PR_CI_DEFAULTS };
|
|
26
|
+
assertPlainObject(value, label);
|
|
27
|
+
assertKnownKeys(value, POST_PR_PLUGIN_KEYS, label);
|
|
28
|
+
const normalized = {};
|
|
29
|
+
if (Object.hasOwn(value, "enabled")) normalized.enabled = booleanValue(value.enabled, `${label}.enabled`);
|
|
30
|
+
normalizeBoundedInteger(normalized, "wait_ms", value.waitMinutes, 30, 1440, 60_000, `${label}.waitMinutes`);
|
|
31
|
+
normalizeBoundedInteger(normalized, "initial_poll_ms", value.initialPollSeconds, 15, 300, 1000, `${label}.initialPollSeconds`);
|
|
32
|
+
normalizeBoundedInteger(normalized, "max_poll_ms", value.maxPollSeconds, 15, 600, 1000, `${label}.maxPollSeconds`);
|
|
33
|
+
normalizeBoundedInteger(normalized, "check_start_grace_ms", value.checkStartGraceSeconds, 60, 900, 1000, `${label}.checkStartGraceSeconds`);
|
|
34
|
+
normalizeBoundedInteger(normalized, "max_transient_errors", value.maxTransientErrors, 1, 50, 1, `${label}.maxTransientErrors`);
|
|
35
|
+
const result = partial ? normalized : { ...POST_PR_CI_DEFAULTS, ...normalized };
|
|
36
|
+
assertPostPrPollOrder(result, label);
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Normalize an already unit-converted driver override. Missing fields stay absent. */
|
|
41
|
+
export function normalizePostPrCiDriverOverride(value, { label = "driver.post_pr_ci" } = {}) {
|
|
42
|
+
if (value === undefined || value === null) return null;
|
|
43
|
+
assertPlainObject(value, label);
|
|
44
|
+
assertKnownKeys(value, POST_PR_DRIVER_KEYS, label);
|
|
45
|
+
const normalized = {};
|
|
46
|
+
if (Object.hasOwn(value, "enabled")) normalized.enabled = booleanValue(value.enabled, `${label}.enabled`);
|
|
47
|
+
for (const [key, min, max] of [
|
|
48
|
+
["wait_ms", 1_800_000, 86_400_000],
|
|
49
|
+
["initial_poll_ms", 15_000, 300_000],
|
|
50
|
+
["max_poll_ms", 15_000, 600_000],
|
|
51
|
+
["check_start_grace_ms", 60_000, 900_000],
|
|
52
|
+
["max_transient_errors", 1, 50],
|
|
53
|
+
]) {
|
|
54
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
55
|
+
const number = value[key];
|
|
56
|
+
if (!Number.isInteger(number) || number < min || number > max) throw new TypeError(`${label}.${key} must be an integer from ${min} to ${max}`);
|
|
57
|
+
normalized[key] = number;
|
|
58
|
+
}
|
|
59
|
+
return normalized;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resolve each field independently: built-in < plugin < inherited parent < driver. */
|
|
63
|
+
export function resolvePostPrCiPolicy({ plugin, parent, driver, reviewer } = {}) {
|
|
64
|
+
const pluginPolicy = normalizePostPrCiConfig(plugin, { partial: true });
|
|
65
|
+
const parentPolicy = normalizePersistedPostPrPolicy(parent, "parent post-PR policy");
|
|
66
|
+
const driverPolicy = normalizePostPrCiDriverOverride(driver) ?? {};
|
|
67
|
+
const effective = { ...POST_PR_CI_DEFAULTS, ...pluginPolicy, ...parentPolicy, ...driverPolicy };
|
|
68
|
+
assertPostPrPollOrder(effective, "effective post-PR policy");
|
|
69
|
+
const reviewerLogin = reviewer === undefined || reviewer === null || reviewer === "" ? null : String(reviewer).trim();
|
|
70
|
+
if (reviewerLogin !== null && !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u.test(reviewerLogin)) throw new TypeError("reviewer must be a valid non-empty GitHub login");
|
|
71
|
+
return {
|
|
72
|
+
...effective,
|
|
73
|
+
review: reviewerLogin
|
|
74
|
+
? { required: true, reviewer_login: reviewerLogin, source: "driver" }
|
|
75
|
+
: parentPolicy?.review ?? { required: false, reviewer_login: null, source: "none" },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizePersistedPostPrPolicy(value, label) {
|
|
80
|
+
if (value === undefined || value === null) return {};
|
|
81
|
+
assertPlainObject(value, label);
|
|
82
|
+
const allowed = new Set([...POST_PR_DRIVER_KEYS, "review"]);
|
|
83
|
+
assertKnownKeys(value, allowed, label);
|
|
84
|
+
const base = normalizePostPrCiDriverOverride(Object.fromEntries(Object.entries(value).filter(([key]) => key !== "review")), { label }) ?? {};
|
|
85
|
+
if (value.review !== undefined) base.review = structuredClone(value.review);
|
|
86
|
+
return base;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function assertPlainObject(value, label) {
|
|
90
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${label} must be an object`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function assertKnownKeys(value, allowed, label) {
|
|
94
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
95
|
+
if (unknown.length) throw new TypeError(`${label} contains unknown key '${unknown[0]}'`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function booleanValue(value, label) {
|
|
99
|
+
if (typeof value !== "boolean") throw new TypeError(`${label} must be a boolean`);
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeBoundedInteger(target, key, value, min, max, multiplier, label) {
|
|
104
|
+
if (value === undefined) return;
|
|
105
|
+
if (!Number.isInteger(value) || value < min || value > max) throw new TypeError(`${label} must be an integer from ${min} to ${max}`);
|
|
106
|
+
target[key] = value * multiplier;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function assertPostPrPollOrder(value, label) {
|
|
110
|
+
if (value.initial_poll_ms !== undefined && value.max_poll_ms !== undefined && value.max_poll_ms < value.initial_poll_ms) {
|
|
111
|
+
throw new TypeError(`${label}.max poll must be greater than or equal to initial poll`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function parseJsoncConfig(raw, { label = "opencode.jsonc" } = {}) {
|
|
116
|
+
const errors = [];
|
|
117
|
+
const input = stripBom(raw);
|
|
118
|
+
const value = parse(input, errors, JSONC_PARSE_OPTIONS);
|
|
119
|
+
const safeLabel = sanitizeLabel(label, "opencode.jsonc");
|
|
120
|
+
|
|
121
|
+
if (errors.length > 0) {
|
|
122
|
+
throw new SyntaxError(formatJsoncParseError(input, errors, safeLabel));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (value === undefined) return {};
|
|
126
|
+
assertConfigObject(value, safeLabel);
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function readJsoncConfig(path, opts = {}) {
|
|
131
|
+
let raw;
|
|
132
|
+
try {
|
|
133
|
+
raw = readFileSync(path, "utf8");
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error?.code === "ENOENT") return {};
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return parseJsoncConfig(raw, { ...opts, label: opts.label ?? basename(path) });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function parseStrictJsonConfig(raw, { label = "opencode.json" } = {}) {
|
|
143
|
+
const safeLabel = sanitizeLabel(label, "opencode.json");
|
|
144
|
+
const input = stripBom(raw);
|
|
145
|
+
let value;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
value = JSON.parse(input);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
throw new SyntaxError(formatStrictJsonParseError(input, error, safeLabel));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
assertConfigObject(value, safeLabel);
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function readStrictJsonConfig(path, opts = {}) {
|
|
158
|
+
let raw;
|
|
159
|
+
try {
|
|
160
|
+
raw = readFileSync(path, "utf8");
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error?.code === "ENOENT") return {};
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return parseStrictJsonConfig(raw, { ...opts, label: opts.label ?? basename(path) });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function assertConfigObject(value, label) {
|
|
170
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
171
|
+
throw new TypeError(`${label}: expected top-level config value to be a non-array object`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function formatJsoncParseError(raw, errors, label) {
|
|
176
|
+
const first = errors[0];
|
|
177
|
+
const location = lineColumnAt(raw, first.offset);
|
|
178
|
+
const codeMessage = printParseErrorCode(first.error);
|
|
179
|
+
const prefix = errors.length === 1 ? "JSONC parse error" : `${errors.length} JSONC parse errors; first error`;
|
|
180
|
+
|
|
181
|
+
return `${label}: ${prefix} at line ${location.line}, column ${location.column}: parser ${codeMessage} (${first.error})`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function formatStrictJsonParseError(raw, error, label) {
|
|
185
|
+
const offset = strictJsonErrorOffset(error);
|
|
186
|
+
if (offset == null) return `${label}: JSON parse error: invalid JSON syntax`;
|
|
187
|
+
|
|
188
|
+
const location = lineColumnAt(raw, offset);
|
|
189
|
+
return `${label}: JSON parse error at line ${location.line}, column ${location.column}: invalid JSON syntax`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function strictJsonErrorOffset(error) {
|
|
193
|
+
const message = String(error?.message ?? "");
|
|
194
|
+
const positionMatch = /position (\d+)/u.exec(message);
|
|
195
|
+
if (positionMatch) return Number(positionMatch[1]);
|
|
196
|
+
|
|
197
|
+
const lineColumnMatch = /line (\d+) column (\d+)/iu.exec(message);
|
|
198
|
+
if (lineColumnMatch) return { line: Number(lineColumnMatch[1]), column: Number(lineColumnMatch[2]) };
|
|
199
|
+
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function lineColumnAt(raw, offsetOrLocation) {
|
|
204
|
+
if (typeof offsetOrLocation === "object" && offsetOrLocation) return offsetOrLocation;
|
|
205
|
+
|
|
206
|
+
const offset = Math.max(0, Number(offsetOrLocation) || 0);
|
|
207
|
+
let line = 1;
|
|
208
|
+
let column = 1;
|
|
209
|
+
|
|
210
|
+
for (let index = 0; index < raw.length && index < offset; index += 1) {
|
|
211
|
+
if (raw[index] === "\n") {
|
|
212
|
+
line += 1;
|
|
213
|
+
column = 1;
|
|
214
|
+
} else {
|
|
215
|
+
column += 1;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { line, column };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sanitizeLabel(label, fallback) {
|
|
223
|
+
const raw = String(label ?? fallback);
|
|
224
|
+
const withoutAbsolutePath = isAbsolute(raw) ? basename(raw) : win32.isAbsolute(raw) ? win32.basename(raw) : raw;
|
|
225
|
+
return withoutAbsolutePath.replace(/[\r\n\t]+/gu, " ").trim() || fallback;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function stripBom(raw) {
|
|
229
|
+
const text = String(raw);
|
|
230
|
+
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
|
|
231
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
export const COST_ATTRIBUTION_SCHEMA_VERSION = 1;
|
|
4
|
+
export const MAX_COST_ATTRIBUTION_ENTRIES = 1000;
|
|
5
|
+
export const COST_ATTRIBUTION_STATUSES = Object.freeze(["available", "partial", "unavailable"]);
|
|
6
|
+
export const COST_CURRENCY_PATTERN = /^[A-Z]{3,12}$/u;
|
|
7
|
+
|
|
8
|
+
export const USAGE_NUMERIC_FIELDS = Object.freeze([
|
|
9
|
+
"input_tokens",
|
|
10
|
+
"output_tokens",
|
|
11
|
+
"total_tokens",
|
|
12
|
+
"cache_creation_input_tokens",
|
|
13
|
+
"cache_read_input_tokens",
|
|
14
|
+
"reasoning_tokens",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
export const COST_NUMERIC_FIELDS = Object.freeze([
|
|
18
|
+
"cost_total",
|
|
19
|
+
"cost_input",
|
|
20
|
+
"cost_output",
|
|
21
|
+
"cost_cache_creation",
|
|
22
|
+
"cost_cache_read",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const OPTIONAL_STRING_FIELDS = Object.freeze(["step", "slice_id", "source", "operation", "provider", "model", "request_id", "cost_currency"]);
|
|
26
|
+
const NUMERIC_FIELDS = Object.freeze([...USAGE_NUMERIC_FIELDS, ...COST_NUMERIC_FIELDS]);
|
|
27
|
+
const STATUS_SET = new Set(COST_ATTRIBUTION_STATUSES);
|
|
28
|
+
const TERMINAL_CONTROL_PATTERN = /[\u0000-\u001F\u007F-\u009F]/gu;
|
|
29
|
+
|
|
30
|
+
export function normalizeCostUsageEntry(input, options = {}) {
|
|
31
|
+
if (!isRecord(input)) throw new Error("cost usage entry must be an object");
|
|
32
|
+
const entry = {};
|
|
33
|
+
|
|
34
|
+
entry.id = safeMetadataString(nonEmptyString(input.id) || nonEmptyString(options.id) || randomUUID(), "id");
|
|
35
|
+
entry.recorded_at = normalizeTimestamp(input.recorded_at ?? input.recordedAt ?? options.now);
|
|
36
|
+
entry.run_id = nonEmptyString(options.runId) || nonEmptyString(input.run_id ?? input.runId);
|
|
37
|
+
entry.agent = safeMetadataString(nonEmptyString(input.agent), "agent");
|
|
38
|
+
if (!entry.run_id) throw new Error("cost usage entry requires run_id");
|
|
39
|
+
if (!entry.agent) throw new Error("cost usage entry requires agent");
|
|
40
|
+
|
|
41
|
+
const aliases = {
|
|
42
|
+
slice_id: input.slice_id ?? input.sliceId,
|
|
43
|
+
request_id: input.request_id ?? input.requestId,
|
|
44
|
+
cost_currency: input.cost_currency ?? input.currency,
|
|
45
|
+
};
|
|
46
|
+
for (const field of OPTIONAL_STRING_FIELDS) {
|
|
47
|
+
const value = Object.prototype.hasOwnProperty.call(aliases, field) ? aliases[field] : input[field];
|
|
48
|
+
if (field === "cost_currency") {
|
|
49
|
+
const currency = normalizeCostCurrency(value);
|
|
50
|
+
if (currency) entry[field] = currency;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const text = nonEmptyString(value);
|
|
54
|
+
if (text) entry[field] = safeMetadataString(text, field);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (const field of NUMERIC_FIELDS) {
|
|
58
|
+
if (input[field] === undefined || input[field] === null) continue;
|
|
59
|
+
entry[field] = normalizeNonNegativeFiniteNumber(input[field], field);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let missing = normalizeMissing(input.missing);
|
|
63
|
+
const hasUsage = USAGE_NUMERIC_FIELDS.some((field) => entry[field] !== undefined);
|
|
64
|
+
const hasCost = COST_NUMERIC_FIELDS.some((field) => entry[field] !== undefined);
|
|
65
|
+
const hasCostTotal = entry.cost_total !== undefined;
|
|
66
|
+
if (hasCost && !entry.cost_currency) missing = addMissing(missing, "cost_currency");
|
|
67
|
+
|
|
68
|
+
if (!entry.provider) missing = addMissing(missing, "provider");
|
|
69
|
+
if (!entry.model) missing = addMissing(missing, "model");
|
|
70
|
+
if (!hasUsage) missing = addMissing(missing, "usage");
|
|
71
|
+
if (!hasCostTotal) missing = addMissing(missing, "cost_total");
|
|
72
|
+
if (!entry.cost_currency) missing = addMissing(missing, "cost_currency");
|
|
73
|
+
|
|
74
|
+
const requestedStatus = STATUS_SET.has(input.status) ? input.status : null;
|
|
75
|
+
if (!hasUsage && !hasCost) {
|
|
76
|
+
entry.status = "unavailable";
|
|
77
|
+
} else if (missing.length > 0 || requestedStatus === "partial" || requestedStatus === "unavailable") {
|
|
78
|
+
entry.status = requestedStatus === "unavailable" && !hasUsage && !hasCost ? "unavailable" : "partial";
|
|
79
|
+
if (missing.length === 0) missing = ["metadata"];
|
|
80
|
+
} else {
|
|
81
|
+
entry.status = "available";
|
|
82
|
+
}
|
|
83
|
+
entry.missing = missing;
|
|
84
|
+
|
|
85
|
+
return entry;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function appendCostAttributionEntry(costAttribution, input, options = {}) {
|
|
89
|
+
const entries = Array.isArray(costAttribution?.entries) ? costAttribution.entries : [];
|
|
90
|
+
return recomputeCostAttribution({ entries: [...entries, input] }, options);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function normalizeCostAttribution(value = {}, options = {}) {
|
|
94
|
+
return recomputeCostAttribution(value, options);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function recomputeCostAttribution(value = {}, options = {}) {
|
|
98
|
+
const inputEntries = Array.isArray(value) ? value : Array.isArray(value?.entries) ? value.entries : [];
|
|
99
|
+
// Dedupe before enforcing the cap so an identical retry at capacity stays a
|
|
100
|
+
// no-op instead of tripping the entry limit.
|
|
101
|
+
const entries = dedupeCostEntries(inputEntries.map((entry) => normalizeCostUsageEntry(entry, { ...options, now: entry?.recorded_at ?? entry?.recordedAt ?? options.now, id: entry?.id ?? options.id })));
|
|
102
|
+
if (entries.length > MAX_COST_ATTRIBUTION_ENTRIES) throw new Error(`cost attribution entries must have at most ${MAX_COST_ATTRIBUTION_ENTRIES} entries`);
|
|
103
|
+
const updatedAt = normalizeTimestamp(options.now ?? value?.updated_at ?? value?.updatedAt);
|
|
104
|
+
const totals = rollupEntries(entries);
|
|
105
|
+
return {
|
|
106
|
+
schema_version: COST_ATTRIBUTION_SCHEMA_VERSION,
|
|
107
|
+
updated_at: updatedAt,
|
|
108
|
+
status: totals.status,
|
|
109
|
+
totals,
|
|
110
|
+
by_agent: rollupBy(entries, "agent"),
|
|
111
|
+
by_slice: rollupBy(entries, "slice_id"),
|
|
112
|
+
entries,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function publicCostAttributionSummary(runOrAttribution) {
|
|
117
|
+
const attribution = runOrAttribution?.cost_attribution || runOrAttribution;
|
|
118
|
+
const totals = isRecord(attribution?.totals) ? attribution.totals : rollupEntries(Array.isArray(attribution?.entries) ? attribution.entries : []);
|
|
119
|
+
const status = safeCostStatus(attribution?.status) || safeCostStatus(totals.status) || "unavailable";
|
|
120
|
+
const summary = {
|
|
121
|
+
schema_version: COST_ATTRIBUTION_SCHEMA_VERSION,
|
|
122
|
+
updated_at: attribution?.updated_at === undefined || attribution?.updated_at === null ? null : sanitizePublicCostText(attribution.updated_at),
|
|
123
|
+
status,
|
|
124
|
+
entry_count: totals.entry_count || 0,
|
|
125
|
+
agent_count: isRecord(attribution?.by_agent) ? Object.keys(attribution.by_agent).length : 0,
|
|
126
|
+
slice_count: isRecord(attribution?.by_slice) ? Object.keys(attribution.by_slice).length : 0,
|
|
127
|
+
mixed_currency: totals.mixed_currency === true,
|
|
128
|
+
missing: Array.isArray(totals.missing) ? totals.missing.map((item) => sanitizePublicCostText(item)).filter(Boolean) : [],
|
|
129
|
+
};
|
|
130
|
+
for (const field of USAGE_NUMERIC_FIELDS) if (totals[field] !== undefined) summary[field] = totals[field];
|
|
131
|
+
if (totals.cost_total !== undefined) summary.cost_total = totals.cost_total;
|
|
132
|
+
const currency = safeCostCurrency(totals.cost_currency);
|
|
133
|
+
if (currency) summary.cost_currency = currency;
|
|
134
|
+
return summary;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function formatCostAttributionSummary(runOrAttribution) {
|
|
138
|
+
const summary = publicCostAttributionSummary(runOrAttribution);
|
|
139
|
+
const parts = [`cost ${summary.status}`, `${summary.entry_count} ${summary.entry_count === 1 ? "entry" : "entries"}`];
|
|
140
|
+
if (summary.total_tokens !== undefined) parts.push(`${summary.total_tokens} tokens`);
|
|
141
|
+
else if (summary.input_tokens !== undefined || summary.output_tokens !== undefined) parts.push(`${summary.input_tokens ?? "?"}/${summary.output_tokens ?? "?"} tokens`);
|
|
142
|
+
if (summary.mixed_currency) parts.push("mixed currency");
|
|
143
|
+
else if (summary.cost_total !== undefined) parts.push(`${formatCost(summary.cost_total)} ${summary.cost_currency || ""}`.trim());
|
|
144
|
+
if (summary.missing.length > 0) parts.push(`missing ${summary.missing.join(",")}`);
|
|
145
|
+
return sanitizePublicCostText(parts.join(" · "));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function normalizeCostCurrency(value, field = "cost_currency") {
|
|
149
|
+
const text = nonEmptyString(value);
|
|
150
|
+
if (!text) return null;
|
|
151
|
+
if (!COST_CURRENCY_PATTERN.test(text) || hasTerminalControl(text)) {
|
|
152
|
+
throw new Error(`${field} must be an uppercase currency code (3-12 letters) with no control characters`);
|
|
153
|
+
}
|
|
154
|
+
return text;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function isSafeCostCurrency(value) {
|
|
158
|
+
return safeCostCurrency(value) !== null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function safeMetadataString(value, field) {
|
|
162
|
+
if (value === null || value === undefined) return value;
|
|
163
|
+
if (hasTerminalControl(value)) throw new Error(`${field} must not contain terminal control characters`);
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Entry ids act as idempotency keys: an exact retry of an already-recorded
|
|
168
|
+
// entry is a no-op, while reusing an id with different content is an error
|
|
169
|
+
// rather than a silent double-count.
|
|
170
|
+
function dedupeCostEntries(entries) {
|
|
171
|
+
const byId = new Map();
|
|
172
|
+
const deduped = [];
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
const existing = byId.get(entry.id);
|
|
175
|
+
if (existing === undefined) {
|
|
176
|
+
byId.set(entry.id, entry);
|
|
177
|
+
deduped.push(entry);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
// Same id = same logical event. `recorded_at` is server-generated when the
|
|
181
|
+
// caller omits --recorded-at, so an identical retry must compare equal
|
|
182
|
+
// regardless of the retry's timestamp; keep the first entry (its original
|
|
183
|
+
// timestamp) and treat the retry as a no-op. Only genuinely different
|
|
184
|
+
// content is a conflict.
|
|
185
|
+
if (costEntryContentSignature(existing) !== costEntryContentSignature(entry)) {
|
|
186
|
+
throw new Error(`cost usage entry id '${entry.id}' already recorded with different content`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return deduped;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function costEntryContentSignature(entry) {
|
|
193
|
+
const { recorded_at, ...rest } = entry;
|
|
194
|
+
void recorded_at;
|
|
195
|
+
return JSON.stringify(rest);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function hasTerminalControl(value) {
|
|
199
|
+
TERMINAL_CONTROL_PATTERN.lastIndex = 0;
|
|
200
|
+
return typeof value === "string" && TERMINAL_CONTROL_PATTERN.test(value);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function sanitizePublicCostText(value) {
|
|
204
|
+
return String(value).replace(/[\t\r\n]+/gu, " ").replace(TERMINAL_CONTROL_PATTERN, "").replace(/\s+/gu, " ").trim();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function rollupBy(entries, key) {
|
|
208
|
+
const groups = new Map();
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
const group = entry[key];
|
|
211
|
+
if (typeof group !== "string" || group.trim().length === 0) continue;
|
|
212
|
+
const groupEntries = groups.get(group) || [];
|
|
213
|
+
groupEntries.push(entry);
|
|
214
|
+
groups.set(group, groupEntries);
|
|
215
|
+
}
|
|
216
|
+
return Object.fromEntries([...groups.entries()].map(([name, groupEntries]) => [name, rollupEntries(groupEntries)]));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function rollupEntries(entries) {
|
|
220
|
+
const rollup = {
|
|
221
|
+
status: "unavailable",
|
|
222
|
+
entry_count: entries.length,
|
|
223
|
+
request_count: entries.length,
|
|
224
|
+
};
|
|
225
|
+
const missing = new Set();
|
|
226
|
+
const costCurrencies = new Set();
|
|
227
|
+
const totalCurrencies = new Set();
|
|
228
|
+
let availableEntries = 0;
|
|
229
|
+
let partial = entries.length === 0;
|
|
230
|
+
|
|
231
|
+
for (const entry of entries) {
|
|
232
|
+
if (entry.status === "available") availableEntries += 1;
|
|
233
|
+
if (entry.status !== "available") partial = true;
|
|
234
|
+
for (const item of Array.isArray(entry.missing) ? entry.missing : []) missing.add(item);
|
|
235
|
+
|
|
236
|
+
for (const field of NUMERIC_FIELDS) {
|
|
237
|
+
if (entry[field] === undefined || entry[field] === null) continue;
|
|
238
|
+
const aggregate = (rollup[field] ?? 0) + entry[field];
|
|
239
|
+
if (!Number.isFinite(aggregate)) throw new Error(`cost attribution aggregate overflow for ${field}`);
|
|
240
|
+
rollup[field] = aggregate;
|
|
241
|
+
if (COST_NUMERIC_FIELDS.includes(field)) {
|
|
242
|
+
const currency = safeCostCurrency(entry.cost_currency);
|
|
243
|
+
if (currency) {
|
|
244
|
+
costCurrencies.add(currency);
|
|
245
|
+
if (field === "cost_total") totalCurrencies.add(currency);
|
|
246
|
+
} else {
|
|
247
|
+
missing.add("cost_currency");
|
|
248
|
+
partial = true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (totalCurrencies.size === 1) rollup.cost_currency = [...totalCurrencies][0];
|
|
255
|
+
const hasMixedCurrency = costCurrencies.size > 1;
|
|
256
|
+
if (hasMixedCurrency) {
|
|
257
|
+
delete rollup.cost_total;
|
|
258
|
+
delete rollup.cost_currency;
|
|
259
|
+
missing.add("mixed_currency");
|
|
260
|
+
partial = true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (availableEntries === entries.length && entries.length > 0 && !partial) rollup.status = "available";
|
|
264
|
+
else if (entries.some((entry) => entry.status === "available" || entry.status === "partial")) rollup.status = "partial";
|
|
265
|
+
else rollup.status = "unavailable";
|
|
266
|
+
rollup.mixed_currency = hasMixedCurrency;
|
|
267
|
+
rollup.missing = entries.length === 0 ? ["entries"] : [...missing].sort();
|
|
268
|
+
return canonicalRollup(rollup);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function canonicalRollup(rollup) {
|
|
272
|
+
const ordered = {
|
|
273
|
+
status: rollup.status,
|
|
274
|
+
entry_count: rollup.entry_count,
|
|
275
|
+
request_count: rollup.request_count,
|
|
276
|
+
};
|
|
277
|
+
for (const field of NUMERIC_FIELDS) {
|
|
278
|
+
if (Object.prototype.hasOwnProperty.call(rollup, field)) ordered[field] = rollup[field];
|
|
279
|
+
}
|
|
280
|
+
if (Object.prototype.hasOwnProperty.call(rollup, "cost_currency")) ordered.cost_currency = rollup.cost_currency;
|
|
281
|
+
ordered.mixed_currency = rollup.mixed_currency;
|
|
282
|
+
ordered.missing = rollup.missing;
|
|
283
|
+
return ordered;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function normalizeMissing(value) {
|
|
287
|
+
if (!Array.isArray(value)) return [];
|
|
288
|
+
return [...new Set(value.map((item) => nonEmptyString(item)).filter(Boolean))].sort();
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function addMissing(missing, item) {
|
|
292
|
+
return [...new Set([...missing, item])].sort();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function normalizeNonNegativeFiniteNumber(value, field) {
|
|
296
|
+
const number = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
|
|
297
|
+
if (typeof number !== "number" || !Number.isFinite(number) || number < 0) throw new Error(`${field} must be a finite non-negative number`);
|
|
298
|
+
return number;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function normalizeTimestamp(value) {
|
|
302
|
+
if (value === undefined || value === null) return new Date().toISOString();
|
|
303
|
+
const parsed = typeof value === "number" ? value : value instanceof Date ? value.getTime() : Date.parse(value);
|
|
304
|
+
if (!Number.isFinite(parsed)) throw new Error("invalid cost attribution timestamp");
|
|
305
|
+
return new Date(parsed).toISOString();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function formatCost(value) {
|
|
309
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(6).replace(/0+$/u, "").replace(/\.$/u, "");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function safeCostCurrency(value) {
|
|
313
|
+
const text = nonEmptyString(value);
|
|
314
|
+
return text && COST_CURRENCY_PATTERN.test(text) && !hasTerminalControl(text) ? text : null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function safeCostStatus(value) {
|
|
318
|
+
return typeof value === "string" && STATUS_SET.has(value) ? value : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function nonEmptyString(value) {
|
|
322
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function isRecord(value) {
|
|
326
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
327
|
+
}
|