@method-ai-hq/sdk 0.18.2 → 0.20.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/README.md +1 -1
- package/dist/packages/sdk/package.json +7 -2
- package/dist/packages/sdk/src/authoring-instructions.d.ts +1 -1
- package/dist/packages/sdk/src/authoring-instructions.js +1 -1
- package/dist/packages/sdk/src/current-inspection.js +3 -0
- package/dist/packages/sdk/src/current-runtime.d.ts +6 -1
- package/dist/packages/sdk/src/current-runtime.js +47 -2
- package/dist/packages/sdk/src/fix-lines.d.ts +82 -0
- package/dist/packages/sdk/src/fix-lines.js +63 -0
- package/dist/packages/sdk/src/fix-outcomes.d.ts +72 -0
- package/dist/packages/sdk/src/fix-outcomes.js +201 -0
- package/dist/packages/sdk/src/home.d.ts +30 -26
- package/dist/packages/sdk/src/home.js +164 -60
- package/dist/packages/sdk/src/improve.d.ts +146 -24
- package/dist/packages/sdk/src/improve.js +665 -105
- package/dist/packages/sdk/src/judge.d.ts +23 -10
- package/dist/packages/sdk/src/judge.js +110 -26
- package/dist/packages/sdk/src/learned.d.ts +43 -0
- package/dist/packages/sdk/src/learned.js +97 -0
- package/dist/packages/sdk/src/local-cli.js +8 -3
- package/dist/packages/sdk/src/local-judgments.d.ts +2 -0
- package/dist/packages/sdk/src/merge.d.ts +38 -0
- package/dist/packages/sdk/src/merge.js +65 -0
- package/dist/packages/sdk/src/method-client.js +1 -1
- package/dist/packages/sdk/src/method-files.d.ts +3 -2
- package/dist/packages/sdk/src/method-files.js +7 -3
- package/dist/packages/sdk/src/method-help.d.ts +1 -1
- package/dist/packages/sdk/src/method-help.js +25 -16
- package/dist/packages/sdk/src/method-sync.d.ts +7 -0
- package/dist/packages/sdk/src/method-sync.js +33 -0
- package/dist/packages/sdk/src/method.d.ts +5 -0
- package/dist/packages/sdk/src/method.js +169 -15
- package/dist/packages/sdk/src/outbox.d.ts +28 -6
- package/dist/packages/sdk/src/outbox.js +13 -6
- package/dist/packages/sdk/src/proposals.d.ts +0 -28
- package/dist/packages/sdk/src/proposals.js +2 -129
- package/dist/packages/sdk/src/quality.d.ts +65 -5
- package/dist/packages/sdk/src/quality.js +82 -17
- package/dist/packages/sdk/src/run-saved.d.ts +4 -0
- package/dist/packages/sdk/src/run-saved.js +33 -2
- package/dist/packages/sdk/src/undo.d.ts +50 -0
- package/dist/packages/sdk/src/undo.js +108 -0
- package/dist/packages/sdk/src/versions.js +5 -3
- package/dist/packages/sdk/src/watch.d.ts +20 -0
- package/dist/packages/sdk/src/watch.js +86 -0
- package/dist/packages/workflow-language/src/inspection.d.ts +30 -0
- package/dist/packages/workflow-language/src/inspection.js +3 -1
- package/package.json +7 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import {
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
3
3
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
4
|
import { isDeepStrictEqual } from 'node:util';
|
|
5
5
|
import { MethodPackageSchema, packageDigest, packagePath } from '../../contracts/src/method-package.js';
|
|
@@ -9,13 +9,62 @@ import { fileHash } from './method-files.js';
|
|
|
9
9
|
import { methodCache } from './prepare.js';
|
|
10
10
|
import { writePrivateJson } from './files.js';
|
|
11
11
|
import { currentVersion, methodContent, methodIdPattern } from './versions.js';
|
|
12
|
-
import {
|
|
12
|
+
import { localRuns, saveLocalProposal, writeMethodFile } from './proposals.js';
|
|
13
|
+
import { changedSteps } from './merge.js';
|
|
13
14
|
import { localJudgments, runFolderIndex } from './local-judgments.js';
|
|
15
|
+
import { fixFolder, readFixState, saveError, saveNotice, saveOutcome, setWorker, storedOutcome, writeFixState } from './fix-outcomes.js';
|
|
16
|
+
import { recordChange, saveVersion, snapshot, undoLast } from './undo.js';
|
|
17
|
+
import { fixUndoneLine, ruleBackLine } from './fix-lines.js';
|
|
18
|
+
const outcomeKinds = ['fixed', 'no_fix_found', 'could_not_test', 'question', 'held', 'not_reproduced', 'not_fixable_here'];
|
|
19
|
+
const reasons = ['missing_file', 'can_send', 'too_large', 'no_live_step'];
|
|
20
|
+
const plainText = (value) => typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
14
21
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
22
|
+
* One outcome of improve.method in the form the lines read: {id, outcome, detail, reason?, question?, held_with?,
|
|
23
|
+
* learned_rule?, again?, tests?: {case, base, candidate: {passes, runs}, guards: []}, tried?}. A fixed outcome's detail
|
|
24
|
+
* says what is different now; a held outcome's detail is about the loop, so the line names only the other person.
|
|
17
25
|
*/
|
|
18
|
-
export function
|
|
26
|
+
export function loopOutcome(o) {
|
|
27
|
+
if (typeof o?.id !== 'string' || !outcomeKinds.includes(o.outcome))
|
|
28
|
+
throw Error('The improvement run has no outcome for each correction.');
|
|
29
|
+
const out = { id: o.id, outcome: o.outcome };
|
|
30
|
+
const detail = plainText(o.detail);
|
|
31
|
+
if (o.outcome === 'fixed') {
|
|
32
|
+
if (detail)
|
|
33
|
+
out.summary = detail;
|
|
34
|
+
}
|
|
35
|
+
else if (o.outcome !== 'held' && detail)
|
|
36
|
+
out.detail = detail;
|
|
37
|
+
if (o.outcome === 'could_not_test')
|
|
38
|
+
out.reason = reasons.includes(o.reason) ? o.reason : 'missing_file';
|
|
39
|
+
if (out.reason === 'missing_file' && typeof o.path === 'string' && o.path)
|
|
40
|
+
out.path = o.path;
|
|
41
|
+
if (o.outcome === 'question')
|
|
42
|
+
out.question = plainText(o.question) ?? 'What should the result have said instead?';
|
|
43
|
+
if (o.outcome === 'held' && plainText(o.held_with))
|
|
44
|
+
out.by = plainText(o.held_with);
|
|
45
|
+
if (typeof o.learned_rule === 'string' && o.learned_rule)
|
|
46
|
+
out.rule_added = true;
|
|
47
|
+
if (Number.isSafeInteger(o.tried) && o.tried > 0)
|
|
48
|
+
out.tried = o.tried;
|
|
49
|
+
const passes = o.tests?.candidate?.passes;
|
|
50
|
+
if (o.outcome === 'fixed' && Number.isSafeInteger(passes) && passes > 0)
|
|
51
|
+
out.tested = { times: passes, earlier: Array.isArray(o.tests?.guards) ? o.tests.guards.length : 0 };
|
|
52
|
+
if (o.again === true)
|
|
53
|
+
out.again = true;
|
|
54
|
+
// Optional words for the lines, when the loop gives them: the plain noun of what the Method works on, and the tested output.
|
|
55
|
+
if (plainText(o.subject))
|
|
56
|
+
out.subject = plainText(o.subject);
|
|
57
|
+
if (o.tested_output && typeof o.tested_output === 'object')
|
|
58
|
+
out.tested_output = o.tested_output;
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Read the result of improve.method: {proposal: <SubmitProposal as JSON text>, kind, proposal_id, outcomes: <JSON text,
|
|
63
|
+
* one outcome per new correction>, background_cases: [case ids], summary: one plain line}. A change has the tested
|
|
64
|
+
* workflow and the changed package files (a script, learned.json). A pruning sweep (mode prune) has no outcomes; its
|
|
65
|
+
* summary says what it removed, and what it left because nothing checks it.
|
|
66
|
+
*/
|
|
67
|
+
export function readImproveResult(result, options = {}) {
|
|
19
68
|
const value = result;
|
|
20
69
|
if (!value || typeof value.proposal !== 'string')
|
|
21
70
|
throw Error('The improvement run gave no proposal.');
|
|
@@ -35,8 +84,20 @@ export function readImproveResult(result) {
|
|
|
35
84
|
throw Error(`The improvement run says kind ${String(value.kind)}, but its proposal is kind ${evidence.kind}.`);
|
|
36
85
|
if (evidence.kind === 'change' && (!proposal.workflow || typeof proposal.workflow !== 'object'))
|
|
37
86
|
throw Error('The improvement proposal is a change without a workflow.');
|
|
87
|
+
let outcomes = [];
|
|
88
|
+
if (!options.prune) {
|
|
89
|
+
try {
|
|
90
|
+
outcomes = typeof value.outcomes === 'string' ? JSON.parse(value.outcomes) : undefined;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
outcomes = undefined;
|
|
94
|
+
}
|
|
95
|
+
if (!Array.isArray(outcomes))
|
|
96
|
+
throw Error('The improvement run has no outcome for each correction.');
|
|
97
|
+
}
|
|
98
|
+
const background = Array.isArray(value.background_cases) ? value.background_cases.filter((c) => typeof c === 'string' && !!c) : [];
|
|
38
99
|
return { cause: proposal.cause.trim(), workflow: evidence.kind === 'change' ? proposal.workflow : null, files: Array.isArray(proposal.files) ? proposal.files : [], evidence,
|
|
39
|
-
capped: typeof value.capped === 'string' ? value.capped : '' };
|
|
100
|
+
capped: typeof value.capped === 'string' ? value.capped : '', outcomes: outcomes.map(loopOutcome), background_cases: [...new Set(background)], summary: plainText(value.summary) ?? '' };
|
|
40
101
|
}
|
|
41
102
|
/**
|
|
42
103
|
* Download the published improve.method and its files into the Method cache, once per version. For developers of
|
|
@@ -76,11 +137,11 @@ export async function cachedImproveMethod(client) {
|
|
|
76
137
|
return { file, root, version_id: saved.version_id };
|
|
77
138
|
}
|
|
78
139
|
/**
|
|
79
|
-
* Run improve.method on this computer. Its model steps and its scripts' model requests follow the rule of every run:
|
|
140
|
+
* Run improve.method on this computer (mode prune: the pruning pass). Its model steps and its scripts' model requests follow the rule of every run:
|
|
80
141
|
* the user's own Claude Code or Codex first, hosted models only without one. The run gets the signed-in key and server
|
|
81
142
|
* as its IMPROVE_API_KEY and IMPROVE_SERVER secrets, for this run only. Its output goes to a log file.
|
|
82
143
|
*/
|
|
83
|
-
export async function runImprovement(client, file, judgments, runFolders, run) {
|
|
144
|
+
export async function runImprovement(client, file, judgments, runFolders, run, mode) {
|
|
84
145
|
const token = client.token();
|
|
85
146
|
if (!token)
|
|
86
147
|
throw Error('Sign in with method login, then run method judge again.');
|
|
@@ -90,7 +151,7 @@ export async function runImprovement(client, file, judgments, runFolders, run) {
|
|
|
90
151
|
const judgmentsFile = join(runDir, 'judgments.json'), inputs = join(runDir, 'improve-inputs.json'), log = join(runDir, 'improve.log');
|
|
91
152
|
// The judgments with their notes go in a private file of this run; they stay on this computer.
|
|
92
153
|
writePrivateJson(judgmentsFile, judgments);
|
|
93
|
-
writePrivateJson(inputs, { method_file: file, grant_id: '', judgment_id: '', judgments_file: judgmentsFile, run_folders: runFolders });
|
|
154
|
+
writePrivateJson(inputs, { method_file: file, grant_id: '', judgment_id: '', judgments_file: judgmentsFile, run_folders: runFolders, ...(mode ? { mode } : {}) });
|
|
94
155
|
const runFile = run ?? (await import('./current-runtime.js')).runCurrentFile;
|
|
95
156
|
// The run prints its own result and progress; they go to the log, so the person reads one line.
|
|
96
157
|
const out = process.stdout.write, err = process.stderr.write;
|
|
@@ -110,52 +171,141 @@ export async function runImprovement(client, file, judgments, runFolders, run) {
|
|
|
110
171
|
}
|
|
111
172
|
if (result.status !== 'completed')
|
|
112
173
|
throw Object.assign(Error(result.failure?.headline ?? `The improvement stopped with status ${result.status}.`), { log });
|
|
113
|
-
|
|
114
|
-
return JSON.parse(readFileSync(join(runDir, 'run', 'summary.json'), 'utf8'));
|
|
115
|
-
}
|
|
116
|
-
catch {
|
|
117
|
-
return {};
|
|
118
|
-
} })();
|
|
119
|
-
return { ...readImproveResult(result.result), log, summary };
|
|
174
|
+
return { ...readImproveResult(result.result, { prune: mode === 'prune' }), log };
|
|
120
175
|
}
|
|
121
176
|
/** The ID of the regression case that a correction makes: one per judgment, so a later loop finds it. */
|
|
122
177
|
export const correctionCaseId = (judgmentId) => `correction-${judgmentId.replace(/^jdg_/, '').slice(0, 12)}`;
|
|
123
|
-
const caseSchema = { type: 'object', properties: {
|
|
178
|
+
const caseSchema = { type: 'object', properties: {
|
|
179
|
+
criteria: { type: 'array', items: { type: 'object', properties: {
|
|
180
|
+
text: { type: 'string', description: 'One plain sentence that a right result meets.' },
|
|
181
|
+
ref: { type: 'string', description: 'The name, from "What the person saw", of the thing the sentence is about.' }
|
|
182
|
+
} } },
|
|
183
|
+
check_script: { type: 'string', description: 'A Node.js script that checks the form or the action, or an empty text.' },
|
|
184
|
+
check_says: { type: 'string', description: 'One plain sentence: what the script checks. Empty when there is no script.' },
|
|
185
|
+
} };
|
|
186
|
+
const textOf = (path) => {
|
|
187
|
+
const bytes = readFileSync(path);
|
|
188
|
+
return bytes.subarray(0, 8192).includes(0) ? `[a binary file of ${bytes.length} bytes]` : bytes.toString('utf8');
|
|
189
|
+
};
|
|
190
|
+
/** What the person saw of a run, with the ref that a case uses for each thing (runtime 0.16.0 refs). */
|
|
191
|
+
export function seenOfRun(outcome) {
|
|
192
|
+
const seen = [];
|
|
193
|
+
if (outcome.result !== undefined && outcome.result !== null)
|
|
194
|
+
seen.push({ ref: 'result', label: 'The result', value: outcome.result });
|
|
195
|
+
for (const [name, value] of Object.entries(outcome.outputs ?? {}))
|
|
196
|
+
seen.push({ ref: `outputs.${name}`, label: `The output ${name}`, value });
|
|
197
|
+
const files = [];
|
|
198
|
+
const walk = (dir, prefix) => {
|
|
199
|
+
let entries = [];
|
|
200
|
+
try {
|
|
201
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
if (files.length >= 20 || entry.name.startsWith('.'))
|
|
208
|
+
continue;
|
|
209
|
+
const path = join(dir, entry.name), name = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
210
|
+
if (entry.isDirectory())
|
|
211
|
+
walk(path, name);
|
|
212
|
+
else if (entry.isFile() && statSync(path).size <= 2_000_000)
|
|
213
|
+
files.push({ ref: `file:${name}`, label: `The file ${name}, which the run wrote`, value: textOf(path) });
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
if (typeof outcome.artifacts === 'string')
|
|
217
|
+
walk(outcome.artifacts, '');
|
|
218
|
+
for (const [path, copy] of Object.entries(outcome.files ?? {})) {
|
|
219
|
+
if (files.length >= 20)
|
|
220
|
+
break;
|
|
221
|
+
try {
|
|
222
|
+
if (statSync(copy).isFile())
|
|
223
|
+
files.push({ ref: `file:${path}`, label: `The file ${path}, which the run wrote`, value: textOf(copy) });
|
|
224
|
+
}
|
|
225
|
+
catch { /* the copy is gone */ }
|
|
226
|
+
}
|
|
227
|
+
seen.push(...files);
|
|
228
|
+
const calls = new Map();
|
|
229
|
+
for (const call of outcome.tool_calls ?? [])
|
|
230
|
+
calls.set(call.tool, [...(calls.get(call.tool) ?? []), call.arguments]);
|
|
231
|
+
for (const [tool, args] of calls)
|
|
232
|
+
seen.push({ ref: `effect:${tool}`, label: `The calls of ${tool} (their arguments)`, value: args });
|
|
233
|
+
for (const effect of outcome.effect_inputs ?? [])
|
|
234
|
+
seen.push({ ref: `effect:${effect.effect}`, label: `What ${effect.effect} sent or changed`, value: effect.inputs });
|
|
235
|
+
return seen;
|
|
236
|
+
}
|
|
124
237
|
/**
|
|
125
|
-
* Make the regression case of a correction, even from vague words such as "it's not even redlined"
|
|
126
|
-
*
|
|
127
|
-
*
|
|
238
|
+
* Make the regression case of a correction, even from vague words such as "it's not even redlined". A model reads what
|
|
239
|
+
* the person saw (the result, the files the run wrote, and the calls and effects it made) and writes 1 to 3 plain
|
|
240
|
+
* sentences, each about one of those things; for a note about form or a missing action, also a small check script. A
|
|
241
|
+
* sentence is kept only when it fails on the corrected run and passes on every approved run of the same part, with two
|
|
242
|
+
* judges when both Claude Code and Codex are signed in. Sentences that do not fail get one more try with the reason.
|
|
128
243
|
*/
|
|
129
|
-
export async function caseFromCorrection(file, judgment, runDir) {
|
|
130
|
-
const id = correctionCaseId(judgment.id);
|
|
131
|
-
if (existsSync(join(
|
|
244
|
+
export async function caseFromCorrection(file, judgment, runDir, options = {}) {
|
|
245
|
+
const id = correctionCaseId(judgment.id), folder = dirname(resolve(file));
|
|
246
|
+
if (existsSync(join(folder, 'cases', id, 'case.json')))
|
|
132
247
|
return { id, made: false };
|
|
133
248
|
const { outcomeOfRun, createCase } = await import('@withmethod/runtime/cases.js');
|
|
134
249
|
const outcome = await outcomeOfRun(runDir);
|
|
250
|
+
const approved = [];
|
|
251
|
+
for (const dir of options.approvedRuns ?? []) {
|
|
252
|
+
try {
|
|
253
|
+
approved.push(await outcomeOfRun(dir));
|
|
254
|
+
}
|
|
255
|
+
catch { /* not a finished run */ }
|
|
256
|
+
}
|
|
257
|
+
const seen = seenOfRun(outcome), refs = new Set(seen.map(s => s.ref));
|
|
258
|
+
const fallback = refs.has('result') ? 'result' : seen[0]?.ref ?? 'result';
|
|
135
259
|
const method = readDocument(file);
|
|
136
|
-
const
|
|
137
|
-
if (!ref)
|
|
138
|
-
throw Error('The Method has a result with several values, so the case needs an output to judge.');
|
|
139
|
-
const value = outcome.outputs?.[ref] ?? outcome.result;
|
|
140
|
-
const note = judgment.note?.trim() || 'The person marked this result wrong.';
|
|
260
|
+
const note = [judgment.note?.trim() || 'The person marked this result wrong.', judgment.answer?.trim() ? `Asked what they meant, the person answered: ${judgment.answer.trim()}` : ''].filter(Boolean).join('\n');
|
|
141
261
|
const { callModel } = await import('./model-call.js');
|
|
142
|
-
const { preparedConfig } = await import('./quality.js');
|
|
262
|
+
const { preparedConfig, judgeProfiles, evaluateWithJudges } = await import('./quality.js');
|
|
143
263
|
const prepared = await preparedConfig(file, {}, { judge: true });
|
|
264
|
+
const profiles = await judgeProfiles(prepared.config);
|
|
265
|
+
const runOptions = { processPath: prepared.processPath, ...(prepared.hostedModels ? { hostedModels: prepared.hostedModels } : {}) };
|
|
266
|
+
let budget = 60_000;
|
|
267
|
+
const shown = seen.map(s => { const text = clip(typeof s.value === 'string' ? s.value : JSON.stringify(s.value, null, 2), Math.max(500, Math.min(20_000, budget))); budget -= text.length; return `[${s.ref}] ${s.label}:\n${text}`; });
|
|
144
268
|
const user = [`The person's correction, in their words:\n${note}`,
|
|
145
269
|
judgment.corrected !== undefined && judgment.corrected !== null ? `The result the person says is right:\n${clip(typeof judgment.corrected === 'string' ? judgment.corrected : JSON.stringify(judgment.corrected, null, 2), 20_000)}` : '',
|
|
146
|
-
`The Method's goal: ${String(method.goal ?? '')}`, `
|
|
270
|
+
`The Method's goal: ${String(method.goal ?? '')}`, `What the person saw (each with its name in brackets):\n\n${shown.join('\n\n')}`].filter(Boolean).join('\n\n');
|
|
271
|
+
const system = [
|
|
272
|
+
'A person corrected the result of a run of a Method. Write 1 to 3 plain sentences that a right result of this run meets and that this result does not meet, so a later version can be checked against the correction.',
|
|
273
|
+
'The person\'s words may be vague, such as "the Ashby one\'s wrong" or "it\'s not even redlined": use what the person saw to find what they mean.',
|
|
274
|
+
'Each sentence is about one thing the person saw: give its name in brackets as ref (result, outputs.NAME, file:PATH or effect:NAME). Judge a file or a message by the file or the message itself, not by a summary of it.',
|
|
275
|
+
'Each sentence names one checkable thing, without names or numbers that only this run has unless the correction is about them. Do not ask for anything that the correction does not ask for.',
|
|
276
|
+
'When the correction is about form or a missing action (a document with no tracked changes, a message with no attachment, a message that was not sent, a missing field), also write check_script: a Node.js ES module that reads JSON from standard input with result, outputs, tool_calls (a list of {tool, arguments, standin}), effect_inputs (a list of {effect, inputs}) and files (an object: each file the run wrote, by the name in brackets after file:, mapped to an absolute path the script can read, for example a .docx to unzip), and prints {"pass": true or false, "reason": "one sentence"}. It must fail on this result, pass on a right one, and fail when the thing it checks is missing. In check_says, say in one plain sentence what it checks. Otherwise leave both empty.',
|
|
277
|
+
].join(' ');
|
|
147
278
|
let reason = '';
|
|
148
279
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
149
|
-
const answer = await callModel({ schema: caseSchema, user: reason ? `${user}\n\nYour last
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
280
|
+
const answer = await callModel({ schema: caseSchema, system, user: reason ? `${user}\n\nYour last answer could not be used: ${reason}` : user });
|
|
281
|
+
const written = (Array.isArray(answer.value?.criteria) ? answer.value.criteria : [])
|
|
282
|
+
.map((c) => typeof c === 'string' ? { text: c.trim(), ref: fallback } : { text: String(c?.text ?? '').trim(), ref: refs.has(String(c?.ref ?? '').trim()) ? String(c.ref).trim() : fallback })
|
|
283
|
+
.filter((c) => c.text).slice(0, 3);
|
|
284
|
+
const script = typeof answer.value?.check_script === 'string' ? answer.value.check_script.trim() : '';
|
|
285
|
+
if (!written.length && !script) {
|
|
153
286
|
reason = 'There were no sentences.';
|
|
154
287
|
continue;
|
|
155
288
|
}
|
|
289
|
+
// The sentences grouped by what they judge, with IDs that stay the same when the case is saved.
|
|
290
|
+
const expect = [];
|
|
291
|
+
written.forEach((c, i) => {
|
|
292
|
+
const item = expect.find(e => e.ref === c.ref) ?? (expect.push({ kind: 'rubric', ref: c.ref, criteria: [] }), expect.at(-1));
|
|
293
|
+
item.criteria.push({ id: `c${i + 1}`, text: c.text });
|
|
294
|
+
});
|
|
295
|
+
// A check script is written next to the Method file for createCase to copy into the case, then removed.
|
|
296
|
+
const entrypoint = `check-${id}.mjs`, scriptPath = join(folder, entrypoint);
|
|
297
|
+
if (script) {
|
|
298
|
+
writeFileSync(scriptPath, script + '\n', { mode: 0o600 });
|
|
299
|
+
expect.push({ kind: 'predicate', runtime: 'node', entrypoint, ...(answer.value?.check_says ? { text: String(answer.value.check_says).trim() } : {}) });
|
|
300
|
+
}
|
|
156
301
|
try {
|
|
157
|
-
|
|
158
|
-
|
|
302
|
+
const context = { caseDir: folder, config: prepared.config, options: { runOptions } };
|
|
303
|
+
const kept = await keptExpectations(expect, outcome, approved, context, profiles, evaluateWithJudges);
|
|
304
|
+
if (!kept.expect.length) {
|
|
305
|
+
reason = kept.reason;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
await createCase({ methodFile: file, runDir, id, note, author: judgment.noted_by ?? null, expect: kept.expect, acceptConflict: note, config: prepared.config, options: { runOptions } });
|
|
159
309
|
return { id, made: true };
|
|
160
310
|
}
|
|
161
311
|
catch (error) {
|
|
@@ -163,26 +313,74 @@ export async function caseFromCorrection(file, judgment, runDir) {
|
|
|
163
313
|
throw error;
|
|
164
314
|
reason = error.message;
|
|
165
315
|
}
|
|
316
|
+
finally {
|
|
317
|
+
rmSync(scriptPath, { force: true });
|
|
318
|
+
}
|
|
166
319
|
}
|
|
167
320
|
throw Object.assign(Error(reason), { code: 'case_not_made' });
|
|
168
321
|
}
|
|
169
322
|
const clip = (text, max) => text.length > max ? `${text.slice(0, max)}…` : text;
|
|
170
|
-
/**
|
|
323
|
+
/**
|
|
324
|
+
* The expectations that capture the correction: each sentence that fails on the corrected run and passes on every
|
|
325
|
+
* approved run, and the check script when it does the same. A sentence the judges disagree on does not fail and is dropped.
|
|
326
|
+
*/
|
|
327
|
+
async function keptExpectations(expect, bad, approved, context, profiles, judge) {
|
|
328
|
+
const onBad = await judge(expect, bad, context, profiles);
|
|
329
|
+
const onGood = await Promise.all(approved.map(outcome => judge(expect, outcome, context, profiles)));
|
|
330
|
+
const reasons = [];
|
|
331
|
+
const kept = expect.flatMap((item, i) => {
|
|
332
|
+
const badResult = onBad.results[i], goodResults = onGood.map(r => r.results[i]);
|
|
333
|
+
if (item.kind === 'predicate') {
|
|
334
|
+
if (badResult?.status !== 'fail') {
|
|
335
|
+
reasons.push('The check script passes on the corrected run.');
|
|
336
|
+
return [];
|
|
337
|
+
}
|
|
338
|
+
if (goodResults.some(r => r?.status !== 'pass')) {
|
|
339
|
+
reasons.push('The check script fails on a run the person approved.');
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
return [item];
|
|
343
|
+
}
|
|
344
|
+
if (badResult?.status === 'unverifiable') {
|
|
345
|
+
reasons.push(`${item.ref} is not in the corrected run.`);
|
|
346
|
+
return [];
|
|
347
|
+
}
|
|
348
|
+
const criteria = item.criteria.filter((c) => {
|
|
349
|
+
const failsBad = badResult?.criteria?.find((x) => x.id === c.id)?.pass === false && !badResult.criteria.find((x) => x.id === c.id)?.unsure;
|
|
350
|
+
if (!failsBad) {
|
|
351
|
+
reasons.push(`"${c.text}" does not fail on the corrected run.`);
|
|
352
|
+
return false;
|
|
353
|
+
}
|
|
354
|
+
if (goodResults.some(r => r?.status === 'unverifiable' || r?.criteria?.find((x) => x.id === c.id)?.pass !== true)) {
|
|
355
|
+
reasons.push(`"${c.text}" fails on a run the person approved.`);
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
return true;
|
|
359
|
+
});
|
|
360
|
+
return criteria.length ? [{ ...item, criteria }] : [];
|
|
361
|
+
});
|
|
362
|
+
return { expect: kept, reason: reasons.join(' ') || 'Nothing could be checked.' };
|
|
363
|
+
}
|
|
364
|
+
/** The Method's judgments: the account's, with the notes and answers that stay on this computer for a device Method. */
|
|
171
365
|
async function methodJudgments(client, file, methodId, waitMs) {
|
|
172
366
|
const { judgments } = await client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments`, 'GET', undefined, true, waitMs ? { signal: AbortSignal.timeout(waitMs), stallMs: waitMs } : {});
|
|
173
367
|
const local = new Map(localJudgments(file).map(j => [j.id, j]));
|
|
174
368
|
const folders = runFolderIndex();
|
|
175
369
|
return judgments.map(j => {
|
|
176
370
|
const kept = local.get(j.id);
|
|
177
|
-
return { ...j, note: j.note ?? kept?.note ?? null, corrected: j.corrected ?? kept?.corrected ?? null,
|
|
371
|
+
return { ...j, note: j.note ?? kept?.note ?? null, corrected: j.corrected ?? kept?.corrected ?? null, answer: j.answer ?? kept?.answer ?? null,
|
|
372
|
+
run_dir: kept?.run_dir && existsSync(kept.run_dir) ? kept.run_dir : folders.get(j.run_id) };
|
|
178
373
|
});
|
|
179
374
|
}
|
|
180
|
-
|
|
375
|
+
/** The corrections of a Method that wait for a fix. */
|
|
376
|
+
export async function waitingCorrections(client, file, methodId, waitMs) {
|
|
377
|
+
return (await methodJudgments(client, file, methodId, waitMs)).filter(j => j.pending && j.verdict === 'wrong');
|
|
378
|
+
}
|
|
181
379
|
/**
|
|
182
|
-
* Fix the pending corrections of a Method file on this computer: the loop. Returns undefined when nothing
|
|
183
|
-
* takes the pending corrections in the account first, so two computers never fix one correction twice, and
|
|
184
|
-
* back when the loop stops before it decides.
|
|
185
|
-
* error.
|
|
380
|
+
* Fix the pending corrections of a Method file on this computer: one pass of the loop. Returns undefined when nothing
|
|
381
|
+
* waits. It takes the pending corrections in the account first, so two computers never fix one correction twice, and
|
|
382
|
+
* gives them back when the loop stops before it decides. Each correction gets its own outcome. A subscription limit or
|
|
383
|
+
* another failure of the model service is a plain error.
|
|
186
384
|
*/
|
|
187
385
|
export async function fixCorrections(client, file, options = {}) {
|
|
188
386
|
const document = readDocument(file);
|
|
@@ -190,28 +388,40 @@ export async function fixCorrections(client, file, options = {}) {
|
|
|
190
388
|
if (!methodId || !client.token())
|
|
191
389
|
return undefined;
|
|
192
390
|
const all = await methodJudgments(client, file, methodId, options.waitMs);
|
|
193
|
-
|
|
391
|
+
// skip: corrections that this process opened again after a later check undid their fix; the next method run takes them.
|
|
392
|
+
const waiting = all.filter(j => j.pending && j.verdict === 'wrong' && !options.skip?.has(j.id));
|
|
194
393
|
if (!waiting.length)
|
|
195
394
|
return undefined;
|
|
196
395
|
const handled = (ids, value) => client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments/handled`, 'POST', { ids, handled: value });
|
|
197
396
|
const taken = new Set((await handled(waiting.map(j => j.id), true)).changed);
|
|
198
|
-
const
|
|
199
|
-
if (!
|
|
397
|
+
const claimed = waiting.filter(j => taken.has(j.id));
|
|
398
|
+
if (!claimed.length)
|
|
200
399
|
return undefined;
|
|
201
|
-
const fromDashboard = fresh.filter(j => j.source === 'dashboard').length;
|
|
202
|
-
process.stderr.write(`Fixing ${basename(file)} from ${plural(fresh.length, 'correction')} on this computer. This takes a few minutes.\n`);
|
|
203
400
|
const base = methodContent(document), baseVersion = await currentVersion(file).catch(() => undefined);
|
|
204
401
|
let decided = false;
|
|
402
|
+
const outcomes = [];
|
|
403
|
+
const empty = { file, judgments: claimed.map(j => j.id), cause: '', steps: [], cases: { made: [], not_made: [] }, usage: null, capped: '', log: '', outcomes, background_cases: [], fixed: [] };
|
|
205
404
|
try {
|
|
206
|
-
// A
|
|
405
|
+
// A correction of a step whose text changed since its run is not fixed: the change may already answer it.
|
|
406
|
+
outcomes.push(...await changedSinceRun(client, methodId, claimed, base));
|
|
407
|
+
const fresh = claimed.filter(j => !outcomes.some(c => c.id === j.id));
|
|
408
|
+
if (!fresh.length) {
|
|
409
|
+
decided = true;
|
|
410
|
+
await reportOutcomes(client, methodId, outcomes);
|
|
411
|
+
return { ...empty, kind: 'none', applied: false };
|
|
412
|
+
}
|
|
413
|
+
process.stderr.write(`Testing fixes for ${fresh.length} correction${fresh.length === 1 ? '' : 's'} of ${file}.\n`);
|
|
414
|
+
// A regression case per correction, so every later change is checked against it; approved runs of the same step must still pass it.
|
|
207
415
|
const cases = { made: [], not_made: [] };
|
|
416
|
+
const approved = all.filter(j => j.verdict === 'right' && j.run_dir);
|
|
208
417
|
for (const j of fresh) {
|
|
209
418
|
if (!j.run_dir) {
|
|
210
419
|
cases.not_made.push({ id: j.id, reason: 'The run is not on this computer.' });
|
|
211
420
|
continue;
|
|
212
421
|
}
|
|
422
|
+
const approvedRuns = approved.filter(a => !j.step_id || !a.step_id || a.step_id === j.step_id).map(a => a.run_dir);
|
|
213
423
|
try {
|
|
214
|
-
const made = await caseFromCorrection(file, j, j.run_dir);
|
|
424
|
+
const made = await caseFromCorrection(file, j, j.run_dir, { approvedRuns });
|
|
215
425
|
if (made.made)
|
|
216
426
|
cases.made.push(made.id);
|
|
217
427
|
}
|
|
@@ -229,52 +439,426 @@ export async function fixCorrections(client, file, options = {}) {
|
|
|
229
439
|
const run = !j.run_dir && (freshIds.has(j.id) || j.verdict === 'wrong') ? await client.request(`/api/workspace/runs/${encodeURIComponent(j.run_id)}`).then(r => r.run).catch(() => undefined) : undefined;
|
|
230
440
|
judgments.push({ id: j.id, run_id: j.run_id, ...(j.run_dir ? { run_dir: j.run_dir } : {}), step_id: j.step_id, verdict: j.verdict, note: j.note, ...(j.corrected !== null ? { corrected: j.corrected } : {}),
|
|
231
441
|
...(existsSync(join(dirname(file), 'cases', caseId, 'case.json')) ? { case_id: caseId } : {}), source: j.source, created_at: j.created_at, ...(freshIds.has(j.id) ? { new: true } : {}),
|
|
442
|
+
...(j.noted_by ? { noted_by: j.noted_by } : {}), ...(j.question ? { question: j.question } : {}), ...(j.answer ? { answer: j.answer } : {}),
|
|
232
443
|
...(run ? { run: { id: run.id, status: run.status, created_at: run.created_at, version_id: run.workflow_version_id, inspection: run.inspection } } : {}) });
|
|
233
444
|
}
|
|
234
445
|
const runFolders = (await localRuns(file)).map(r => r.dir);
|
|
235
446
|
const improved = await runImprovement(client, file, judgments, runFolders, options.run);
|
|
236
447
|
decided = true;
|
|
237
448
|
const evidence = improved.evidence;
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
const
|
|
449
|
+
const fromLoop = new Map(improved.outcomes.map(o => [o.id, o]));
|
|
450
|
+
// again: another correction's fix changed the same part; this one waits for the next pass, on the changed file.
|
|
451
|
+
const decidedNow = fresh.map(j => { const o = fromLoop.get(j.id); return o?.again ? { id: j.id, outcome: 'retry', again: true } : { ...(o ?? { outcome: 'no_fix_found' }), id: j.id }; });
|
|
452
|
+
const changes = (o) => o.outcome === 'fixed' || (o.outcome === 'could_not_test' && !!o.rule_added);
|
|
453
|
+
const result = { ...empty, cause: improved.cause, cases, usage: evidence.budget ?? null, capped: improved.capped, log: improved.log, steps: evidence.steps ?? [] };
|
|
242
454
|
const record = (kind, extra = {}) => saveLocalProposal(file, { id: `lp_${randomBytes(8).toString('hex')}`, method_id: methodId, base_version_id: baseVersion?.version_id ?? null,
|
|
243
|
-
status: kind, cause: improved.cause, evidence, created_at: new Date().toISOString(), base_workflow: base, proposed_workflow: improved.workflow, judgments: [...freshIds], ...extra });
|
|
244
|
-
|
|
455
|
+
status: kind, cause: improved.cause, evidence, created_at: new Date().toISOString(), base_workflow: base, proposed_workflow: improved.workflow, judgments: [...freshIds], outcomes: decidedNow, ...extra });
|
|
456
|
+
// Nothing is applied: the corrections that the change would have answered wait for the next pass.
|
|
457
|
+
const notApplied = (why) => decidedNow.map(o => why.find(h => h.id === o.id) ?? (changes(o) ? { id: o.id, outcome: 'retry' } : o));
|
|
458
|
+
// A change that passes only by breaking another person's case is held: two people want different results.
|
|
459
|
+
const held = heldBy(file, fresh.filter(j => changes(fromLoop.get(j.id) ?? { id: j.id, outcome: 'no_fix_found' })), evidence);
|
|
460
|
+
let final, applied = false, version, undoId;
|
|
461
|
+
if (held.length) {
|
|
462
|
+
record('none', { reason: 'held' });
|
|
463
|
+
final = notApplied(held);
|
|
464
|
+
}
|
|
465
|
+
else if (evidence.kind !== 'change') {
|
|
245
466
|
record('none');
|
|
246
|
-
|
|
247
|
-
return { ...outcome, kind: 'none', steps: evidence.steps ?? [], line: `No safe change found: ${why}${used}` };
|
|
467
|
+
final = decidedNow.map(o => changes(o) ? { ...o, rule_added: false } : o);
|
|
248
468
|
}
|
|
249
|
-
|
|
250
|
-
|
|
469
|
+
else if (!isDeepStrictEqual(methodContent(readDocument(file)), base)) {
|
|
470
|
+
// The file must be the one the loop started from; a change made meanwhile is not overwritten.
|
|
251
471
|
record('none', { reason: 'The file changed while Method fixed it.' });
|
|
252
|
-
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
472
|
+
final = notApplied([]);
|
|
473
|
+
}
|
|
474
|
+
else {
|
|
475
|
+
const before = snapshot(file, improved.files.map(f => f.path));
|
|
476
|
+
applyChange(file, methodId, improved);
|
|
477
|
+
const notes = fresh.filter(j => changes(decidedNow.find(o => o.id === j.id))).map(j => j.note?.trim()).filter(Boolean);
|
|
478
|
+
undoId = recordChange(file, 'fix', improved.cause, notes.length ? `the fix for "${notes.join('" and "').slice(0, 300)}"` : 'the last fix', before).id;
|
|
479
|
+
version = await saveVersion(client, file, `Method fixed it from a correction: ${improved.cause}`.slice(0, 500));
|
|
480
|
+
record('applied', { applied_at: new Date().toISOString(), ...(version ? { applied_version_id: version.version_id } : {}) });
|
|
481
|
+
final = decidedNow;
|
|
482
|
+
applied = true;
|
|
483
|
+
}
|
|
484
|
+
outcomes.push(...final);
|
|
485
|
+
const retry = outcomes.filter(o => o.outcome === 'retry').map(o => o.id);
|
|
486
|
+
if (retry.length)
|
|
487
|
+
await handled(retry, false).catch(() => undefined);
|
|
488
|
+
await reportOutcomes(client, methodId, outcomes.filter(o => o.outcome !== 'retry' && o.outcome !== 'elsewhere'));
|
|
489
|
+
const fixed = applied ? fresh.filter(j => changes(final.find(o => o.id === j.id) ?? { id: j.id, outcome: 'no_fix_found' })).map(j => ({ id: j.id, note: j.note })) : [];
|
|
490
|
+
return { ...result, kind: applied ? 'change' : 'none', applied, ...(version ? { version } : {}), background_cases: applied ? improved.background_cases : [], fixed, ...(undoId ? { undo_id: undoId } : {}) };
|
|
264
491
|
}
|
|
265
492
|
catch (error) {
|
|
266
493
|
// Stopped before it decided: the corrections wait for the next loop.
|
|
267
494
|
if (!decided)
|
|
268
|
-
await handled([...taken], false).catch(() => undefined);
|
|
269
|
-
|
|
495
|
+
await handled([...taken].filter(id => !outcomes.some(o => o.id === id)), false).catch(() => undefined);
|
|
496
|
+
if (!decided && outcomes.length)
|
|
497
|
+
await reportOutcomes(client, methodId, outcomes);
|
|
498
|
+
throw Object.assign(error instanceof Error ? error : Error(String(error)), { judgments: [...taken] });
|
|
270
499
|
}
|
|
271
500
|
}
|
|
272
|
-
|
|
501
|
+
// One fix at a time per Method on this computer. A correction that arrives while a fix runs joins it: the running fix
|
|
502
|
+
// takes every waiting correction in one more pass before it finishes, and writes the outcome of each correction for
|
|
503
|
+
// method wait and the next method run.
|
|
504
|
+
const fileMethodId = (file) => { const id = readDocument(file).id; return typeof id === 'string' && methodIdPattern.test(id) ? id : undefined; };
|
|
505
|
+
/** Take the Method's lock: a file with this process ID. A lock whose process has stopped is taken over. */
|
|
506
|
+
function takeLock(path) {
|
|
507
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
508
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
509
|
+
try {
|
|
510
|
+
writeFileSync(path, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 });
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
catch (error) {
|
|
514
|
+
if (error.code !== 'EEXIST')
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
let pid;
|
|
518
|
+
try {
|
|
519
|
+
pid = JSON.parse(readFileSync(path, 'utf8')).pid;
|
|
520
|
+
}
|
|
521
|
+
catch { /* written now, or gone */ }
|
|
522
|
+
if (pid === undefined) {
|
|
523
|
+
try {
|
|
524
|
+
if (Date.now() - statSync(path).mtimeMs < 10_000)
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
try {
|
|
533
|
+
process.kill(pid, 0);
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
if (error.code !== 'ESRCH')
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
rmSync(path, { force: true });
|
|
542
|
+
}
|
|
543
|
+
return false;
|
|
544
|
+
}
|
|
545
|
+
/** Whether a fix of the Method runs on this computer now. */
|
|
546
|
+
export function fixRunning(methodId) {
|
|
547
|
+
try {
|
|
548
|
+
const pid = JSON.parse(readFileSync(join(fixFolder(methodId), 'lock'), 'utf8')).pid;
|
|
549
|
+
process.kill(pid, 0);
|
|
550
|
+
return true;
|
|
551
|
+
}
|
|
552
|
+
catch {
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/** After 5 applied fixes, or when the agent that runs the fixes changed, the loop prunes the Method. */
|
|
557
|
+
const pruneEvery = 5;
|
|
558
|
+
/**
|
|
559
|
+
* Fix the waiting corrections of a Method file while holding its lock, pass after pass until none waits, so the
|
|
560
|
+
* corrections that arrive during a pass join the next. Each correction's outcome is saved for method wait. After a pass
|
|
561
|
+
* that changed the file, the other cases run (improve.method's background_cases): a failure undoes that change. Then a
|
|
562
|
+
* pruning pass runs when it is due. Returns undefined when another fix of the Method holds the lock.
|
|
563
|
+
*/
|
|
564
|
+
export async function fixWaiting(client, file, options = {}) {
|
|
565
|
+
const methodId = fileMethodId(file);
|
|
566
|
+
if (!methodId || !client.token())
|
|
567
|
+
return [];
|
|
568
|
+
const lock = join(fixFolder(methodId), 'lock');
|
|
569
|
+
if (!takeLock(lock))
|
|
570
|
+
return undefined;
|
|
571
|
+
const fixes = [], skip = new Set();
|
|
572
|
+
try {
|
|
573
|
+
for (;;) {
|
|
574
|
+
let fix;
|
|
575
|
+
try {
|
|
576
|
+
fix = await fixCorrections(client, file, { ...options, skip });
|
|
577
|
+
}
|
|
578
|
+
catch (error) {
|
|
579
|
+
for (const id of error.judgments ?? [])
|
|
580
|
+
saveError(methodId, file, id, error.message, error.log);
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
if (!fix)
|
|
584
|
+
break;
|
|
585
|
+
// A correction given back with again stays "working": the next pass fixes it on the changed file.
|
|
586
|
+
for (const outcome of fix.outcomes)
|
|
587
|
+
if (!outcome.again)
|
|
588
|
+
saveOutcome(methodId, file, outcome, fix.log);
|
|
589
|
+
fixes.push(fix);
|
|
590
|
+
if (fix.applied && fix.background_cases.length) {
|
|
591
|
+
const checked = await checkBackground(client, file, fix, options).catch(error => { process.stderr.write(`The later check stopped: ${error.message}\n`); return undefined; });
|
|
592
|
+
for (const id of checked?.reopened ?? [])
|
|
593
|
+
skip.add(id);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
const { localAgent } = await import('@withmethod/runtime/agents.js');
|
|
597
|
+
const state = readFixState(methodId), agent = (await localAgent().catch(() => undefined)) ?? null;
|
|
598
|
+
// Every 5 corrections that a change answered, the loop prunes the Method.
|
|
599
|
+
const count = state.fixes_since_prune + fixes.filter(f => f.applied).reduce((n, f) => n + f.fixed.length, 0);
|
|
600
|
+
const due = count >= pruneEvery || (!!fixes.length && !!state.agent && !!agent && agent !== state.agent);
|
|
601
|
+
writeFixState(methodId, { fixes_since_prune: due ? 0 : count, agent: fixes.length ? agent : state.agent });
|
|
602
|
+
if (due)
|
|
603
|
+
await pruneMethod(client, file, options).catch(error => process.stderr.write(`The pruning pass stopped: ${error.message}\n`));
|
|
604
|
+
return fixes;
|
|
605
|
+
}
|
|
606
|
+
finally {
|
|
607
|
+
rmSync(lock, { force: true });
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* The later check of an applied change: the Method's other cases (improve.method's background_cases) run on the changed
|
|
612
|
+
* file. A failing case that belongs to a retired learned rule (its from.case) brings that rule back. Any other failure
|
|
613
|
+
* undoes the change, saves the restored file as a new version, and opens the corrections it answered again, so the next
|
|
614
|
+
* method run fixes them anew. Each of these says one line on the next method run or method wait.
|
|
615
|
+
*/
|
|
616
|
+
export async function checkBackground(client, file, fix, options = {}) {
|
|
617
|
+
const methodId = fileMethodId(file);
|
|
618
|
+
const { listCases } = await import('@withmethod/runtime/cases.js');
|
|
619
|
+
const active = new Set((await listCases(file)).filter((c) => c.status === 'active').map((c) => c.id));
|
|
620
|
+
const ids = fix.background_cases.filter(id => active.has(id));
|
|
621
|
+
const done = { failed: [], restored: [], undone: false, reopened: [] };
|
|
622
|
+
if (!ids.length)
|
|
623
|
+
return done;
|
|
624
|
+
const run = options.runCases ?? (await import('./quality.js')).runCases;
|
|
625
|
+
const report = await run(file, {}, { ids });
|
|
626
|
+
done.failed = report.cases.filter(c => c.verdict === 'fail' || c.verdict === 'regression').map(c => c.id);
|
|
627
|
+
if (!done.failed.length)
|
|
628
|
+
return done;
|
|
629
|
+
const { readLearned, learnedFile, ruleText } = await import('./learned.js');
|
|
630
|
+
const ruleCases = new Set();
|
|
631
|
+
try {
|
|
632
|
+
for (const rules of Object.values(readLearned(file).steps))
|
|
633
|
+
for (const r of rules)
|
|
634
|
+
if (r.status === 'retired' && r.from?.case)
|
|
635
|
+
ruleCases.add(r.from.case);
|
|
636
|
+
}
|
|
637
|
+
catch { /* no readable learned rules */ }
|
|
638
|
+
const broken = done.failed.filter(id => !ruleCases.has(id));
|
|
639
|
+
if (broken.length && fix.undo_id) {
|
|
640
|
+
try {
|
|
641
|
+
undoLast(file, fix.undo_id);
|
|
642
|
+
done.undone = true;
|
|
643
|
+
}
|
|
644
|
+
catch (error) {
|
|
645
|
+
process.stderr.write(`The fix broke an earlier result, but it could not be undone: ${error.message}\n`);
|
|
646
|
+
}
|
|
647
|
+
if (done.undone) {
|
|
648
|
+
await saveVersion(client, file, `Undid a fix that broke an earlier result: ${fix.cause}`.slice(0, 500));
|
|
649
|
+
const answered = fix.fixed.map(f => f.id);
|
|
650
|
+
if (answered.length) {
|
|
651
|
+
try {
|
|
652
|
+
done.reopened = (await client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments/reopen`, 'POST', { ids: answered, detail: 'The fix changed a result that was right before, so it was undone. Method will fix it again.' })).changed;
|
|
653
|
+
}
|
|
654
|
+
catch (error) {
|
|
655
|
+
process.stderr.write(`The account did not open the corrections again: ${error.message}\n`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
for (const f of fix.fixed)
|
|
659
|
+
saveNotice(methodId, file, 'came_back', fixUndoneLine(f.note ?? undefined));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
// A retired rule whose case fails again comes back (after the undo, which may have restored learned.json).
|
|
663
|
+
const restoredRules = [];
|
|
664
|
+
const learned = (() => { try {
|
|
665
|
+
return readLearned(file);
|
|
666
|
+
}
|
|
667
|
+
catch {
|
|
668
|
+
return undefined;
|
|
669
|
+
} })();
|
|
670
|
+
if (learned) {
|
|
671
|
+
const before = snapshot(file, ['learned.json']);
|
|
672
|
+
for (const rules of Object.values(learned.steps))
|
|
673
|
+
for (const rule of rules) {
|
|
674
|
+
if (rule.status !== 'retired' || !rule.from?.case || !done.failed.includes(rule.from.case))
|
|
675
|
+
continue;
|
|
676
|
+
Object.assign(rule, { status: 'active', retired_at: null, retired_reason: null });
|
|
677
|
+
done.restored.push(rule.id);
|
|
678
|
+
restoredRules.push(ruleText(rule));
|
|
679
|
+
}
|
|
680
|
+
if (done.restored.length) {
|
|
681
|
+
writeFileSync(learnedFile(file), JSON.stringify(learned, null, 2) + '\n');
|
|
682
|
+
recordChange(file, 'learned', `Put back ${done.restored.length} learned rule${done.restored.length === 1 ? '' : 's'}`, 'the rules that came back', before);
|
|
683
|
+
await saveVersion(client, file, `Put back learned rules whose problem came back: ${restoredRules.join(' ')}`.slice(0, 500));
|
|
684
|
+
for (const rule of restoredRules)
|
|
685
|
+
saveNotice(methodId, file, 'came_back', ruleBackLine(rule));
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return done;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* The pruning pass (improve.method with mode prune): it retires learned rules and deletes prompt text that tests show
|
|
692
|
+
* no longer change any result, and reports text that no test covers without deleting it. The change applies at once,
|
|
693
|
+
* with undo; the next method run or method wait shows the sweep's summary line.
|
|
694
|
+
*/
|
|
695
|
+
export async function pruneMethod(client, file, options = {}) {
|
|
696
|
+
const methodId = fileMethodId(file);
|
|
697
|
+
if (!methodId || !client.token())
|
|
698
|
+
return undefined;
|
|
699
|
+
const base = methodContent(readDocument(file));
|
|
700
|
+
const all = await methodJudgments(client, file, methodId);
|
|
701
|
+
const judgments = all.map(j => ({ id: j.id, run_id: j.run_id, ...(j.run_dir ? { run_dir: j.run_dir } : {}), step_id: j.step_id, verdict: j.verdict, note: j.note,
|
|
702
|
+
...(existsSync(join(dirname(file), 'cases', correctionCaseId(j.id), 'case.json')) ? { case_id: correctionCaseId(j.id) } : {}), source: j.source, created_at: j.created_at,
|
|
703
|
+
...(j.noted_by ? { noted_by: j.noted_by } : {}) }));
|
|
704
|
+
const improved = await runImprovement(client, file, judgments, (await localRuns(file)).map(r => r.dir), options.run, 'prune');
|
|
705
|
+
const { prunedLine } = await import('./fix-lines.js');
|
|
706
|
+
if (improved.evidence.kind !== 'change') {
|
|
707
|
+
// Nothing removed; the summary can still say which instructions nothing checks.
|
|
708
|
+
if (improved.summary)
|
|
709
|
+
saveNotice(methodId, file, 'prune', improved.summary);
|
|
710
|
+
return { changed: false };
|
|
711
|
+
}
|
|
712
|
+
if (!isDeepStrictEqual(methodContent(readDocument(file)), base))
|
|
713
|
+
return { changed: false };
|
|
714
|
+
const before = snapshot(file, improved.files.map(f => f.path));
|
|
715
|
+
applyChange(file, methodId, improved);
|
|
716
|
+
recordChange(file, 'prune', improved.cause, 'the cleanup of rules and instructions that no longer changed any result', before);
|
|
717
|
+
await saveVersion(client, file, `Method removed what no longer changed any result: ${improved.cause}`.slice(0, 500));
|
|
718
|
+
if (improved.summary)
|
|
719
|
+
saveNotice(methodId, file, 'prune', prunedLine(improved.summary));
|
|
720
|
+
return { changed: true };
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Fix the Method for one correction: run the fix, or, when a fix of the Method already runs on this computer, wait for
|
|
724
|
+
* the fix that takes this correction. Returns the stored outcome, or undefined when no fix here took it.
|
|
725
|
+
*/
|
|
726
|
+
export async function fixCorrection(client, file, judgmentId, options = {}) {
|
|
727
|
+
const methodId = fileMethodId(file);
|
|
728
|
+
if (!methodId || !client.token())
|
|
729
|
+
return undefined;
|
|
730
|
+
const done = () => { const record = storedOutcome(methodId, judgmentId); return record && record.status !== 'working' ? record : undefined; };
|
|
731
|
+
for (;;) {
|
|
732
|
+
const saved = done();
|
|
733
|
+
if (saved)
|
|
734
|
+
return saved;
|
|
735
|
+
const ran = await fixWaiting(client, file, options);
|
|
736
|
+
if (ran)
|
|
737
|
+
return done();
|
|
738
|
+
await new Promise(wait => setTimeout(wait, options.pollMs ?? 500));
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* `method __fix FILE --judgment ID... [--prune]`: the fix of method judge, method answer and method run, in its own
|
|
743
|
+
* process. Every correction it was started for ends with a stored outcome or error.
|
|
744
|
+
*/
|
|
745
|
+
export async function backgroundFix(client, file, ids, options = {}) {
|
|
746
|
+
const methodId = fileMethodId(file);
|
|
747
|
+
if (!methodId)
|
|
748
|
+
return;
|
|
749
|
+
setWorker(methodId, ids, process.pid);
|
|
750
|
+
if (options.prune) {
|
|
751
|
+
const lock = join(fixFolder(methodId), 'lock');
|
|
752
|
+
while (!takeLock(lock))
|
|
753
|
+
await new Promise(wait => setTimeout(wait, options.pollMs ?? 500));
|
|
754
|
+
try {
|
|
755
|
+
await pruneMethod(client, file, options);
|
|
756
|
+
}
|
|
757
|
+
finally {
|
|
758
|
+
rmSync(lock, { force: true });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
for (const id of ids) {
|
|
762
|
+
try {
|
|
763
|
+
if (!await fixCorrection(client, file, id, options))
|
|
764
|
+
saveOutcome(methodId, file, { id, outcome: 'elsewhere' });
|
|
765
|
+
}
|
|
766
|
+
catch (error) {
|
|
767
|
+
if (storedOutcome(methodId, id)?.status === 'working')
|
|
768
|
+
saveError(methodId, file, id, error.message, error.log);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
/** Send what the loop did with each correction, with the reason or the question. */
|
|
773
|
+
async function reportOutcomes(client, methodId, outcomes) {
|
|
774
|
+
if (!outcomes.length)
|
|
775
|
+
return;
|
|
776
|
+
try {
|
|
777
|
+
await client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments/outcomes`, 'POST', { outcomes: outcomes.map(o => {
|
|
778
|
+
const detail = o.outcome === 'held' ? [o.by ?? 'Someone', o.detail].filter(Boolean).join(': ') : o.outcome === 'already_changed' ? (o.by ? `${o.by} changed it after this run.` : undefined) : o.summary ?? o.detail;
|
|
779
|
+
return { id: o.id, outcome: o.outcome, ...(o.outcome === 'could_not_test' && o.reason ? { reason: o.reason } : {}), ...(o.outcome === 'question' && o.question ? { question: o.question.slice(0, 1000) } : {}),
|
|
780
|
+
...(detail?.trim() ? { detail: detail.trim().slice(0, 500) } : {}) };
|
|
781
|
+
}) });
|
|
782
|
+
}
|
|
783
|
+
catch (error) {
|
|
784
|
+
process.stderr.write(`The account did not record what happened to the corrections: ${error.message}\n`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/** The version that a judged run ran: from its run folder here, else from its account record. */
|
|
788
|
+
async function runVersion(client, j) {
|
|
789
|
+
if (j.run_dir) {
|
|
790
|
+
try {
|
|
791
|
+
const sync = JSON.parse(readFileSync(join(j.run_dir, 'method-sync.json'), 'utf8'));
|
|
792
|
+
if (typeof sync.version_id === 'string')
|
|
793
|
+
return sync.version_id;
|
|
794
|
+
}
|
|
795
|
+
catch { /* no account link */ }
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
return (await client.request(`/api/workspace/runs/${encodeURIComponent(j.run_id)}`)).run?.workflow_version_id;
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
return undefined;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* The corrections of a step whose text in the run's version differs from the file: outcome already_changed, with the
|
|
806
|
+
* person who saved the newest version when that version has the file's text of the step.
|
|
807
|
+
*/
|
|
808
|
+
async function changedSinceRun(client, methodId, judgments, current) {
|
|
809
|
+
const changed = [], versions = new Map();
|
|
810
|
+
let file;
|
|
811
|
+
try {
|
|
812
|
+
file = loadWorkflow(current);
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
return changed;
|
|
816
|
+
}
|
|
817
|
+
const saved = (version) => {
|
|
818
|
+
const key = version ?? '';
|
|
819
|
+
if (!versions.has(key))
|
|
820
|
+
versions.set(key, client.request(`/api/cli/methods/${encodeURIComponent(methodId)}${version ? `?version=${encodeURIComponent(version)}` : ''}`).catch(() => undefined));
|
|
821
|
+
return versions.get(key);
|
|
822
|
+
};
|
|
823
|
+
for (const j of judgments) {
|
|
824
|
+
if (!j.step_id || !Object.hasOwn(file.steps ?? {}, j.step_id))
|
|
825
|
+
continue;
|
|
826
|
+
const version = await runVersion(client, j), ran = version ? await saved(version) : undefined;
|
|
827
|
+
if (!ran?.workflow?.steps || isDeepStrictEqual(ran.workflow.steps[j.step_id], file.steps[j.step_id]))
|
|
828
|
+
continue;
|
|
829
|
+
const newest = await saved();
|
|
830
|
+
const by = newest?.saved_by && isDeepStrictEqual(newest.workflow?.steps?.[j.step_id], file.steps[j.step_id]) ? newest.saved_by : undefined;
|
|
831
|
+
changed.push({ id: j.id, outcome: 'already_changed', ...(by ? { by } : {}) });
|
|
832
|
+
}
|
|
833
|
+
return changed;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* The corrections whose tested change breaks a case that another person made (a case that passed before and fails
|
|
837
|
+
* after), with the other person and what they asked for. A case or a correction with no name is not held.
|
|
838
|
+
*/
|
|
839
|
+
function heldBy(file, fresh, evidence) {
|
|
840
|
+
const broken = (Array.isArray(evidence?.cases) ? evidence.cases : []).filter((c) => !c.target && c.before === 'pass' && c.after !== 'pass').map((c) => {
|
|
841
|
+
try {
|
|
842
|
+
const made = JSON.parse(readFileSync(join(dirname(file), 'cases', c.id, 'case.json'), 'utf8'));
|
|
843
|
+
return { author: made.author, note: String(made.note ?? '') };
|
|
844
|
+
}
|
|
845
|
+
catch {
|
|
846
|
+
return undefined;
|
|
847
|
+
}
|
|
848
|
+
}).filter((c) => c?.author);
|
|
849
|
+
return fresh.flatMap(j => {
|
|
850
|
+
const other = j.noted_by ? broken.find((c) => c.author !== j.noted_by) : undefined;
|
|
851
|
+
return other ? [{ id: j.id, outcome: 'held', by: other.author, detail: other.note.slice(0, 200) }] : [];
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
/** Write the tested change: the Method content (its id: line kept) and the changed package files (a script, learned.json). */
|
|
273
855
|
function applyChange(file, methodId, improved) {
|
|
274
856
|
const before = methodContent(readDocument(file));
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
857
|
+
if (improved.workflow) {
|
|
858
|
+
loadWorkflow(improved.workflow);
|
|
859
|
+
if (!isDeepStrictEqual(improved.workflow, before))
|
|
860
|
+
writeMethodFile(file, methodId, improved.workflow);
|
|
861
|
+
}
|
|
278
862
|
const folder = dirname(resolve(file));
|
|
279
863
|
for (const changed of improved.files) {
|
|
280
864
|
const path = resolve(folder, packagePath(changed.path));
|
|
@@ -283,32 +867,8 @@ function applyChange(file, methodId, improved) {
|
|
|
283
867
|
const bytes = Buffer.from(changed.content_base64, 'base64');
|
|
284
868
|
if (fileHash(bytes) !== changed.sha256)
|
|
285
869
|
throw Error(`The changed file ${changed.path} does not match its sha256.`);
|
|
870
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
286
871
|
writeFileSync(path, bytes);
|
|
287
872
|
}
|
|
288
|
-
return changedSteps(before, improved.workflow);
|
|
289
|
-
}
|
|
290
|
-
/**
|
|
291
|
-
* Save the fixed file as a new version of the Method, with the cause as its reason. The version before stays in the
|
|
292
|
-
* Method's history: restoring it on the Method page undoes the change.
|
|
293
|
-
*/
|
|
294
|
-
async function saveFixedVersion(client, outbox, file, methodId, cause, previous) {
|
|
295
|
-
try {
|
|
296
|
-
const { savedVersions } = await import('./outbox.js');
|
|
297
|
-
const { prepareVersion } = await import('./versions.js');
|
|
298
|
-
const version = await prepareVersion(client, outbox, file, `Method fixed it from a correction: ${cause}`.slice(0, 500));
|
|
299
|
-
await outbox.drain();
|
|
300
|
-
if (!await outbox.whenSaved(version.version_id))
|
|
301
|
-
return undefined;
|
|
302
|
-
const number = previous && await outbox.whenSaved(previous) ? savedVersions(client.server)[previous]?.version_number : undefined;
|
|
303
|
-
const undo = previous && number ? `To undo it, restore version ${number}: ${client.server}/methods/${methodId}?version=${previous}` : 'The version before it is in its history on the Method page.';
|
|
304
|
-
return { version_id: version.version_id, url: version.url, undo };
|
|
305
|
-
}
|
|
306
|
-
catch {
|
|
307
|
-
return undefined;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
/** The one line for corrections that waited, printed before method run or method judge goes on. */
|
|
311
|
-
export function waitingLine(outcome) {
|
|
312
|
-
const what = outcome.from_dashboard === outcome.corrections ? `${plural(outcome.corrections, 'correction')} from the dashboard` : plural(outcome.corrections, 'correction');
|
|
313
|
-
return outcome.kind === 'change' ? `${what}: fixed, passes ${plural(outcome.earlier_passed, 'earlier correction')}. ${outcome.changed}` : `${what}: ${outcome.line}`;
|
|
873
|
+
return improved.workflow ? changedSteps(before, improved.workflow) : [];
|
|
314
874
|
}
|