@method-ai-hq/sdk 0.18.2 → 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 +47 -2
- 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 +0 -2
- 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 +124 -12
- 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 +33 -2
- package/dist/packages/sdk/src/versions.js +5 -3
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
MIT-licensed JavaScript API and the `method` CLI. Source and releases: https://github.com/method-ai-hq/method-sdk
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
npm install https://github.com/method-ai-hq/method-sdk/releases/download/v0.
|
|
6
|
+
npm install https://github.com/method-ai-hq/method-sdk/releases/download/v0.19.0/method-ai-hq-sdk-0.19.0.tgz
|
|
7
7
|
npx method --version
|
|
8
8
|
```
|
|
9
9
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@method-ai-hq/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Build and run reusable .method files with checked steps and saved results.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -157,6 +157,11 @@
|
|
|
157
157
|
"import": "./dist/packages/sdk/src/versions.js",
|
|
158
158
|
"default": "./dist/packages/sdk/src/versions.js"
|
|
159
159
|
},
|
|
160
|
+
"./merge": {
|
|
161
|
+
"types": "./dist/packages/sdk/src/merge.d.ts",
|
|
162
|
+
"import": "./dist/packages/sdk/src/merge.js",
|
|
163
|
+
"default": "./dist/packages/sdk/src/merge.js"
|
|
164
|
+
},
|
|
160
165
|
"./outbox": {
|
|
161
166
|
"types": "./dist/packages/sdk/src/outbox.d.ts",
|
|
162
167
|
"import": "./dist/packages/sdk/src/outbox.js",
|
|
@@ -5,6 +5,11 @@ import type { parse } from "./local-cli.js";
|
|
|
5
5
|
export { executeMethod as runCurrentMethod };
|
|
6
6
|
/** Recent run folders on this computer, newest first. The runtime reuses only iterations whose key matches. */
|
|
7
7
|
export declare function recentRuns(exclude: string, limit?: number): string[];
|
|
8
|
+
/**
|
|
9
|
+
* A step that stopped because a file is not there (ENOENT, or FileNotFoundError in Python) needs that file on this
|
|
10
|
+
* computer: the failure names the file and who saved this version, with owner setup.
|
|
11
|
+
*/
|
|
12
|
+
export declare function missingFileFailure(failure: any, file: string, savedBy: string | null): any;
|
|
8
13
|
/** Answers for ask steps: a key without an iteration ("confirm") answers its first iteration ("confirm:0"). */
|
|
9
14
|
export declare function answers(human: any): any;
|
|
10
15
|
/**
|
|
@@ -13,4 +18,4 @@ export declare function answers(human: any): any;
|
|
|
13
18
|
* runSecrets are values that the SDK gives this run only (such as the signed-in key for improve.method); they come
|
|
14
19
|
* before the shell and the secret store, and are never saved.
|
|
15
20
|
*/
|
|
16
|
-
export declare function runCurrentFile(file: string, flags: ReturnType<typeof parse>["values"], syncFactory?: () => MethodSync, onEvent?: (event: any) => Promise<void>, client?: MethodClient, baseConfig?: any, runSecrets?: Record<string, string
|
|
21
|
+
export declare function runCurrentFile(file: string, flags: ReturnType<typeof parse>["values"], syncFactory?: () => MethodSync, onEvent?: (event: any) => Promise<void>, client?: MethodClient, baseConfig?: any, runSecrets?: Record<string, string>, savedBy?: string | null): Promise<import("@withmethod/runtime/api-types.js").RunResult>;
|
|
@@ -2,7 +2,10 @@ import { managedClassification } from './classification-client.js';
|
|
|
2
2
|
import { MethodClient } from './method-client.js';
|
|
3
3
|
import { openBrowser } from './browser.js';
|
|
4
4
|
import { readFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
5
|
-
import { join, dirname, resolve } from 'node:path';
|
|
5
|
+
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
+
import { lastSaved } from './outbox.js';
|
|
8
|
+
import { loadWorkflow } from '../../workflow-language/src/validate.js';
|
|
6
9
|
import { hostedModelsFor, managedModels } from './hosted-models.js';
|
|
7
10
|
import { localAgent } from '@withmethod/runtime/agents.js';
|
|
8
11
|
import { resolveSecrets } from './secrets.js';
|
|
@@ -41,6 +44,32 @@ export function recentRuns(exclude, limit = 20) {
|
|
|
41
44
|
}
|
|
42
45
|
return [...found].sort((a, b) => b[1] - a[1]).slice(0, limit).map(([dir]) => dir);
|
|
43
46
|
}
|
|
47
|
+
/** Who saved the version that a file holds, when this computer got or saved that version last. */
|
|
48
|
+
function savedByOf(server, method) {
|
|
49
|
+
try {
|
|
50
|
+
if (typeof method?.id !== 'string')
|
|
51
|
+
return null;
|
|
52
|
+
const { id: _id, ...content } = method, record = lastSaved(server, method.id);
|
|
53
|
+
return record?.saved_by && isDeepStrictEqual(loadWorkflow(content), record.workflow) ? record.saved_by : null;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* A step that stopped because a file is not there (ENOENT, or FileNotFoundError in Python) needs that file on this
|
|
61
|
+
* computer: the failure names the file and who saved this version, with owner setup.
|
|
62
|
+
*/
|
|
63
|
+
export function missingFileFailure(failure, file, savedBy) {
|
|
64
|
+
const text = [failure.details, failure.diagnostics, ...(failure.evidence ?? [])].filter(Boolean).join('\n');
|
|
65
|
+
const found = /ENOENT: no such file or directory, \w+ '([^']+)'/.exec(text)?.[1] ?? /FileNotFoundError: \[Errno 2\] No such file or directory: '([^']+)'/.exec(text)?.[1];
|
|
66
|
+
if (!found)
|
|
67
|
+
return failure;
|
|
68
|
+
const folder = dirname(resolve(file)), inside = relative(folder, resolve(folder, found));
|
|
69
|
+
const shown = !inside.startsWith('..') && !isAbsolute(inside) ? inside : found;
|
|
70
|
+
return { ...failure, owner: 'setup', headline: `This Method reads ${shown}. It is not on this computer and is not saved in the Method.${savedBy ? ` Ask ${savedBy} (who saved this version) for it.` : ''}`,
|
|
71
|
+
next: { action: 'fix_setup', text: `Put ${shown} on this computer, then run again. Steps that finished are reused.` } };
|
|
72
|
+
}
|
|
44
73
|
/** Answers for ask steps: a key without an iteration ("confirm") answers its first iteration ("confirm:0"). */
|
|
45
74
|
export function answers(human) {
|
|
46
75
|
if (!human?.steps)
|
|
@@ -53,7 +82,7 @@ export function answers(human) {
|
|
|
53
82
|
* runSecrets are values that the SDK gives this run only (such as the signed-in key for improve.method); they come
|
|
54
83
|
* before the shell and the secret store, and are never saved.
|
|
55
84
|
*/
|
|
56
|
-
export async function runCurrentFile(file, flags, syncFactory, onEvent, client = new MethodClient(flags.server), baseConfig, runSecrets = {}) {
|
|
85
|
+
export async function runCurrentFile(file, flags, syncFactory, onEvent, client = new MethodClient(flags.server), baseConfig, runSecrets = {}, savedBy) {
|
|
57
86
|
if (flags.resume && !flags['run-dir'])
|
|
58
87
|
throw Error('Resume needs --run-dir.');
|
|
59
88
|
flags = { ...flags, 'run-dir': flags['run-dir'] ?? join(process.cwd(), '.method-runs', randomUUID()) };
|
|
@@ -84,6 +113,10 @@ export async function runCurrentFile(file, flags, syncFactory, onEvent, client =
|
|
|
84
113
|
}
|
|
85
114
|
catch { /* the run's setup failure */ }
|
|
86
115
|
await sync?.start(method, inputs, {}, false);
|
|
116
|
+
// Every key that this computer lacks, in one list, before any setup or step.
|
|
117
|
+
const missingKeys = Object.keys(method.secrets ?? {}).filter(key => !runSecrets[key] && !resolveSecrets([key])[key]);
|
|
118
|
+
if (missingKeys.length)
|
|
119
|
+
throw Object.assign(Error(`This Method needs ${missingKeys.length === 1 ? 'a key that is' : 'keys that are'} not on this computer: ${missingKeys.join(', ')}. Run ${missingKeys.map(key => `\`method secret set ${key}\``).join(', then ')}`), { code: 'missing_secret', owner: 'setup', missing: missingKeys });
|
|
87
120
|
const setup = await localSetup(file, flags, baseConfig);
|
|
88
121
|
let config = setup.config;
|
|
89
122
|
const sourceRoot = setup.sourceRoot;
|
|
@@ -156,6 +189,18 @@ export async function runCurrentFile(file, flags, syncFactory, onEvent, client =
|
|
|
156
189
|
process.stderr.write(JSON.stringify(event) + "\n"); },
|
|
157
190
|
});
|
|
158
191
|
runFailed = !['completed', 'unconfirmed'].includes(result.status);
|
|
192
|
+
if (result.status === 'failed' && result.failure) {
|
|
193
|
+
const named = missingFileFailure(result.failure, file, savedBy !== undefined ? savedBy : savedByOf(client.server, method));
|
|
194
|
+
// The run's record says it too, so the dashboard and method wait show the same failure.
|
|
195
|
+
const summary = join(authoringPath(flags['run-dir']), 'summary.json');
|
|
196
|
+
if (named !== result.failure) {
|
|
197
|
+
result.failure = named;
|
|
198
|
+
try {
|
|
199
|
+
writePrivateJson(summary, { ...JSON.parse(readFileSync(summary, 'utf8')), failure: named });
|
|
200
|
+
}
|
|
201
|
+
catch { /* no record to change */ }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
159
204
|
const synced = await sync?.finish();
|
|
160
205
|
let display = synced ? { ...result, dashboard: sync.dashboard, dashboard_sync: synced } : result;
|
|
161
206
|
// A run that waits for a person shows the question itself, so the agent can ask the user and pass on the answer.
|
|
@@ -1,46 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Each Method has one home file on this computer: the file its versions are saved from. A copy in a temporary folder,
|
|
3
3
|
* or in another folder while the home file still exists, is not saved, so one Method never grows two histories.
|
|
4
|
-
* Git worktrees and clones of one repository share a home.
|
|
5
|
-
* wrote, so an unchanged home file can take a newer version from the account.
|
|
4
|
+
* Git worktrees and clones of one repository share a home.
|
|
6
5
|
*/
|
|
7
6
|
export type Home = {
|
|
8
7
|
path: string;
|
|
9
8
|
key: string;
|
|
10
|
-
version_id?: string;
|
|
11
|
-
files?: Record<string, string>;
|
|
12
9
|
};
|
|
13
10
|
export declare function readHome(server: string, methodId: string): Home | undefined;
|
|
14
11
|
export declare const temporary: (path: string) => boolean;
|
|
15
12
|
/** The same file in every checkout of one repository has the same key. */
|
|
16
13
|
export declare function homeKey(file: string): string;
|
|
17
|
-
|
|
18
|
-
export declare function writtenFiles(file: string, packageFiles: Array<{
|
|
19
|
-
path: string;
|
|
20
|
-
sha256: string;
|
|
21
|
-
}>): {
|
|
22
|
-
[x: string]: string;
|
|
23
|
-
};
|
|
24
|
-
export declare function recordHome(server: string, methodId: string, file: string, version?: {
|
|
25
|
-
version_id: string;
|
|
26
|
-
files: Record<string, string>;
|
|
27
|
-
}): void;
|
|
14
|
+
export declare function recordHome(server: string, methodId: string, file: string): void;
|
|
28
15
|
/**
|
|
29
16
|
* Throws, with the home file named, when this file may not save the Method. Otherwise this file is the home after
|
|
30
17
|
* the call. A home that is gone, holds another Method, or is a temporary copy gives way to a file outside a
|
|
31
18
|
* temporary folder.
|
|
32
19
|
*/
|
|
33
20
|
export declare function claimHome(server: string, methodId: string, file: string): void;
|
|
34
|
-
|
|
35
|
-
|
|
21
|
+
type Client = Pick<import('./method-client.js').MethodClient, 'server' | 'token' | 'request' | 'transfer'>;
|
|
22
|
+
/** Both texts of one part that this computer and a teammate changed in different ways. */
|
|
23
|
+
export type CombineConflict = {
|
|
24
|
+
part: string;
|
|
25
|
+
file: string;
|
|
26
|
+
yours: unknown;
|
|
27
|
+
theirs: unknown;
|
|
28
|
+
};
|
|
29
|
+
export type CatchUp = {
|
|
30
|
+
status: 'skipped' | 'current';
|
|
31
|
+
} | {
|
|
32
|
+
status: 'offline' | 'updated';
|
|
33
|
+
line: string;
|
|
34
|
+
} | {
|
|
35
|
+
status: 'conflict';
|
|
36
|
+
result: {
|
|
37
|
+
status: 'needs_you';
|
|
38
|
+
failure: import('../../workflow-language/src/inspection.js').RunFailure;
|
|
39
|
+
conflicts: CombineConflict[];
|
|
40
|
+
};
|
|
41
|
+
};
|
|
36
42
|
/**
|
|
37
|
-
* Before a run of
|
|
38
|
-
*
|
|
39
|
-
*
|
|
43
|
+
* Before a run, a fix or a publish of a Method file: bring the file to the newest version of its Method. base is the
|
|
44
|
+
* version this computer last saved or got, ours the file, theirs the newest version. An unchanged file takes theirs;
|
|
45
|
+
* a changed one combines step by step and file by file (an empty base when this computer has none). A part that both
|
|
46
|
+
* sides changed in different ways is a conflict: nothing changes, and the result has both texts. Offline, the file
|
|
47
|
+
* runs as it is. Signed out, a file with no id: line, or a Method with no version yet: nothing.
|
|
40
48
|
*/
|
|
41
|
-
export declare function
|
|
42
|
-
|
|
43
|
-
token(): string | null | undefined;
|
|
44
|
-
request<T>(path: string): Promise<T>;
|
|
45
|
-
transfer(path: string): Promise<Uint8Array>;
|
|
46
|
-
}, file: string): Promise<boolean>;
|
|
49
|
+
export declare function catchUp(client: Client, file: string, doing?: string): Promise<CatchUp>;
|
|
50
|
+
export {};
|
|
@@ -43,13 +43,8 @@ export function homeKey(file) {
|
|
|
43
43
|
const path = real(authoringPath(file)), repo = repository(path);
|
|
44
44
|
return repo ? `${repo.main}:${relative(repo.root, path)}` : path;
|
|
45
45
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const path = authoringPath(file);
|
|
49
|
-
return { [relative(dirname(path), path)]: fileHash(readFileSync(path)), ...Object.fromEntries(packageFiles.map(f => [f.path, f.sha256])) };
|
|
50
|
-
}
|
|
51
|
-
export function recordHome(server, methodId, file, version) {
|
|
52
|
-
writePrivateJson(homeFile(server, methodId), { path: authoringPath(file), key: homeKey(file), ...version });
|
|
46
|
+
export function recordHome(server, methodId, file) {
|
|
47
|
+
writePrivateJson(homeFile(server, methodId), { path: authoringPath(file), key: homeKey(file) });
|
|
53
48
|
}
|
|
54
49
|
/**
|
|
55
50
|
* Throws, with the home file named, when this file may not save the Method. Otherwise this file is the home after
|
|
@@ -60,7 +55,7 @@ export function claimHome(server, methodId, file) {
|
|
|
60
55
|
const home = readHome(server, methodId), key = homeKey(file);
|
|
61
56
|
if (home && home.key === key) {
|
|
62
57
|
if (home.path !== authoringPath(file))
|
|
63
|
-
recordHome(server, methodId, file
|
|
58
|
+
recordHome(server, methodId, file);
|
|
64
59
|
return;
|
|
65
60
|
}
|
|
66
61
|
const homeLives = (() => { try {
|
|
@@ -73,65 +68,174 @@ export function claimHome(server, methodId, file) {
|
|
|
73
68
|
throw Object.assign(Error(`This Method is saved from ${home.path}. Make the change in that file and run it there; this copy was not run or saved.`), { code: 'other_home', home: home.path });
|
|
74
69
|
recordHome(server, methodId, file);
|
|
75
70
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return Object.entries(home.files).every(([name, hash]) => { try {
|
|
82
|
-
return fileHash(readFileSync(join(root, name))) === hash;
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
return false;
|
|
86
|
-
} });
|
|
87
|
-
}
|
|
71
|
+
const list = (word, items) => items.length ? `${word}${items.length === 1 ? '' : 's'} ${items.join(', ')}` : '';
|
|
72
|
+
const parts = (steps, files) => [list('step', steps), list('file', files)].filter(Boolean).join(' and ');
|
|
73
|
+
const clock = (at) => { const date = at ? new Date(at) : undefined; return date && !isNaN(+date) ? ` from ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` : ''; };
|
|
74
|
+
const clip = (text) => text.length > 20_000 ? `${text.slice(0, 20_000)}…` : text;
|
|
75
|
+
const unreachable = (error) => error?.code === 'network' || error?.code === 'stalled' || error?.name === 'TimeoutError' || error?.name === 'AbortError' || error?.status >= 500 || error?.status === 429;
|
|
88
76
|
/**
|
|
89
|
-
* Before a run of
|
|
90
|
-
*
|
|
91
|
-
*
|
|
77
|
+
* Before a run, a fix or a publish of a Method file: bring the file to the newest version of its Method. base is the
|
|
78
|
+
* version this computer last saved or got, ours the file, theirs the newest version. An unchanged file takes theirs;
|
|
79
|
+
* a changed one combines step by step and file by file (an empty base when this computer has none). A part that both
|
|
80
|
+
* sides changed in different ways is a conflict: nothing changes, and the result has both texts. Offline, the file
|
|
81
|
+
* runs as it is. Signed out, a file with no id: line, or a Method with no version yet: nothing.
|
|
92
82
|
*/
|
|
93
|
-
export async function
|
|
94
|
-
const path = authoringPath(file);
|
|
83
|
+
export async function catchUp(client, file, doing = 'running') {
|
|
84
|
+
const path = authoringPath(file), root = dirname(path);
|
|
85
|
+
const { methodContent, methodIdPattern, versionIdFor } = await import('./versions.js');
|
|
86
|
+
let document;
|
|
87
|
+
try {
|
|
88
|
+
document = readDocument(path);
|
|
89
|
+
if (typeof document.id !== 'string' || !methodIdPattern.test(document.id) || !client.token())
|
|
90
|
+
return { status: 'skipped' };
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return { status: 'skipped' };
|
|
94
|
+
}
|
|
95
|
+
const id = document.id, options = { signal: AbortSignal.timeout(5000), stallMs: 5000 };
|
|
96
|
+
const methodPath = `/api/cli/methods/${encodeURIComponent(id)}`;
|
|
97
|
+
const { lastSaved, recordCheckout } = await import('./outbox.js');
|
|
98
|
+
const record = lastSaved(client.server, id);
|
|
99
|
+
// merge_base: the newest version that the server has as an ancestor of both this computer's version and the newest one.
|
|
100
|
+
let theirs;
|
|
101
|
+
try {
|
|
102
|
+
theirs = await client.request(record?.version_id ? `${methodPath}?base=${encodeURIComponent(record.version_id)}` : methodPath, 'GET', undefined, true, options);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return unreachable(error) ? { status: 'offline', line: `Could not check for a newer version; ${doing} this file.` } : { status: 'skipped' };
|
|
106
|
+
}
|
|
107
|
+
const { loadWorkflow } = await import('../../workflow-language/src/validate.js');
|
|
108
|
+
const { buildPackage } = await import('./method-files.js');
|
|
109
|
+
// A file that does not load fails its run with its own error.
|
|
110
|
+
let ours;
|
|
95
111
|
try {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
const workflow = loadWorkflow(methodContent(document)), { pack } = await buildPackage(path, workflow);
|
|
113
|
+
ours = { workflow, files: pack.files, version_id: versionIdFor(id, pack.digest) };
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { status: 'skipped' };
|
|
117
|
+
}
|
|
118
|
+
const savedBy = theirs.saved_by ?? null;
|
|
119
|
+
if (ours.version_id === theirs.version_id) {
|
|
120
|
+
if (record?.version_id !== theirs.version_id)
|
|
121
|
+
recordCheckout(client.server, id, theirs.version_id, theirs.version_number, theirs.workflow, { saved_by: savedBy });
|
|
122
|
+
return { status: 'current' };
|
|
123
|
+
}
|
|
124
|
+
if (record?.version_id === theirs.version_id)
|
|
125
|
+
return { status: 'current' };
|
|
126
|
+
let base = { workflow: {}, files: [] };
|
|
127
|
+
// An older server sends no merge_base: then the version this computer last saved or got is the base.
|
|
128
|
+
const baseId = theirs.merge_base !== undefined ? theirs.merge_base : record?.version_id;
|
|
129
|
+
// This computer's version was kept beside the newest one (an earlier CLI recorded it as its last save): it is combined now.
|
|
130
|
+
const beside = record?.version_id && baseId !== record.version_id ? record.version_id : undefined;
|
|
131
|
+
if (record?.version_id && baseId) {
|
|
108
132
|
try {
|
|
109
|
-
await
|
|
110
|
-
|
|
111
|
-
// A local file that the last save did not write, and that differs, is the user's: then nothing changes.
|
|
112
|
-
for (const f of incoming) {
|
|
113
|
-
const local = join(root, f.path);
|
|
114
|
-
if (existsSync(local) && !home.files?.[f.path] && fileHash(readFileSync(local)) !== f.sha256)
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
for (const f of incoming) {
|
|
118
|
-
mkdirSync(dirname(join(root, f.path)), { recursive: true, mode: 0o700 });
|
|
119
|
-
copyFileSync(join(staging, f.path), join(root, f.path));
|
|
120
|
-
}
|
|
133
|
+
const saved = await client.request(`${methodPath}?version=${encodeURIComponent(baseId)}`, 'GET', undefined, true, options);
|
|
134
|
+
base = { workflow: saved.workflow, files: saved.package?.files ?? [] };
|
|
121
135
|
}
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (unreachable(error))
|
|
138
|
+
return { status: 'offline', line: `Could not check for a newer version; ${doing} this file.` };
|
|
139
|
+
base = { workflow: record.workflow ?? {}, files: [] };
|
|
124
140
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
141
|
+
}
|
|
142
|
+
const { mergeMethod, mergeFiles, changedSteps } = await import('./merge.js');
|
|
143
|
+
const theirFiles = theirs.package?.files ?? [];
|
|
144
|
+
const steps = mergeMethod(base.workflow, ours.workflow, theirs.workflow), files = mergeFiles(base.files, ours.files, theirFiles);
|
|
145
|
+
const ourHashes = new Map(ours.files.map(f => [f.path, f.sha256]));
|
|
146
|
+
// Files that come from theirs. A file here that no save wrote and that differs is the person's own: a conflict too.
|
|
147
|
+
const incoming = files.files.filter(f => ourHashes.get(f.path) !== f.sha256);
|
|
148
|
+
const local = (name) => { try {
|
|
149
|
+
return fileHash(readFileSync(join(root, name)));
|
|
133
150
|
}
|
|
134
151
|
catch {
|
|
135
|
-
return
|
|
152
|
+
return undefined;
|
|
153
|
+
} };
|
|
154
|
+
const { isDeepStrictEqual } = await import('node:util');
|
|
155
|
+
// After a combine stopped, a part whose text changed on this computer since then is combined by the person: theirs is used.
|
|
156
|
+
const stop = record?.conflict?.theirs === theirs.version_id ? record.conflict : undefined;
|
|
157
|
+
const combinedStep = (at, now) => !!stop && at in stop.parts && !isDeepStrictEqual(now, stop.parts[at]);
|
|
158
|
+
const combinedFile = (at) => !!stop && at in stop.files && (local(at) ?? null) !== stop.files[at];
|
|
159
|
+
const fileConflicts = [...files.conflicts.map(c => c.path), ...incoming.filter(f => !ourHashes.has(f.path) && local(f.path) !== undefined && local(f.path) !== f.sha256).map(f => f.path)];
|
|
160
|
+
const stepConflicts = steps.clean ? [] : steps.conflicts;
|
|
161
|
+
const openSteps = stepConflicts.filter(c => !combinedStep(c.path, c.ours)), openFiles = fileConflicts.filter(at => !combinedFile(at));
|
|
162
|
+
const keepLocal = new Set(fileConflicts.filter(combinedFile));
|
|
163
|
+
const take = incoming.filter(f => !keepLocal.has(f.path));
|
|
164
|
+
const download = async (f) => {
|
|
165
|
+
const bytes = await client.transfer(`${methodPath}/files/${f.sha256}?version=${encodeURIComponent(theirs.version_id)}`);
|
|
166
|
+
if (fileHash(bytes) !== f.sha256)
|
|
167
|
+
throw Error(`The newer version's file ${f.path} does not match its checksum.`);
|
|
168
|
+
return bytes;
|
|
169
|
+
};
|
|
170
|
+
const who = savedBy ?? 'A teammate', name = relative(root, path);
|
|
171
|
+
if (openSteps.length || openFiles.length) {
|
|
172
|
+
// Remember this computer's text of each part, so that the next run sees which parts the person combined.
|
|
173
|
+
const { recordCombineStop } = await import('./outbox.js');
|
|
174
|
+
recordCombineStop(client.server, id, {
|
|
175
|
+
theirs: theirs.version_id,
|
|
176
|
+
parts: Object.fromEntries(stepConflicts.map(c => [c.path, combinedStep(c.path, c.ours) ? stop.parts[c.path] : c.ours])),
|
|
177
|
+
files: Object.fromEntries(fileConflicts.map(at => [at, combinedFile(at) ? stop.files[at] : local(at) ?? null])),
|
|
178
|
+
});
|
|
179
|
+
const conflicts = [];
|
|
180
|
+
for (const c of openSteps)
|
|
181
|
+
conflicts.push({ part: c.path.replace(/^steps\./, 'step '), file: name, yours: c.ours, theirs: c.theirs });
|
|
182
|
+
for (const at of openFiles) {
|
|
183
|
+
const theirFile = theirFiles.find(f => f.path === at);
|
|
184
|
+
const text = async () => { try {
|
|
185
|
+
return clip(Buffer.from(await download(theirFile)).toString('utf8'));
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
return `(not read: ${error.message})`;
|
|
189
|
+
} };
|
|
190
|
+
conflicts.push({ part: `file ${at}`, file: at, yours: (() => { try {
|
|
191
|
+
return clip(readFileSync(join(root, at), 'utf8'));
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return null;
|
|
195
|
+
} })(), theirs: theirFile ? await text() : null });
|
|
196
|
+
}
|
|
197
|
+
const where = [...new Set(conflicts.map(c => c.file))];
|
|
198
|
+
const headline = `${who} also changed ${conflicts.map(c => c.part).join(', ')}. Combine the two texts in ${where.join(' and ')}, then run again.`;
|
|
199
|
+
return { status: 'conflict', result: { status: 'needs_you', failure: combineFailure(headline, conflicts, who), conflicts } };
|
|
136
200
|
}
|
|
201
|
+
try {
|
|
202
|
+
loadWorkflow(steps.workflow);
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
const headline = `Your changes and ${savedBy ? `${savedBy}'s` : 'the newest'} version combine into a Method that does not load: ${error.message} Combine them in ${name} by hand, then run again.`;
|
|
206
|
+
return { status: 'conflict', result: { status: 'needs_you', failure: combineFailure(headline, [], who), conflicts: [] } };
|
|
207
|
+
}
|
|
208
|
+
// Every file is read before any file changes, so a failed download changes nothing.
|
|
209
|
+
let written;
|
|
210
|
+
try {
|
|
211
|
+
written = await Promise.all(take.map(async (f) => ({ path: f.path, bytes: await download(f) })));
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
return unreachable(error) ? { status: 'offline', line: `Could not check for a newer version; ${doing} this file.` } : { status: 'skipped' };
|
|
215
|
+
}
|
|
216
|
+
const { mkdirSync, renameSync, writeFileSync } = await import('node:fs');
|
|
217
|
+
const { packagePath } = await import('../../contracts/src/method-package.js');
|
|
218
|
+
for (const f of written) {
|
|
219
|
+
const target = resolve(root, packagePath(f.path));
|
|
220
|
+
mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
|
|
221
|
+
writeFileSync(`${target}.download`, f.bytes, { mode: 0o600 });
|
|
222
|
+
renameSync(`${target}.download`, target);
|
|
223
|
+
}
|
|
224
|
+
if (!isDeepStrictEqual(steps.workflow, ours.workflow))
|
|
225
|
+
(await import('./proposals.js')).writeMethodFile(path, id, steps.workflow);
|
|
226
|
+
(await import('./legacy.js')).convertLegacy(path);
|
|
227
|
+
const fileChanges = (from, to) => [...new Set([...from, ...to].map(f => f.path))].filter(at => from.find(f => f.path === at)?.sha256 !== to.find(f => f.path === at)?.sha256);
|
|
228
|
+
const yours = parts(changedSteps(base.workflow, ours.workflow), fileChanges(base.files, ours.files));
|
|
229
|
+
const kept = !isDeepStrictEqual(ours.workflow, base.workflow) || fileChanges(base.files, ours.files).length > 0;
|
|
230
|
+
// theirs is the parent of the next save; a side version of this computer that the file now combines goes with it.
|
|
231
|
+
recordCheckout(client.server, id, theirs.version_id, theirs.version_number, theirs.workflow, { saved_by: savedBy, ...(kept && (record?.side_version_id ?? beside) ? { merged_version_id: (record?.side_version_id ?? beside) } : {}) });
|
|
232
|
+
const changed = parts(changedSteps(base.workflow, theirs.workflow), fileChanges(base.files, theirFiles));
|
|
233
|
+
return { status: 'updated', line: `Updated to ${savedBy ? `${savedBy}'s` : 'the newest'} version${clock(theirs.saved_at)}${changed ? ` (changed ${changed})` : ''}.${kept ? ` Your changes${yours ? ` to ${yours}` : ''} are kept.` : ''}` };
|
|
234
|
+
}
|
|
235
|
+
/** The failure record of a combine that needs the person: needs_you, with both texts of each part as evidence. */
|
|
236
|
+
function combineFailure(headline, conflicts, who) {
|
|
237
|
+
const text = (value) => typeof value === 'string' ? value : JSON.stringify(value);
|
|
238
|
+
return { owner: 'needs_you', code: 'combine_versions', headline, step: null, step_name: null, iteration: null, failed_iterations: [], total: null, phase: 'setup', check_name: null,
|
|
239
|
+
next: { action: 'fix_method', text: 'Edit the file so that each part keeps both changes, then run again. No run started and no file changed.' },
|
|
240
|
+
evidence: conflicts.flatMap(c => [`Your ${c.part}: ${text(c.yours)}`, `${who}'s ${c.part}: ${text(c.theirs)}`]) };
|
|
137
241
|
}
|
|
@@ -14,6 +14,12 @@ export type ImproveJudgment = {
|
|
|
14
14
|
new?: boolean;
|
|
15
15
|
run?: unknown;
|
|
16
16
|
};
|
|
17
|
+
/** What the loop did with one correction, as the account records it. */
|
|
18
|
+
export type CorrectionOutcome = {
|
|
19
|
+
id: string;
|
|
20
|
+
outcome: 'fixed' | 'no_change' | 'already_changed' | 'held';
|
|
21
|
+
detail?: string;
|
|
22
|
+
};
|
|
17
23
|
/** Runs a local Method file; the default is the runtime that `method run FILE` uses. */
|
|
18
24
|
export type RunFile = (file: string, flags: Record<string, unknown>, syncFactory: undefined, onEvent: undefined, client: MethodClient, baseConfig: undefined, secrets: Record<string, string>) => Promise<{
|
|
19
25
|
status: string;
|
|
@@ -37,6 +43,7 @@ export type ImproveProposal = {
|
|
|
37
43
|
};
|
|
38
44
|
export type FixOutcome = {
|
|
39
45
|
file: string;
|
|
46
|
+
judgments: string[];
|
|
40
47
|
corrections: number;
|
|
41
48
|
from_dashboard: number;
|
|
42
49
|
kind: 'change' | 'none';
|
|
@@ -64,6 +71,7 @@ export type FixOutcome = {
|
|
|
64
71
|
capped: string;
|
|
65
72
|
log: string;
|
|
66
73
|
changed?: string;
|
|
74
|
+
outcomes?: CorrectionOutcome[];
|
|
67
75
|
};
|
|
68
76
|
/**
|
|
69
77
|
* Read the result of improve.method: {proposal: <SubmitProposal as JSON text>, kind, proposal_id, capped}. A change has
|
|
@@ -114,6 +122,7 @@ export declare function caseFromCorrection(file: string, judgment: {
|
|
|
114
122
|
id: string;
|
|
115
123
|
note: string | null;
|
|
116
124
|
corrected?: unknown;
|
|
125
|
+
noted_by?: string | null;
|
|
117
126
|
}, runDir: string): Promise<{
|
|
118
127
|
id: string;
|
|
119
128
|
made: boolean;
|
|
@@ -128,5 +137,21 @@ export declare function fixCorrections(client: MethodClient, file: string, optio
|
|
|
128
137
|
run?: RunFile;
|
|
129
138
|
waitMs?: number;
|
|
130
139
|
}): Promise<FixOutcome | undefined>;
|
|
140
|
+
/**
|
|
141
|
+
* Fix the waiting corrections of a Method file while holding its lock, pass after pass until none waits, so the
|
|
142
|
+
* corrections that arrive during a pass join the next. Returns undefined when another fix of the Method holds the lock.
|
|
143
|
+
*/
|
|
144
|
+
export declare function fixWaiting(client: MethodClient, file: string, options?: {
|
|
145
|
+
run?: RunFile;
|
|
146
|
+
waitMs?: number;
|
|
147
|
+
}): Promise<FixOutcome[] | undefined>;
|
|
148
|
+
/**
|
|
149
|
+
* Fix the Method for one correction: run the fix, or, when a fix of the Method already runs on this computer, wait for
|
|
150
|
+
* the fix that takes this correction. Returns the outcome of the fix that took it, or undefined when no fix here took it.
|
|
151
|
+
*/
|
|
152
|
+
export declare function fixCorrection(client: MethodClient, file: string, judgmentId: string, options?: {
|
|
153
|
+
run?: RunFile;
|
|
154
|
+
pollMs?: number;
|
|
155
|
+
}): Promise<FixOutcome | undefined>;
|
|
131
156
|
/** The one line for corrections that waited, printed before method run or method judge goes on. */
|
|
132
157
|
export declare function waitingLine(outcome: FixOutcome): string;
|