@myvillage/cli 1.73.0 → 1.75.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/package.json +1 -1
- package/src/commands/agent-credentials.js +158 -0
- package/src/commands/dataset.js +51 -0
- package/src/index.js +46 -0
- package/src/utils/api.js +28 -0
- package/src/utils/dataset-workspace.js +87 -0
package/package.json
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { villageSpinner, brand } from '../utils/brand.js';
|
|
3
|
+
import { isAuthenticated } from '../utils/auth.js';
|
|
4
|
+
import { agentExists, readAgentConfig } from '../utils/local-agent.js';
|
|
5
|
+
import {
|
|
6
|
+
listAgentCredentialGrants,
|
|
7
|
+
grantAgentCredential,
|
|
8
|
+
revokeAgentCredential,
|
|
9
|
+
} from '../utils/api.js';
|
|
10
|
+
|
|
11
|
+
// ── Agent OAuth credential grants ───────────────────────
|
|
12
|
+
//
|
|
13
|
+
// `myvillage agent grant/revoke/grants <name> [provider]` — authorize a local
|
|
14
|
+
// agent to use the villager's connected providers (Google, Microsoft, Zoom)
|
|
15
|
+
// when it runs through the MyVillage MCP server. The platform vends
|
|
16
|
+
// short-lived tokens to the MCP server; raw OAuth tokens never touch the CLI.
|
|
17
|
+
//
|
|
18
|
+
// The villager connects the provider once in MyVillage Studio or at
|
|
19
|
+
// myvillageproject.ai. These commands only manage the per-agent consent.
|
|
20
|
+
|
|
21
|
+
const VALID_PROVIDERS = ['google', 'microsoft', 'zoom'];
|
|
22
|
+
|
|
23
|
+
// Human-facing labels keyed by the lowercase slug the CLI accepts.
|
|
24
|
+
const PROVIDER_LABELS = {
|
|
25
|
+
google: 'Google (Gmail, Calendar, Drive)',
|
|
26
|
+
microsoft: 'Microsoft 365 (Outlook mail, Calendar)',
|
|
27
|
+
zoom: 'Zoom',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function fail(message) {
|
|
31
|
+
console.log(chalk.red(` ✗ ${message}`));
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function normalizeProvider(provider) {
|
|
36
|
+
return (provider || '').trim().toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Resolve the AgentProfile id (man.agent_id) for a local agent, with friendly
|
|
40
|
+
// errors when the agent is unknown or hasn't been registered yet.
|
|
41
|
+
function resolveAgentId(name) {
|
|
42
|
+
if (!agentExists(name)) {
|
|
43
|
+
return { error: `Agent "${name}" not found. Run \`myvillage agent\` to see your agents.` };
|
|
44
|
+
}
|
|
45
|
+
const config = readAgentConfig(name);
|
|
46
|
+
const agentId = config?.man?.agent_id;
|
|
47
|
+
if (!agentId) {
|
|
48
|
+
return {
|
|
49
|
+
error:
|
|
50
|
+
`Agent "${name}" hasn't been registered with the network yet. ` +
|
|
51
|
+
`Run \`myvillage agent start ${name}\` once so it gets an agent id, then retry.`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { agentId };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Map a platform error response to a villager-readable, actionable message.
|
|
58
|
+
function describeGrantError(err, provider) {
|
|
59
|
+
const data = err.response?.data;
|
|
60
|
+
const code = data?.code;
|
|
61
|
+
const label = PROVIDER_LABELS[provider] || provider;
|
|
62
|
+
switch (code) {
|
|
63
|
+
case 'CREDENTIAL_NOT_CONNECTED':
|
|
64
|
+
return `${label} isn't connected for your account. Connect it in MyVillage Studio or at myvillageproject.ai, then retry.`;
|
|
65
|
+
case 'GRANT_NOT_FOUND':
|
|
66
|
+
return `No active ${label} grant for this agent.`;
|
|
67
|
+
case 'FORBIDDEN_NOT_OWNER':
|
|
68
|
+
return `You don't own this agent, so you can't change its credential grants.`;
|
|
69
|
+
case 'AGENT_NOT_FOUND':
|
|
70
|
+
return `The platform has no record of this agent. Run \`myvillage agent start\` to register it.`;
|
|
71
|
+
default:
|
|
72
|
+
return data?.error || err.message;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── grant ───────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export async function agentGrantCommand(name, provider) {
|
|
79
|
+
if (!isAuthenticated()) return fail('Authentication required. Run `myvillage login` first.');
|
|
80
|
+
|
|
81
|
+
const slug = normalizeProvider(provider);
|
|
82
|
+
if (!VALID_PROVIDERS.includes(slug)) {
|
|
83
|
+
return fail(`Invalid provider "${provider}". Must be one of: ${VALID_PROVIDERS.join(', ')}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { agentId, error } = resolveAgentId(name);
|
|
87
|
+
if (error) return fail(error);
|
|
88
|
+
|
|
89
|
+
const label = PROVIDER_LABELS[slug];
|
|
90
|
+
const spinner = villageSpinner(`Granting ${label} to "${name}"...`).start();
|
|
91
|
+
try {
|
|
92
|
+
await grantAgentCredential(agentId, slug.toUpperCase());
|
|
93
|
+
spinner.succeed(`"${name}" can now use your ${label}.`);
|
|
94
|
+
console.log(brand.teal(`\n The agent reads credentials through the MyVillage MCP server — no tokens are stored locally.`));
|
|
95
|
+
console.log(brand.teal(` Revoke any time: ${chalk.bold(`myvillage agent revoke ${name} ${slug}`)}\n`));
|
|
96
|
+
} catch (err) {
|
|
97
|
+
spinner.fail(`Failed to grant ${label}: ${describeGrantError(err, slug)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── revoke ──────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
export async function agentRevokeCredentialCommand(name, provider) {
|
|
104
|
+
if (!isAuthenticated()) return fail('Authentication required. Run `myvillage login` first.');
|
|
105
|
+
|
|
106
|
+
const slug = normalizeProvider(provider);
|
|
107
|
+
if (!VALID_PROVIDERS.includes(slug)) {
|
|
108
|
+
return fail(`Invalid provider "${provider}". Must be one of: ${VALID_PROVIDERS.join(', ')}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { agentId, error } = resolveAgentId(name);
|
|
112
|
+
if (error) return fail(error);
|
|
113
|
+
|
|
114
|
+
const label = PROVIDER_LABELS[slug];
|
|
115
|
+
const spinner = villageSpinner(`Revoking ${label} from "${name}"...`).start();
|
|
116
|
+
try {
|
|
117
|
+
await revokeAgentCredential(agentId, slug.toUpperCase());
|
|
118
|
+
spinner.succeed(`Revoked ${label} from "${name}". (Propagates to running agents within ~60s.)`);
|
|
119
|
+
console.log('');
|
|
120
|
+
} catch (err) {
|
|
121
|
+
spinner.fail(`Failed to revoke ${label}: ${describeGrantError(err, slug)}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── grants (list) ───────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export async function agentGrantsListCommand(name) {
|
|
128
|
+
if (!isAuthenticated()) return fail('Authentication required. Run `myvillage login` first.');
|
|
129
|
+
|
|
130
|
+
const { agentId, error } = resolveAgentId(name);
|
|
131
|
+
if (error) return fail(error);
|
|
132
|
+
|
|
133
|
+
const spinner = villageSpinner(`Loading credential grants for "${name}"...`).start();
|
|
134
|
+
try {
|
|
135
|
+
const result = await listAgentCredentialGrants(agentId);
|
|
136
|
+
const grants = result?.data || result || [];
|
|
137
|
+
spinner.stop();
|
|
138
|
+
|
|
139
|
+
if (!Array.isArray(grants) || grants.length === 0) {
|
|
140
|
+
console.log(brand.teal(`\n "${name}" has no active credential grants.`));
|
|
141
|
+
console.log(brand.teal(` Authorize one: ${chalk.bold(`myvillage agent grant ${name} <google|microsoft|zoom>`)}\n`));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log('');
|
|
146
|
+
console.log(chalk.bold(` Credential grants for "${name}"`));
|
|
147
|
+
console.log('');
|
|
148
|
+
for (const g of grants) {
|
|
149
|
+
const slug = String(g.provider || '').toLowerCase();
|
|
150
|
+
const label = PROVIDER_LABELS[slug] || g.provider;
|
|
151
|
+
const when = g.grantedAt ? ` ${chalk.dim(`(granted ${new Date(g.grantedAt).toLocaleDateString()})`)}` : '';
|
|
152
|
+
console.log(` ${brand.green('●')} ${label}${when}`);
|
|
153
|
+
}
|
|
154
|
+
console.log('');
|
|
155
|
+
} catch (err) {
|
|
156
|
+
spinner.fail(`Failed to load grants: ${err.response?.data?.error || err.message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/commands/dataset.js
CHANGED
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
ingestAsset,
|
|
20
20
|
readRows,
|
|
21
21
|
freezeVersion,
|
|
22
|
+
deleteRow,
|
|
23
|
+
updateRow,
|
|
22
24
|
} from '../utils/dataset-workspace.js';
|
|
23
25
|
|
|
24
26
|
// ── create ─────────────────────────────────────────────
|
|
@@ -176,6 +178,55 @@ export async function datasetFreezeCommand(slug, options) {
|
|
|
176
178
|
}
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
// ── delete a row ───────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
export async function datasetRowDeleteCommand(options) {
|
|
184
|
+
try {
|
|
185
|
+
const slug = options.dataset;
|
|
186
|
+
if (!slug) throw new Error('--dataset <slug> is required.');
|
|
187
|
+
if (!options.id) throw new Error('--id <rowId> is required.');
|
|
188
|
+
const result = deleteRow(slug, options.id);
|
|
189
|
+
if (options.json) {
|
|
190
|
+
console.log(JSON.stringify(result, null, 2));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
success(`Deleted row ${options.id} from ${slug}`);
|
|
194
|
+
info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val})`);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
error(e.message);
|
|
197
|
+
process.exitCode = 1;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── update a row ───────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export async function datasetRowUpdateCommand(options) {
|
|
204
|
+
try {
|
|
205
|
+
const slug = options.dataset;
|
|
206
|
+
if (!slug) throw new Error('--dataset <slug> is required.');
|
|
207
|
+
if (!options.id) throw new Error('--id <rowId> is required.');
|
|
208
|
+
const updates = {};
|
|
209
|
+
if (options.features !== undefined) updates.features = parseFeatures(options.features);
|
|
210
|
+
else if (options.text !== undefined) updates.features = options.text;
|
|
211
|
+
if (options.label !== undefined) updates.label = options.label;
|
|
212
|
+
if (options.target !== undefined) updates.target = Number(options.target);
|
|
213
|
+
if (options.split !== undefined) updates.split = options.split;
|
|
214
|
+
if (Object.keys(updates).length === 0) {
|
|
215
|
+
throw new Error('Nothing to update. Pass --text/--features, --label, --target, or --split.');
|
|
216
|
+
}
|
|
217
|
+
const result = updateRow(slug, options.id, updates);
|
|
218
|
+
if (options.json) {
|
|
219
|
+
console.log(JSON.stringify(result, null, 2));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
success(`Updated row ${options.id} in ${slug}`);
|
|
223
|
+
info(` total: ${result.stats.rowCount} (train ${result.stats.bySplit.train} / val ${result.stats.bySplit.val})`);
|
|
224
|
+
} catch (e) {
|
|
225
|
+
error(e.message);
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
179
230
|
// ── helpers ────────────────────────────────────────────
|
|
180
231
|
|
|
181
232
|
function readRowsFromFile(path) {
|
package/src/index.js
CHANGED
|
@@ -84,6 +84,11 @@ import {
|
|
|
84
84
|
agentListMcpCommand,
|
|
85
85
|
recipesListCommand,
|
|
86
86
|
} from './commands/agent-mcp.js';
|
|
87
|
+
import {
|
|
88
|
+
agentGrantCommand,
|
|
89
|
+
agentRevokeCredentialCommand,
|
|
90
|
+
agentGrantsListCommand,
|
|
91
|
+
} from './commands/agent-credentials.js';
|
|
87
92
|
import {
|
|
88
93
|
gameUpdateCommand,
|
|
89
94
|
gameUploadThumbnailCommand,
|
|
@@ -124,6 +129,8 @@ import {
|
|
|
124
129
|
datasetAppendCommand,
|
|
125
130
|
datasetShowCommand,
|
|
126
131
|
datasetFreezeCommand,
|
|
132
|
+
datasetRowDeleteCommand,
|
|
133
|
+
datasetRowUpdateCommand,
|
|
127
134
|
} from './commands/dataset.js';
|
|
128
135
|
import {
|
|
129
136
|
modelTrainCommand,
|
|
@@ -551,6 +558,24 @@ export function run() {
|
|
|
551
558
|
.description('List the MCP servers currently configured for an agent')
|
|
552
559
|
.action(agentListMcpCommand);
|
|
553
560
|
|
|
561
|
+
// Per-agent OAuth credential grants (Google/Microsoft/Zoom). The villager
|
|
562
|
+
// connects the provider once in the app/portal; these authorize a specific
|
|
563
|
+
// agent to use it via the MyVillage MCP server. Tokens never touch the CLI.
|
|
564
|
+
agentCmd
|
|
565
|
+
.command('grant <name> <provider>')
|
|
566
|
+
.description('Authorize an agent to use a connected provider (google, microsoft, zoom)')
|
|
567
|
+
.action(agentGrantCommand);
|
|
568
|
+
|
|
569
|
+
agentCmd
|
|
570
|
+
.command('revoke <name> <provider>')
|
|
571
|
+
.description('Revoke an agent\'s access to a provider (google, microsoft, zoom)')
|
|
572
|
+
.action(agentRevokeCredentialCommand);
|
|
573
|
+
|
|
574
|
+
agentCmd
|
|
575
|
+
.command('grants <name>')
|
|
576
|
+
.description('List an agent\'s active OAuth credential grants')
|
|
577
|
+
.action(agentGrantsListCommand);
|
|
578
|
+
|
|
554
579
|
program
|
|
555
580
|
.command('recipes')
|
|
556
581
|
.description('List the MCP recipes bundled with the CLI')
|
|
@@ -900,6 +925,27 @@ export function run() {
|
|
|
900
925
|
.option('--json', 'Output raw JSON')
|
|
901
926
|
.action(datasetFreezeCommand);
|
|
902
927
|
|
|
928
|
+
datasetCmd
|
|
929
|
+
.command('rm-row')
|
|
930
|
+
.description('Delete a row from the working set (by id)')
|
|
931
|
+
.requiredOption('--dataset <slug>', 'Target dataset slug')
|
|
932
|
+
.requiredOption('--id <rowId>', 'Row id to delete')
|
|
933
|
+
.option('--json', 'Output raw JSON')
|
|
934
|
+
.action(datasetRowDeleteCommand);
|
|
935
|
+
|
|
936
|
+
datasetCmd
|
|
937
|
+
.command('update-row')
|
|
938
|
+
.description('Update a working-set row (by id)')
|
|
939
|
+
.requiredOption('--dataset <slug>', 'Target dataset slug')
|
|
940
|
+
.requiredOption('--id <rowId>', 'Row id to update')
|
|
941
|
+
.option('--text <text>', 'New text features')
|
|
942
|
+
.option('--features <value>', 'New features (string, or numeric vector for tabular)')
|
|
943
|
+
.option('--label <label>', 'New class label (classification)')
|
|
944
|
+
.option('--target <number>', 'New numeric target (regression)')
|
|
945
|
+
.option('--split <split>', 'train | val')
|
|
946
|
+
.option('--json', 'Output raw JSON')
|
|
947
|
+
.action(datasetRowUpdateCommand);
|
|
948
|
+
|
|
903
949
|
// ── Studio Lab: model training (local, TF.js CPU engine) ───────────────
|
|
904
950
|
const modelCmd = program
|
|
905
951
|
.command('model')
|
package/src/utils/api.js
CHANGED
|
@@ -777,3 +777,31 @@ export async function shareKnowledgeAsAgent(data) {
|
|
|
777
777
|
const response = await client.post('/agent-tools/share-knowledge', data);
|
|
778
778
|
return response.data;
|
|
779
779
|
}
|
|
780
|
+
|
|
781
|
+
// ── Agent OAuth credential grants ───────────────────────
|
|
782
|
+
//
|
|
783
|
+
// Per-agent authorization to use the villager's connected providers
|
|
784
|
+
// (GOOGLE / MICROSOFT / ZOOM) when the agent runs through the MyVillage MCP
|
|
785
|
+
// server. The platform vends short-lived tokens to the MCP server — the raw
|
|
786
|
+
// OAuth tokens never reach the CLI. `agentId` is the AgentProfile id, i.e.
|
|
787
|
+
// the local agent's `man.agent_id` (populated on first `agent start`).
|
|
788
|
+
//
|
|
789
|
+
// Endpoints live under platformBaseUrl (/api), not /api/v1.
|
|
790
|
+
|
|
791
|
+
export async function listAgentCredentialGrants(agentId) {
|
|
792
|
+
const client = getPlatformClient();
|
|
793
|
+
const response = await client.get(`/agents/${agentId}/credential-grants`);
|
|
794
|
+
return response.data;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
export async function grantAgentCredential(agentId, provider) {
|
|
798
|
+
const client = getPlatformClient();
|
|
799
|
+
const response = await client.post(`/agents/${agentId}/credential-grants`, { provider });
|
|
800
|
+
return response.data;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export async function revokeAgentCredential(agentId, provider) {
|
|
804
|
+
const client = getPlatformClient();
|
|
805
|
+
const response = await client.delete(`/agents/${agentId}/credential-grants/${provider}`);
|
|
806
|
+
return response.data;
|
|
807
|
+
}
|
|
@@ -308,6 +308,93 @@ export function readRows(slug, { version, split, limit } = {}) {
|
|
|
308
308
|
return out;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
+
// ── Edit / delete working-set rows ─────────────────────
|
|
312
|
+
//
|
|
313
|
+
// Only the working set (current.jsonl) is mutable; frozen versions (v*.jsonl)
|
|
314
|
+
// are immutable snapshots and are never touched here. Both ops rewrite the
|
|
315
|
+
// whole working-set file and recompute manifest stats from the result, so the
|
|
316
|
+
// manifest can never drift from the rows on disk.
|
|
317
|
+
|
|
318
|
+
/** Recompute manifest stats (rowCount / bySplit / byLabel) from a row array. */
|
|
319
|
+
function recomputeStats(rows) {
|
|
320
|
+
const stats = emptyStats();
|
|
321
|
+
for (const row of rows) {
|
|
322
|
+
stats.rowCount += 1;
|
|
323
|
+
if (stats.bySplit[row.split] != null) stats.bySplit[row.split] += 1;
|
|
324
|
+
if (row.label != null) {
|
|
325
|
+
stats.byLabel[row.label] = (stats.byLabel[row.label] || 0) + 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return stats;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Overwrite the working set with `rows` and refresh the manifest's stats. */
|
|
332
|
+
function writeWorkingSet(slug, manifest, rows) {
|
|
333
|
+
const body = rows.map((r) => JSON.stringify(r)).join('\n');
|
|
334
|
+
writeFileSync(getCurrentDataFile(slug), rows.length ? body + '\n' : '');
|
|
335
|
+
manifest.stats = recomputeStats(rows);
|
|
336
|
+
return writeManifest(slug, manifest);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Delete one working-set row by id. Throws if the id isn't in current.jsonl
|
|
341
|
+
* (e.g. it only exists in a frozen version, which can't be edited).
|
|
342
|
+
* @returns {{datasetId:string, deleted:string, stats:object}}
|
|
343
|
+
*/
|
|
344
|
+
export function deleteRow(slug, rowId) {
|
|
345
|
+
const manifest = readManifest(slug);
|
|
346
|
+
if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
|
|
347
|
+
if (!rowId) throw new Error('A row id is required.');
|
|
348
|
+
const rows = readRows(slug);
|
|
349
|
+
const idx = rows.findIndex((r) => r.id === rowId);
|
|
350
|
+
if (idx === -1) {
|
|
351
|
+
throw new Error(`Row "${rowId}" not found in the working set of "${slug}".`);
|
|
352
|
+
}
|
|
353
|
+
const [removed] = rows.splice(idx, 1);
|
|
354
|
+
writeWorkingSet(slug, manifest, rows);
|
|
355
|
+
return { datasetId: slug, deleted: removed.id, stats: manifest.stats };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Update one working-set row by id. `updates` is a partial
|
|
360
|
+
* {features, label, target, split}; the merged row is re-validated against the
|
|
361
|
+
* dataset's modality/task (same rules as append). The row id is preserved and
|
|
362
|
+
* provenance.editedAt is stamped. Image/audio asset refs can't be edited here —
|
|
363
|
+
* delete the row and re-add the asset instead.
|
|
364
|
+
* @returns {{datasetId:string, updated:string, stats:object}}
|
|
365
|
+
*/
|
|
366
|
+
export function updateRow(slug, rowId, updates = {}) {
|
|
367
|
+
const manifest = readManifest(slug);
|
|
368
|
+
if (!manifest) throw new Error(`Dataset "${slug}" not found.`);
|
|
369
|
+
if (!rowId) throw new Error('A row id is required.');
|
|
370
|
+
const rows = readRows(slug);
|
|
371
|
+
const idx = rows.findIndex((r) => r.id === rowId);
|
|
372
|
+
if (idx === -1) {
|
|
373
|
+
throw new Error(`Row "${rowId}" not found in the working set of "${slug}".`);
|
|
374
|
+
}
|
|
375
|
+
const existing = rows[idx];
|
|
376
|
+
if ((manifest.modality === 'image' || manifest.modality === 'audio') && updates.features !== undefined) {
|
|
377
|
+
throw new Error(`Can't edit the asset of an ${manifest.modality} row — delete it and re-add.`);
|
|
378
|
+
}
|
|
379
|
+
const merged = normalizeRow(
|
|
380
|
+
{
|
|
381
|
+
features: updates.features !== undefined ? updates.features : existing.features,
|
|
382
|
+
split: updates.split !== undefined ? updates.split : existing.split,
|
|
383
|
+
label: updates.label !== undefined ? updates.label : existing.label,
|
|
384
|
+
target: updates.target !== undefined ? updates.target : existing.target,
|
|
385
|
+
},
|
|
386
|
+
manifest,
|
|
387
|
+
);
|
|
388
|
+
merged.id = existing.id;
|
|
389
|
+
merged.provenance = { ...(existing.provenance || {}), editedAt: nowISO() };
|
|
390
|
+
if (merged.label != null && !manifest.labels.includes(merged.label)) {
|
|
391
|
+
manifest.labels.push(merged.label);
|
|
392
|
+
}
|
|
393
|
+
rows[idx] = merged;
|
|
394
|
+
writeWorkingSet(slug, manifest, rows);
|
|
395
|
+
return { datasetId: slug, updated: merged.id, stats: manifest.stats };
|
|
396
|
+
}
|
|
397
|
+
|
|
311
398
|
// ── Freeze a version ───────────────────────────────────
|
|
312
399
|
|
|
313
400
|
/**
|