codecartographer-pi 0.25.0 → 0.26.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/.codecarto/broadside/SKILL.md +4 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +2 -2
- package/dist/core/broadside/client.d.ts +56 -0
- package/dist/core/broadside/client.js +200 -0
- package/dist/core/broadside/collect.d.ts +68 -0
- package/dist/core/broadside/collect.js +676 -0
- package/dist/core/broadside/constants.d.ts +51 -0
- package/dist/core/broadside/constants.js +74 -0
- package/dist/core/broadside/lenses.d.ts +31 -0
- package/dist/core/broadside/lenses.js +312 -0
- package/dist/core/broadside/models.d.ts +46 -0
- package/dist/core/broadside/models.js +321 -0
- package/dist/core/broadside/render.d.ts +20 -0
- package/dist/core/broadside/render.js +285 -0
- package/dist/core/broadside/repo.d.ts +58 -0
- package/dist/core/broadside/repo.js +592 -0
- package/dist/core/broadside/requests.d.ts +23 -0
- package/dist/core/broadside/requests.js +71 -0
- package/dist/core/broadside/results.d.ts +36 -0
- package/dist/core/broadside/results.js +163 -0
- package/dist/core/broadside/schemas.d.ts +2 -0
- package/dist/core/broadside/schemas.js +342 -0
- package/dist/core/broadside/state.d.ts +99 -0
- package/dist/core/broadside/state.js +384 -0
- package/dist/core/broadside/submit.d.ts +30 -0
- package/dist/core/broadside/submit.js +350 -0
- package/dist/core/broadside/types.d.ts +491 -0
- package/dist/core/broadside/types.js +107 -0
- package/dist/core/{broadside-verify.d.ts → broadside/verify.d.ts} +23 -2
- package/dist/core/{broadside-verify.js → broadside/verify.js} +43 -5
- package/dist/core/broadside.d.ts +14 -952
- package/dist/core/broadside.js +25 -3726
- package/dist/core/completion.js +91 -72
- package/dist/core/dashboard-writer.js +9 -1
- package/dist/core/index.d.ts +0 -1
- package/dist/core/index.js +0 -1
- package/dist/core/library.d.ts +24 -1
- package/dist/core/library.js +46 -15
- package/dist/core/orchestrator-config.js +22 -8
- package/dist/core/status.d.ts +42 -23
- package/dist/core/status.js +163 -137
- package/dist/core/workspace.d.ts +2 -0
- package/dist/core/workspace.js +49 -25
- package/dist/core/yaml.js +9 -3
- package/dist/extensions/codecarto/auto-runner.js +41 -23
- package/dist/extensions/codecarto/index.js +9 -4
- package/dist/extensions/codecarto/phase-compaction.js +6 -2
- package/dist/mcp-server/server.js +15 -4
- package/package.json +1 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type BroadsideConfig, type BroadsideRun, type BroadsideRunSlot, type BroadsideStateFile } from "./types.ts";
|
|
2
|
+
export declare function broadsideDirFor(cwd: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Read the Broad-Side reading guide.
|
|
5
|
+
*
|
|
6
|
+
* It is deliberately not a post-pipeline skill under `.codecarto/skills/`: a
|
|
7
|
+
* scout run is read *before* or *during* the interactive pipeline, and the
|
|
8
|
+
* post-pipeline machinery gates on a completed run and wraps its prompt in
|
|
9
|
+
* post-pipeline framing that would be false here. It is also readable on a
|
|
10
|
+
* repository that has scout state and no workspace at all, which is why this
|
|
11
|
+
* falls back to the packaged copy.
|
|
12
|
+
*
|
|
13
|
+
* @param cwd - Absolute path to the target repository.
|
|
14
|
+
* @returns the skill text and the path it came from.
|
|
15
|
+
* @throws when neither the workspace copy nor the packaged copy exists.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readBroadsideSkill(cwd: string): Promise<{
|
|
18
|
+
path: string;
|
|
19
|
+
content: string;
|
|
20
|
+
}>;
|
|
21
|
+
export declare function defaultBroadsideState(): BroadsideStateFile;
|
|
22
|
+
export declare function loadBroadsideState(broadsideDir: string): Promise<BroadsideStateFile>;
|
|
23
|
+
/**
|
|
24
|
+
* Overwrite `state.json` wholesale with `state`.
|
|
25
|
+
*
|
|
26
|
+
* Prefer {@link persistBroadsideRun} anywhere a live operation is recording its
|
|
27
|
+
* own progress — this entry point replaces the file, so any run a concurrent
|
|
28
|
+
* process recorded in the meantime is erased. It remains the right call for
|
|
29
|
+
* seeding a fresh workspace and for test fixtures, where "make the file exactly
|
|
30
|
+
* this" is the intent.
|
|
31
|
+
*/
|
|
32
|
+
export declare function saveBroadsideState(broadsideDir: string, state: BroadsideStateFile): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Read-modify-write `state.json` under a lock.
|
|
35
|
+
*
|
|
36
|
+
* The lock is held only for the read-modify-write, never for the surrounding
|
|
37
|
+
* operation: a `collect` can poll for the better part of an hour, and holding
|
|
38
|
+
* the lock across that would push every concurrent caller past the 5s lock
|
|
39
|
+
* timeout.
|
|
40
|
+
*/
|
|
41
|
+
export declare function updateBroadsideStateAtomically(broadsideDir: string, mutate: (state: BroadsideStateFile) => void | Promise<void>): Promise<BroadsideStateFile>;
|
|
42
|
+
/**
|
|
43
|
+
* Record one run's current shape, merged into whatever is on disk *now*.
|
|
44
|
+
*
|
|
45
|
+
* Broad-Side operations are long-lived and hold their state in memory while
|
|
46
|
+
* they poll. Writing that snapshot back wholesale silently erased any run a
|
|
47
|
+
* concurrent operation had recorded since it was loaded, orphaning that run's
|
|
48
|
+
* paid results on disk — present as files, invisible to `list`, and unreachable
|
|
49
|
+
* by `collect`, which finds its run by position in `state.runs`. Observed live:
|
|
50
|
+
* a submit at 23:35 was erased by a collect that had loaded state before it and
|
|
51
|
+
* wrote back at 00:08.
|
|
52
|
+
*
|
|
53
|
+
* Merging by run id also self-heals: a run erased by an older writer is
|
|
54
|
+
* restored the next time its own operation checkpoints.
|
|
55
|
+
*/
|
|
56
|
+
export declare function persistBroadsideRun(broadsideDir: string, run: BroadsideRun): Promise<BroadsideStateFile>;
|
|
57
|
+
/**
|
|
58
|
+
* Record a collect's view of its run, keeping whatever is further along on
|
|
59
|
+
* disk (#322).
|
|
60
|
+
*
|
|
61
|
+
* Two collects on one run each hold the run in memory and each used to write
|
|
62
|
+
* the whole thing back, so the last writer replaced the other's post-pass
|
|
63
|
+
* entries with its own — and both had submitted their own post-passes, since
|
|
64
|
+
* each decided from the copy it loaded at entry. This writer merges slot by
|
|
65
|
+
* slot: a post-pass or retry entry that is further along on disk (claimed
|
|
66
|
+
* over pending, submitted over claimed, settled over submitted) wins and is
|
|
67
|
+
* copied into `run`, so the caller reports what is true; a lens entry never
|
|
68
|
+
* goes backwards from terminal to polling. A tie keeps this collect's copy,
|
|
69
|
+
* so the collect that settled a pass records its cost. Submitting is guarded
|
|
70
|
+
* separately by {@link claimRunSlot}.
|
|
71
|
+
*/
|
|
72
|
+
export declare function persistBroadsideRunMerging(broadsideDir: string, run: BroadsideRun): Promise<BroadsideStateFile>;
|
|
73
|
+
/**
|
|
74
|
+
* Claim one spending slot of a run for this collect (#322).
|
|
75
|
+
*
|
|
76
|
+
* Read-modify-write under the state lock: if the slot on disk is still
|
|
77
|
+
* unclaimed (`pending`, or absent for the retry), it is marked `submitted`
|
|
78
|
+
* with no batch id *before* any network call and `true` comes back — this
|
|
79
|
+
* collect owns it and may submit. Otherwise another collect got there first:
|
|
80
|
+
* its entry is copied into `run` and `false` comes back. An adopted entry
|
|
81
|
+
* with a batch id can be polled (polling is idempotent); one without an id
|
|
82
|
+
* is a claim whose owner has not recorded the id yet, and is reported as in
|
|
83
|
+
* flight elsewhere.
|
|
84
|
+
*/
|
|
85
|
+
export declare function claimRunSlot(broadsideDir: string, run: BroadsideRun, slot: BroadsideRunSlot): Promise<boolean>;
|
|
86
|
+
/**
|
|
87
|
+
* Put a run's settled post-passes back to `pending` on disk so the next
|
|
88
|
+
* claim re-runs them (#338). A pass another collect has in flight is left
|
|
89
|
+
* alone — its result is still coming. The replaced results' cost moves to
|
|
90
|
+
* `retiredCost`, so the run's total keeps counting money it spent. Returns
|
|
91
|
+
* the passes that were reset, in the order they will be re-run.
|
|
92
|
+
*/
|
|
93
|
+
export declare function resetRunPostPasses(broadsideDir: string, run: BroadsideRun, wanted: {
|
|
94
|
+
synthesis: boolean;
|
|
95
|
+
triage: boolean;
|
|
96
|
+
}): Promise<Array<"synthesis" | "triage">>;
|
|
97
|
+
export declare function loadBroadsideConfig(broadsideDir: string): Promise<BroadsideConfig>;
|
|
98
|
+
/** The shipped defaults: what an absent config.yaml means. */
|
|
99
|
+
export declare function defaultBroadsideConfig(): BroadsideConfig;
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// The .codecarto/broadside/ state file (runs, claims, merging persists) and config.yaml loading.
|
|
2
|
+
//
|
|
3
|
+
// Split out of core/broadside.ts (#339); the barrel there re-exports every
|
|
4
|
+
// name, so `core/index.ts` and the tests see one module as before.
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { atomicWriteFile, pathExists } from "../utils.js";
|
|
9
|
+
import { acquireLock } from "../status.js";
|
|
10
|
+
import { loadYamlFile } from "../yaml.js";
|
|
11
|
+
import { packagedWorkspaceDir } from "../workspace.js";
|
|
12
|
+
import { BROADSIDE_CONFIG_FILE, BROADSIDE_TERMINAL_ENTRY_STATUSES, BROADSIDE_DEFAULT_MAX_COST, BROADSIDE_DIR, BROADSIDE_LENS_IDS, BROADSIDE_MODEL, BROADSIDE_STATE_FILE, BROADSIDE_STATE_SCHEMA_VERSION } from "./constants.js";
|
|
13
|
+
import { BroadsideConfigError, BroadsideStateError } from "./types.js";
|
|
14
|
+
// ---------- state & config ----------
|
|
15
|
+
export function broadsideDirFor(cwd) {
|
|
16
|
+
return join(cwd, ".codecarto", BROADSIDE_DIR);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Read the Broad-Side reading guide.
|
|
20
|
+
*
|
|
21
|
+
* It is deliberately not a post-pipeline skill under `.codecarto/skills/`: a
|
|
22
|
+
* scout run is read *before* or *during* the interactive pipeline, and the
|
|
23
|
+
* post-pipeline machinery gates on a completed run and wraps its prompt in
|
|
24
|
+
* post-pipeline framing that would be false here. It is also readable on a
|
|
25
|
+
* repository that has scout state and no workspace at all, which is why this
|
|
26
|
+
* falls back to the packaged copy.
|
|
27
|
+
*
|
|
28
|
+
* @param cwd - Absolute path to the target repository.
|
|
29
|
+
* @returns the skill text and the path it came from.
|
|
30
|
+
* @throws when neither the workspace copy nor the packaged copy exists.
|
|
31
|
+
*/
|
|
32
|
+
export async function readBroadsideSkill(cwd) {
|
|
33
|
+
const candidates = [
|
|
34
|
+
join(broadsideDirFor(cwd), "SKILL.md"),
|
|
35
|
+
join(packagedWorkspaceDir, BROADSIDE_DIR, "SKILL.md"),
|
|
36
|
+
];
|
|
37
|
+
for (const path of candidates) {
|
|
38
|
+
if (await pathExists(path))
|
|
39
|
+
return { path, content: await readFile(path, "utf8") };
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`Broad-Side skill not found at ${candidates.join(" or ")}. Reinstall codecartographer-pi.`);
|
|
42
|
+
}
|
|
43
|
+
export function defaultBroadsideState() {
|
|
44
|
+
return { schema_version: BROADSIDE_STATE_SCHEMA_VERSION, runs: [] };
|
|
45
|
+
}
|
|
46
|
+
export async function loadBroadsideState(broadsideDir) {
|
|
47
|
+
const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
|
|
48
|
+
if (!(await pathExists(statePath)))
|
|
49
|
+
return defaultBroadsideState();
|
|
50
|
+
const text = await readFile(statePath, "utf8");
|
|
51
|
+
let raw;
|
|
52
|
+
try {
|
|
53
|
+
raw = JSON.parse(text);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
|
|
57
|
+
}
|
|
58
|
+
if (!raw || typeof raw !== "object" || !Array.isArray(raw.runs)) {
|
|
59
|
+
throw new BroadsideStateError(statePath, await preserveCorruptState(statePath, text), "is not a state file (expected an object with a runs array)");
|
|
60
|
+
}
|
|
61
|
+
return raw;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Copy an unreadable state file to `state.json.corrupt-<hash>` beside it,
|
|
65
|
+
* named by content so repeated loads do not multiply copies. Returns the
|
|
66
|
+
* copy's path (the existing one, when the same content was preserved before).
|
|
67
|
+
*/
|
|
68
|
+
async function preserveCorruptState(statePath, text) {
|
|
69
|
+
const digest = createHash("sha1").update(text).digest("hex").slice(0, 8);
|
|
70
|
+
const backupPath = `${statePath}.corrupt-${digest}`;
|
|
71
|
+
if (!(await pathExists(backupPath)))
|
|
72
|
+
await writeFile(backupPath, text, "utf8");
|
|
73
|
+
return backupPath;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Overwrite `state.json` wholesale with `state`.
|
|
77
|
+
*
|
|
78
|
+
* Prefer {@link persistBroadsideRun} anywhere a live operation is recording its
|
|
79
|
+
* own progress — this entry point replaces the file, so any run a concurrent
|
|
80
|
+
* process recorded in the meantime is erased. It remains the right call for
|
|
81
|
+
* seeding a fresh workspace and for test fixtures, where "make the file exactly
|
|
82
|
+
* this" is the intent.
|
|
83
|
+
*/
|
|
84
|
+
export async function saveBroadsideState(broadsideDir, state) {
|
|
85
|
+
await mkdir(broadsideDir, { recursive: true });
|
|
86
|
+
const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
|
|
87
|
+
const lock = await acquireLock(`${statePath}.lock`);
|
|
88
|
+
try {
|
|
89
|
+
await writeBroadsideStateFile(statePath, state);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
await lock.release();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Serialize through a temp file so a crash mid-write cannot truncate state.json. */
|
|
96
|
+
async function writeBroadsideStateFile(statePath, state) {
|
|
97
|
+
await atomicWriteFile(statePath, `${JSON.stringify(state, null, "\t")}\n`);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Read-modify-write `state.json` under a lock.
|
|
101
|
+
*
|
|
102
|
+
* The lock is held only for the read-modify-write, never for the surrounding
|
|
103
|
+
* operation: a `collect` can poll for the better part of an hour, and holding
|
|
104
|
+
* the lock across that would push every concurrent caller past the 5s lock
|
|
105
|
+
* timeout.
|
|
106
|
+
*/
|
|
107
|
+
export async function updateBroadsideStateAtomically(broadsideDir, mutate) {
|
|
108
|
+
await mkdir(broadsideDir, { recursive: true });
|
|
109
|
+
const statePath = join(broadsideDir, BROADSIDE_STATE_FILE);
|
|
110
|
+
const lock = await acquireLock(`${statePath}.lock`);
|
|
111
|
+
try {
|
|
112
|
+
const state = await loadBroadsideState(broadsideDir);
|
|
113
|
+
await mutate(state);
|
|
114
|
+
await writeBroadsideStateFile(statePath, state);
|
|
115
|
+
return state;
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
await lock.release();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Record one run's current shape, merged into whatever is on disk *now*.
|
|
123
|
+
*
|
|
124
|
+
* Broad-Side operations are long-lived and hold their state in memory while
|
|
125
|
+
* they poll. Writing that snapshot back wholesale silently erased any run a
|
|
126
|
+
* concurrent operation had recorded since it was loaded, orphaning that run's
|
|
127
|
+
* paid results on disk — present as files, invisible to `list`, and unreachable
|
|
128
|
+
* by `collect`, which finds its run by position in `state.runs`. Observed live:
|
|
129
|
+
* a submit at 23:35 was erased by a collect that had loaded state before it and
|
|
130
|
+
* wrote back at 00:08.
|
|
131
|
+
*
|
|
132
|
+
* Merging by run id also self-heals: a run erased by an older writer is
|
|
133
|
+
* restored the next time its own operation checkpoints.
|
|
134
|
+
*/
|
|
135
|
+
export async function persistBroadsideRun(broadsideDir, run) {
|
|
136
|
+
return updateBroadsideStateAtomically(broadsideDir, (state) => {
|
|
137
|
+
const index = state.runs.findIndex((candidate) => candidate.id === run.id);
|
|
138
|
+
if (index === -1)
|
|
139
|
+
state.runs.push(run);
|
|
140
|
+
else
|
|
141
|
+
state.runs[index] = run;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/** Where a lens batch entry stands, for keeping the more advanced of two. */
|
|
145
|
+
function batchEntryRank(entry) {
|
|
146
|
+
if (!entry)
|
|
147
|
+
return -1;
|
|
148
|
+
if (BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status))
|
|
149
|
+
return 2;
|
|
150
|
+
if (entry.batchId)
|
|
151
|
+
return 1;
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
/** Where a post-pass entry stands: unclaimed, claimed, submitted, settled. */
|
|
155
|
+
function passEntryRank(entry) {
|
|
156
|
+
if (!entry || entry.status === "pending")
|
|
157
|
+
return 0;
|
|
158
|
+
if (entry.status === "submitted")
|
|
159
|
+
return entry.batchId ? 2 : 1;
|
|
160
|
+
return 3;
|
|
161
|
+
}
|
|
162
|
+
/** Where the retry pass stands: absent, claimed, submitted, settled. */
|
|
163
|
+
function retryEntryRank(entry) {
|
|
164
|
+
if (!entry)
|
|
165
|
+
return 0;
|
|
166
|
+
if (entry.status === "submitted")
|
|
167
|
+
return entry.batches.length > 0 ? 2 : 1;
|
|
168
|
+
return 3;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Record a collect's view of its run, keeping whatever is further along on
|
|
172
|
+
* disk (#322).
|
|
173
|
+
*
|
|
174
|
+
* Two collects on one run each hold the run in memory and each used to write
|
|
175
|
+
* the whole thing back, so the last writer replaced the other's post-pass
|
|
176
|
+
* entries with its own — and both had submitted their own post-passes, since
|
|
177
|
+
* each decided from the copy it loaded at entry. This writer merges slot by
|
|
178
|
+
* slot: a post-pass or retry entry that is further along on disk (claimed
|
|
179
|
+
* over pending, submitted over claimed, settled over submitted) wins and is
|
|
180
|
+
* copied into `run`, so the caller reports what is true; a lens entry never
|
|
181
|
+
* goes backwards from terminal to polling. A tie keeps this collect's copy,
|
|
182
|
+
* so the collect that settled a pass records its cost. Submitting is guarded
|
|
183
|
+
* separately by {@link claimRunSlot}.
|
|
184
|
+
*/
|
|
185
|
+
export async function persistBroadsideRunMerging(broadsideDir, run) {
|
|
186
|
+
return updateBroadsideStateAtomically(broadsideDir, (state) => {
|
|
187
|
+
const index = state.runs.findIndex((candidate) => candidate.id === run.id);
|
|
188
|
+
const onDisk = index === -1 ? undefined : state.runs[index];
|
|
189
|
+
if (onDisk) {
|
|
190
|
+
if (passEntryRank(onDisk.synthesis) > passEntryRank(run.synthesis))
|
|
191
|
+
run.synthesis = onDisk.synthesis;
|
|
192
|
+
if (passEntryRank(onDisk.triage) > passEntryRank(run.triage))
|
|
193
|
+
run.triage = onDisk.triage;
|
|
194
|
+
if (retryEntryRank(onDisk.retry) > retryEntryRank(run.retry))
|
|
195
|
+
run.retry = onDisk.retry;
|
|
196
|
+
// A verification pass another process recorded is never dropped by
|
|
197
|
+
// a collect that never knew about it; a newer pass replaces an older.
|
|
198
|
+
if (onDisk.verify && (!run.verify || onDisk.verify.at > run.verify.at))
|
|
199
|
+
run.verify = onDisk.verify;
|
|
200
|
+
for (const [lensId, theirs] of Object.entries(onDisk.batches)) {
|
|
201
|
+
if (theirs && batchEntryRank(theirs) > batchEntryRank(run.batches[lensId]))
|
|
202
|
+
run.batches[lensId] = theirs;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (index === -1)
|
|
206
|
+
state.runs.push(run);
|
|
207
|
+
else
|
|
208
|
+
state.runs[index] = run;
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Claim one spending slot of a run for this collect (#322).
|
|
213
|
+
*
|
|
214
|
+
* Read-modify-write under the state lock: if the slot on disk is still
|
|
215
|
+
* unclaimed (`pending`, or absent for the retry), it is marked `submitted`
|
|
216
|
+
* with no batch id *before* any network call and `true` comes back — this
|
|
217
|
+
* collect owns it and may submit. Otherwise another collect got there first:
|
|
218
|
+
* its entry is copied into `run` and `false` comes back. An adopted entry
|
|
219
|
+
* with a batch id can be polled (polling is idempotent); one without an id
|
|
220
|
+
* is a claim whose owner has not recorded the id yet, and is reported as in
|
|
221
|
+
* flight elsewhere.
|
|
222
|
+
*/
|
|
223
|
+
export async function claimRunSlot(broadsideDir, run, slot) {
|
|
224
|
+
let owned = false;
|
|
225
|
+
const claimedAt = new Date().toISOString();
|
|
226
|
+
await updateBroadsideStateAtomically(broadsideDir, (state) => {
|
|
227
|
+
const index = state.runs.findIndex((candidate) => candidate.id === run.id);
|
|
228
|
+
const onDisk = index === -1 ? undefined : state.runs[index];
|
|
229
|
+
const theirs = onDisk?.[slot];
|
|
230
|
+
const unclaimed = slot === "retry" ? theirs === undefined : theirs?.status === "pending";
|
|
231
|
+
if (onDisk && !unclaimed) {
|
|
232
|
+
run[slot] = theirs;
|
|
233
|
+
owned = false;
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
owned = true;
|
|
237
|
+
if (slot === "retry") {
|
|
238
|
+
run.retry = { status: "submitted", batches: [], claimedAt };
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
run[slot] = { ...run[slot], status: "submitted", batchId: undefined };
|
|
242
|
+
}
|
|
243
|
+
if (!onDisk) {
|
|
244
|
+
state.runs.push(run);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
onDisk[slot] = run[slot];
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
return owned;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Put a run's settled post-passes back to `pending` on disk so the next
|
|
254
|
+
* claim re-runs them (#338). A pass another collect has in flight is left
|
|
255
|
+
* alone — its result is still coming. The replaced results' cost moves to
|
|
256
|
+
* `retiredCost`, so the run's total keeps counting money it spent. Returns
|
|
257
|
+
* the passes that were reset, in the order they will be re-run.
|
|
258
|
+
*/
|
|
259
|
+
export async function resetRunPostPasses(broadsideDir, run, wanted) {
|
|
260
|
+
const reset = [];
|
|
261
|
+
await updateBroadsideStateAtomically(broadsideDir, (state) => {
|
|
262
|
+
const index = state.runs.findIndex((candidate) => candidate.id === run.id);
|
|
263
|
+
const onDisk = index === -1 ? run : state.runs[index];
|
|
264
|
+
for (const kind of ["synthesis", "triage"]) {
|
|
265
|
+
if (!wanted[kind])
|
|
266
|
+
continue;
|
|
267
|
+
const theirs = onDisk[kind] ?? { status: "pending" };
|
|
268
|
+
if (theirs.status !== "completed" && theirs.status !== "failed") {
|
|
269
|
+
// pending: nothing to reset; submitted: in flight elsewhere.
|
|
270
|
+
run[kind] = theirs;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (theirs.cost)
|
|
274
|
+
onDisk.retiredCost = (onDisk.retiredCost ?? 0) + theirs.cost;
|
|
275
|
+
onDisk[kind] = { status: "pending" };
|
|
276
|
+
run[kind] = onDisk[kind];
|
|
277
|
+
run.retiredCost = onDisk.retiredCost;
|
|
278
|
+
reset.push(kind);
|
|
279
|
+
}
|
|
280
|
+
if (index === -1)
|
|
281
|
+
state.runs.push(run);
|
|
282
|
+
});
|
|
283
|
+
return reset;
|
|
284
|
+
}
|
|
285
|
+
/** Read a `reasoning:` block from config.yaml, ignoring anything malformed. */
|
|
286
|
+
function parseReasoningConfig(raw) {
|
|
287
|
+
if (raw === false)
|
|
288
|
+
return { enabled: false };
|
|
289
|
+
if (raw === true)
|
|
290
|
+
return { enabled: true };
|
|
291
|
+
if (!raw || typeof raw !== "object")
|
|
292
|
+
return null;
|
|
293
|
+
const value = raw;
|
|
294
|
+
const out = {};
|
|
295
|
+
if (typeof value.enabled === "boolean")
|
|
296
|
+
out.enabled = value.enabled;
|
|
297
|
+
if (value.effort === "minimal" || value.effort === "low" || value.effort === "medium" || value.effort === "high")
|
|
298
|
+
out.effort = value.effort;
|
|
299
|
+
if (typeof value.max_tokens === "number" && value.max_tokens > 0)
|
|
300
|
+
out.max_tokens = value.max_tokens;
|
|
301
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
302
|
+
}
|
|
303
|
+
export async function loadBroadsideConfig(broadsideDir) {
|
|
304
|
+
const configPath = join(broadsideDir, BROADSIDE_CONFIG_FILE);
|
|
305
|
+
let raw = {};
|
|
306
|
+
if (await pathExists(configPath)) {
|
|
307
|
+
let parsed;
|
|
308
|
+
try {
|
|
309
|
+
parsed = await loadYamlFile(configPath);
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
throw new BroadsideConfigError(configPath, `could not be parsed (${error instanceof Error ? error.message : String(error)})`);
|
|
313
|
+
}
|
|
314
|
+
if (parsed !== null && parsed !== undefined) {
|
|
315
|
+
if (typeof parsed !== "object" || Array.isArray(parsed))
|
|
316
|
+
throw new BroadsideConfigError(configPath, "is not a YAML mapping");
|
|
317
|
+
raw = parsed;
|
|
318
|
+
}
|
|
319
|
+
// OpenRouter accepts `reasoning.effort` or `reasoning.max_tokens`, not
|
|
320
|
+
// both: a request carrying both is refused per request *after* the batch
|
|
321
|
+
// is accepted, so every lens fails at $0 with the reason in each
|
|
322
|
+
// result's error. Seen live on 0.22.0 with the two keys set together.
|
|
323
|
+
// Refuse here, where the file can be fixed, rather than submit a run
|
|
324
|
+
// that cannot produce a result.
|
|
325
|
+
const reasoning = raw.reasoning;
|
|
326
|
+
if (reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)) {
|
|
327
|
+
const value = reasoning;
|
|
328
|
+
const hasEffort = typeof value.effort === "string";
|
|
329
|
+
const hasBudget = typeof value.max_tokens === "number" && value.max_tokens > 0;
|
|
330
|
+
if (hasEffort && hasBudget) {
|
|
331
|
+
throw new BroadsideConfigError(configPath, 'sets both reasoning.effort and reasoning.max_tokens; OpenRouter accepts one or the other ("Only one of reasoning.effort and reasoning.max_tokens can be specified"), and every lens request would fail after the batch is accepted. Keep one');
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return buildBroadsideConfig(raw);
|
|
336
|
+
}
|
|
337
|
+
/** The shipped defaults: what an absent config.yaml means. */
|
|
338
|
+
export function defaultBroadsideConfig() {
|
|
339
|
+
return buildBroadsideConfig({});
|
|
340
|
+
}
|
|
341
|
+
function buildBroadsideConfig(raw) {
|
|
342
|
+
const lenses = Array.isArray(raw.default_lenses)
|
|
343
|
+
? (raw.default_lenses.filter((l) => BROADSIDE_LENS_IDS.includes(l)))
|
|
344
|
+
: [];
|
|
345
|
+
const rawPricing = (raw.pricing ?? {});
|
|
346
|
+
const inputOverride = typeof rawPricing.input_per_m === "number" ? rawPricing.input_per_m : undefined;
|
|
347
|
+
const outputOverride = typeof rawPricing.output_per_m === "number" ? rawPricing.output_per_m : undefined;
|
|
348
|
+
// A malformed value falls back to the shipped default rather than failing
|
|
349
|
+
// the run: config.yaml is hand-edited, and a typo in a poll budget must not
|
|
350
|
+
// cost a user their batches.
|
|
351
|
+
const flag = (key, fallback) => typeof raw[key] === "boolean" ? raw[key] : fallback;
|
|
352
|
+
// An override for an unknown lens id is dropped rather than carried: it can
|
|
353
|
+
// only be a typo, and a silently-ignored key that looks applied is worse
|
|
354
|
+
// than one that never appears.
|
|
355
|
+
const lensModels = {};
|
|
356
|
+
const rawLensModels = (raw.lens_models ?? {});
|
|
357
|
+
for (const lensId of BROADSIDE_LENS_IDS) {
|
|
358
|
+
const value = rawLensModels[lensId];
|
|
359
|
+
if (typeof value === "string" && value.trim())
|
|
360
|
+
lensModels[lensId] = value.trim();
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
model: typeof raw.model === "string" && raw.model.trim() ? raw.model.trim() : BROADSIDE_MODEL,
|
|
364
|
+
apiKey: typeof raw.api_key === "string" ? raw.api_key.trim() : "",
|
|
365
|
+
defaultLenses: lenses.length > 0 ? lenses : [...BROADSIDE_LENS_IDS],
|
|
366
|
+
// Absent: the shipped default. An explicit 0 is "no limit", spelled out
|
|
367
|
+
// on purpose; a negative or non-numeric value is not a limit at all.
|
|
368
|
+
maxCost: typeof raw.max_cost === "number" && raw.max_cost >= 0 ? raw.max_cost : BROADSIDE_DEFAULT_MAX_COST,
|
|
369
|
+
pricing: inputOverride !== undefined && outputOverride !== undefined
|
|
370
|
+
? { inputPerM: inputOverride, outputPerM: outputOverride }
|
|
371
|
+
: null,
|
|
372
|
+
lensModels,
|
|
373
|
+
// An escape hatch, not a knob to reach for: a model whose reasoning is
|
|
374
|
+
// worth paying for needs its lens maxTokens raised to cover both the
|
|
375
|
+
// thinking and the answer, or the JSON truncates exactly as before.
|
|
376
|
+
reasoning: parseReasoningConfig(raw.reasoning),
|
|
377
|
+
incremental: flag("incremental", false),
|
|
378
|
+
retryTruncated: flag("retry_truncated", true),
|
|
379
|
+
includeSynthesis: flag("include_synthesis", true),
|
|
380
|
+
includeTriage: flag("include_triage", true),
|
|
381
|
+
waitSeconds: typeof raw.wait_seconds === "number" && raw.wait_seconds > 0 ? raw.wait_seconds : 0,
|
|
382
|
+
redactSecrets: flag("redact_secrets", true),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type BroadsideLensId } from "./constants.ts";
|
|
2
|
+
import { type BroadsideEstimate, type BroadsideSubmitResult } from "./types.ts";
|
|
3
|
+
import { type FetchLike } from "./client.ts";
|
|
4
|
+
export declare function runBroadsideSubmit(cwd: string, apiKey: string, opts?: {
|
|
5
|
+
lenses?: BroadsideLensId[];
|
|
6
|
+
fetcher?: FetchLike;
|
|
7
|
+
model?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Per-lens model overrides for this run, layered over config.yaml's
|
|
10
|
+
* `lens_models`: a lens named here runs on this model, a lens named only
|
|
11
|
+
* in the file runs on the file's, and the rest run on `model` (#141).
|
|
12
|
+
*/
|
|
13
|
+
lensModels?: Partial<Record<BroadsideLensId, string>>;
|
|
14
|
+
/** Approximate run expense limit in USD; 0 means no limit. */
|
|
15
|
+
maxCost?: number;
|
|
16
|
+
/** Submit even when the estimate exceeds maxCost. */
|
|
17
|
+
force?: boolean;
|
|
18
|
+
/** Diff against the previous run's HEAD and scan only changed modules (#142). */
|
|
19
|
+
incremental?: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Called with the pre-flight estimate after slicing and before any state
|
|
22
|
+
* write or submission. Returning false throws {@link BroadsideCancelledError}
|
|
23
|
+
* and nothing is submitted; returning true proceeds even past maxCost,
|
|
24
|
+
* because an interactive approval of a priced run *is* the force flag.
|
|
25
|
+
*
|
|
26
|
+
* A surface that cannot ask a human (MCP) omits this and keeps the
|
|
27
|
+
* refuse-unless-force behavior.
|
|
28
|
+
*/
|
|
29
|
+
confirm?: (estimate: BroadsideEstimate) => boolean | Promise<boolean>;
|
|
30
|
+
}): Promise<BroadsideSubmitResult>;
|