@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.
@@ -1,61 +1,11 @@
1
1
  import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { basename, dirname, join } from 'node:path';
3
- import { isDeepStrictEqual, parseArgs } from 'node:util';
4
- import { loadWorkflow } from '../../workflow-language/src/validate.js';
3
+ import { parseArgs } from 'node:util';
5
4
  import { authoringPath, readDocument, writeDocument } from './authoring.js';
6
5
  import { DEFAULT_SERVER } from './method-client.js';
7
6
  import { writePrivateJson } from './files.js';
8
- import { currentVersion, methodContent, methodIdPattern } from './versions.js';
7
+ import { methodIdPattern } from './versions.js';
9
8
  import { localMethodFolder } from './local-judgments.js';
10
- const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
11
- /** Keys of ours in their order; a key that only theirs has goes after the key before it in theirs. */
12
- function keyOrder(ours, theirs) {
13
- const keys = Object.keys(ours), theirKeys = Object.keys(theirs);
14
- theirKeys.forEach((key, index) => {
15
- if (keys.includes(key))
16
- return;
17
- const before = theirKeys.slice(0, index).reverse().find(other => keys.includes(other));
18
- keys.splice(before === undefined ? 0 : keys.indexOf(before) + 1, 0, key);
19
- });
20
- return keys;
21
- }
22
- /**
23
- * Three-way merge of Method documents, per step and per top-level key. base is the version the proposal started from,
24
- * ours is the local file, and theirs is the proposed version. A part that only one side changed takes that change; a part
25
- * that both sides changed in different ways is a conflict.
26
- */
27
- export function mergeMethod(base, ours, theirs) {
28
- if (isDeepStrictEqual(ours, base))
29
- return { clean: true, workflow: theirs };
30
- if (isDeepStrictEqual(theirs, base) || isDeepStrictEqual(ours, theirs))
31
- return { clean: true, workflow: ours };
32
- const conflicts = [];
33
- const pick = (path, b, o, t) => {
34
- if (isDeepStrictEqual(o, t) || isDeepStrictEqual(b, t))
35
- return o;
36
- if (isDeepStrictEqual(b, o))
37
- return t;
38
- conflicts.push({ path, base: b, ours: o, theirs: t });
39
- return o;
40
- };
41
- const merge = (path, b, o, t, nested) => {
42
- const result = {};
43
- for (const key of keyOrder(o, t)) {
44
- const at = path ? `${path}.${key}` : key;
45
- const value = nested(key) && isObject(o[key]) && isObject(t[key]) && isObject(b?.[key] ?? {})
46
- ? merge(at, b?.[key] ?? {}, o[key], t[key], () => false) : pick(at, b?.[key], o[key], t[key]);
47
- if (value !== undefined)
48
- result[key] = value;
49
- }
50
- return result;
51
- };
52
- const workflow = merge('', isObject(base) ? base : {}, ours, theirs, key => key === 'steps');
53
- return conflicts.length ? { clean: false, conflicts } : { clean: true, workflow };
54
- }
55
- /** The steps whose definition differs between two documents. */
56
- export function changedSteps(before, after) {
57
- return [...new Set([...Object.keys(before?.steps ?? {}), ...Object.keys(after?.steps ?? {})])].filter(id => !isDeepStrictEqual(before?.steps?.[id], after?.steps?.[id]));
58
- }
59
9
  /** Write Method content to a file and keep its id: line. A JSON file stays JSON. */
60
10
  export function writeMethodFile(file, id, content) {
61
11
  const { format, ...rest } = content;
@@ -91,83 +41,6 @@ export function localProposals(file) {
91
41
  return { ...record, proposed_workflow: record.proposed_workflow ?? null, path };
92
42
  }).filter(record => record.file === basename(file)).sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
93
43
  }
94
- async function accountProposal(client, id, options = {}) {
95
- const { proposal } = await client.request(`/api/proposals/${encodeURIComponent(id)}`, 'GET', undefined, true, options);
96
- return { ...proposal, source: 'account', proposed_workflow: proposal.proposed_workflow ?? null };
97
- }
98
- async function newestAccepted(client, methodId, options = {}) {
99
- const { proposals } = await client.request(`/api/methods/${encodeURIComponent(methodId)}/proposals?status=accepted`, 'GET', undefined, true, options);
100
- const accepted = (proposals ?? []).filter(p => p?.status === 'accepted').sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
101
- return accepted[0] ? accountProposal(client, accepted[0].id, options) : undefined;
102
- }
103
- /** Merge a change Method made in the account into the file. A clean merge writes the file and records the change as applied. Never publishes. */
104
- async function applyProposal(client, file, proposal, options = {}) {
105
- const document = readDocument(file), ours = methodContent(document);
106
- const merged = mergeMethod(proposal.base_workflow, ours, proposal.proposed_workflow);
107
- if (!merged.clean)
108
- return { status: 'conflict', conflicts: merged.conflicts };
109
- try {
110
- loadWorkflow(merged.workflow);
111
- }
112
- catch (error) {
113
- return { status: 'invalid', error: error.message };
114
- }
115
- if (!isDeepStrictEqual(merged.workflow, ours))
116
- writeMethodFile(file, typeof document.id === 'string' ? document.id : undefined, merged.workflow);
117
- return { status: 'applied', steps: changedSteps(ours, merged.workflow), ...await markApplied(client, file, proposal, options) };
118
- }
119
- async function markApplied(client, file, proposal, options = {}) {
120
- const version_id = (await currentVersion(file).catch(() => undefined))?.version_id;
121
- try {
122
- await client.request(`/api/proposals/${encodeURIComponent(proposal.id)}/applied`, 'POST', version_id ? { version_id } : {}, true, options);
123
- return { version_id };
124
- }
125
- catch (error) {
126
- return { version_id, warning: `The file is changed, but Method did not record the proposal as applied: ${error.message}` };
127
- }
128
- }
129
- const serverFlag = (args) => { const at = args.findIndex(arg => arg === '--server' || arg.startsWith('--server=')); return at < 0 ? undefined : args[at].startsWith('--server=') ? args[at].slice(9) : args[at + 1]; };
130
- const skipAutoApply = new Set(['changes', 'judge', 'init', 'new-id', 'diff', 'help', 'authoring', 'schema', '__worker', '__checks', '__model', 'worker', 'keys', 'secret', 'models', 'login', 'logout', 'sync', 'progress']);
131
- /**
132
- * Before a command that takes a .method FILE: when this computer is signed in and Method changed the Method in the
133
- * account (keeping run data in the account, or a fix of a Method that had not run on a computer), merge the change into
134
- * the file and print one line. A conflict leaves the file unchanged and writes both versions of each conflicting part
135
- * to a file for the coding agent to merge; the command goes on. Offline, signed out, or on any error, it does nothing.
136
- */
137
- export async function autoApply(args, clientFactory) {
138
- if (!args[0] || skipAutoApply.has(args[0]) || args.includes('--help') || process.env.METHOD_RUN_WORKER === '1')
139
- return;
140
- const target = args.slice(1).find(arg => !arg.startsWith('-') && /\.method$/i.test(arg));
141
- if (!target || !existsSync(target))
142
- return;
143
- try {
144
- const file = authoringPath(target), id = readDocument(file).id;
145
- if (typeof id !== 'string' || !methodIdPattern.test(id))
146
- return;
147
- const client = clientFactory(serverFlag(args) ?? DEFAULT_SERVER);
148
- if (!client.token())
149
- return;
150
- const options = { signal: AbortSignal.timeout(3000), stallMs: 3000 };
151
- const proposal = await newestAccepted(client, id, options);
152
- if (!proposal?.proposed_workflow)
153
- return;
154
- const result = await applyProposal(client, file, proposal, options);
155
- if (result.status === 'applied') {
156
- if (result.steps.length || result.warning)
157
- process.stderr.write(`Method changed ${target} (${proposal.id}): ${proposal.cause}${result.steps.length ? ` Changed steps: ${result.steps.join(', ')}.` : ''}${result.warning ? ` ${result.warning}` : ''}\n`);
158
- return;
159
- }
160
- // The agent merges by hand from the saved versions; the change is recorded as applied, so it is not merged again.
161
- localMethodFolder(file);
162
- const path = join(dirname(file), '.method', `conflict-${proposal.id}.json`);
163
- writePrivateJson(path, { proposal_id: proposal.id, cause: proposal.cause, ...(result.status === 'conflict' ? { conflicts: result.conflicts } : { error: result.error }), proposed_workflow: proposal.proposed_workflow });
164
- await markApplied(client, file, proposal, options).catch(() => undefined);
165
- process.stderr.write(result.status === 'conflict'
166
- ? `Method's change ${proposal.id} (${proposal.cause}) conflicts with your edits of ${target} in ${result.conflicts.map(c => c.path).join(', ')}. The file is not changed. Both versions of each part are in ${path}: edit ${target} so each part keeps both changes.\n`
167
- : `Method's change ${proposal.id} does not merge into a valid Method: ${result.error} The file is not changed. The changed version is in ${path}.\n`);
168
- }
169
- catch { /* offline or signed out: the command goes on without the change. */ }
170
- }
171
44
  /** Run folders of this Method on this computer, newest first, with the account run ID of each run that was saved to the account. */
172
45
  export async function localRuns(file) {
173
46
  const { recentRuns } = await import('./current-runtime.js');
@@ -1,3 +1,4 @@
1
+ import { MethodClient } from "./method-client.js";
1
2
  /** The same runtime preparation as method run, without starting a run. Hosted models are used when signed in. */
2
3
  export declare function preparedConfig(file: string, flags: {
3
4
  workspace?: string;
@@ -16,8 +17,16 @@ export declare const defaultRunRoots: () => string[];
16
17
  export declare function observeCommand(args: string[]): Promise<void>;
17
18
  /** A fingerprint of a Method and the files it runs, to tell whether its cases were checked since the last change. */
18
19
  export declare function methodFingerprint(file: string): string;
19
- /** One line for method run when the Method changed since its cases last passed. */
20
+ /** A case as a person reads it: "Gráinne's case dates-conflict: the note" when the case names who made it. */
21
+ export declare const caseName: (c: {
22
+ id: string;
23
+ author?: string | null;
24
+ note?: unknown;
25
+ }) => string;
26
+ /** One line for method run when the Method changed since its cases last passed. Cases with an author are named. */
20
27
  export declare function casesNotice(file: string): Promise<string | undefined>;
28
+ /** The name of the signed-in person, for the author of a new case; null when signed out or offline. Asked once per sign-in in a process. */
29
+ export declare function signedInName(client?: Pick<MethodClient, "server" | "request" | "token">): Promise<string | null>;
21
30
  /** Run the cases of a Method with the same preparation as method run. */
22
31
  export declare function runCases(file: string, flags: {
23
32
  workspace?: string;
@@ -125,7 +125,9 @@ function recordChecked(file, report) {
125
125
  mkdirSync(dirname(checkedFile(file)), { recursive: true, mode: 0o700 });
126
126
  writePrivateJson(checkedFile(file), { file, fingerprint: methodFingerprint(file), passed: report.passed, cases: report.cases.length, at: new Date().toISOString() });
127
127
  }
128
- /** One line for method run when the Method changed since its cases last passed. */
128
+ /** A case as a person reads it: "Gráinne's case dates-conflict: the note" when the case names who made it. */
129
+ export const caseName = (c) => `${c.author}'s case ${c.id}: ${String(c.note ?? "").slice(0, 200).replace(/[.\s]+$/, "")}`;
130
+ /** One line for method run when the Method changed since its cases last passed. Cases with an author are named. */
129
131
  export async function casesNotice(file) {
130
132
  const cases = (await listCases(file)).filter((c) => c.status === "active");
131
133
  if (!cases.length)
@@ -137,7 +139,33 @@ export async function casesNotice(file) {
137
139
  catch { /* never checked */ }
138
140
  if (checked?.passed && checked.fingerprint === methodFingerprint(file))
139
141
  return undefined;
140
- return `${cases.length} case${cases.length === 1 ? "" : "s"} not checked since this Method changed: method test ${file}`;
142
+ const named = cases.filter((c) => c.author);
143
+ return `${cases.length} case${cases.length === 1 ? "" : "s"} not checked since this Method changed${named.length ? ` (${named.map(caseName).join("; ")})` : ""}: method test ${file}`;
144
+ }
145
+ const signedIn = new Map();
146
+ /** The name of the signed-in person, for the author of a new case; null when signed out or offline. Asked once per sign-in in a process. */
147
+ export function signedInName(client = new MethodClient()) {
148
+ let token;
149
+ try {
150
+ token = client.token();
151
+ }
152
+ catch {
153
+ return Promise.resolve(null);
154
+ }
155
+ if (!token)
156
+ return Promise.resolve(null);
157
+ const key = `${client.server}\n${token}`;
158
+ if (!signedIn.has(key))
159
+ signedIn.set(key, (async () => {
160
+ try {
161
+ const me = await client.request("/api/me", "GET", undefined, true, { signal: AbortSignal.timeout(3000), stallMs: 3000 });
162
+ return typeof me?.user?.name === "string" && me.user.name.trim() ? me.user.name.trim() : null;
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ })());
168
+ return signedIn.get(key);
141
169
  }
142
170
  /** The Method's declared secrets from this computer or the environment, as method run gives them. */
143
171
  async function declaredSecrets(file) {
@@ -178,8 +206,10 @@ export async function caseGate(file, accept = [], reason) {
178
206
  process.stderr.write(`Cases checked in ${Math.round(t.duration_ms / 1000)} s: ${t.live_model_steps} live model step${t.live_model_steps === 1 ? "" : "s"}, ${t.judge_calls} judge call${t.judge_calls === 1 ? "" : "s"}.\n`);
179
207
  const failing = report.cases.filter((c) => !["pass", "fixed"].includes(c.verdict));
180
208
  const unaccepted = failing.filter((c) => !accept.includes(c.id));
209
+ const authors = new Map(cases.map((c) => [c.id, c.author]));
210
+ const shown = (c) => authors.get(c.id) ? `${caseName({ ...c, author: authors.get(c.id) })} (${c.verdict})` : `${c.id} (${c.verdict}: ${c.note})`;
181
211
  if (unaccepted.length)
182
- throw Object.assign(Error(`This version breaks ${unaccepted.length} approved case${unaccepted.length === 1 ? "" : "s"}: ${unaccepted.map((c) => `${c.id} (${c.verdict}: ${c.note})`).join("; ")}. Fix the Method and run method test, or retire a case whose rule no longer applies with method case retire. To publish anyway, add --accept-failing-case ${unaccepted.map((c) => c.id).join(",")} with --reason.`), { code: "cases_failed" });
212
+ throw Object.assign(Error(`This version breaks ${unaccepted.length} approved case${unaccepted.length === 1 ? "" : "s"}: ${unaccepted.map(shown).join("; ")}. Fix the Method and run method test, or retire a case whose rule no longer applies with method case retire. To publish anyway, add --accept-failing-case ${unaccepted.map((c) => c.id).join(",")} with --reason.`), { code: "cases_failed" });
183
213
  if (failing.length && !reason)
184
214
  throw Error("Give --reason for accepting a failing case.");
185
215
  return { report, line: failing.length ? `Cases: ${report.cases.length - failing.length} of ${report.cases.length} passed; accepted failing ${failing.map((c) => c.id).join(", ")}.` : `Cases: ${report.cases.length} of ${report.cases.length} passed.` };
@@ -214,7 +244,7 @@ export async function caseCommand(args) {
214
244
  if (privacy)
215
245
  process.stderr.write(privacy + "\n");
216
246
  print(await createCase({ methodFile, runDir: values.run ? authoringPath(values.run) : undefined, passingRun: values["passing-run"] ? authoringPath(values["passing-run"]) : undefined,
217
- id: values.id, note: values.note, author: values.author ?? null, rubric: values.rubric ?? [], ref: values.ref, context: values.context ?? [], expect: json(values.expect) ?? [],
247
+ id: values.id, note: values.note, author: values.author ?? await signedInName(), rubric: values.rubric ?? [], ref: values.ref, context: values.context ?? [], expect: json(values.expect) ?? [],
218
248
  observations: json(values.observations), redact: json(values.redact), runs: integer(values.runs, "--runs"), minPass: integer(values["min-pass"], "--min-pass"),
219
249
  retentionDays: integer(values["retention-days"], "--retention-days"), supersedes: values.supersedes ?? [], casesDir, acceptConflict: values["accept-conflict"], config: prepared.config,
220
250
  options: { runOptions: { processPath: prepared.processPath, secrets: await declaredSecrets(methodFile), ...(prepared.classification ? { classification: prepared.classification } : {}), ...(prepared.hostedModels ? { hostedModels: prepared.hostedModels } : {}) } } }));
@@ -1,5 +1,9 @@
1
1
  import { MethodClient } from './method-client.js';
2
2
  import type { parse } from './local-cli.js';
3
+ /** How often a run that waits for the shared state checks again. Tests make it short. */
4
+ export declare const stateWait: {
5
+ pollMs: number;
6
+ };
3
7
  /** A run whose steps all finished: completed, or unconfirmed (an external change could not be confirmed). */
4
8
  export declare const finishedRun: (status: string) => status is "unconfirmed" | "completed";
5
9
  export declare function runSaved(saved: any, flags: ReturnType<typeof parse>['values'], client: MethodClient): Promise<any>;
@@ -13,6 +13,37 @@ import { workerRunId } from './run-worker.js';
13
13
  import { readDocument } from './authoring.js';
14
14
  import { withInputsFrom } from './inputs-from.js';
15
15
  import { setupFailure } from './failure.js';
16
+ /** How often a run that waits for the shared state checks again. Tests make it short. */
17
+ export const stateWait = { pollMs: 10_000 };
18
+ const clock = (at) => { const date = at ? new Date(at) : undefined; return date && !isNaN(+date) ? `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}` : undefined; };
19
+ /**
20
+ * Take the shared state. While another run that still runs (or lost its connection) holds it, say so once and check
21
+ * again until it is free: there is no time limit, and method cancel stops the wait. A run that stopped while it held
22
+ * the state stops this one: a person must check that run's actions before releasing it.
23
+ */
24
+ async function acquireState(client, statePath, runId, methodId) {
25
+ let told = false;
26
+ for (;;) {
27
+ try {
28
+ return await client.request(statePath, 'POST', { action: 'acquire', run_id: runId });
29
+ }
30
+ catch (error) {
31
+ const owner = error?.body?.owner;
32
+ if (error?.code !== 'state_owned')
33
+ throw error;
34
+ if (owner && (owner.status === 'finished' || owner.status === 'failed')) {
35
+ const name = owner.started_by ?? 'Another person';
36
+ throw Object.assign(Error(`${name}'s run stopped while it held the shared state. ${name} must check its actions, then run \`method state ${methodId} --release ${owner.run_id}\`.`), { code: 'state_held', owner: 'setup' });
37
+ }
38
+ if (!told) {
39
+ const started = clock(owner?.started_at);
40
+ process.stderr.write(`Waiting for ${owner?.started_by ? `${owner.started_by}'s run` : 'another run'}${started ? ` (started ${started})` : ''} to finish with the shared state${owner?.link ? `: ${owner.link}` : '.'}\n`);
41
+ told = true;
42
+ }
43
+ await new Promise(resolve => setTimeout(resolve, stateWait.pollMs));
44
+ }
45
+ }
46
+ }
16
47
  // Older versions are listed only after their saved-package tests pass.
17
48
  const supportedPackageRuntimes = new Set([runtimeVersion, '0.14.0', '0.12.0', '0.11.1', '0.11.0', '0.10.0', '0.9.5', '0.9.4', '0.9.3', '0.7.0', '0.7.1', '0.7.2', '0.8.0', '0.8.1', '0.8.2', '0.8.3']);
18
49
  /** A run whose steps all finished: completed, or unconfirmed (an external change could not be confirmed). */
@@ -48,10 +79,17 @@ export async function runSaved(saved, flags, client) {
48
79
  return completed;
49
80
  }
50
81
  }
51
- flags = await withInputsFrom(flags, directory, undefined, client);
52
82
  const inputFile = join(directory, 'requested-inputs.json');
53
- const inputs = flags.inputs ? JSON.parse(readFileSync(resolve(flags.inputs), 'utf8')) : existsSync(inputFile) ? JSON.parse(readFileSync(inputFile, 'utf8')) : {};
54
- writePrivateJson(inputFile, inputs);
83
+ // The start line already linked to this run: inputs that cannot be read fail it below, after its start is recorded.
84
+ let inputs = {}, unreadable;
85
+ try {
86
+ flags = await withInputsFrom(flags, directory, undefined, client);
87
+ inputs = flags.inputs ? JSON.parse(readFileSync(resolve(flags.inputs), 'utf8')) : existsSync(inputFile) ? JSON.parse(readFileSync(inputFile, 'utf8')) : {};
88
+ writePrivateJson(inputFile, inputs);
89
+ }
90
+ catch (error) {
91
+ unreadable = error;
92
+ }
55
93
  const setupFile = join(directory, 'setup.json');
56
94
  const setupEvents = existsSync(setupFile) ? JSON.parse(readFileSync(setupFile, 'utf8')) : [];
57
95
  setupEvents.push({ at: new Date().toISOString(), type: 'setup_started' });
@@ -61,6 +99,8 @@ export async function runSaved(saved, flags, client) {
61
99
  let acquired = false, started = false, revision = 0;
62
100
  const statePath = `/api/cli/methods/${encodeURIComponent(saved.workflow_id)}/state`;
63
101
  try {
102
+ if (unreadable)
103
+ throw unreadable;
64
104
  const resuming = !!flags.resume && existsSync(join(directory, 'checkpoint.json'));
65
105
  if (resuming)
66
106
  assertCheckpointExecutor(JSON.parse(readFileSync(join(directory, 'checkpoint.json'), 'utf8')));
@@ -91,7 +131,7 @@ export async function runSaved(saved, flags, client) {
91
131
  if (shared.enabled) {
92
132
  if (flags.state)
93
133
  throw Error('This Method uses account state. Do not replace it with --state.');
94
- const state = await client.request(statePath, 'POST', { action: 'acquire', run_id: request.run_id });
134
+ const state = await acquireState(client, statePath, request.run_id, saved.workflow_id);
95
135
  acquired = true;
96
136
  revision = state.revision;
97
137
  const pendingFile = join(directory, 'state-pending.json');
@@ -116,7 +156,7 @@ export async function runSaved(saved, flags, client) {
116
156
  const result = await client.request(statePath, 'POST', commit);
117
157
  revision = result.revision;
118
158
  }
119
- }, client, config);
159
+ }, client, config, {}, saved.saved_by ?? null);
120
160
  // Every step finished (an unconfirmed run too), so the run no longer owns the shared state.
121
161
  if (acquired && finishedRun(result.status)) {
122
162
  await client.request(statePath, 'POST', { action: 'release', run_id: request.run_id });
@@ -6,7 +6,7 @@ import { loadWorkflow, parseDocumentValue, serializeWorkflow } from '../../workf
6
6
  import { buildPackage } from './method-files.js';
7
7
  import { authoringPath, readDocument } from './authoring.js';
8
8
  import { Outbox, lastSaved, savedVersions } from './outbox.js';
9
- import { claimHome, recordHome, writtenFiles } from './home.js';
9
+ import { claimHome } from './home.js';
10
10
  export const methodIdPattern = /^wf_[a-f0-9]{32}$/;
11
11
  /** A new account Method ID, made on this computer. The first save of this ID creates the Method. */
12
12
  export const newMethodId = () => `wf_${randomBytes(16).toString('hex')}`;
@@ -98,7 +98,6 @@ export async function prepareVersion(client, outbox, file, reason) {
98
98
  const { pack, sources, tooLarge } = await buildPackage(file, workflow);
99
99
  const versionId = versionIdFor(id, pack.digest);
100
100
  const url = `${client.server}/methods/${id}?version=${versionId}`;
101
- recordHome(client.server, id, file, { version_id: versionId, files: writtenFiles(file, pack.files) });
102
101
  if (savedVersions(client.server)[versionId])
103
102
  return { method_id: id, version_id: versionId, url, unchanged: true, workflow };
104
103
  const previous = lastSaved(client.server, id);
@@ -107,7 +106,8 @@ export async function prepareVersion(client, outbox, file, reason) {
107
106
  for (const f of pack.files)
108
107
  outbox.add({ kind: 'file', server: client.server, sha256: f.sha256, size: f.size }, sources[f.sha256]);
109
108
  outbox.add({ kind: 'version', server: client.server, method_id: id, version_id: versionId, file: authoringPath(file), body: {
110
- workflow, package: pack, ...(previous && previous.version_id !== versionId ? { parent_version_id: previous.version_id } : {}),
109
+ workflow, package: pack, ...(previous?.version_id && previous.version_id !== versionId ? { parent_version_id: previous.version_id } : {}),
110
+ ...(previous?.merged_version_id && previous.merged_version_id !== versionId ? { merged_version_id: previous.merged_version_id } : {}),
111
111
  reason: reason ?? changeReason(previous?.workflow, workflow),
112
112
  } });
113
113
  return { method_id: id, version_id: versionId, url, unchanged: false, workflow };
@@ -115,6 +115,8 @@ export async function prepareVersion(client, outbox, file, reason) {
115
115
  /** The one line a run prints about its version. */
116
116
  export function savedLine(client, outbox, version) {
117
117
  const known = savedVersions(client.server)[version.version_id];
118
+ if (known?.beside !== undefined)
119
+ return `Saved beside ${known.beside ? `${known.beside}'s` : 'a'} newer version; your next run combines them.`;
118
120
  if (known)
119
121
  return `Saved as version ${known.version_number}: ${version.url}`;
120
122
  const failure = outbox.failure(version.version_id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@method-ai-hq/sdk",
3
- "version": "0.18.1",
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",