@agentionai/fieldwork-cli 0.4.0 → 0.8.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 +42 -20
- package/dist/changelog.js +113 -0
- package/dist/client.js +96 -7
- package/dist/credentials.js +78 -0
- package/dist/fieldwork-skill.md +107 -21
- package/dist/main.js +521 -85
- package/dist/select.js +124 -0
- package/dist/version.js +50 -0
- package/dist/workspace.js +72 -21
- package/package.json +28 -7
package/dist/main.js
CHANGED
|
@@ -1,97 +1,309 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { Command } from 'commander';
|
|
4
|
-
import { request } from './client.js';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import { request, requestAll } from './client.js';
|
|
5
|
+
import { CLI_VERSION } from './version.js';
|
|
6
|
+
import { DEFAULT_SERVER, Scope, setupCampaign } from './workspace.js';
|
|
7
|
+
import { filterRows, parseCondition, projectRows, resolve, toTsv } from './select.js';
|
|
8
|
+
import { selectChangelog } from './changelog.js';
|
|
9
|
+
import { credentialsPath, forgetCredential, resolveCredential, storeCredential, } from './credentials.js';
|
|
10
|
+
const version = CLI_VERSION;
|
|
11
|
+
const program = new Command()
|
|
12
|
+
.name('fieldwork')
|
|
13
|
+
.description('Fieldwork API client; JSON results on stdout, JSON failures on stderr')
|
|
14
|
+
.version(version)
|
|
15
|
+
.option('--url <url>', `API server URL (defaults to FIELDWORK_URL, LAB_URL, workspace config, then ${DEFAULT_SERVER})`)
|
|
16
|
+
.option('--token <token>', 'API credential (defaults to FIELDWORK_TOKEN)')
|
|
17
|
+
.option('--org <id>', 'Organization to act in (defaults to FIELDWORK_ORGANIZATION); unnecessary for a credential bound to one organization');
|
|
8
18
|
program.exitOverride();
|
|
9
19
|
program.configureOutput({ writeErr: () => { } });
|
|
10
20
|
const output = (value) => console.log(JSON.stringify(value, null, 2));
|
|
11
|
-
|
|
21
|
+
/** Credentials resolve like --url does: explicit flag, then environment, then the stored
|
|
22
|
+
* credential for this server. Deliberately no prompt fallback -- a secret typed at a
|
|
23
|
+
* prompt ends up in shell history. */
|
|
24
|
+
const credentials = () => {
|
|
25
|
+
const options = program.opts();
|
|
26
|
+
// serverUrl(), not url(): url() builds a Scope that carries credentials, and asking for
|
|
27
|
+
// credentials to resolve credentials recurses until the stack gives out.
|
|
28
|
+
const { token, organization } = resolveCredential(serverUrl(), {
|
|
29
|
+
token: options.token,
|
|
30
|
+
org: options.org,
|
|
31
|
+
});
|
|
32
|
+
return { token, organization };
|
|
33
|
+
};
|
|
34
|
+
const send = (path, method, body) => request(url(), path, method, body, credentials()).then(output);
|
|
12
35
|
function input(value) {
|
|
13
36
|
let parsed;
|
|
14
37
|
try {
|
|
15
38
|
parsed = JSON.parse(value === '-' ? readFileSync(0, 'utf8') : value);
|
|
16
39
|
}
|
|
17
40
|
catch (error) {
|
|
18
|
-
throw Object.assign(new Error(`Cannot read JSON input: ${error.message}`), {
|
|
41
|
+
throw Object.assign(new Error(`Cannot read JSON input: ${error.message}`), {
|
|
42
|
+
code: 'INVALID_JSON',
|
|
43
|
+
hint: 'Supply a valid JSON object with --json, or use --json - to read it from stdin; quote keys and strings with double quotes.',
|
|
44
|
+
});
|
|
19
45
|
}
|
|
20
46
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
21
|
-
throw Object.assign(new Error('JSON input must be an object'), {
|
|
47
|
+
throw Object.assign(new Error('JSON input must be an object'), {
|
|
48
|
+
code: 'INVALID_JSON',
|
|
49
|
+
hint: 'Use a JSON object such as {"name":"Example"}, not null, an array, or a scalar; see the command’s --help for required fields.',
|
|
50
|
+
});
|
|
22
51
|
return parsed;
|
|
23
52
|
}
|
|
24
|
-
function
|
|
25
|
-
|
|
53
|
+
function fail(message) {
|
|
54
|
+
throw new Error(message);
|
|
55
|
+
}
|
|
56
|
+
/** Server resolution alone, with no credentials, so credential resolution can use it. */
|
|
57
|
+
const serverUrl = () => new Scope(program.opts().url).url;
|
|
58
|
+
/** One scope per invocation, carrying both the resolved server and the credentials, so no
|
|
59
|
+
* call site can reach the API without them. */
|
|
60
|
+
const apiScope = () => new Scope(program.opts().url, process.cwd(), credentials());
|
|
61
|
+
const url = () => serverUrl();
|
|
26
62
|
const path = (id) => `/campaigns/${encodeURIComponent(id)}`;
|
|
27
63
|
const collection = (product) => product ? `/products/${encodeURIComponent(product)}/campaigns` : '/campaigns';
|
|
28
|
-
const
|
|
29
|
-
|
|
64
|
+
const auth = program.command('auth').description('Manage the API credential for this machine');
|
|
65
|
+
auth
|
|
66
|
+
.command('login')
|
|
67
|
+
.description('Store a credential for this server')
|
|
68
|
+
.action(async () => {
|
|
69
|
+
const options = program.opts();
|
|
70
|
+
// --token and --org are global, so they are spelled the same here as everywhere else.
|
|
71
|
+
// Declaring them again on this subcommand shadowed the global ones and made login the
|
|
72
|
+
// one command that could not see its own credential.
|
|
73
|
+
if (!options.token)
|
|
74
|
+
throw Object.assign(new Error('auth login requires --token'), {
|
|
75
|
+
code: 'MISSING_TOKEN',
|
|
76
|
+
hint: 'Pass the credential with --token. It is stored for this server only, under your config directory.',
|
|
77
|
+
});
|
|
78
|
+
const server = url();
|
|
79
|
+
// Verified before it is written. Storing an unusable credential turns a typo into a
|
|
80
|
+
// failure on some later command, far from the paste that caused it.
|
|
81
|
+
const organizations = (await request(server, '/organizations', 'GET', undefined, {
|
|
82
|
+
token: options.token,
|
|
83
|
+
organization: options.org,
|
|
84
|
+
}));
|
|
85
|
+
const chosen = options.org ?? (organizations.length === 1 ? organizations[0]?.id : undefined);
|
|
86
|
+
const path = storeCredential(server, {
|
|
87
|
+
token: options.token,
|
|
88
|
+
...(chosen ? { organization: chosen } : {}),
|
|
89
|
+
});
|
|
90
|
+
output({
|
|
91
|
+
server,
|
|
92
|
+
storedAt: path,
|
|
93
|
+
organizations,
|
|
94
|
+
organization: chosen ?? null,
|
|
95
|
+
// Several organizations and no --org means later commands need one; say so now
|
|
96
|
+
// rather than letting every request fail with a permission error.
|
|
97
|
+
...(chosen
|
|
98
|
+
? {}
|
|
99
|
+
: {
|
|
100
|
+
note: 'Several organizations are available; pass --org, or set FIELDWORK_ORGANIZATION.',
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
auth
|
|
105
|
+
.command('status')
|
|
106
|
+
.description('Show which credential this server would use, and whether it works')
|
|
107
|
+
.action(async () => {
|
|
108
|
+
const server = url();
|
|
109
|
+
const resolved = resolveCredential(server, program.opts());
|
|
110
|
+
if (!resolved.token)
|
|
111
|
+
return output({
|
|
112
|
+
server,
|
|
113
|
+
source: 'none',
|
|
114
|
+
credentialsFile: credentialsPath(),
|
|
115
|
+
note: 'No credential. Use fieldwork auth login --token, or set FIELDWORK_TOKEN.',
|
|
116
|
+
});
|
|
117
|
+
const organizations = (await request(server, '/organizations', 'GET', undefined, resolved));
|
|
118
|
+
output({
|
|
119
|
+
server,
|
|
120
|
+
// Which source is in play matters: a stale FIELDWORK_TOKEN silently shadowing a
|
|
121
|
+
// fresh login is the usual confusion.
|
|
122
|
+
source: resolved.source,
|
|
123
|
+
credentialsFile: credentialsPath(),
|
|
124
|
+
organization: resolved.organization ?? null,
|
|
125
|
+
organizations,
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
auth
|
|
129
|
+
.command('logout')
|
|
130
|
+
.description('Remove the stored credential for this server')
|
|
131
|
+
.action(() => {
|
|
132
|
+
const server = url();
|
|
133
|
+
// Reports what it did rather than failing when there was nothing stored: logging out
|
|
134
|
+
// twice is not an error, and an environment variable is not ours to unset.
|
|
135
|
+
output({ server, removed: forgetCredential(server), credentialsFile: credentialsPath() });
|
|
136
|
+
});
|
|
137
|
+
const setup = program
|
|
138
|
+
.command('setup')
|
|
139
|
+
.description('Attach the current directory to an existing campaign');
|
|
140
|
+
setup
|
|
141
|
+
.command('campaign')
|
|
142
|
+
.description('Create local config and workspace directories; use --create to add a missing campaign')
|
|
30
143
|
.requiredOption('--campaign <ref>', 'Campaign stub or ID')
|
|
31
144
|
.option('--product <ref>', 'Product stub or ID; validates parentage')
|
|
32
145
|
.option('--create', 'Create the campaign when not found; requires --goal')
|
|
33
146
|
.option('--goal <text>', 'Goal for --create')
|
|
34
147
|
.action(async (options) => {
|
|
35
|
-
const result = await setupCampaign(url(), { ...options, campaign: options.campaign }, process.cwd());
|
|
36
|
-
output({
|
|
148
|
+
const result = await setupCampaign(url(), { ...options, campaign: options.campaign }, process.cwd(), credentials());
|
|
149
|
+
output({
|
|
150
|
+
workspaceRoot: result.root,
|
|
151
|
+
created: result.created,
|
|
152
|
+
product: result.config.product,
|
|
153
|
+
campaign: result.config.campaign,
|
|
154
|
+
});
|
|
37
155
|
});
|
|
38
|
-
program
|
|
156
|
+
program
|
|
157
|
+
.command('context')
|
|
158
|
+
.description('Effective server, workspace scope, and live inherited research context (setup campaign required when no scope is given)')
|
|
39
159
|
.option('--campaign <ref>', 'Inspect without a local workspace')
|
|
40
|
-
.action(async (options) => output(await
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.
|
|
47
|
-
|
|
48
|
-
campaigns
|
|
160
|
+
.action(async (options) => output(await apiScope().context(options.campaign)));
|
|
161
|
+
program
|
|
162
|
+
.command('changelog')
|
|
163
|
+
.description('Recent releases of this CLI, offline; each change states whether it needs an updated API')
|
|
164
|
+
.option('--since <version>', 'Only releases after this version')
|
|
165
|
+
.option('--release <version>', 'Only this release')
|
|
166
|
+
.addHelpText('after', '\nThe package carries recent releases only; see the repository changelog for the full history.\nrequiresApi records what a change needs from the server. The CLI cannot verify what an API\nprovides, and installing a newer client never upgrades a server.')
|
|
167
|
+
.action((options) => output(selectChangelog(version, options)));
|
|
168
|
+
const campaigns = program
|
|
169
|
+
.command('campaigns')
|
|
170
|
+
.description('Manage research goals and inspect inherited product context');
|
|
171
|
+
campaigns
|
|
172
|
+
.command('list')
|
|
173
|
+
.option('--product <ref>', 'Scope to product (stub or ID) via /products/:id/campaigns')
|
|
174
|
+
.description('List campaigns; omit product for the whole workspace')
|
|
175
|
+
.action(async (options) => send(collection(options.product && (await apiScope().product(options.product)).id)));
|
|
176
|
+
campaigns
|
|
177
|
+
.command('get')
|
|
178
|
+
.argument('<ref>')
|
|
179
|
+
.description('Get campaign by stub or ID, with live parent context and runs')
|
|
180
|
+
.action(async (reference) => send(path((await apiScope().campaign(reference)).id)));
|
|
181
|
+
campaigns
|
|
182
|
+
.command('context')
|
|
183
|
+
.argument('<ref>')
|
|
184
|
+
.description('Get campaign goal, criteria, constraints and inherited context (same complete response as get)')
|
|
185
|
+
.action(async (reference) => send(path((await apiScope().campaign(reference)).id)));
|
|
186
|
+
campaigns
|
|
187
|
+
.command('create')
|
|
188
|
+
.option('--product <ref>', 'Product (stub or ID) in the URL; omit for independent campaigns or a productId in JSON')
|
|
189
|
+
.requiredOption('--json <json|->', 'JSON object, or - to read stdin; requires name and goal')
|
|
190
|
+
.action(async (options) => send(collection(options.product && (await apiScope().product(options.product)).id), 'POST', input(options.json)));
|
|
191
|
+
campaigns
|
|
192
|
+
.command('update')
|
|
193
|
+
.argument('<ref>')
|
|
194
|
+
.requiredOption('--json <json|->', 'Patch including expected revision')
|
|
195
|
+
.description('Patch by stub or ID; JSON includes expected revision')
|
|
196
|
+
.action(async (reference, options) => send(path((await apiScope().campaign(reference)).id), 'PATCH', input(options.json)));
|
|
197
|
+
campaigns
|
|
198
|
+
.command('delete')
|
|
199
|
+
.argument('<ref>')
|
|
200
|
+
.requiredOption('--revision <number>', 'Expected revision')
|
|
201
|
+
.option('--confirm <name>', 'The campaign name, required when it holds work: everything in it is deleted too')
|
|
202
|
+
.description('Delete a campaign and everything in it, for good. To keep the work out of the way instead, use archive')
|
|
49
203
|
.action(async (reference, options) => {
|
|
50
204
|
const revision = Number(options.revision);
|
|
51
205
|
if (!Number.isSafeInteger(revision) || revision < 1)
|
|
52
206
|
throw new Error('Revision must be a positive integer');
|
|
53
|
-
return send(path((await
|
|
207
|
+
return send(path((await apiScope().campaign(reference)).id), 'DELETE', {
|
|
208
|
+
revision,
|
|
209
|
+
...(options.confirm ? { confirm: options.confirm } : {}),
|
|
210
|
+
});
|
|
54
211
|
});
|
|
55
|
-
const artifacts = program
|
|
56
|
-
|
|
212
|
+
const artifacts = program
|
|
213
|
+
.command('artifacts')
|
|
214
|
+
.description('Product-scoped recipes and file metadata; permanently immutable after reference');
|
|
215
|
+
const artifactProduct = async (ref) => (await apiScope().product(ref)).id;
|
|
57
216
|
async function artifactRef(ref, product) {
|
|
58
217
|
if (/^[0-9a-f-]{36}$/i.test(ref) && !product)
|
|
59
218
|
return ref;
|
|
60
219
|
if (!product)
|
|
61
|
-
|
|
62
|
-
const values = await request(url(), `/products/${encodeURIComponent(await artifactProduct(product))}/artifacts
|
|
220
|
+
fail('Artifact stubs require --product REF; IDs can be used directly');
|
|
221
|
+
const values = (await request(url(), `/products/${encodeURIComponent(await artifactProduct(product))}/artifacts`, 'GET', undefined, credentials()));
|
|
63
222
|
const value = values.find((v) => v.id === ref || v.stub === ref);
|
|
64
223
|
if (!value)
|
|
65
|
-
|
|
224
|
+
fail('Artifact not found in product');
|
|
66
225
|
return value.id;
|
|
67
226
|
}
|
|
68
|
-
artifacts
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
artifacts
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
227
|
+
artifacts
|
|
228
|
+
.command('list')
|
|
229
|
+
.requiredOption('--product <ref>', 'Product stub or ID')
|
|
230
|
+
.action(async (o) => send(`/products/${encodeURIComponent(await artifactProduct(o.product))}/artifacts`));
|
|
231
|
+
artifacts
|
|
232
|
+
.command('create')
|
|
233
|
+
.requiredOption('--product <ref>', 'Product stub or ID')
|
|
234
|
+
.requiredOption('--json <json|->', 'Requires stub/name; optional kind, description, definition object, files [{role,uri,sha256?}], derivedFrom ID')
|
|
235
|
+
.action(async (o) => send(`/products/${encodeURIComponent(await artifactProduct(o.product))}/artifacts`, 'POST', input(o.json)));
|
|
236
|
+
artifacts
|
|
237
|
+
.command('get')
|
|
238
|
+
.argument('<ref>')
|
|
239
|
+
.option('--product <ref>', 'Required for artifact stubs')
|
|
240
|
+
.action(async (ref, o) => send(`/artifacts/${encodeURIComponent(await artifactRef(ref, o.product))}`));
|
|
241
|
+
artifacts
|
|
242
|
+
.command('update')
|
|
243
|
+
.argument('<ref>')
|
|
244
|
+
.option('--product <ref>', 'Required for artifact stubs')
|
|
245
|
+
.requiredOption('--json <json|->', 'Patch with current revision; definition/files replace whole fields; frozen artifacts reject edits')
|
|
246
|
+
.action(async (ref, o) => send(`/artifacts/${encodeURIComponent(await artifactRef(ref, o.product))}`, 'PATCH', input(o.json)));
|
|
247
|
+
artifacts
|
|
248
|
+
.command('delete')
|
|
249
|
+
.argument('<ref>')
|
|
250
|
+
.option('--product <ref>', 'Required for artifact stubs')
|
|
251
|
+
.requiredOption('--revision <number>', 'Expected revision; referenced artifacts cannot be deleted')
|
|
252
|
+
.action(async (ref, o) => {
|
|
253
|
+
const revision = Number(o.revision);
|
|
254
|
+
if (!Number.isSafeInteger(revision) || revision < 1)
|
|
255
|
+
fail('Revision must be a positive integer');
|
|
256
|
+
return send(`/artifacts/${encodeURIComponent(await artifactRef(ref, o.product))}`, 'DELETE', {
|
|
257
|
+
revision,
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
artifacts
|
|
261
|
+
.command('diff')
|
|
262
|
+
.argument('<from>')
|
|
263
|
+
.argument('<to>')
|
|
264
|
+
.option('--product <ref>', 'Required for artifact stubs')
|
|
265
|
+
.description('Compare recipe definitions/files within one product; paths use JSON Pointer')
|
|
266
|
+
.action(async (from, to, o) => send(`/artifacts/${encodeURIComponent(await artifactRef(from, o.product))}/diff/${encodeURIComponent(await artifactRef(to, o.product))}`));
|
|
267
|
+
const resolveSchema = async (ref) => {
|
|
268
|
+
if (!/^[0-9a-f-]{36}$/i.test(ref))
|
|
269
|
+
fail('Schema references are version IDs; use schemas list --product REF to obtain one');
|
|
270
|
+
return ref;
|
|
271
|
+
};
|
|
272
|
+
const schemas = program
|
|
273
|
+
.command('schemas')
|
|
274
|
+
.description('Manage typed research schemas: publish immutable versions, inspect templates, and validate payloads before writing records');
|
|
78
275
|
async function schemaScope(options) {
|
|
79
|
-
const scope =
|
|
276
|
+
const scope = apiScope();
|
|
80
277
|
if (options.product) {
|
|
81
278
|
if (options.campaign || options.experiment)
|
|
82
|
-
|
|
279
|
+
fail('--product cannot be combined with --campaign or --experiment for schemas');
|
|
83
280
|
return `/products/${encodeURIComponent((await scope.product(options.product)).id)}`;
|
|
84
281
|
}
|
|
85
282
|
const campaign = await scope.campaign(options.campaign);
|
|
86
|
-
return options.experiment
|
|
283
|
+
return options.experiment
|
|
284
|
+
? `/experiments/${encodeURIComponent(await scope.work('experiments', options.experiment, campaign.id))}`
|
|
285
|
+
: path(campaign.id);
|
|
87
286
|
}
|
|
88
287
|
for (const operation of ['list', 'publish', 'default', 'set-default']) {
|
|
89
|
-
const command = schemas
|
|
288
|
+
const command = schemas
|
|
289
|
+
.command(operation)
|
|
290
|
+
.option('--product <ref>', 'Product scope; cannot combine with campaign/experiment')
|
|
291
|
+
.option('--campaign <ref>', 'Campaign scope; defaults to workspace')
|
|
292
|
+
.option('--experiment <ref>', 'Experiment within campaign');
|
|
90
293
|
if (operation === 'list')
|
|
91
294
|
command.option('--inherited', 'Include schemas owned by ancestors');
|
|
92
295
|
if (operation === 'publish' || operation === 'set-default')
|
|
93
|
-
command.requiredOption('--json <json|->', operation === 'publish'
|
|
94
|
-
|
|
296
|
+
command.requiredOption('--json <json|->', operation === 'publish'
|
|
297
|
+
? 'JSON with stub, version, definition; - reads stdin'
|
|
298
|
+
: 'JSON with schemaVersionId and current default revision; null clears product/campaign default');
|
|
299
|
+
command
|
|
300
|
+
.description(operation === 'default'
|
|
301
|
+
? 'Inspect local and effective schema default; no changes to existing records'
|
|
302
|
+
: operation === 'set-default'
|
|
303
|
+
? 'Set a product/campaign default or pin an experiment (immutable once runs exist)'
|
|
304
|
+
: operation === 'list'
|
|
305
|
+
? 'List local schemas; --inherited includes ancestors'
|
|
306
|
+
: 'Publish an immutable schema owned by product, campaign or experiment')
|
|
95
307
|
.action(async (options) => {
|
|
96
308
|
const target = await schemaScope(options);
|
|
97
309
|
if (operation === 'set-default')
|
|
@@ -100,105 +312,329 @@ for (const operation of ['list', 'publish', 'default', 'set-default']) {
|
|
|
100
312
|
});
|
|
101
313
|
}
|
|
102
314
|
for (const operation of ['get', 'template'])
|
|
103
|
-
schemas
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
315
|
+
schemas
|
|
316
|
+
.command(operation)
|
|
317
|
+
.argument('<ref>')
|
|
318
|
+
.description(operation === 'template'
|
|
319
|
+
? 'Illustrative experiment and observation placeholders; replace, never record as measured values'
|
|
320
|
+
: 'Published schema definition')
|
|
321
|
+
.action(async (ref) => send(`/schemas/${encodeURIComponent(await resolveSchema(ref))}${operation === 'template' ? '/template' : ''}`));
|
|
322
|
+
schemas
|
|
323
|
+
.command('extend')
|
|
324
|
+
.argument('<ref>')
|
|
325
|
+
.requiredOption('--json <json|->', 'Complete replacement definition: {definition:{parameters,observations,comparisonContext}}; optional backfill:{field:{RUN_ID:value}} for fields a run predates')
|
|
326
|
+
.option('--dry-run', 'Validate and preview affected records without saving')
|
|
327
|
+
.description('Publish a compatible successor and atomically re-pin matching experiments, runs, defaults and charts; old versions and snapshots remain intact')
|
|
328
|
+
.addHelpText('after', '\nbackfill fills in parameter or context fields runs left empty -- added by this extension,\nor there all along and forgotten -- on runs this extension re-pins, finished runs included, e.g.\n {"definition":{...},"backfill":{"packager":{"RUN_ID":"unsloth"}}}\nName a field parameters.<field> or comparisonContext.<field> if the bare name is\nambiguous. Never over a recorded value; values are validated with the extension. Runs note\nthem in research.backfilled with when and by whom, and a backfilled value stays correctable\nwith runs update. For a field the schema already has, runs backfill needs no extension.\nAdding enum values is an ordinary extension.')
|
|
329
|
+
.action(async (ref, options) => {
|
|
330
|
+
const data = input(options.json);
|
|
331
|
+
if (options.dryRun)
|
|
332
|
+
data.dryRun = true;
|
|
333
|
+
return send(`/schemas/${encodeURIComponent(await resolveSchema(ref))}/extend`, 'POST', data);
|
|
334
|
+
});
|
|
335
|
+
schemas
|
|
336
|
+
.command('validate')
|
|
337
|
+
.argument('<ref>')
|
|
338
|
+
.requiredOption('--json <json|->', 'Payload with parameters/observations/comparisonContext')
|
|
339
|
+
.option('--ready', 'Treat required parameters as blocking rather than advisory')
|
|
340
|
+
.description('Check values without saving; exit code 1 with VALIDATION_FAILED when the payload does not satisfy the schema')
|
|
108
341
|
.action(async (ref, options) => {
|
|
109
342
|
const data = input(options.json);
|
|
110
343
|
if (!data || typeof data !== 'object' || Array.isArray(data))
|
|
111
|
-
|
|
344
|
+
fail('Validation input must be a JSON object');
|
|
112
345
|
if (options.ready)
|
|
113
346
|
data.ready = true;
|
|
114
|
-
const result = await request(url(), `/schemas/${encodeURIComponent(await resolveSchema(ref))}/validate`, 'POST', data);
|
|
347
|
+
const result = (await request(url(), `/schemas/${encodeURIComponent(await resolveSchema(ref))}/validate`, 'POST', data, credentials()));
|
|
115
348
|
output(result);
|
|
116
349
|
if (result.valid === false) {
|
|
117
|
-
console.error(JSON.stringify({
|
|
350
|
+
console.error(JSON.stringify({
|
|
351
|
+
code: 'VALIDATION_FAILED',
|
|
352
|
+
message: 'Payload does not satisfy the schema; see stdout for the full report',
|
|
353
|
+
hint: 'Repair stdout issues using their paths, expected values and hints, then rerun validation before writing.',
|
|
354
|
+
}));
|
|
118
355
|
process.exitCode = 1;
|
|
119
356
|
}
|
|
120
357
|
});
|
|
121
|
-
const charts = program
|
|
358
|
+
const charts = program
|
|
359
|
+
.command('charts')
|
|
360
|
+
.description('Create saved typed bar, line and scatter charts for campaigns or experiments');
|
|
122
361
|
for (const operation of ['list', 'fields', 'create']) {
|
|
123
|
-
const command = charts
|
|
362
|
+
const command = charts
|
|
363
|
+
.command(operation)
|
|
364
|
+
.option('--campaign <ref>', 'Campaign stub or ID; defaults to workspace')
|
|
365
|
+
.option('--experiment <ref>', 'Experiment stub or ID within campaign');
|
|
124
366
|
if (operation === 'create')
|
|
125
367
|
command.requiredOption('--json <json|->', 'Object with title, type (bar/line/scatter), schemaVersionId, x and y: {section,field,label?}; - reads stdin');
|
|
126
368
|
if (operation === 'create')
|
|
127
369
|
command.addHelpText('after', '\nOptional aggregation: {groupBy:[{section,field}],metric:"mean"|"stdev",spread:"none"|"whiskers"|"band"}. Groups retain X and context boundaries; sample SD uses n-1 (unavailable for n<2). Spread requires mean; bands require line. No arbitrary expressions.');
|
|
128
|
-
command
|
|
370
|
+
command
|
|
371
|
+
.description(operation === 'fields'
|
|
372
|
+
? 'Discover scoped schema versions, typed fields and supported chart types'
|
|
373
|
+
: operation === 'list'
|
|
374
|
+
? 'List saved chart definitions'
|
|
375
|
+
: 'Save an immutable chart definition; live successful runs, optionally grouped with mean or sample stdev')
|
|
129
376
|
.action(async (options) => {
|
|
130
|
-
const scope =
|
|
377
|
+
const scope = apiScope();
|
|
131
378
|
const campaign = await scope.campaign(options.campaign);
|
|
132
|
-
const target = options.experiment
|
|
379
|
+
const target = options.experiment
|
|
380
|
+
? `/experiments/${encodeURIComponent(await scope.work('experiments', options.experiment, campaign.id))}`
|
|
381
|
+
: path(campaign.id);
|
|
133
382
|
return send(`${target}/charts${operation === 'fields' ? '/fields' : ''}`, operation === 'create' ? 'POST' : 'GET', operation === 'create' ? input(options.json) : undefined);
|
|
134
383
|
});
|
|
135
384
|
}
|
|
136
385
|
for (const operation of ['get', 'data', 'delete'])
|
|
137
|
-
charts
|
|
386
|
+
charts
|
|
387
|
+
.command(operation)
|
|
388
|
+
.argument('<id>', 'Chart ID returned by create/list')
|
|
389
|
+
.description(operation === 'data'
|
|
390
|
+
? 'Live points with source run IDs/revisions, context series, exclusions and timestamp'
|
|
391
|
+
: operation === 'delete'
|
|
392
|
+
? 'Delete a saved chart definition, never its source runs'
|
|
393
|
+
: 'Get a saved chart definition')
|
|
394
|
+
.action((id) => send(`/charts/${encodeURIComponent(id)}${operation === 'data' ? '/data' : ''}`, operation === 'delete' ? 'DELETE' : 'GET'));
|
|
395
|
+
charts
|
|
396
|
+
.command('series')
|
|
397
|
+
.argument('<id>', 'Chart ID returned by create/list')
|
|
398
|
+
.requiredOption('--json <json|->', 'Object with series: {by:"field",section,field}, {by:"groups",groups:[{label,runIds}],otherLabel?}, or null')
|
|
399
|
+
.description("Choose what a raw chart's colours mean: a typed field, groups of runs you define, or null for comparison context")
|
|
400
|
+
.addHelpText('after', '\nBy field colours each point by a parameter or comparison-context value, e.g.\n {"series":{"by":"field","section":"parameters","field":"recipe_family"}}\nBy groups names the split no single field records, e.g. ours against as shipped:\n {"series":{"by":"groups","groups":[{"label":"Ours","runIds":["RUN_ID"]}],"otherLabel":"Shipped"}}\nOnly runs the chart plots, each in one group; the rest join otherLabel. Each point\nstill reports its comparison context. Not for aggregated charts. Also accepted by create.')
|
|
401
|
+
.action((id, options) => send(`/charts/${encodeURIComponent(id)}/series`, 'PUT', input(options.json)));
|
|
402
|
+
charts
|
|
403
|
+
.command('frontier')
|
|
404
|
+
.argument('<id>', 'Chart ID returned by create/list')
|
|
405
|
+
.requiredOption('--json <json|->', 'Object with runIds: the run IDs to join, replacing any earlier choice; [] clears it')
|
|
406
|
+
.description('Choose the runs a scatter chart joins as its frontier; only runs it plots, never computed')
|
|
407
|
+
.addHelpText('after', '\nA frontier is a judgement, not a calculation: pick the runs that are comparable and\nrepresent the trade-off. Scatter charts without aggregation only. The line is drawn\nthrough the chosen runs in X order.')
|
|
408
|
+
.action((id, options) => send(`/charts/${encodeURIComponent(id)}/frontier`, 'PUT', input(options.json)));
|
|
409
|
+
program
|
|
410
|
+
.command('whoami')
|
|
411
|
+
.description('Who this credential is, the organization it acts in, and the permissions it currently holds')
|
|
412
|
+
.addHelpText('after', '\nPermissions are derived per request, so this is what a write would actually be allowed\nto do -- not a profile name or a role. An agent credential also reports itself, since\nnothing else tells it which agent it is or whose work it will be attributed to.')
|
|
413
|
+
.action(() => send('/me'));
|
|
138
414
|
const products = program.command('products').description('Manage products');
|
|
139
415
|
products.command('list').action(() => send('/products'));
|
|
140
|
-
products
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
416
|
+
products
|
|
417
|
+
.command('get')
|
|
418
|
+
.argument('<ref>')
|
|
419
|
+
.description('Get a product by stub or ID')
|
|
420
|
+
.action(async (reference) => send(`/products/${encodeURIComponent((await apiScope().product(reference)).id)}`));
|
|
421
|
+
products
|
|
422
|
+
.command('create')
|
|
423
|
+
.requiredOption('--json <json|->', 'JSON with name; - reads stdin')
|
|
424
|
+
.action((options) => send('/products', 'POST', input(options.json)));
|
|
425
|
+
products
|
|
426
|
+
.command('update')
|
|
427
|
+
.argument('<ref>')
|
|
428
|
+
.requiredOption('--json <json|->', 'Patch with expected revision')
|
|
429
|
+
.action(async (reference, options) => send(`/products/${encodeURIComponent((await apiScope().product(reference)).id)}`, 'PATCH', input(options.json)));
|
|
430
|
+
products
|
|
431
|
+
.command('delete')
|
|
432
|
+
.argument('<ref>')
|
|
433
|
+
.option('--confirm <name>', 'The product name, required when it holds work: its campaigns and everything in them go too')
|
|
434
|
+
.description('Delete a product and everything in it, for good. To keep the work out of the way instead, use archive')
|
|
435
|
+
.action(async (reference, options) => send(`/products/${encodeURIComponent((await apiScope().product(reference)).id)}`, 'DELETE', options.confirm ? { confirm: options.confirm } : undefined));
|
|
144
436
|
for (const kind of ['experiments', 'runs']) {
|
|
145
|
-
const group = program
|
|
146
|
-
|
|
147
|
-
|
|
437
|
+
const group = program
|
|
438
|
+
.command(kind)
|
|
439
|
+
.description(`Manage ${kind}; status updates record state, never launch or stop jobs`);
|
|
440
|
+
const resolveRef = (reference, campaignRef) => apiScope().work(kind, reference, campaignRef);
|
|
441
|
+
const list = group
|
|
442
|
+
.command('list')
|
|
443
|
+
.option('--campaign <ref>', 'Defaults to the local workspace campaign')
|
|
444
|
+
.option('--where <expression>', 'Keep records matching FIELD=VALUE, or != >= <= > <; repeatable and combined with AND', (value, previous) => [...previous, value], [])
|
|
445
|
+
.option('--fields <paths>', 'Comma-separated paths to print instead of whole records, such as parameters.n,observations.accuracy')
|
|
446
|
+
.option('--format <format>', 'json (default) or tsv', 'json')
|
|
447
|
+
.option('--include-superseded', 'Include records whose extras.superseded_by names a replacement')
|
|
448
|
+
.option('--include-abandoned', kind === 'runs' ? 'Include runs of abandoned experiments' : 'Include abandoned experiments');
|
|
449
|
+
if (kind === 'runs')
|
|
450
|
+
list.option('--experiment <ref>', 'Only runs of this experiment, by stub or ID');
|
|
451
|
+
else
|
|
452
|
+
list.option('--include-archived', 'Include archived experiments');
|
|
453
|
+
list
|
|
454
|
+
.description(`List ${kind}; superseded records, abandoned and archived work are excluded unless asked for`)
|
|
455
|
+
.action(async (options) => {
|
|
456
|
+
if (!['json', 'tsv'].includes(options.format))
|
|
457
|
+
fail('--format must be json or tsv');
|
|
458
|
+
const scope = apiScope();
|
|
459
|
+
const campaign = await scope.campaign(options.campaign);
|
|
460
|
+
let rows = await requestAll(url(), `${path(campaign.id)}/${kind}${options.includeArchived ? '?archived=include' : ''}`, credentials());
|
|
461
|
+
if (options.experiment) {
|
|
462
|
+
const experimentId = await scope.work('experiments', options.experiment, campaign.id);
|
|
463
|
+
rows = rows.filter((row) => row['experimentId'] === experimentId);
|
|
464
|
+
}
|
|
465
|
+
if (!options.includeSuperseded)
|
|
466
|
+
rows = rows.filter((row) => resolve(kind, row, 'extras.superseded_by') == null);
|
|
467
|
+
if (!options.includeAbandoned) {
|
|
468
|
+
if (kind === 'experiments')
|
|
469
|
+
rows = rows.filter((row) => row['status'] !== 'abandoned');
|
|
470
|
+
else {
|
|
471
|
+
const abandoned = new Set((await requestAll(url(), `${path(campaign.id)}/experiments`, credentials()))
|
|
472
|
+
.filter((e) => e['status'] === 'abandoned')
|
|
473
|
+
.map((e) => e['id']));
|
|
474
|
+
rows = rows.filter((row) => !abandoned.has(row['experimentId']));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
rows = filterRows(kind, rows, options.where.map(parseCondition));
|
|
478
|
+
const fields = options.fields
|
|
479
|
+
? options.fields
|
|
480
|
+
.split(',')
|
|
481
|
+
.map((field) => field.trim())
|
|
482
|
+
.filter(Boolean)
|
|
483
|
+
: [];
|
|
484
|
+
if (fields.length)
|
|
485
|
+
rows = projectRows(kind, rows, fields);
|
|
486
|
+
return options.format === 'tsv' ? console.log(toTsv(rows, fields)) : output(rows);
|
|
487
|
+
});
|
|
488
|
+
if (kind === 'runs')
|
|
489
|
+
group
|
|
490
|
+
.command('record')
|
|
491
|
+
.argument('<ref>')
|
|
492
|
+
.option('--campaign <ref>', 'Defaults to the local workspace campaign')
|
|
493
|
+
.requiredOption('--json <json|->', 'Object with observations and/or a status; optional errorSummary, extras, extrasMode (replace|merge), comparisonContext, allowIncompleteComparisonContext, artifacts, startedAt, finishedAt, revision')
|
|
494
|
+
.description('Record an outcome in one call: the server reads the current revision, and a result may be recorded directly from planned. Summary observations, not logs: link those with logsUri')
|
|
495
|
+
.action(async (reference, options) => send(`/runs/${encodeURIComponent(await resolveRef(reference, options.campaign))}/record`, 'POST', input(options.json)));
|
|
496
|
+
if (kind === 'runs')
|
|
497
|
+
group
|
|
498
|
+
.command('backfill')
|
|
499
|
+
.argument('<ref>')
|
|
500
|
+
.option('--campaign <ref>', 'Defaults to the local workspace campaign')
|
|
501
|
+
.requiredOption('--json <json|->', 'Object with parameters and/or comparisonContext: the fields to fill in, e.g. {"comparisonContext":{"git_commit":"a1b2c3d"}}')
|
|
502
|
+
.description('Fill in a parameter or comparison-context field a run left empty, finished runs included. Never over a recorded value; noted as added later, with when and by whom. A field the schema lacks is added with schemas extend.')
|
|
503
|
+
.action(async (reference, options) => send(`/runs/${encodeURIComponent(await resolveRef(reference, options.campaign))}/backfill`, 'POST', input(options.json)));
|
|
148
504
|
for (const operation of ['get', 'context'])
|
|
149
|
-
group
|
|
150
|
-
|
|
505
|
+
group
|
|
506
|
+
.command(operation)
|
|
507
|
+
.argument('<ref>')
|
|
508
|
+
.option('--campaign <ref>', 'Defaults to the local workspace campaign')
|
|
509
|
+
.description('Record by stub or ID with live parent context' +
|
|
510
|
+
(kind === 'runs' ? ' and creation-time snapshot' : ' and child runs'))
|
|
511
|
+
.action(async (reference, options) => send(`/${kind}/${encodeURIComponent(await resolveRef(reference, options.campaign))}`));
|
|
512
|
+
const create = group
|
|
513
|
+
.command('create')
|
|
514
|
+
.option('--campaign <ref>', 'Campaign stub or ID; defaults to the local workspace campaign');
|
|
151
515
|
if (kind === 'runs')
|
|
152
516
|
create.option('--experiment <ref>', 'Experiment stub or ID in campaign scope; cannot combine with JSON experimentId');
|
|
153
|
-
create
|
|
517
|
+
create
|
|
518
|
+
.description(kind === 'runs'
|
|
519
|
+
? 'Create a run record, optionally already terminal with its results; this does not execute a job. Record parameters, comparison context and summary numbers; link logs and raw outputs with logsUri or artifacts (run data is limited by plan: 64 KB on Free)'
|
|
520
|
+
: 'Create an experiment with a hypothesis and optional schema version')
|
|
521
|
+
.requiredOption('--json <json|->', kind === 'runs'
|
|
522
|
+
? 'Object requiring title; optional stub, status, config, observations, comparisonContext, extras, startedAt, finishedAt, errorSummary; - reads stdin'
|
|
523
|
+
: 'Object requiring name and hypothesis; optional parameters, schemaVersionId, varying, comparisonContext, extras; - reads stdin')
|
|
154
524
|
.action(async (options) => {
|
|
155
525
|
const payload = input(options.json);
|
|
156
526
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
157
|
-
|
|
158
|
-
const campaign = await
|
|
527
|
+
fail('JSON input must be an object');
|
|
528
|
+
const campaign = await apiScope().campaign(options.campaign);
|
|
159
529
|
if (options.experiment) {
|
|
160
530
|
if (kind !== 'runs')
|
|
161
|
-
|
|
531
|
+
fail('--experiment is only supported for runs');
|
|
162
532
|
if ('experimentId' in payload)
|
|
163
|
-
|
|
164
|
-
payload.experimentId = await
|
|
533
|
+
fail('Provide the experiment via --experiment or experimentId, not both');
|
|
534
|
+
payload.experimentId = await apiScope().work('experiments', options.experiment, campaign.id);
|
|
165
535
|
}
|
|
166
536
|
return send(`${path(campaign.id)}/${kind}`, 'POST', payload);
|
|
167
537
|
});
|
|
168
|
-
group
|
|
538
|
+
group
|
|
539
|
+
.command('update')
|
|
540
|
+
.argument('<ref>')
|
|
541
|
+
.option('--campaign <ref>', 'Defaults to the local workspace campaign')
|
|
542
|
+
.requiredOption('--json <json|->', 'Patch with expected revision')
|
|
169
543
|
.action(async (reference, options) => send(`/${kind}/${encodeURIComponent(await resolveRef(reference, options.campaign))}`, 'PATCH', input(options.json)));
|
|
170
|
-
group
|
|
544
|
+
group
|
|
545
|
+
.command('delete')
|
|
546
|
+
.argument('<ref>')
|
|
547
|
+
.option('--campaign <ref>', 'Defaults to the local workspace campaign')
|
|
548
|
+
.requiredOption('--revision <number>', kind === 'runs'
|
|
549
|
+
? 'Expected revision; only a planned run can be deleted, a run that started is kept'
|
|
550
|
+
: 'Expected revision')
|
|
551
|
+
.option('--confirm <name>', kind === 'experiments'
|
|
552
|
+
? 'The experiment name, required when it holds runs: they are deleted too'
|
|
553
|
+
: 'Not used for runs')
|
|
171
554
|
.action(async (reference, options) => {
|
|
172
555
|
const revision = Number(options.revision);
|
|
173
556
|
if (!Number.isSafeInteger(revision) || revision < 1)
|
|
174
557
|
throw new Error('Revision must be a positive integer');
|
|
175
|
-
return send(`/${kind}/${encodeURIComponent(await resolveRef(reference, options.campaign))}`, 'DELETE', {
|
|
558
|
+
return send(`/${kind}/${encodeURIComponent(await resolveRef(reference, options.campaign))}`, 'DELETE', {
|
|
559
|
+
revision,
|
|
560
|
+
...(options.confirm && kind === 'experiments' ? { confirm: options.confirm } : {}),
|
|
561
|
+
});
|
|
176
562
|
});
|
|
563
|
+
if (kind === 'experiments')
|
|
564
|
+
for (const action of ['archive', 'restore'])
|
|
565
|
+
group
|
|
566
|
+
.command(action)
|
|
567
|
+
.argument('<ref>')
|
|
568
|
+
.option('--campaign <ref>', 'Defaults to the local workspace campaign')
|
|
569
|
+
.description(action === 'archive'
|
|
570
|
+
? 'File an experiment away: out of lists and closed to new runs; nothing is lost'
|
|
571
|
+
: 'Bring an archived experiment back')
|
|
572
|
+
.action(async (reference, options) => send(`/experiments/${encodeURIComponent(await resolveRef(reference, options.campaign))}/${action}`, 'POST'));
|
|
573
|
+
}
|
|
574
|
+
for (const action of ['archive', 'restore']) {
|
|
575
|
+
campaigns
|
|
576
|
+
.command(action)
|
|
577
|
+
.argument('<ref>')
|
|
578
|
+
.description(action === 'archive'
|
|
579
|
+
? 'File a campaign away: out of lists and closed to new work; nothing is lost'
|
|
580
|
+
: 'Bring an archived campaign back')
|
|
581
|
+
.action(async (reference) => send(`${path((await apiScope().campaign(reference)).id)}/${action}`, 'POST'));
|
|
582
|
+
products
|
|
583
|
+
.command(action)
|
|
584
|
+
.argument('<ref>')
|
|
585
|
+
.description(action === 'archive'
|
|
586
|
+
? 'File a product away: out of lists and closed to new campaigns; nothing is lost'
|
|
587
|
+
: 'Bring an archived product back')
|
|
588
|
+
.action(async (reference) => send(`/products/${encodeURIComponent((await apiScope().product(reference)).id)}/${action}`, 'POST'));
|
|
177
589
|
}
|
|
178
|
-
program.addHelpText('after', '\nExamples:\n fieldwork setup campaign --product model-a --campaign memory-study\n fieldwork runs create --experiment awq --json \'{"title":"Attempt 1"}\'\n fieldwork schemas validate VERSION_ID --ready --json -\n\nData commands output JSON. Validation reports go to stdout; invalid reports exit 1.\nUse fieldwork <group> <command> --help for payload and scope guidance.');
|
|
590
|
+
program.addHelpText('after', '\nExamples:\n fieldwork setup campaign --product model-a --campaign memory-study\n fieldwork runs create --experiment awq --json \'{"title":"Attempt 1"}\'\n fieldwork schemas validate VERSION_ID --ready --json -\n\nData commands output JSON. Validation reports go to stdout; invalid reports exit 1.\nUse fieldwork <group> <command> --help for payload and scope guidance.\n\nFieldwork is a ledger of experiments, not a log store: record parameters, comparison\ncontext and summary numbers, and link logs and raw outputs with logsUri or artifacts.\nA refusal over a limit carries a hint saying what to record instead.');
|
|
179
591
|
for (const group of program.commands) {
|
|
180
592
|
for (const command of group.commands) {
|
|
181
593
|
if (!command.description())
|
|
182
594
|
command.description(`${command.name()} ${group.name()} records`);
|
|
183
|
-
command.addHelpText('after',
|
|
184
|
-
if (['experiments', 'runs'].includes(group.name()) &&
|
|
185
|
-
|
|
595
|
+
command.addHelpText('after', `\nServer: --url overrides FIELDWORK_URL, LAB_URL, workspace config, then ${DEFAULT_SERVER}.\nA local server needs --url http://127.0.0.1:4310 or FIELDWORK_URL.\nUse --json - for stdin where supported. IDs and stubs are accepted for records;\nschemas require immutable version IDs. Commands never launch or stop jobs.`);
|
|
596
|
+
if (['experiments', 'runs'].includes(group.name()) &&
|
|
597
|
+
['create', 'update'].includes(command.name()))
|
|
598
|
+
command.addHelpText('after', '\ncomparisonContext, extras, and ' +
|
|
599
|
+
(group.name() === 'runs' ? 'config and observations' : 'parameters') +
|
|
600
|
+
' must be JSON objects, not strings.');
|
|
186
601
|
if (group.name() === 'experiments' && ['create', 'update'].includes(command.name()))
|
|
187
602
|
command.addHelpText('after', '\nExperiment text limits: name 120 characters; hypothesis, objective, method,\nand conclusion (update only) 4000 characters each. Keep longer procedures\nin a referenced source file; include the exact extraction command in method.');
|
|
188
603
|
if (command.name() === 'update')
|
|
189
604
|
command.addHelpText('after', '\nUpdates require the current revision in JSON. Re-read on a conflict; do not\nblindly increment it. Config/parameter/observation objects replace the whole field.');
|
|
190
605
|
}
|
|
191
606
|
}
|
|
192
|
-
campaigns.commands
|
|
193
|
-
|
|
194
|
-
|
|
607
|
+
campaigns.commands
|
|
608
|
+
.find((c) => c.name() === 'create')
|
|
609
|
+
.addHelpText('after', '\nExample: fieldwork campaigns create --product model-a --json \'{"name":"Memory study","goal":"Fit in 24 GiB"}\'\nOmit --product and JSON productId for an independent campaign.');
|
|
610
|
+
schemas.commands
|
|
611
|
+
.find((c) => c.name() === 'publish')
|
|
612
|
+
.addHelpText('after', '\nExample definition:\n {"stub":"study","version":1,"definition":{"parameters":{"bits":{"type":"integer","values":[4,8],"required":true}}}}\nField names use snake_case. Sections: parameters, observations, comparisonContext.\nTypes: number, integer, string, boolean, enum. Published versions cannot be changed.');
|
|
613
|
+
schemas.commands
|
|
614
|
+
.find((c) => c.name() === 'validate')
|
|
615
|
+
.addHelpText('after', '\nReads parameters (not run config), observations and comparisonContext.\nNo records are saved. Invalid reports: stdout JSON + stderr VALIDATION_FAILED, exit 1.\nValid reports, including missing-observation warnings: exit 0. --ready checks\nrequired execution fields, but does not start a run or check experiment varying fields.');
|
|
195
616
|
try {
|
|
196
617
|
await program.parseAsync();
|
|
197
618
|
}
|
|
198
619
|
catch (error) {
|
|
199
620
|
const failure = error;
|
|
200
621
|
if (failure.exitCode !== 0) {
|
|
201
|
-
console.error(JSON.stringify({
|
|
622
|
+
console.error(JSON.stringify({
|
|
623
|
+
code: failure.code ?? 'CLIENT_ERROR',
|
|
624
|
+
message: failure.message ?? 'Request failed',
|
|
625
|
+
hint: failure.hint ??
|
|
626
|
+
(failure.code?.startsWith('commander.')
|
|
627
|
+
? 'Run fieldwork --help or fieldwork <group> <command> --help for supported commands, arguments and required options.'
|
|
628
|
+
: failure.code === 'REVISION_CONFLICT'
|
|
629
|
+
? 'Get the latest record and reconcile your patch using its current revision; do not blindly retry.'
|
|
630
|
+
: failure.message === 'fetch failed'
|
|
631
|
+
? 'Check the server is running and verify --url, FIELDWORK_URL and workspace configuration; inspect state before retrying a write.'
|
|
632
|
+
: 'Inspect any details.issues for field paths and fixes; use the command’s --help and fieldwork context to verify input and scope.'),
|
|
633
|
+
...(failure.status ? { status: failure.status } : {}),
|
|
634
|
+
...(failure.requestId ? { requestId: failure.requestId } : {}),
|
|
635
|
+
...(failure.details !== undefined ? { details: failure.details } : {}),
|
|
636
|
+
...(failure.update !== undefined ? { update: failure.update } : {}),
|
|
637
|
+
}));
|
|
202
638
|
process.exitCode = 1;
|
|
203
639
|
}
|
|
204
640
|
}
|