@method-ai-hq/sdk 0.18.1 → 0.19.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 +6 -1
- package/dist/packages/sdk/src/current-runtime.d.ts +6 -1
- package/dist/packages/sdk/src/current-runtime.js +50 -5
- 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 +25 -0
- package/dist/packages/sdk/src/improve.js +256 -10
- package/dist/packages/sdk/src/judge.d.ts +14 -2
- package/dist/packages/sdk/src/judge.js +73 -13
- package/dist/packages/sdk/src/local-cli.js +4 -6
- 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-help.js +11 -6
- 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 +139 -14
- 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 +10 -1
- package/dist/packages/sdk/src/quality.js +34 -4
- package/dist/packages/sdk/src/run-saved.d.ts +4 -0
- package/dist/packages/sdk/src/run-saved.js +45 -5
- package/dist/packages/sdk/src/versions.js +5 -3
- package/package.json +6 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
3
3
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
4
4
|
import { isDeepStrictEqual } from 'node:util';
|
|
@@ -9,7 +9,8 @@ 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';
|
|
14
15
|
/**
|
|
15
16
|
* Read the result of improve.method: {proposal: <SubmitProposal as JSON text>, kind, proposal_id, capped}. A change has
|
|
@@ -154,7 +155,7 @@ export async function caseFromCorrection(file, judgment, runDir) {
|
|
|
154
155
|
continue;
|
|
155
156
|
}
|
|
156
157
|
try {
|
|
157
|
-
await createCase({ methodFile: file, runDir, id, note, rubric: criteria, ref: `outputs.${ref}`, acceptConflict: note, config: prepared.config,
|
|
158
|
+
await createCase({ methodFile: file, runDir, id, note, author: judgment.noted_by ?? null, rubric: criteria, ref: `outputs.${ref}`, acceptConflict: note, config: prepared.config,
|
|
158
159
|
options: { runOptions: { processPath: prepared.processPath, ...(prepared.hostedModels ? { hostedModels: prepared.hostedModels } : {}) } } });
|
|
159
160
|
return { id, made: true };
|
|
160
161
|
}
|
|
@@ -195,14 +196,26 @@ export async function fixCorrections(client, file, options = {}) {
|
|
|
195
196
|
return undefined;
|
|
196
197
|
const handled = (ids, value) => client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments/handled`, 'POST', { ids, handled: value });
|
|
197
198
|
const taken = new Set((await handled(waiting.map(j => j.id), true)).changed);
|
|
198
|
-
const
|
|
199
|
-
if (!
|
|
199
|
+
const claimed = waiting.filter(j => taken.has(j.id));
|
|
200
|
+
if (!claimed.length)
|
|
200
201
|
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
202
|
const base = methodContent(document), baseVersion = await currentVersion(file).catch(() => undefined);
|
|
204
203
|
let decided = false;
|
|
204
|
+
const outcomes = [];
|
|
205
|
+
const report = () => reportOutcomes(client, methodId, outcomes);
|
|
205
206
|
try {
|
|
207
|
+
// A correction of a step whose text changed since its run is not fixed: the change may already answer it.
|
|
208
|
+
const stale = await changedSinceRun(client, methodId, claimed, base);
|
|
209
|
+
outcomes.push(...stale);
|
|
210
|
+
const fresh = claimed.filter(j => !stale.some(c => c.id === j.id));
|
|
211
|
+
const fromDashboard = fresh.filter(j => j.source === 'dashboard').length;
|
|
212
|
+
if (!fresh.length) {
|
|
213
|
+
decided = true;
|
|
214
|
+
await report();
|
|
215
|
+
return { file, judgments: claimed.map(j => j.id), corrections: claimed.length, from_dashboard: claimed.filter(j => j.source === 'dashboard').length, kind: 'none', cause: '', steps: [], cases: { made: [], not_made: [] }, earlier_passed: 0,
|
|
216
|
+
usage: null, capped: '', log: '', outcomes, line: `Not fixed: ${stale.map(c => c.detail).join(' ')}` };
|
|
217
|
+
}
|
|
218
|
+
process.stderr.write(`Fixing ${basename(file)} from ${plural(fresh.length, 'correction')} on this computer. This takes a few minutes.\n`);
|
|
206
219
|
// A regression case per correction, so every later change is checked against it.
|
|
207
220
|
const cases = { made: [], not_made: [] };
|
|
208
221
|
for (const j of fresh) {
|
|
@@ -238,11 +251,21 @@ export async function fixCorrections(client, file, options = {}) {
|
|
|
238
251
|
const earlier = (evidence.cases ?? []).filter((c) => !c.target && c.after === 'pass').length;
|
|
239
252
|
const usage = evidence.budget ?? null;
|
|
240
253
|
const used = usage ? ` (${plural(usage.model_calls, 'model call')}, ${usage.minutes < 1 ? 'under a minute' : `${Math.round(usage.minutes)} min`}${typeof usage.usd === 'number' ? `, $${usage.usd.toFixed(2)}` : ''})` : '';
|
|
241
|
-
const outcome = { file, corrections: fresh.length, from_dashboard: fromDashboard, cause: improved.cause, cases, earlier_passed: earlier, usage, capped: improved.capped, log: improved.log };
|
|
254
|
+
const outcome = { file, judgments: claimed.map(j => j.id), corrections: fresh.length, from_dashboard: fromDashboard, cause: improved.cause, cases, earlier_passed: earlier, usage, capped: improved.capped, log: improved.log, outcomes };
|
|
242
255
|
const record = (kind, extra = {}) => saveLocalProposal(file, { id: `lp_${randomBytes(8).toString('hex')}`, method_id: methodId, base_version_id: baseVersion?.version_id ?? null,
|
|
243
256
|
status: kind, cause: improved.cause, evidence, created_at: new Date().toISOString(), base_workflow: base, proposed_workflow: improved.workflow, judgments: [...freshIds], ...extra });
|
|
257
|
+
// 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);
|
|
259
|
+
if (held.length) {
|
|
260
|
+
record('none', { reason: 'held' });
|
|
261
|
+
outcomes.push(...fresh.map(j => held.find(h => h.id === j.id) ?? { id: j.id, outcome: 'no_change' }));
|
|
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.` };
|
|
264
|
+
}
|
|
244
265
|
if (evidence.kind !== 'change') {
|
|
245
266
|
record('none');
|
|
267
|
+
outcomes.push(...fresh.map(j => ({ id: j.id, outcome: 'no_change' })));
|
|
268
|
+
await report();
|
|
246
269
|
const why = improved.cause.replace(/^No safe change was found\.\s*/, '').split('\n')[0].slice(0, 300);
|
|
247
270
|
return { ...outcome, kind: 'none', steps: evidence.steps ?? [], line: `No safe change found: ${why}${used}` };
|
|
248
271
|
}
|
|
@@ -258,6 +281,8 @@ export async function fixCorrections(client, file, options = {}) {
|
|
|
258
281
|
const steps = applyChange(file, methodId, improved);
|
|
259
282
|
const version = await saveFixedVersion(client, outbox, file, methodId, improved.cause, before?.version_id);
|
|
260
283
|
record('applied', { applied_at: new Date().toISOString(), ...(version ? { applied_version_id: version.version_id } : {}) });
|
|
284
|
+
outcomes.push(...fresh.map(j => ({ id: j.id, outcome: 'fixed' })));
|
|
285
|
+
await report();
|
|
261
286
|
const changed = `Changed ${steps.length ? `step ${steps.join(', ')}` : 'its files'} of ${basename(file)}: ${improved.cause}`, undo = version ? ` ${version.undo}` : '';
|
|
262
287
|
return { ...outcome, kind: 'change', steps, version, changed: `${changed}${undo}${used}`,
|
|
263
288
|
line: `${changed} It fixes the corrected run and passes ${plural(earlier, 'earlier correction')}.${undo}${used}` };
|
|
@@ -265,10 +290,231 @@ export async function fixCorrections(client, file, options = {}) {
|
|
|
265
290
|
catch (error) {
|
|
266
291
|
// Stopped before it decided: the corrections wait for the next loop.
|
|
267
292
|
if (!decided)
|
|
268
|
-
await handled([...taken], false).catch(() => undefined);
|
|
269
|
-
|
|
293
|
+
await handled([...taken].filter(id => !outcomes.some(o => o.id === id)), false).catch(() => undefined);
|
|
294
|
+
if (!decided && outcomes.length)
|
|
295
|
+
await report();
|
|
296
|
+
throw Object.assign(error instanceof Error ? error : Error(String(error)), { judgments: [...taken] });
|
|
270
297
|
}
|
|
271
298
|
}
|
|
299
|
+
// 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 to a
|
|
301
|
+
// file that the method judge command of that correction waits for.
|
|
302
|
+
const fixFolder = (methodId) => join(methodCache(), 'fixes', methodId);
|
|
303
|
+
const fileMethodId = (file) => { const id = readDocument(file).id; return typeof id === 'string' && methodIdPattern.test(id) ? id : undefined; };
|
|
304
|
+
/** Take the Method's lock: a file with this process ID. A lock whose process has stopped is taken over. */
|
|
305
|
+
function takeLock(path) {
|
|
306
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
307
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
308
|
+
try {
|
|
309
|
+
writeFileSync(path, JSON.stringify({ pid: process.pid }), { flag: 'wx', mode: 0o600 });
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (error.code !== 'EEXIST')
|
|
314
|
+
throw error;
|
|
315
|
+
}
|
|
316
|
+
let pid;
|
|
317
|
+
try {
|
|
318
|
+
pid = JSON.parse(readFileSync(path, 'utf8')).pid;
|
|
319
|
+
}
|
|
320
|
+
catch { /* written now, or gone */ }
|
|
321
|
+
if (pid === undefined) {
|
|
322
|
+
try {
|
|
323
|
+
if (Date.now() - statSync(path).mtimeMs < 10_000)
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
try {
|
|
332
|
+
process.kill(pid, 0);
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
if (error.code !== 'ESRCH')
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
rmSync(path, { force: true });
|
|
341
|
+
}
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
function saveOutcome(folder, id, outcome) { writePrivateJson(join(folder, `${id}.json`), outcome); }
|
|
345
|
+
/** The saved outcome of a correction, removed once read: a function that returns the fix or throws its error. */
|
|
346
|
+
function takeOutcome(folder, id) {
|
|
347
|
+
const path = join(folder, `${id}.json`);
|
|
348
|
+
let saved;
|
|
349
|
+
try {
|
|
350
|
+
saved = JSON.parse(readFileSync(path, 'utf8'));
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
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
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* 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. Returns undefined when another fix of the Method holds the lock.
|
|
362
|
+
*/
|
|
363
|
+
export async function fixWaiting(client, file, options = {}) {
|
|
364
|
+
const methodId = fileMethodId(file);
|
|
365
|
+
if (!methodId || !client.token())
|
|
366
|
+
return [];
|
|
367
|
+
const folder = fixFolder(methodId), lock = join(folder, 'lock');
|
|
368
|
+
if (!takeLock(lock))
|
|
369
|
+
return undefined;
|
|
370
|
+
const fixes = [];
|
|
371
|
+
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
|
+
for (;;) {
|
|
382
|
+
let fix;
|
|
383
|
+
try {
|
|
384
|
+
fix = await fixCorrections(client, file, options);
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
for (const id of error.judgments ?? [])
|
|
388
|
+
saveOutcome(folder, id, { error: error.message, ...(error.log ? { log: error.log } : {}) });
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
if (!fix)
|
|
392
|
+
return fixes;
|
|
393
|
+
for (const id of fix.judgments)
|
|
394
|
+
saveOutcome(folder, id, { fix });
|
|
395
|
+
fixes.push(fix);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
finally {
|
|
399
|
+
rmSync(lock, { force: true });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* 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 of the fix that took it, or undefined when no fix here took it.
|
|
405
|
+
*/
|
|
406
|
+
export async function fixCorrection(client, file, judgmentId, options = {}) {
|
|
407
|
+
const methodId = fileMethodId(file);
|
|
408
|
+
if (!methodId || !client.token())
|
|
409
|
+
return undefined;
|
|
410
|
+
const folder = fixFolder(methodId);
|
|
411
|
+
let joined = false;
|
|
412
|
+
for (;;) {
|
|
413
|
+
const saved = takeOutcome(folder, judgmentId);
|
|
414
|
+
if (saved)
|
|
415
|
+
return saved();
|
|
416
|
+
let ran, failure;
|
|
417
|
+
try {
|
|
418
|
+
ran = await fixWaiting(client, file, options);
|
|
419
|
+
}
|
|
420
|
+
catch (error) {
|
|
421
|
+
failure = error;
|
|
422
|
+
ran = [];
|
|
423
|
+
}
|
|
424
|
+
if (ran) {
|
|
425
|
+
const done = takeOutcome(folder, judgmentId);
|
|
426
|
+
if (done)
|
|
427
|
+
return done();
|
|
428
|
+
if (failure)
|
|
429
|
+
throw failure;
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
if (!joined) {
|
|
433
|
+
process.stderr.write(`Method is fixing ${basename(file)} on this computer; this correction joins that fix.\n`);
|
|
434
|
+
joined = true;
|
|
435
|
+
}
|
|
436
|
+
await new Promise(done => setTimeout(done, options.pollMs ?? 500));
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/** Send what the loop did with each correction. An older server without the route keeps today's record. */
|
|
440
|
+
async function reportOutcomes(client, methodId, outcomes) {
|
|
441
|
+
if (!outcomes.length)
|
|
442
|
+
return;
|
|
443
|
+
try {
|
|
444
|
+
await client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments/outcomes`, 'POST', { outcomes: outcomes.map(o => ({ id: o.id, outcome: o.outcome, ...(o.detail ? { detail: o.detail.slice(0, 500) } : {}) })) });
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
if (error?.status !== 404 && !/^404:/.test(String(error?.message)))
|
|
448
|
+
process.stderr.write(`The account did not record what happened to the corrections: ${error.message}\n`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
/** The version that a judged run ran: from its run folder here, else from its account record. */
|
|
452
|
+
async function runVersion(client, j) {
|
|
453
|
+
if (j.run_dir) {
|
|
454
|
+
try {
|
|
455
|
+
const sync = JSON.parse(readFileSync(join(j.run_dir, 'method-sync.json'), 'utf8'));
|
|
456
|
+
if (typeof sync.version_id === 'string')
|
|
457
|
+
return sync.version_id;
|
|
458
|
+
}
|
|
459
|
+
catch { /* no account link */ }
|
|
460
|
+
}
|
|
461
|
+
try {
|
|
462
|
+
return (await client.request(`/api/workspace/runs/${encodeURIComponent(j.run_id)}`)).run?.workflow_version_id;
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* The corrections of a step whose text in the run's version differs from the file: outcome already_changed, with the
|
|
470
|
+
* person who saved the newest version when that version has the file's text of the step.
|
|
471
|
+
*/
|
|
472
|
+
async function changedSinceRun(client, methodId, judgments, current) {
|
|
473
|
+
const changed = [], versions = new Map();
|
|
474
|
+
let file;
|
|
475
|
+
try {
|
|
476
|
+
file = loadWorkflow(current);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
return changed;
|
|
480
|
+
}
|
|
481
|
+
const saved = (version) => {
|
|
482
|
+
const key = version ?? '';
|
|
483
|
+
if (!versions.has(key))
|
|
484
|
+
versions.set(key, client.request(`/api/cli/methods/${encodeURIComponent(methodId)}${version ? `?version=${encodeURIComponent(version)}` : ''}`).catch(() => undefined));
|
|
485
|
+
return versions.get(key);
|
|
486
|
+
};
|
|
487
|
+
for (const j of judgments) {
|
|
488
|
+
if (!j.step_id || !Object.hasOwn(file.steps ?? {}, j.step_id))
|
|
489
|
+
continue;
|
|
490
|
+
const version = await runVersion(client, j), ran = version ? await saved(version) : undefined;
|
|
491
|
+
if (!ran?.workflow?.steps || isDeepStrictEqual(ran.workflow.steps[j.step_id], file.steps[j.step_id]))
|
|
492
|
+
continue;
|
|
493
|
+
const newest = await saved();
|
|
494
|
+
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', detail: by ? `${by} changed step ${j.step_id} after this run.` : `Step ${j.step_id} changed after this run.` });
|
|
496
|
+
}
|
|
497
|
+
return changed;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* The corrections whose tested change breaks a case that another person made (a case that passed before and fails
|
|
501
|
+
* after), with both names. A case or a correction with no name is not held.
|
|
502
|
+
*/
|
|
503
|
+
function heldBy(file, fresh, evidence) {
|
|
504
|
+
const broken = (Array.isArray(evidence?.cases) ? evidence.cases : []).filter((c) => !c.target && c.before === 'pass' && c.after !== 'pass').map((c) => {
|
|
505
|
+
try {
|
|
506
|
+
const made = JSON.parse(readFileSync(join(dirname(file), 'cases', c.id, 'case.json'), 'utf8'));
|
|
507
|
+
return { id: c.id, author: made.author, note: String(made.note ?? '') };
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
return undefined;
|
|
511
|
+
}
|
|
512
|
+
}).filter((c) => c?.author);
|
|
513
|
+
return fresh.flatMap(j => {
|
|
514
|
+
const other = j.noted_by ? broken.find((c) => c.author !== j.noted_by) : undefined;
|
|
515
|
+
return other ? [{ id: j.id, outcome: 'held', detail: `A fix for ${j.noted_by}'s correction breaks ${other.author}'s case ${other.id}: ${other.note.slice(0, 200)}`.replace(/[.\s]*$/, '.') }] : [];
|
|
516
|
+
});
|
|
517
|
+
}
|
|
272
518
|
/** Write the tested change: the Method content (its id: line kept) and a changed package file of a script change. */
|
|
273
519
|
function applyChange(file, methodId, improved) {
|
|
274
520
|
const before = methodContent(readDocument(file));
|
|
@@ -20,9 +20,21 @@ export declare function judgmentLine(judgment: Pick<Judgment, 'verdict' | 'step_
|
|
|
20
20
|
* content stays on this computer (run_data: device), the account gets the verdict and step only, and the judgment with
|
|
21
21
|
* its note and corrected result goes in .method/judgments.jsonl next to the Method file. A wrong judgment then fixes the
|
|
22
22
|
* Method here, in the foreground: method judge makes a regression case from the note, finds a change with the user's
|
|
23
|
-
* own Claude Code or Codex, and applies it only when it fixes the run and breaks no earlier correction.
|
|
24
|
-
*
|
|
23
|
+
* own Claude Code or Codex, and applies it only when it fixes the run and breaks no earlier correction. When a fix of
|
|
24
|
+
* the same Method already runs on this computer, the correction joins it: that fix takes it in one more pass, and this
|
|
25
|
+
* command waits for it. It prints one line: what changed, or why nothing did.
|
|
25
26
|
*/
|
|
26
27
|
export declare function judgeCommand(args: string[], clientFactory: (server: string) => MethodClient, options?: {
|
|
27
28
|
run?: RunFile;
|
|
28
29
|
}): Promise<void>;
|
|
30
|
+
/** Two judgments of the same run and step by two people with opposite verdicts: neither is fixed until one is kept. */
|
|
31
|
+
export type HeldPair = {
|
|
32
|
+
run_id: string;
|
|
33
|
+
step_id: string | null;
|
|
34
|
+
judgments: Array<{
|
|
35
|
+
id: string;
|
|
36
|
+
verdict: string;
|
|
37
|
+
noted_by: string | null;
|
|
38
|
+
note: string | null;
|
|
39
|
+
}>;
|
|
40
|
+
};
|
|
@@ -3,8 +3,8 @@ import { basename } from 'node:path';
|
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import { DEFAULT_SERVER } from './method-client.js';
|
|
5
5
|
import { deviceRun, findRunFolder, methodFileOfRun, saveLocalJudgment } from './local-judgments.js';
|
|
6
|
-
import {
|
|
7
|
-
import { readHome } from './home.js';
|
|
6
|
+
import { fixCorrection, waitingLine } from './improve.js';
|
|
7
|
+
import { catchUp, readHome } from './home.js';
|
|
8
8
|
export const maxNoteLength = 4000;
|
|
9
9
|
export const maxCorrectedBytes = 1024 * 1024;
|
|
10
10
|
/** The corrected result in a file: its JSON value when it parses as JSON, or else its text. */
|
|
@@ -36,15 +36,20 @@ export function judgmentLine(judgment) {
|
|
|
36
36
|
* content stays on this computer (run_data: device), the account gets the verdict and step only, and the judgment with
|
|
37
37
|
* its note and corrected result goes in .method/judgments.jsonl next to the Method file. A wrong judgment then fixes the
|
|
38
38
|
* Method here, in the foreground: method judge makes a regression case from the note, finds a change with the user's
|
|
39
|
-
* own Claude Code or Codex, and applies it only when it fixes the run and breaks no earlier correction.
|
|
40
|
-
*
|
|
39
|
+
* own Claude Code or Codex, and applies it only when it fixes the run and breaks no earlier correction. When a fix of
|
|
40
|
+
* the same Method already runs on this computer, the correction joins it: that fix takes it in one more pass, and this
|
|
41
|
+
* command waits for it. It prints one line: what changed, or why nothing did.
|
|
41
42
|
*/
|
|
42
43
|
export async function judgeCommand(args, clientFactory, options = {}) {
|
|
43
|
-
|
|
44
|
+
// The output stream at the start: a fix that runs in this process sends its run's own output to a log meanwhile.
|
|
45
|
+
const print = process.stdout.write.bind(process.stdout);
|
|
46
|
+
const usage = 'Use method judge RUN_ID right|wrong [--step STEP_ID] [--note TEXT] [--corrected FILE], or method judge RUN_ID keep JUDGMENT_ID.';
|
|
44
47
|
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
|
|
45
48
|
step: { type: 'string' }, note: { type: 'string' }, corrected: { type: 'string' }, json: { type: 'boolean' }, server: { type: 'string' }
|
|
46
49
|
} });
|
|
47
50
|
const [runId, verdict] = positionals;
|
|
51
|
+
if (runId && verdict === 'keep' && positionals.length === 3)
|
|
52
|
+
return keepCommand(runId, positionals[2], values, clientFactory, options);
|
|
48
53
|
if (!runId || !verdict || positionals.length !== 2)
|
|
49
54
|
throw Error(usage);
|
|
50
55
|
if (verdict !== 'right' && verdict !== 'wrong')
|
|
@@ -73,23 +78,78 @@ export async function judgeCommand(args, clientFactory, options = {}) {
|
|
|
73
78
|
file: basename(runFile), step_id: step ?? null, verdict, note: note ?? null, ...(corrected !== undefined ? { corrected } : {}), created_at: response.judgment.created_at,
|
|
74
79
|
});
|
|
75
80
|
const recorded = judgmentLine(response.judgment);
|
|
76
|
-
|
|
77
|
-
|
|
81
|
+
// A judgment that contradicts another person's judgment of the same step is held: show both, with the two choices.
|
|
82
|
+
const pairs = response.held ? await heldPairs(client, response.judgment.method_id, response.judgment.id) : [];
|
|
83
|
+
if (verdict === 'right' || response.held) {
|
|
84
|
+
print(values.json ? JSON.stringify({ judgment: response.judgment, ...(pairs.length ? { held: pairs } : {}) }, null, 2) + '\n' : [recorded, ...pairs.map(heldLine)].join('\n') + '\n');
|
|
78
85
|
return;
|
|
79
86
|
}
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
return fixAfter(client, response.judgment, recorded, runFile, values, options, pairs, print);
|
|
88
|
+
}
|
|
89
|
+
/** The run's Method file fixes the correction: the file that ran the run, else the Method's home file on this computer. */
|
|
90
|
+
async function fixAfter(client, judgment, recorded, runFile, values, options, pairs, print = process.stdout.write.bind(process.stdout)) {
|
|
91
|
+
const home = judgment.method_id ? readHome(client.server, judgment.method_id)?.path : undefined;
|
|
82
92
|
const file = runFile ?? (home && existsSync(home) ? home : undefined);
|
|
93
|
+
const held = pairs.map(heldLine), extra = pairs.length ? { held: pairs } : {};
|
|
83
94
|
if (!file) {
|
|
84
95
|
const line = `${recorded} The Method file is not on this computer, so the next method run of it on a computer fixes it.`;
|
|
85
|
-
|
|
96
|
+
print(values.json ? JSON.stringify({ judgment, fix: null, ...extra }, null, 2) + '\n' : [line, ...held].join('\n') + '\n');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// The fix starts from the newest version; a teammate's change of the same step stops it until the person combines them.
|
|
100
|
+
const caught = await catchUp(client, file, 'fixing');
|
|
101
|
+
if (caught.status === 'conflict') {
|
|
102
|
+
print(values.json ? JSON.stringify({ judgment, fix: null, stopped: caught.result, ...extra }, null, 2) + '\n' : [`${recorded} ${caught.result.failure.headline}`, ...held].join('\n') + '\n');
|
|
103
|
+
process.exitCode = 2;
|
|
86
104
|
return;
|
|
87
105
|
}
|
|
88
|
-
|
|
106
|
+
if ('line' in caught)
|
|
107
|
+
process.stderr.write(caught.line + '\n');
|
|
108
|
+
const fix = await fixCorrection(client, file, judgment.id, options);
|
|
89
109
|
if (values.json) {
|
|
90
|
-
|
|
110
|
+
print(JSON.stringify({ judgment, fix: fix ?? null, ...extra }, null, 2) + '\n');
|
|
91
111
|
return;
|
|
92
112
|
}
|
|
93
113
|
const line = !fix ? `${recorded} Another loop is fixing it.` : fix.corrections > 1 ? `${recorded} ${waitingLine(fix)}` : `${recorded} ${fix.line}`;
|
|
94
|
-
|
|
114
|
+
print([line, ...held].join('\n') + '\n');
|
|
115
|
+
}
|
|
116
|
+
/** The held pairs of a Method, from its judgments in the account. Nothing when they cannot be read. */
|
|
117
|
+
async function heldPairs(client, methodId, mine) {
|
|
118
|
+
if (!methodId)
|
|
119
|
+
return [];
|
|
120
|
+
let judgments;
|
|
121
|
+
try {
|
|
122
|
+
judgments = (await client.request(`/api/cli/methods/${encodeURIComponent(methodId)}/judgments`)).judgments ?? [];
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
const byId = new Map(judgments.map(j => [j.id, j])), pairs = [], seen = new Set();
|
|
128
|
+
for (const j of judgments) {
|
|
129
|
+
const other = j.outcome === 'held' && j.held_with ? byId.get(j.held_with) : undefined;
|
|
130
|
+
if (!other || seen.has(j.id) || seen.has(other.id))
|
|
131
|
+
continue;
|
|
132
|
+
seen.add(j.id);
|
|
133
|
+
seen.add(other.id);
|
|
134
|
+
const [first, second] = mine === other.id ? [other, j] : [j, other];
|
|
135
|
+
pairs.push({ run_id: j.run_id, step_id: j.step_id ?? null, judgments: [first, second].map(x => ({ id: x.id, verdict: x.verdict, noted_by: x.noted_by ?? null, note: x.note ?? null })) });
|
|
136
|
+
}
|
|
137
|
+
return pairs;
|
|
138
|
+
}
|
|
139
|
+
const heldLine = (pair) => {
|
|
140
|
+
const said = (j) => `${j.noted_by ?? 'Someone'} says it is ${j.verdict}${j.note ? ` ("${j.note.slice(0, 200)}")` : ''}`;
|
|
141
|
+
return `Held: ${pair.step_id ? `step ${pair.step_id} of run ${pair.run_id}` : `run ${pair.run_id}`}. ${pair.judgments.map(said).join('; ')}. Nothing is fixed until one is kept: ${pair.judgments.map(j => `method judge ${pair.run_id} keep ${j.id} (${j.noted_by ?? 'this one'}'s)`).join(' or ')}.`;
|
|
142
|
+
};
|
|
143
|
+
/** `method judge RUN keep JUDGMENT_ID`: keep this judgment of a held pair; the other one is set aside. A kept wrong judgment is fixed. */
|
|
144
|
+
async function keepCommand(runId, judgmentId, values, clientFactory, options) {
|
|
145
|
+
const client = clientFactory(values.server ?? DEFAULT_SERVER);
|
|
146
|
+
const response = await client.request(`/api/judgments/${encodeURIComponent(judgmentId)}/settle`, 'POST', { keep: true });
|
|
147
|
+
const kept = response.judgment;
|
|
148
|
+
const recorded = `Kept ${kept?.noted_by ? `${kept.noted_by}'s judgment` : 'this judgment'}${kept?.verdict ? ` (${kept.verdict})` : ''}; the other one is set aside.`;
|
|
149
|
+
if (kept?.verdict !== 'wrong' || !kept.method_id) {
|
|
150
|
+
process.stdout.write(values.json ? JSON.stringify({ judgment: kept ?? { id: judgmentId } }, null, 2) + '\n' : recorded + '\n');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const runDir = findRunFolder(runId);
|
|
154
|
+
return fixAfter(client, kept, recorded, runDir ? methodFileOfRun(runDir) : undefined, values, options, []);
|
|
95
155
|
}
|
|
@@ -33,8 +33,6 @@ export function parse(args) {
|
|
|
33
33
|
export async function run(target, flags, syncFactory) {
|
|
34
34
|
const path = isWorkflowLink(target) ? (await saveWorkflowLink(target, process.cwd())).path : resolve(target);
|
|
35
35
|
(await import("./legacy.js")).convertLegacyOrThrow(path);
|
|
36
|
-
if (!syncFactory)
|
|
37
|
-
await (await import("./home.js")).followAccount(new MethodClient(flags.server), path);
|
|
38
36
|
loadWorkflow(methodContent(readDocument(path)));
|
|
39
37
|
const notice = await (await import("./quality.js")).casesNotice(path);
|
|
40
38
|
if (notice)
|
|
@@ -85,12 +83,12 @@ async function accountSync(path, flags) {
|
|
|
85
83
|
return { client, outbox, version, sync: () => new Sync(client, runDir, state.workflow_id, state.version_id, state.id, readDocument(path).run_data === "device", outbox) };
|
|
86
84
|
}
|
|
87
85
|
const method = readDocument(path);
|
|
88
|
-
|
|
89
|
-
const needs = accountNeeds(method, setup.config);
|
|
90
|
-
if (needs
|
|
86
|
+
// A setup that fails here fails the run too, and the run sends its failed record.
|
|
87
|
+
const needs = await localSetup(path, flags).then(setup => accountNeeds(method, setup.config), () => undefined);
|
|
88
|
+
if (needs?.classification && !client.token())
|
|
91
89
|
await client.login();
|
|
92
90
|
if (!client.token()) {
|
|
93
|
-
process.stderr.write(`Sign in to keep versions and runs in your account${needs
|
|
91
|
+
process.stderr.write(`Sign in to keep versions and runs in your account${needs?.models ? " and to use hosted models" : ""}: method login\n`);
|
|
94
92
|
return undefined;
|
|
95
93
|
}
|
|
96
94
|
const version = await prepareVersion(client, outbox, path);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type MergeConflict = {
|
|
2
|
+
path: string;
|
|
3
|
+
base: unknown;
|
|
4
|
+
ours: unknown;
|
|
5
|
+
theirs: unknown;
|
|
6
|
+
};
|
|
7
|
+
/** Not clean: workflow keeps ours at each conflict. */
|
|
8
|
+
export type MergeResult = {
|
|
9
|
+
clean: true;
|
|
10
|
+
workflow: any;
|
|
11
|
+
} | {
|
|
12
|
+
clean: false;
|
|
13
|
+
conflicts: MergeConflict[];
|
|
14
|
+
workflow: any;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Three-way merge of Method documents, per step and per top-level key. base is the version both sides started from
|
|
18
|
+
* (an empty base when there is none), ours is the local file, and theirs is the other version.
|
|
19
|
+
*/
|
|
20
|
+
export declare function mergeMethod(base: any, ours: any, theirs: any): MergeResult;
|
|
21
|
+
export type PackageFile = {
|
|
22
|
+
path: string;
|
|
23
|
+
sha256: string;
|
|
24
|
+
};
|
|
25
|
+
export type FilesMergeResult<T extends PackageFile = PackageFile> = {
|
|
26
|
+
clean: boolean;
|
|
27
|
+
files: T[];
|
|
28
|
+
conflicts: {
|
|
29
|
+
path: string;
|
|
30
|
+
}[];
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Three-way merge of a Method's package files, by path and sha256. A file that only one side added, changed or removed
|
|
34
|
+
* takes that change; a file that both sides changed in different ways is a conflict (ours stays in files).
|
|
35
|
+
*/
|
|
36
|
+
export declare function mergeFiles<T extends PackageFile>(base: T[], ours: T[], theirs: T[]): FilesMergeResult<T>;
|
|
37
|
+
/** The steps whose definition differs between two documents. */
|
|
38
|
+
export declare function changedSteps(before: any, after: any): string[];
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
3
|
+
/** Keys of ours in their order; a key that only theirs has goes after the key before it in theirs. */
|
|
4
|
+
function keyOrder(ours, theirs) {
|
|
5
|
+
const keys = Object.keys(ours), theirKeys = Object.keys(theirs);
|
|
6
|
+
theirKeys.forEach((key, index) => {
|
|
7
|
+
if (keys.includes(key))
|
|
8
|
+
return;
|
|
9
|
+
const before = theirKeys.slice(0, index).reverse().find(other => keys.includes(other));
|
|
10
|
+
keys.splice(before === undefined ? 0 : keys.indexOf(before) + 1, 0, key);
|
|
11
|
+
});
|
|
12
|
+
return keys;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Three-way merge of Method documents, per step and per top-level key. base is the version both sides started from
|
|
16
|
+
* (an empty base when there is none), ours is the local file, and theirs is the other version.
|
|
17
|
+
*/
|
|
18
|
+
export function mergeMethod(base, ours, theirs) {
|
|
19
|
+
if (isDeepStrictEqual(ours, base))
|
|
20
|
+
return { clean: true, workflow: theirs };
|
|
21
|
+
if (isDeepStrictEqual(theirs, base) || isDeepStrictEqual(ours, theirs))
|
|
22
|
+
return { clean: true, workflow: ours };
|
|
23
|
+
const conflicts = [];
|
|
24
|
+
const pick = (path, b, o, t) => {
|
|
25
|
+
if (isDeepStrictEqual(o, t) || isDeepStrictEqual(b, t))
|
|
26
|
+
return o;
|
|
27
|
+
if (isDeepStrictEqual(b, o))
|
|
28
|
+
return t;
|
|
29
|
+
conflicts.push({ path, base: b, ours: o, theirs: t });
|
|
30
|
+
return o;
|
|
31
|
+
};
|
|
32
|
+
const merge = (path, b, o, t, nested) => {
|
|
33
|
+
const result = {};
|
|
34
|
+
for (const key of keyOrder(o, t)) {
|
|
35
|
+
const at = path ? `${path}.${key}` : key;
|
|
36
|
+
const value = nested(key) && isObject(o[key]) && isObject(t[key]) && isObject(b?.[key] ?? {})
|
|
37
|
+
? merge(at, b?.[key] ?? {}, o[key], t[key], () => false) : pick(at, b?.[key], o[key], t[key]);
|
|
38
|
+
if (value !== undefined)
|
|
39
|
+
result[key] = value;
|
|
40
|
+
}
|
|
41
|
+
return result;
|
|
42
|
+
};
|
|
43
|
+
const workflow = merge('', isObject(base) ? base : {}, ours, theirs, key => key === 'steps');
|
|
44
|
+
return conflicts.length ? { clean: false, conflicts, workflow } : { clean: true, workflow };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Three-way merge of a Method's package files, by path and sha256. A file that only one side added, changed or removed
|
|
48
|
+
* takes that change; a file that both sides changed in different ways is a conflict (ours stays in files).
|
|
49
|
+
*/
|
|
50
|
+
export function mergeFiles(base, ours, theirs) {
|
|
51
|
+
const byPath = (list) => new Map(list.map(f => [f.path, f]));
|
|
52
|
+
const b = byPath(base), o = byPath(ours), t = byPath(theirs);
|
|
53
|
+
const files = [], conflicts = [];
|
|
54
|
+
for (const path of [...new Set([...o.keys(), ...t.keys(), ...b.keys()])].sort()) {
|
|
55
|
+
const [bs, os, ts] = [b.get(path)?.sha256, o.get(path)?.sha256, t.get(path)?.sha256];
|
|
56
|
+
const kept = os === ts || bs === ts ? o.get(path) : bs === os ? t.get(path) : (conflicts.push({ path }), o.get(path));
|
|
57
|
+
if (kept)
|
|
58
|
+
files.push(kept);
|
|
59
|
+
}
|
|
60
|
+
return { clean: !conflicts.length, files, conflicts };
|
|
61
|
+
}
|
|
62
|
+
/** The steps whose definition differs between two documents. */
|
|
63
|
+
export function changedSteps(before, after) {
|
|
64
|
+
return [...new Set([...Object.keys(before?.steps ?? {}), ...Object.keys(after?.steps ?? {})])].filter(id => !isDeepStrictEqual(before?.steps?.[id], after?.steps?.[id]));
|
|
65
|
+
}
|
|
@@ -90,7 +90,7 @@ export class MethodClient {
|
|
|
90
90
|
throw Object.assign(Error(`${status}: ${title || statusText || "Method returned a non-JSON response"} (${method} ${path}). Local run files are preserved.`), { status });
|
|
91
91
|
}
|
|
92
92
|
if (!ok)
|
|
93
|
-
throw Object.assign(Error(`${status}: ${String(result.message ?? result.error ?? "Method request failed").slice(0, 1000)}`), { status, code: typeof result.code === 'string' ? result.code.slice(0, 100) : 'method_request_failed' });
|
|
93
|
+
throw Object.assign(Error(`${status}: ${String(result.message ?? result.error ?? "Method request failed").slice(0, 1000)}`), { status, code: typeof result.code === 'string' ? result.code.slice(0, 100) : 'method_request_failed', body: result });
|
|
94
94
|
return result;
|
|
95
95
|
}
|
|
96
96
|
/** Upload (data) or download bytes. Retries a lost connection or a server error twice; stops only when no bytes move. */
|