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