recess-cli 1.9.2 → 2.0.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 +82 -55
- package/dist/api.js +42 -10
- package/dist/args.js +12 -1
- package/dist/auth.js +11 -10
- package/dist/cli.js +1190 -245
- package/dist/command-schema.js +257 -0
- package/dist/config.js +116 -19
- package/dist/delivery.js +26 -0
- package/dist/feedback.js +64 -0
- package/dist/http.js +25 -0
- package/dist/index.js +15 -3
- package/dist/jobs.js +80 -0
- package/dist/skill-update.js +2 -1
- package/dist/ui/index.js +2 -1
- package/package.json +1 -1
- package/skill/recess-cli/SKILL.md +18 -8
- package/skill/recess-cli/agents/version.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -1,21 +1,38 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
|
-
import { RecessAdminApi, unwrap } from "./api.js";
|
|
5
|
+
import { RecessAdminApi, unwrap, withIdempotencyContext } from "./api.js";
|
|
5
6
|
import { flagList, flagNumber, flagString, hasFlag, parseArgs, } from "./args.js";
|
|
6
7
|
import { login, pollDeviceAuth, requestDeviceAuth } from "./auth.js";
|
|
7
|
-
import { clearStoredSession, resolveConfig, } from "./config.js";
|
|
8
|
+
import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, saveProfile, useProfile, } from "./config.js";
|
|
9
|
+
import { agentContext, buildCommandSchema, scopedHelp, validateInvocation, } from "./command-schema.js";
|
|
8
10
|
import { CliError } from "./errors.js";
|
|
11
|
+
import { listFeedback, submitFeedback } from "./feedback.js";
|
|
12
|
+
import { cliRequestHeaders, requireCliRequestReason } from "./http.js";
|
|
9
13
|
import { requireConfirmation } from "./safety.js";
|
|
10
14
|
import { installSkill, isEphemeralInstall, readBundledSkillVersion, readCliVersion, } from "./setup.js";
|
|
11
15
|
import { compareVersions, updateSkillFromServer } from "./skill-update.js";
|
|
12
16
|
import { readSkillCache, writeSkillCache } from "./skills-cache.js";
|
|
17
|
+
import { appendJobEvent, getJob, listJobs, pruneJobs } from "./jobs.js";
|
|
13
18
|
export const HELP = `recess — safe Recess administration and family AI tools
|
|
14
19
|
|
|
15
20
|
Usage:
|
|
16
21
|
recess [--json] --version
|
|
22
|
+
recess [--json] agent-context
|
|
17
23
|
recess [--json] setup [--skill-only]
|
|
18
24
|
recess [--json] doctor
|
|
25
|
+
recess [--json] profile list
|
|
26
|
+
recess [--json] profile show <name>
|
|
27
|
+
recess [--json] profile save <name> [--api-origin URL] [--web-origin URL]
|
|
28
|
+
[--oauth-client-id ID]
|
|
29
|
+
recess [--json] profile use <name>
|
|
30
|
+
recess [--json] profile delete <name>
|
|
31
|
+
recess [--json] jobs list [--limit 20]
|
|
32
|
+
recess [--json] jobs get <operation-key>
|
|
33
|
+
recess [--json] jobs prune [--older-than-days 30]
|
|
34
|
+
recess [--json] feedback list [--limit 20]
|
|
35
|
+
recess [--json] feedback submit <text> [--confirm]
|
|
19
36
|
recess [--json] auth login [--client-id ID] [--callback-port 8765]
|
|
20
37
|
recess [--json] auth request [--label TEXT]
|
|
21
38
|
recess [--json] auth poll [--timeout 300]
|
|
@@ -53,7 +70,8 @@ Usage:
|
|
|
53
70
|
[--family <family-id>] [--unassign <kid-id>] (repeat) [--note TEXT] [--confirm]
|
|
54
71
|
recess [--json] cohorts search <query>
|
|
55
72
|
recess [--json] enrollments create --user <kid-id> --cohort <id>
|
|
56
|
-
[--first-charge-at ISO_DATETIME] [--send-email] [--force]
|
|
73
|
+
[--first-charge-at ISO_DATETIME] [--send-email] [--force]
|
|
74
|
+
[--confirm --approval-token TOKEN]
|
|
57
75
|
recess [--json] enrollments register-cohort --enrollment <id>
|
|
58
76
|
--user <id> --cohort <id> [--confirm]
|
|
59
77
|
recess [--json] enrollments unregister-cohort --user <id>
|
|
@@ -65,6 +83,7 @@ Usage:
|
|
|
65
83
|
[--immediate] [--reason TEXT] [--restore] [--confirm]
|
|
66
84
|
recess [--json] payout payruns list [--status A,B] [--schedule <id>]
|
|
67
85
|
recess [--json] payout recipients list [--search <name>] [--user <id>] [--id <id>]
|
|
86
|
+
[--limit 20] [--cursor <id>]
|
|
68
87
|
recess [--json] payout invoices list [--payrun <id>] [--recipient <account-id>]
|
|
69
88
|
[--user <id>] [--status A,B] (at least one filter)
|
|
70
89
|
recess [--json] payout invoices get <invoice-id>
|
|
@@ -132,11 +151,11 @@ Usage:
|
|
|
132
151
|
recess [--json] content-library search <query> [--limit 8]
|
|
133
152
|
recess [--json] content-library status <gem-id-or-url>
|
|
134
153
|
recess [--json] content-library set-stage <gem-id-or-url...> [--file <path>]
|
|
135
|
-
--stage review|polishing|live|archived [--confirm]
|
|
154
|
+
--stage review|polishing|live|archived [--wait] [--timeout 900] [--confirm]
|
|
136
155
|
recess [--json] content-library submit <url...> [--file <path>]
|
|
137
156
|
[--stage review|polish] [--title TEXT] [--summary TEXT]
|
|
138
157
|
[--lane web-toys|mechanics|explorables|data-stories|sims|maps-scale|sound-art|puzzles|wonder|idea-games]
|
|
139
|
-
[--confirm]
|
|
158
|
+
[--wait] [--timeout 900] [--confirm]
|
|
140
159
|
recess [--json] skills guardian list [--query TEXT] [--category TEXT]
|
|
141
160
|
recess [--json] skills guardian get <skill-name>
|
|
142
161
|
[--reference NAME | --all-references] [--refresh]
|
|
@@ -146,12 +165,13 @@ Usage:
|
|
|
146
165
|
[--refresh]
|
|
147
166
|
recess [--json] goal-templates list [--query TEXT] [--category TEXT]
|
|
148
167
|
[--kind SIMPLE|BLUEPRINT] [--starter-only] [--include-deleted]
|
|
168
|
+
[--limit 20] [--cursor <id>]
|
|
149
169
|
recess [--json] goal-templates get <template-id|slug> [--spec-only]
|
|
150
170
|
recess [--json] goal-templates versions <template-id> [--version N]
|
|
151
171
|
recess [--json] goal-templates validate-spec --file <path/template.json>
|
|
152
172
|
recess [--json] goal-templates create --file <path/template.json> [--confirm]
|
|
153
173
|
recess [--json] goal-templates patch-spec <template-id|slug> --expected-version N
|
|
154
|
-
--patches-file <path/patches.json> [--confirm]
|
|
174
|
+
--patches-file <path/patches.json> [--confirm --approval-token TOKEN]
|
|
155
175
|
[--confirm-destructive-changes --destructive-change-token TOKEN]
|
|
156
176
|
recess [--json] goal-templates set-metadata <template-id> --expected-version N
|
|
157
177
|
[--title TEXT] [--description TEXT] [--emoji X] [--category TEXT] [--tags A,B]
|
|
@@ -162,20 +182,23 @@ Usage:
|
|
|
162
182
|
recess [--json] goal-templates delete <template-id> --expected-version N [--confirm]
|
|
163
183
|
recess [--json] goal-templates snapshot-files <template-id> [--path P]
|
|
164
184
|
recess [--json] goal-templates capture-snapshot <template-id|slug>
|
|
165
|
-
(--source-goal <goal-id> | --source-draft <draft-slug> --student <goal-owner-id>
|
|
166
|
-
|
|
185
|
+
(--source-goal <goal-id> | --source-draft <draft-slug> --student <goal-owner-id>
|
|
186
|
+
| --source-dir <local-checkout>)
|
|
187
|
+
[--dry-run] [--confirm --approval-token TOKEN]
|
|
167
188
|
recess [--json] goal-templates apply <template-id> --answers-file <path>
|
|
168
189
|
[--dry-run] [--confirm --approval-token TOKEN]
|
|
169
190
|
recess [--json] goal-templates apply-starter <template-id> --student <kid-id>
|
|
170
191
|
[--answers-file <path>] [--confirm]
|
|
171
192
|
recess [--json] goals list --student <kid-id>
|
|
172
|
-
recess [--json] goals create --student <kid-id> --title TEXT
|
|
193
|
+
recess [--json] goals create [--student <kid-id>] --title TEXT
|
|
173
194
|
(--description TEXT | --description-file <path>) [--target-date <iso>]
|
|
174
|
-
[--schedule TEXT] [--draft <draft-slug>
|
|
195
|
+
[--schedule TEXT] [(--draft <draft-slug> | --source-dir <local-checkout>)
|
|
175
196
|
(--enable-applet-follow-ups | --disable-applet-follow-ups)]
|
|
176
197
|
[--confirm --approval-token TOKEN]
|
|
177
198
|
recess [--json] goals edit <goal-id> --student <kid-id> --patch-file <path/patch.json>
|
|
178
199
|
--delta TEXT [--confirm --approval-token TOKEN]
|
|
200
|
+
recess [--json] goals delete <goal-id> --student <kid-id>
|
|
201
|
+
[--confirm --approval-token TOKEN]
|
|
179
202
|
recess [--json] goals queue get <goal-id> --student <kid-id>
|
|
180
203
|
recess [--json] goals queue set <goal-id> --student <kid-id>
|
|
181
204
|
--entries-file <path.json> --delta TEXT [--replace-description-pointer]
|
|
@@ -184,6 +207,8 @@ Usage:
|
|
|
184
207
|
[--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
|
|
185
208
|
recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
|
|
186
209
|
[--confirm --approval-token TOKEN]
|
|
210
|
+
recess [--json] todos delete <todo-id>
|
|
211
|
+
[--confirm --approval-token TOKEN]
|
|
187
212
|
recess [--json] todos generate-applet <todo-id> --student <kid-id>
|
|
188
213
|
[--due-date YYYY-MM-DD] [--confirm --approval-token TOKEN]
|
|
189
214
|
recess [--json] memories context --student <kid-id>
|
|
@@ -193,6 +218,13 @@ Usage:
|
|
|
193
218
|
--expected-updated-at ISO|none [--confirm]
|
|
194
219
|
recess [--json] goals files list --student <goal-owner-id> --goal <goal-id>
|
|
195
220
|
recess [--json] goals files read --student <goal-owner-id> --goal <goal-id> --path P
|
|
221
|
+
recess [--json] goals files init --student <goal-owner-id> --draft <draft-slug>
|
|
222
|
+
--output-dir <local-dir>
|
|
223
|
+
recess [--json] goals files checkout --student <goal-owner-id>
|
|
224
|
+
(--goal <goal-id> | --draft <draft-slug>)
|
|
225
|
+
--output-dir <local-dir>
|
|
226
|
+
recess [--json] goals files push --source-dir <local-checkout>
|
|
227
|
+
[--message TEXT] [--confirm --approval-token TOKEN]
|
|
196
228
|
recess [--json] goals files write --student <goal-owner-id>
|
|
197
229
|
(--goal <goal-id> | --draft <draft-slug>)
|
|
198
230
|
(--source-dir <local-dir> | --source-file <local-file> --path P)
|
|
@@ -211,10 +243,10 @@ standard. Responses cache under ~/.recess-cli/skills-cache/ (--refresh re-fetche
|
|
|
211
243
|
For goal workspace and PDF commands, --student names the goal owner. A
|
|
212
244
|
full_admin session may use a KID or ADMIN user id, including its own ADMIN id;
|
|
213
245
|
family_ai sessions remain limited to managed KID profiles.
|
|
214
|
-
"goals create --
|
|
215
|
-
module-backed goal
|
|
216
|
-
|
|
217
|
-
follow-up choice.
|
|
246
|
+
"goals create --source-dir" materializes a clean, fully pushed draft checkout as
|
|
247
|
+
a personal module-backed goal and retargets that checkout to the resulting live
|
|
248
|
+
goal. It does not create or apply a reusable template. Its preview is bound to
|
|
249
|
+
the exact workspace revision, hash, module inventory, and applet follow-up choice.
|
|
218
250
|
Every template created here is setupMode DETERMINISTIC_WORKFLOW and CANNOT be
|
|
219
251
|
converted back, so "validate-spec" against the same file until it passes, then
|
|
220
252
|
"create". "create" runs a real server-side validation before its gate, so the
|
|
@@ -261,8 +293,14 @@ bundled copy, and an unreachable server is not an error. "doctor" reports whethe
|
|
|
261
293
|
a newer skill exists and names the command; it never writes.
|
|
262
294
|
|
|
263
295
|
Writes preview and exit 2 unless --confirm is supplied after explicit human approval.
|
|
296
|
+
Every preview includes an operationKey; confirmed writes must echo it with
|
|
297
|
+
--operation-key so an interrupted invocation can be retried without duplicating the write.
|
|
264
298
|
Environment overrides: RECESS_CLI_API_ORIGIN, RECESS_CLI_WEB_ORIGIN,
|
|
265
|
-
RECESS_CLI_OAUTH_CLIENT_ID, RECESS_CLI_COOKIE, RECESS_CLI_CONFIG
|
|
299
|
+
RECESS_CLI_OAUTH_CLIENT_ID, RECESS_CLI_COOKIE, RECESS_CLI_CONFIG,
|
|
300
|
+
RECESS_CLI_PROFILE, RECESS_CLI_FEEDBACK_ENDPOINT.
|
|
301
|
+
|
|
302
|
+
Every command that calls the Recess API, except auth commands, requires
|
|
303
|
+
--reason TEXT: a non-empty human-readable purpose of at most 1024 characters.`;
|
|
266
304
|
function positional(parsed, index, label) {
|
|
267
305
|
const value = parsed.positionals[index];
|
|
268
306
|
if (!value) {
|
|
@@ -270,6 +308,9 @@ function positional(parsed, index, label) {
|
|
|
270
308
|
}
|
|
271
309
|
return value;
|
|
272
310
|
}
|
|
311
|
+
function requiredRequestReason(parsed) {
|
|
312
|
+
return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
|
|
313
|
+
}
|
|
273
314
|
async function sessionStatus(config) {
|
|
274
315
|
if (!config.sessionCookie) {
|
|
275
316
|
return {
|
|
@@ -291,18 +332,20 @@ async function sessionStatus(config) {
|
|
|
291
332
|
}
|
|
292
333
|
return { ...result.data, authSource: config.authSource };
|
|
293
334
|
}
|
|
294
|
-
async function doctor(config) {
|
|
335
|
+
async function doctor(config, reason) {
|
|
295
336
|
const checks = {
|
|
296
337
|
config: {
|
|
297
338
|
path: config.configPath,
|
|
298
339
|
apiOrigin: config.apiOrigin,
|
|
299
340
|
webOrigin: config.webOrigin,
|
|
300
341
|
oauthClientIdConfigured: Boolean(config.oauthClientId),
|
|
342
|
+
profile: config.profileName ?? null,
|
|
301
343
|
},
|
|
302
344
|
auth: await sessionStatus(config),
|
|
303
345
|
};
|
|
304
346
|
try {
|
|
305
347
|
const response = await fetch(new URL("/health", config.apiOrigin), {
|
|
348
|
+
headers: cliRequestHeaders(undefined, reason),
|
|
306
349
|
signal: AbortSignal.timeout(5000),
|
|
307
350
|
});
|
|
308
351
|
checks.api = { reachable: response.ok, status: response.status };
|
|
@@ -313,7 +356,7 @@ async function doctor(config) {
|
|
|
313
356
|
message: error instanceof Error ? error.message : String(error),
|
|
314
357
|
};
|
|
315
358
|
}
|
|
316
|
-
checks.skill = await skillStatus(config);
|
|
359
|
+
checks.skill = await skillStatus(config, reason);
|
|
317
360
|
return checks;
|
|
318
361
|
}
|
|
319
362
|
/**
|
|
@@ -322,7 +365,7 @@ async function doctor(config) {
|
|
|
322
365
|
* silently rewrote `~/.claude/skills` mid-session would change what the agent is
|
|
323
366
|
* reading out from under it. It names the command instead.
|
|
324
367
|
*/
|
|
325
|
-
async function skillStatus(config) {
|
|
368
|
+
async function skillStatus(config, reason) {
|
|
326
369
|
const cliVersion = await readCliVersion();
|
|
327
370
|
const installedVersion = await readBundledSkillVersion();
|
|
328
371
|
const base = { cliVersion, bundledSkillVersion: installedVersion };
|
|
@@ -331,7 +374,7 @@ async function skillStatus(config) {
|
|
|
331
374
|
}
|
|
332
375
|
try {
|
|
333
376
|
const response = await fetch(new URL("/auth/admin-cli/skill/", config.apiOrigin), {
|
|
334
|
-
headers: { cookie: config.sessionCookie },
|
|
377
|
+
headers: cliRequestHeaders({ cookie: config.sessionCookie }, reason),
|
|
335
378
|
signal: AbortSignal.timeout(5000),
|
|
336
379
|
});
|
|
337
380
|
if (!response.ok) {
|
|
@@ -366,30 +409,433 @@ async function skillStatus(config) {
|
|
|
366
409
|
}
|
|
367
410
|
}
|
|
368
411
|
async function writeCommand(parsed, preview, execute) {
|
|
369
|
-
|
|
370
|
-
|
|
412
|
+
const fingerprint = approvalTokenFor(preview);
|
|
413
|
+
const suppliedOperationKey = flagString(parsed, "operation-key");
|
|
414
|
+
if (!hasFlag(parsed, "confirm")) {
|
|
415
|
+
const operationKey = operationKeyFor(fingerprint);
|
|
416
|
+
const boundPreview = {
|
|
417
|
+
...preview,
|
|
418
|
+
details: {
|
|
419
|
+
...preview.details,
|
|
420
|
+
operationKey,
|
|
421
|
+
retry: "Rerun the unchanged command with --confirm --operation-key <operationKey>. Reuse that same key after an interrupted invocation.",
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
await appendJobEvent({
|
|
425
|
+
jobId: operationKey,
|
|
426
|
+
status: "awaiting_confirmation",
|
|
427
|
+
timestamp: new Date().toISOString(),
|
|
428
|
+
action: preview.action,
|
|
429
|
+
fingerprint,
|
|
430
|
+
target: preview.target,
|
|
431
|
+
});
|
|
432
|
+
requireConfirmation(false, boundPreview);
|
|
433
|
+
}
|
|
434
|
+
if (!suppliedOperationKey) {
|
|
435
|
+
throw new CliError("confirmation_required", "A confirmed write requires the operation key from its approved preview.", 2, { preview, requiredFlag: "--operation-key" });
|
|
436
|
+
}
|
|
437
|
+
assertOperationKeyMatchesPreview(suppliedOperationKey, fingerprint, preview);
|
|
438
|
+
await appendJobEvent({
|
|
439
|
+
jobId: suppliedOperationKey,
|
|
440
|
+
status: "running",
|
|
441
|
+
timestamp: new Date().toISOString(),
|
|
442
|
+
action: preview.action,
|
|
443
|
+
fingerprint,
|
|
444
|
+
target: preview.target,
|
|
445
|
+
});
|
|
446
|
+
try {
|
|
447
|
+
const result = await withIdempotencyContext({ operationKey: suppliedOperationKey, fingerprint }, execute);
|
|
448
|
+
await appendJobEvent({
|
|
449
|
+
jobId: suppliedOperationKey,
|
|
450
|
+
status: "completed",
|
|
451
|
+
timestamp: new Date().toISOString(),
|
|
452
|
+
action: preview.action,
|
|
453
|
+
fingerprint,
|
|
454
|
+
target: preview.target,
|
|
455
|
+
result,
|
|
456
|
+
});
|
|
457
|
+
return result;
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
await appendJobEvent({
|
|
461
|
+
jobId: suppliedOperationKey,
|
|
462
|
+
status: "unknown",
|
|
463
|
+
timestamp: new Date().toISOString(),
|
|
464
|
+
action: preview.action,
|
|
465
|
+
fingerprint,
|
|
466
|
+
target: preview.target,
|
|
467
|
+
message: error instanceof Error ? error.message : String(error),
|
|
468
|
+
});
|
|
469
|
+
throw error;
|
|
470
|
+
}
|
|
371
471
|
}
|
|
372
472
|
const GOAL_WORKSPACE_WRITE_MAX_FILES = 1_000;
|
|
373
473
|
const GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
374
474
|
const GOAL_PDF_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
|
375
475
|
const GOAL_QUEUE_MAX_ENTRIES = 500;
|
|
376
476
|
const URL_QUEUE_DESCRIPTION_POINTER = "Skill queue managed by the system.";
|
|
477
|
+
const RECESS_GIT_METADATA = "recess-workspace.json";
|
|
478
|
+
async function runGit(cwd, args, options = {}) {
|
|
479
|
+
return new Promise((resolve, reject) => {
|
|
480
|
+
execFile("git", ["-C", cwd, ...args], {
|
|
481
|
+
encoding: "utf8",
|
|
482
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
483
|
+
env: options.identity
|
|
484
|
+
? {
|
|
485
|
+
...process.env,
|
|
486
|
+
GIT_AUTHOR_NAME: "Recess CLI",
|
|
487
|
+
GIT_AUTHOR_EMAIL: "cli@recess.gg",
|
|
488
|
+
GIT_COMMITTER_NAME: "Recess CLI",
|
|
489
|
+
GIT_COMMITTER_EMAIL: "cli@recess.gg",
|
|
490
|
+
}
|
|
491
|
+
: process.env,
|
|
492
|
+
}, (error, stdout, stderr) => {
|
|
493
|
+
if (error) {
|
|
494
|
+
reject(new CliError("git_failed", `Git command failed: git ${args.join(" ")} (${stderr.trim() || error.message})`));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
resolve(stdout);
|
|
498
|
+
});
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
async function ensureLocalGitIdentity(root) {
|
|
502
|
+
for (const [key, fallback] of [
|
|
503
|
+
["user.name", "Recess CLI"],
|
|
504
|
+
["user.email", "cli@recess.gg"],
|
|
505
|
+
]) {
|
|
506
|
+
let configured = "";
|
|
507
|
+
try {
|
|
508
|
+
configured = (await runGit(root, ["config", "--get", key])).trim();
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
// A missing inherited value exits 1. Install a repo-local fallback so
|
|
512
|
+
// the checkout can be committed without changing the user's global Git.
|
|
513
|
+
}
|
|
514
|
+
if (!configured)
|
|
515
|
+
await runGit(root, ["config", key, fallback]);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function resolveWorkspaceCheckoutPath(root, workspacePath) {
|
|
519
|
+
const normalized = workspacePath.replaceAll("\\", "/");
|
|
520
|
+
const segments = normalized.split("/");
|
|
521
|
+
const isGitMetadataSegment = (segment) => {
|
|
522
|
+
// NTFS canonicalizes trailing dots/spaces, supports alternate data streams,
|
|
523
|
+
// and may expose .git through an 8.3 alias such as GIT~1. Reject those
|
|
524
|
+
// spellings too so a checkout cannot write Git metadata before `git init`.
|
|
525
|
+
const ntfsName = segment
|
|
526
|
+
.split(":", 1)[0]
|
|
527
|
+
.replace(/[ .]+$/g, "")
|
|
528
|
+
.toLowerCase();
|
|
529
|
+
return ntfsName === ".git" || /^git~\d+$/.test(ntfsName);
|
|
530
|
+
};
|
|
531
|
+
if (!normalized ||
|
|
532
|
+
path.posix.isAbsolute(normalized) ||
|
|
533
|
+
segments.some((segment) => !segment ||
|
|
534
|
+
segment === "." ||
|
|
535
|
+
segment === ".." ||
|
|
536
|
+
isGitMetadataSegment(segment))) {
|
|
537
|
+
throw new CliError("invalid_workspace", `Refusing unsafe workspace path from Mesa: ${workspacePath}`);
|
|
538
|
+
}
|
|
539
|
+
const target = path.resolve(root, ...segments);
|
|
540
|
+
if (!target.startsWith(`${root}${path.sep}`)) {
|
|
541
|
+
throw new CliError("invalid_workspace", `Refusing workspace path outside checkout: ${workspacePath}`);
|
|
542
|
+
}
|
|
543
|
+
return target;
|
|
544
|
+
}
|
|
545
|
+
function recessGitMetadataPath(root) {
|
|
546
|
+
return path.join(root, ".git", RECESS_GIT_METADATA);
|
|
547
|
+
}
|
|
548
|
+
async function writeRecessGitMetadata(root, metadata) {
|
|
549
|
+
const destination = recessGitMetadataPath(root);
|
|
550
|
+
const temporary = `${destination}.${process.pid}.tmp`;
|
|
551
|
+
await fs.writeFile(temporary, `${JSON.stringify(metadata, null, 2)}\n`, {
|
|
552
|
+
encoding: "utf8",
|
|
553
|
+
mode: 0o600,
|
|
554
|
+
});
|
|
555
|
+
await fs.rename(temporary, destination);
|
|
556
|
+
}
|
|
557
|
+
async function readRecessGitMetadata(sourceDirectory) {
|
|
558
|
+
const requestedRoot = path.resolve(sourceDirectory);
|
|
559
|
+
const requestedRealRoot = await fs.realpath(requestedRoot).catch(() => null);
|
|
560
|
+
if (!requestedRealRoot) {
|
|
561
|
+
throw new CliError("invalid_checkout", `Recess checkout does not exist: ${requestedRoot}`);
|
|
562
|
+
}
|
|
563
|
+
const root = (await runGit(requestedRoot, ["rev-parse", "--show-toplevel"])).trim();
|
|
564
|
+
const realRoot = await fs.realpath(root);
|
|
565
|
+
if (realRoot !== requestedRealRoot) {
|
|
566
|
+
throw new CliError("invalid_checkout", `--source-dir must be the Recess checkout root: ${root}`);
|
|
567
|
+
}
|
|
568
|
+
let parsed;
|
|
569
|
+
try {
|
|
570
|
+
parsed = JSON.parse(await fs.readFile(recessGitMetadataPath(root), "utf8"));
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
throw new CliError("invalid_checkout", `${root} is not a Recess goal workspace checkout.`);
|
|
574
|
+
}
|
|
575
|
+
const value = parsed;
|
|
576
|
+
const candidateTarget = value.target;
|
|
577
|
+
const target = value.schemaVersion === 1 && typeof value.goalId === "string"
|
|
578
|
+
? { kind: "goal", goalId: value.goalId }
|
|
579
|
+
: candidateTarget;
|
|
580
|
+
if (![1, 2].includes(typeof value.schemaVersion === "number" ? value.schemaVersion : 0) ||
|
|
581
|
+
typeof value.studentId !== "string" ||
|
|
582
|
+
!target ||
|
|
583
|
+
(target.kind !== "goal" && target.kind !== "draft") ||
|
|
584
|
+
(target.kind === "goal" && typeof target.goalId !== "string") ||
|
|
585
|
+
(target.kind === "draft" && typeof target.draftSlug !== "string") ||
|
|
586
|
+
typeof value.changeId !== "string" ||
|
|
587
|
+
typeof value.upstreamCommit !== "string") {
|
|
588
|
+
throw new CliError("invalid_checkout", `${root} has invalid Recess workspace metadata.`);
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
root: realRoot,
|
|
592
|
+
metadata: {
|
|
593
|
+
schemaVersion: 2,
|
|
594
|
+
studentId: value.studentId,
|
|
595
|
+
target: target,
|
|
596
|
+
changeId: value.changeId,
|
|
597
|
+
upstreamCommit: value.upstreamCommit,
|
|
598
|
+
},
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
async function checkoutGoalWorkspace(input) {
|
|
602
|
+
const outputDirectory = path.resolve(input.outputDirectory);
|
|
603
|
+
if (await fs.lstat(outputDirectory).catch(() => null)) {
|
|
604
|
+
throw new CliError("destination_exists", `Checkout destination already exists: ${outputDirectory}`);
|
|
605
|
+
}
|
|
606
|
+
if (input.snapshot.files.length > GOAL_WORKSPACE_WRITE_MAX_FILES) {
|
|
607
|
+
throw new CliError("workspace_too_large", `Workspace has more than ${GOAL_WORKSPACE_WRITE_MAX_FILES} files.`);
|
|
608
|
+
}
|
|
609
|
+
const sizeBytes = input.snapshot.files.reduce((total, file) => total + file.sizeBytes, 0);
|
|
610
|
+
if (sizeBytes > GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES) {
|
|
611
|
+
throw new CliError("workspace_too_large", `Workspace exceeds ${GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
|
|
612
|
+
}
|
|
613
|
+
const parent = path.dirname(outputDirectory);
|
|
614
|
+
await fs.mkdir(parent, { recursive: true });
|
|
615
|
+
const temporary = await fs.mkdtemp(path.join(parent, ".recess-checkout-"));
|
|
616
|
+
let moved = false;
|
|
617
|
+
try {
|
|
618
|
+
for (const file of input.snapshot.files) {
|
|
619
|
+
const destination = resolveWorkspaceCheckoutPath(temporary, file.path);
|
|
620
|
+
const bytes = Buffer.from(file.content, file.contentEncoding === "base64" ? "base64" : "utf8");
|
|
621
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
622
|
+
if (bytes.byteLength !== file.sizeBytes || sha256 !== file.sha256) {
|
|
623
|
+
throw new CliError("invalid_workspace", `Mesa snapshot integrity check failed for ${file.path}.`);
|
|
624
|
+
}
|
|
625
|
+
await fs.mkdir(path.dirname(destination), { recursive: true });
|
|
626
|
+
await fs.writeFile(destination, bytes);
|
|
627
|
+
}
|
|
628
|
+
await runGit(temporary, ["init", "--quiet", "--initial-branch=main"]);
|
|
629
|
+
await ensureLocalGitIdentity(temporary);
|
|
630
|
+
await runGit(temporary, ["add", "-A"]);
|
|
631
|
+
await runGit(temporary, [
|
|
632
|
+
"commit",
|
|
633
|
+
"--quiet",
|
|
634
|
+
"--allow-empty",
|
|
635
|
+
"-m",
|
|
636
|
+
`Checkout Recess ${input.target.kind} workspace at ${input.snapshot.changeId}`,
|
|
637
|
+
], { identity: true });
|
|
638
|
+
const upstreamCommit = (await runGit(temporary, ["rev-parse", "HEAD"])).trim();
|
|
639
|
+
await writeRecessGitMetadata(temporary, {
|
|
640
|
+
schemaVersion: 2,
|
|
641
|
+
studentId: input.studentId,
|
|
642
|
+
target: input.target,
|
|
643
|
+
changeId: input.snapshot.changeId,
|
|
644
|
+
upstreamCommit,
|
|
645
|
+
});
|
|
646
|
+
await fs.rename(temporary, outputDirectory);
|
|
647
|
+
moved = true;
|
|
648
|
+
return {
|
|
649
|
+
directory: outputDirectory,
|
|
650
|
+
studentId: input.studentId,
|
|
651
|
+
target: input.target,
|
|
652
|
+
changeId: input.snapshot.changeId,
|
|
653
|
+
upstreamCommit,
|
|
654
|
+
fileCount: input.snapshot.files.length,
|
|
655
|
+
sizeBytes,
|
|
656
|
+
next: `Edit with normal file tools, inspect with git -C ${JSON.stringify(outputDirectory)} diff, commit locally, then run recess --json goals files push --source-dir ${JSON.stringify(outputDirectory)} --reason <purpose>.`,
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
finally {
|
|
660
|
+
if (!moved)
|
|
661
|
+
await fs.rm(temporary, { recursive: true, force: true });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
async function readRemoteWorkspaceSnapshot(api, studentId, target) {
|
|
665
|
+
return unwrap(await api.client.GET("/tutor/browser/mesa/workspace-files", {
|
|
666
|
+
params: {
|
|
667
|
+
query: {
|
|
668
|
+
studentUserId: studentId,
|
|
669
|
+
...(target.kind === "goal"
|
|
670
|
+
? { goalId: target.goalId }
|
|
671
|
+
: { draftSlug: target.draftSlug }),
|
|
672
|
+
},
|
|
673
|
+
},
|
|
674
|
+
}));
|
|
675
|
+
}
|
|
676
|
+
async function assertCheckoutReadyForMaterialization(checkout) {
|
|
677
|
+
const status = await runGit(checkout.root, ["status", "--porcelain=v1"]);
|
|
678
|
+
if (status.trim()) {
|
|
679
|
+
throw new CliError("uncommitted_changes", "Commit or discard all local workspace changes before creating or capturing.");
|
|
680
|
+
}
|
|
681
|
+
const head = (await runGit(checkout.root, ["rev-parse", "HEAD"])).trim();
|
|
682
|
+
if (head !== checkout.metadata.upstreamCommit) {
|
|
683
|
+
throw new CliError("unpushed_changes", "Push every local workspace commit before creating a goal or capturing a template.");
|
|
684
|
+
}
|
|
685
|
+
return head;
|
|
686
|
+
}
|
|
687
|
+
async function readGoalWorkspaceGitChanges(root, base) {
|
|
688
|
+
const status = await runGit(root, ["status", "--porcelain=v1"]);
|
|
689
|
+
if (status.trim()) {
|
|
690
|
+
throw new CliError("uncommitted_changes", "Commit or discard all local workspace changes before pushing.");
|
|
691
|
+
}
|
|
692
|
+
const head = (await runGit(root, ["rev-parse", "HEAD"])).trim();
|
|
693
|
+
if (head === base) {
|
|
694
|
+
throw new CliError("no_changes", "There are no local commits to push.");
|
|
695
|
+
}
|
|
696
|
+
await runGit(root, ["merge-base", "--is-ancestor", base, head]);
|
|
697
|
+
const commitLines = (await runGit(root, [
|
|
698
|
+
"log",
|
|
699
|
+
"--reverse",
|
|
700
|
+
"--format=%H%x09%s",
|
|
701
|
+
`${base}..${head}`,
|
|
702
|
+
]))
|
|
703
|
+
.trim()
|
|
704
|
+
.split("\n")
|
|
705
|
+
.filter(Boolean);
|
|
706
|
+
const commits = commitLines.map((line) => {
|
|
707
|
+
const tab = line.indexOf("\t");
|
|
708
|
+
return { id: line.slice(0, tab), subject: line.slice(tab + 1) };
|
|
709
|
+
});
|
|
710
|
+
const raw = await runGit(root, [
|
|
711
|
+
"diff",
|
|
712
|
+
"--name-status",
|
|
713
|
+
"--no-renames",
|
|
714
|
+
"-z",
|
|
715
|
+
`${base}..${head}`,
|
|
716
|
+
]);
|
|
717
|
+
const fields = raw.split("\0").filter((field) => field.length > 0);
|
|
718
|
+
if (fields.length % 2 !== 0) {
|
|
719
|
+
throw new CliError("git_failed", "Git returned an invalid workspace diff.");
|
|
720
|
+
}
|
|
721
|
+
const changes = [];
|
|
722
|
+
let sizeBytes = 0;
|
|
723
|
+
for (let index = 0; index < fields.length; index += 2) {
|
|
724
|
+
const statusCode = fields[index][0];
|
|
725
|
+
const workspacePath = fields[index + 1];
|
|
726
|
+
resolveWorkspaceCheckoutPath(root, workspacePath);
|
|
727
|
+
if (statusCode === "D") {
|
|
728
|
+
changes.push({ path: workspacePath, action: "delete" });
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
if (!["A", "M", "T"].includes(statusCode)) {
|
|
732
|
+
throw new CliError("unsupported_git_change", `Unsupported Git change ${fields[index]} for ${workspacePath}.`);
|
|
733
|
+
}
|
|
734
|
+
const absolutePath = resolveWorkspaceCheckoutPath(root, workspacePath);
|
|
735
|
+
const stat = await fs.lstat(absolutePath).catch(() => null);
|
|
736
|
+
if (!stat?.isFile() || stat.isSymbolicLink()) {
|
|
737
|
+
throw new CliError("invalid_workspace", `Refusing non-file workspace entry: ${workspacePath}`);
|
|
738
|
+
}
|
|
739
|
+
const bytes = await fs.readFile(absolutePath);
|
|
740
|
+
sizeBytes += bytes.byteLength;
|
|
741
|
+
changes.push({
|
|
742
|
+
path: workspacePath,
|
|
743
|
+
action: "upsert",
|
|
744
|
+
content: bytes.toString("base64"),
|
|
745
|
+
contentEncoding: "base64",
|
|
746
|
+
sizeBytes: bytes.byteLength,
|
|
747
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
if (changes.length === 0) {
|
|
751
|
+
throw new CliError("no_changes", "Local commits do not change workspace file contents.");
|
|
752
|
+
}
|
|
753
|
+
if (changes.length > GOAL_WORKSPACE_WRITE_MAX_FILES) {
|
|
754
|
+
throw new CliError("workspace_too_large", `Push changes more than ${GOAL_WORKSPACE_WRITE_MAX_FILES} files.`);
|
|
755
|
+
}
|
|
756
|
+
if (sizeBytes > GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES) {
|
|
757
|
+
throw new CliError("workspace_too_large", `Push exceeds ${GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES} decoded bytes.`);
|
|
758
|
+
}
|
|
759
|
+
return { head, commits, changes, sizeBytes };
|
|
760
|
+
}
|
|
377
761
|
function approvalTokenFor(preview) {
|
|
378
762
|
return createHash("sha256").update(JSON.stringify(preview)).digest("hex");
|
|
379
763
|
}
|
|
380
|
-
function
|
|
764
|
+
function operationKeyFor(fingerprint) {
|
|
765
|
+
return `op_${randomUUID().replaceAll("-", "")}_${fingerprint}`;
|
|
766
|
+
}
|
|
767
|
+
function assertOperationKeyMatchesPreview(operationKey, fingerprint, preview) {
|
|
768
|
+
if (!operationKey.endsWith(`_${fingerprint}`)) {
|
|
769
|
+
throw new CliError("approval_mismatch", "The operation key does not belong to this preview. Start from a new preview and obtain approval again.", 2, { preview, requiredFlag: "--operation-key" });
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
async function requirePreviewBoundConfirmation(parsed, preview) {
|
|
381
773
|
const approvalToken = approvalTokenFor(preview);
|
|
774
|
+
const suppliedOperationKey = flagString(parsed, "operation-key");
|
|
382
775
|
const boundPreview = {
|
|
383
776
|
...preview,
|
|
384
|
-
details: {
|
|
777
|
+
details: {
|
|
778
|
+
...preview.details,
|
|
779
|
+
approvalToken,
|
|
780
|
+
operationKey: suppliedOperationKey ?? operationKeyFor(approvalToken),
|
|
781
|
+
},
|
|
385
782
|
};
|
|
386
783
|
if (!hasFlag(parsed, "confirm")) {
|
|
784
|
+
await appendJobEvent({
|
|
785
|
+
jobId: String(boundPreview.details.operationKey),
|
|
786
|
+
status: "awaiting_confirmation",
|
|
787
|
+
timestamp: new Date().toISOString(),
|
|
788
|
+
action: preview.action,
|
|
789
|
+
fingerprint: approvalToken,
|
|
790
|
+
target: preview.target,
|
|
791
|
+
});
|
|
387
792
|
requireConfirmation(false, boundPreview);
|
|
388
793
|
}
|
|
389
794
|
const supplied = flagString(parsed, "approval-token");
|
|
390
795
|
if (supplied !== approvalToken) {
|
|
391
796
|
throw new CliError("approval_mismatch", "The approved preview no longer matches this request. Review the current preview and rerun with --confirm --approval-token <token>.", 2, { preview: boundPreview, requiredFlag: "--approval-token" });
|
|
392
797
|
}
|
|
798
|
+
if (!suppliedOperationKey) {
|
|
799
|
+
throw new CliError("confirmation_required", "A confirmed write requires the operation key from its approved preview.", 2, { preview: boundPreview, requiredFlag: "--operation-key" });
|
|
800
|
+
}
|
|
801
|
+
assertOperationKeyMatchesPreview(suppliedOperationKey, approvalToken, boundPreview);
|
|
802
|
+
await appendJobEvent({
|
|
803
|
+
jobId: suppliedOperationKey,
|
|
804
|
+
status: "running",
|
|
805
|
+
timestamp: new Date().toISOString(),
|
|
806
|
+
action: preview.action,
|
|
807
|
+
fingerprint: approvalToken,
|
|
808
|
+
target: preview.target,
|
|
809
|
+
});
|
|
810
|
+
return { operationKey: suppliedOperationKey, fingerprint: approvalToken };
|
|
811
|
+
}
|
|
812
|
+
async function previewBoundWrite(parsed, preview, execute) {
|
|
813
|
+
const context = await requirePreviewBoundConfirmation(parsed, preview);
|
|
814
|
+
try {
|
|
815
|
+
const result = await withIdempotencyContext(context, execute);
|
|
816
|
+
await appendJobEvent({
|
|
817
|
+
jobId: context.operationKey,
|
|
818
|
+
status: "completed",
|
|
819
|
+
timestamp: new Date().toISOString(),
|
|
820
|
+
action: preview.action,
|
|
821
|
+
fingerprint: context.fingerprint,
|
|
822
|
+
target: preview.target,
|
|
823
|
+
result,
|
|
824
|
+
});
|
|
825
|
+
return result;
|
|
826
|
+
}
|
|
827
|
+
catch (error) {
|
|
828
|
+
await appendJobEvent({
|
|
829
|
+
jobId: context.operationKey,
|
|
830
|
+
status: "unknown",
|
|
831
|
+
timestamp: new Date().toISOString(),
|
|
832
|
+
action: preview.action,
|
|
833
|
+
fingerprint: context.fingerprint,
|
|
834
|
+
target: preview.target,
|
|
835
|
+
message: error instanceof Error ? error.message : String(error),
|
|
836
|
+
});
|
|
837
|
+
throw error;
|
|
838
|
+
}
|
|
393
839
|
}
|
|
394
840
|
async function cleanupPlannerUpload(api, conversationId, signed, includeDelete) {
|
|
395
841
|
if (signed.kind === "multipart") {
|
|
@@ -1025,7 +1471,7 @@ function optionalDocString(doc, key) {
|
|
|
1025
1471
|
return value;
|
|
1026
1472
|
}
|
|
1027
1473
|
/**
|
|
1028
|
-
* Coerce an authored template file into the exact `POST /
|
|
1474
|
+
* Coerce an authored template file into the exact `POST /ai/goal-templates/`
|
|
1029
1475
|
* body. Every unknown key is dropped rather than forwarded, so a stale field
|
|
1030
1476
|
* copied from an old export cannot ride along into a create, and the shape
|
|
1031
1477
|
* failures an agent actually makes (missing slug, spec as a string, tags as a
|
|
@@ -1253,6 +1699,48 @@ function flagDateOnly(parsed, name) {
|
|
|
1253
1699
|
return raw;
|
|
1254
1700
|
}
|
|
1255
1701
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
1702
|
+
async function waitForContentLibraryLive(api, gems, timeoutSeconds) {
|
|
1703
|
+
const pending = new Set(gems);
|
|
1704
|
+
const latest = new Map();
|
|
1705
|
+
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
1706
|
+
let delayMs = 2_000;
|
|
1707
|
+
while (pending.size > 0) {
|
|
1708
|
+
const targets = Array.from(pending);
|
|
1709
|
+
for (let index = 0; index < targets.length; index += 3) {
|
|
1710
|
+
const batch = targets.slice(index, index + 3);
|
|
1711
|
+
await Promise.all(batch.map(async (gem) => {
|
|
1712
|
+
const status = unwrap(await api.client.GET("/admin/content-library/status", {
|
|
1713
|
+
params: { query: { gem } },
|
|
1714
|
+
}));
|
|
1715
|
+
latest.set(gem, status);
|
|
1716
|
+
if (status.stage === "live") {
|
|
1717
|
+
pending.delete(gem);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
const errors = [
|
|
1721
|
+
status.progress.metadata,
|
|
1722
|
+
status.progress.cover,
|
|
1723
|
+
status.progress.searchIndex,
|
|
1724
|
+
].filter((step) => step.state === "error");
|
|
1725
|
+
if (errors.length > 0) {
|
|
1726
|
+
throw new CliError("async_job_failed", `Content Library polishing failed for ${gem}.`, 1, { status });
|
|
1727
|
+
}
|
|
1728
|
+
}));
|
|
1729
|
+
}
|
|
1730
|
+
if (pending.size === 0)
|
|
1731
|
+
break;
|
|
1732
|
+
if (Date.now() + delayMs >= deadline) {
|
|
1733
|
+
throw new CliError("async_timeout", `Timed out waiting for ${pending.size} Content Library resource(s) to become live. Retry the unchanged confirmed command with the same operation key to resume polling.`, 1, {
|
|
1734
|
+
pending: Array.from(pending),
|
|
1735
|
+
latest: Object.fromEntries(latest),
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
const jitter = Math.floor(Math.random() * Math.max(1, delayMs / 4));
|
|
1739
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs + jitter));
|
|
1740
|
+
delayMs = Math.min(15_000, Math.round(delayMs * 1.7));
|
|
1741
|
+
}
|
|
1742
|
+
return { completed: true, resources: Array.from(latest.values()) };
|
|
1743
|
+
}
|
|
1256
1744
|
// The cohort event routes take zoneless local wall-clock datetimes
|
|
1257
1745
|
// (interpreted in the cohort's timezone server-side).
|
|
1258
1746
|
function flagLocalDateTime(parsed, name, options = {}) {
|
|
@@ -1325,20 +1813,148 @@ function tierSlots(parsed) {
|
|
|
1325
1813
|
export async function runCommand(argv) {
|
|
1326
1814
|
const parsed = parseArgs(argv);
|
|
1327
1815
|
const [noun, verb] = parsed.positionals;
|
|
1816
|
+
const commands = buildCommandSchema(HELP);
|
|
1328
1817
|
// Before the help branch: `--version` parses as a FLAG, so `noun` is
|
|
1329
1818
|
// undefined and `!noun` would return help instead. (Found by running it.)
|
|
1330
1819
|
if (noun === "version" || hasFlag(parsed, "version")) {
|
|
1820
|
+
const unknown = Array.from(parsed.flags.keys()).filter((name) => !["deliver", "json", "version"].includes(name));
|
|
1821
|
+
if (unknown.length > 0) {
|
|
1822
|
+
throw new CliError("unknown_flag", `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown
|
|
1823
|
+
.map((name) => `--${name}`)
|
|
1824
|
+
.join(", ")}.`, 1, { validFlags: ["--deliver", "--json", "--version"] });
|
|
1825
|
+
}
|
|
1331
1826
|
return {
|
|
1332
1827
|
cliVersion: await readCliVersion(),
|
|
1333
1828
|
skillVersion: await readBundledSkillVersion(),
|
|
1334
1829
|
};
|
|
1335
1830
|
}
|
|
1336
|
-
if (!noun
|
|
1337
|
-
|
|
1831
|
+
if (!noun) {
|
|
1832
|
+
const unknown = Array.from(parsed.flags.keys()).filter((name) => !["deliver", "help", "json", "profile"].includes(name));
|
|
1833
|
+
if (unknown.length > 0) {
|
|
1834
|
+
throw new CliError("unknown_flag", `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown
|
|
1835
|
+
.map((name) => `--${name}`)
|
|
1836
|
+
.join(", ")}.`, 1, { validFlags: ["--deliver", "--help", "--json", "--profile"] });
|
|
1837
|
+
}
|
|
1838
|
+
return { help: scopedHelp(HELP, commands, []) };
|
|
1839
|
+
}
|
|
1840
|
+
if (noun === "help" || hasFlag(parsed, "help")) {
|
|
1841
|
+
const scope = noun === "help" ? parsed.positionals.slice(1) : parsed.positionals;
|
|
1842
|
+
return { help: scopedHelp(HELP, commands, scope) };
|
|
1843
|
+
}
|
|
1844
|
+
validateInvocation(parsed, commands);
|
|
1845
|
+
if (noun === "agent-context") {
|
|
1846
|
+
const profiles = await listProfiles();
|
|
1847
|
+
return agentContext(commands, {
|
|
1848
|
+
cliVersion: await readCliVersion(),
|
|
1849
|
+
availableProfiles: profiles.profiles.map((profile) => profile.name),
|
|
1850
|
+
feedbackUpstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
if (noun === "profile") {
|
|
1854
|
+
const name = parsed.positionals[2];
|
|
1855
|
+
if (verb === "list")
|
|
1856
|
+
return listProfiles();
|
|
1857
|
+
if (verb === "show") {
|
|
1858
|
+
if (!name)
|
|
1859
|
+
throw new CliError("invalid_arguments", "Missing profile name.");
|
|
1860
|
+
const profiles = await listProfiles();
|
|
1861
|
+
const profile = profiles.profiles.find((entry) => entry.name === name);
|
|
1862
|
+
if (!profile)
|
|
1863
|
+
throw new CliError("not_found", `Unknown profile ${name}.`);
|
|
1864
|
+
return { ...profile, active: profiles.activeProfile === name };
|
|
1865
|
+
}
|
|
1866
|
+
if (verb === "save") {
|
|
1867
|
+
if (!name)
|
|
1868
|
+
throw new CliError("invalid_arguments", "Missing profile name.");
|
|
1869
|
+
const current = await resolveConfig(flagString(parsed, "profile"));
|
|
1870
|
+
const apiOrigin = flagString(parsed, "api-origin") ?? current.apiOrigin;
|
|
1871
|
+
const webOrigin = flagString(parsed, "web-origin") ?? current.webOrigin;
|
|
1872
|
+
const oauthClientId = flagString(parsed, "oauth-client-id") ?? current.oauthClientId;
|
|
1873
|
+
for (const [label, value] of [
|
|
1874
|
+
["--api-origin", apiOrigin],
|
|
1875
|
+
["--web-origin", webOrigin],
|
|
1876
|
+
]) {
|
|
1877
|
+
try {
|
|
1878
|
+
new URL(value);
|
|
1879
|
+
}
|
|
1880
|
+
catch {
|
|
1881
|
+
throw new CliError("invalid_arguments", `${label} must be a URL.`);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
await saveProfile(name, { apiOrigin, webOrigin, oauthClientId });
|
|
1885
|
+
return { saved: name, apiOrigin, webOrigin, oauthClientId };
|
|
1886
|
+
}
|
|
1887
|
+
if (verb === "use") {
|
|
1888
|
+
if (!name)
|
|
1889
|
+
throw new CliError("invalid_arguments", "Missing profile name.");
|
|
1890
|
+
await useProfile(name);
|
|
1891
|
+
return { activeProfile: name };
|
|
1892
|
+
}
|
|
1893
|
+
if (verb === "delete") {
|
|
1894
|
+
if (!name)
|
|
1895
|
+
throw new CliError("invalid_arguments", "Missing profile name.");
|
|
1896
|
+
await deleteProfile(name);
|
|
1897
|
+
return { deleted: name };
|
|
1898
|
+
}
|
|
1338
1899
|
}
|
|
1339
|
-
|
|
1900
|
+
if (noun === "jobs") {
|
|
1901
|
+
if (verb === "list") {
|
|
1902
|
+
const limit = flagNumber(parsed, "limit") ?? 20;
|
|
1903
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
1904
|
+
throw new CliError("invalid_arguments", "--limit must be an integer from 1 through 100.");
|
|
1905
|
+
}
|
|
1906
|
+
return listJobs(limit);
|
|
1907
|
+
}
|
|
1908
|
+
if (verb === "get") {
|
|
1909
|
+
const jobId = positional(parsed, 2, "operation key");
|
|
1910
|
+
const result = await getJob(jobId);
|
|
1911
|
+
if (!result.job)
|
|
1912
|
+
throw new CliError("not_found", `Unknown job ${jobId}.`);
|
|
1913
|
+
return result;
|
|
1914
|
+
}
|
|
1915
|
+
if (verb === "prune") {
|
|
1916
|
+
const olderThanDays = flagNumber(parsed, "older-than-days") ?? 30;
|
|
1917
|
+
if (!Number.isInteger(olderThanDays) ||
|
|
1918
|
+
olderThanDays < 1 ||
|
|
1919
|
+
olderThanDays > 3650) {
|
|
1920
|
+
throw new CliError("invalid_arguments", "--older-than-days must be an integer from 1 through 3650.");
|
|
1921
|
+
}
|
|
1922
|
+
return pruneJobs(olderThanDays);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
if (noun === "feedback") {
|
|
1926
|
+
if (verb === "list") {
|
|
1927
|
+
const limit = flagNumber(parsed, "limit") ?? 20;
|
|
1928
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
1929
|
+
throw new CliError("invalid_arguments", "--limit must be an integer from 1 through 100.");
|
|
1930
|
+
}
|
|
1931
|
+
return listFeedback(limit);
|
|
1932
|
+
}
|
|
1933
|
+
if (verb === "submit") {
|
|
1934
|
+
const feedbackText = parsed.positionals.slice(2).join(" ").trim();
|
|
1935
|
+
if (!feedbackText) {
|
|
1936
|
+
throw new CliError("invalid_arguments", "Feedback text must not be empty.");
|
|
1937
|
+
}
|
|
1938
|
+
if (feedbackText.length > 4_000) {
|
|
1939
|
+
throw new CliError("invalid_arguments", "Feedback text must be 4,000 characters or fewer.");
|
|
1940
|
+
}
|
|
1941
|
+
return writeCommand(parsed, {
|
|
1942
|
+
action: "record Recess CLI feedback locally and send it upstream when configured",
|
|
1943
|
+
target: {
|
|
1944
|
+
upstreamConfigured: Boolean(process.env.RECESS_CLI_FEEDBACK_ENDPOINT),
|
|
1945
|
+
},
|
|
1946
|
+
request: { text: feedbackText },
|
|
1947
|
+
}, () => submitFeedback({
|
|
1948
|
+
id: flagString(parsed, "operation-key", { required: true }),
|
|
1949
|
+
text: feedbackText,
|
|
1950
|
+
createdAt: new Date().toISOString(),
|
|
1951
|
+
}));
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
const config = await resolveConfig(flagString(parsed, "profile"));
|
|
1955
|
+
const requestReason = noun === "auth" ? undefined : requiredRequestReason(parsed);
|
|
1340
1956
|
if (noun === "doctor")
|
|
1341
|
-
return doctor(config);
|
|
1957
|
+
return doctor(config, requestReason);
|
|
1342
1958
|
if (noun === "setup") {
|
|
1343
1959
|
// Always lay down the bundled copy first: it is the floor, and it is the
|
|
1344
1960
|
// only copy guaranteed to match this binary. The served upgrade below is
|
|
@@ -1348,6 +1964,7 @@ export async function runCommand(argv) {
|
|
|
1348
1964
|
apiOrigin: config.apiOrigin,
|
|
1349
1965
|
sessionCookie: config.sessionCookie,
|
|
1350
1966
|
cliVersion: await readCliVersion(),
|
|
1967
|
+
reason: requestReason,
|
|
1351
1968
|
});
|
|
1352
1969
|
const ephemeral = isEphemeralInstall();
|
|
1353
1970
|
// Reuse a session that is still live; a missing or already-expired one is
|
|
@@ -1400,12 +2017,12 @@ export async function runCommand(argv) {
|
|
|
1400
2017
|
if (verb === "status")
|
|
1401
2018
|
return sessionStatus(config);
|
|
1402
2019
|
if (verb === "logout") {
|
|
1403
|
-
await clearStoredSession();
|
|
2020
|
+
await clearStoredSession(config.profileName);
|
|
1404
2021
|
return { loggedOut: true };
|
|
1405
2022
|
}
|
|
1406
2023
|
throw new CliError("invalid_arguments", "Use auth login, request, poll, status, or logout.");
|
|
1407
2024
|
}
|
|
1408
|
-
const api = new RecessAdminApi(config);
|
|
2025
|
+
const api = new RecessAdminApi(config, requestReason);
|
|
1409
2026
|
api.requireAuth();
|
|
1410
2027
|
if (noun === "village") {
|
|
1411
2028
|
const targetWorldId = flagString(parsed, "world") ?? "village-1";
|
|
@@ -2001,52 +2618,47 @@ export async function runCommand(argv) {
|
|
|
2001
2618
|
? { firstChargeAt: new Date(firstChargeAt).toISOString() }
|
|
2002
2619
|
: {}),
|
|
2003
2620
|
};
|
|
2004
|
-
//
|
|
2005
|
-
//
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
// the human before the confirmation gate.
|
|
2009
|
-
if (!hasFlag(parsed, "confirm")) {
|
|
2010
|
-
const preview = unwrap(await api.client.POST("/admin/cohorts/enroll/", {
|
|
2011
|
-
body: { ...body, dryRun: true },
|
|
2012
|
-
}));
|
|
2013
|
-
const b = preview.plan.billing;
|
|
2014
|
-
const usd = (cents) => `${(cents / 100).toFixed(2)} USD`;
|
|
2015
|
-
const recurrence = b.isRecurring ? `, recurring per ${b.interval}` : "";
|
|
2016
|
-
// effectivePriceCents already has the course discount applied; credits are
|
|
2017
|
-
// deducted later as an invoice line, so this is an upper bound.
|
|
2018
|
-
const amount = b.discountKind || b.creditBalanceCents > 0
|
|
2019
|
-
? `${usd(b.effectivePriceCents)} (list ${usd(b.listPriceCents)}${b.discountKind ? `, ${b.discountKind} discount applied` : ""}${b.creditBalanceCents > 0 ? `; up to ${usd(b.creditBalanceCents)} of Recess credits may reduce this further` : ""})`
|
|
2020
|
-
: usd(b.effectivePriceCents);
|
|
2021
|
-
requireConfirmation(false, {
|
|
2022
|
-
action: preview.plan.reusedEnrollmentId
|
|
2023
|
-
? "enroll kid into cohort by reusing an existing enrollment (no charge)"
|
|
2024
|
-
: b.chargesImmediately
|
|
2025
|
-
? `enroll kid into cohort and CHARGE ${amount} NOW${recurrence}`
|
|
2026
|
-
: `enroll kid into cohort with first charge of ${amount} on ${b.firstChargeAt}${recurrence}`,
|
|
2027
|
-
target: {
|
|
2028
|
-
kid: `${preview.plan.kid.firstName ?? ""} ${preview.plan.kid.lastName ?? ""}`.trim(),
|
|
2029
|
-
userId,
|
|
2030
|
-
cohort: preview.plan.cohort.name,
|
|
2031
|
-
cohortId,
|
|
2032
|
-
course: preview.plan.cohort.courseName,
|
|
2033
|
-
},
|
|
2034
|
-
request: { ...body, dryRun: false },
|
|
2035
|
-
details: {
|
|
2036
|
-
billing: b,
|
|
2037
|
-
reusedEnrollmentId: preview.plan.reusedEnrollmentId,
|
|
2038
|
-
warnings: preview.warnings,
|
|
2039
|
-
...(preview.warnings.length > 0 && !body.force
|
|
2040
|
-
? {
|
|
2041
|
-
blocked: "These warnings will reject the write unless you also pass --force.",
|
|
2042
|
-
}
|
|
2043
|
-
: {}),
|
|
2044
|
-
},
|
|
2045
|
-
});
|
|
2046
|
-
}
|
|
2047
|
-
return unwrap(await api.client.POST("/admin/cohorts/enroll/", {
|
|
2048
|
-
body: { ...body, dryRun: false },
|
|
2621
|
+
// The backend owns the price/capacity plan. Recompute it on every run and
|
|
2622
|
+
// bind the confirmation token to that exact plan before the mutation.
|
|
2623
|
+
const preflight = unwrap(await api.client.POST("/admin/cohorts/enroll/", {
|
|
2624
|
+
body: { ...body, dryRun: true },
|
|
2049
2625
|
}));
|
|
2626
|
+
const billing = preflight.plan.billing;
|
|
2627
|
+
const usd = (cents) => `${(cents / 100).toFixed(2)} USD`;
|
|
2628
|
+
const recurrence = billing.isRecurring
|
|
2629
|
+
? `, recurring per ${billing.interval}`
|
|
2630
|
+
: "";
|
|
2631
|
+
const amount = billing.discountKind || billing.creditBalanceCents > 0
|
|
2632
|
+
? `${usd(billing.effectivePriceCents)} (list ${usd(billing.listPriceCents)}${billing.discountKind ? `, ${billing.discountKind} discount applied` : ""}${billing.creditBalanceCents > 0 ? `; up to ${usd(billing.creditBalanceCents)} of Recess credits may reduce this further` : ""})`
|
|
2633
|
+
: usd(billing.effectivePriceCents);
|
|
2634
|
+
const preview = {
|
|
2635
|
+
action: preflight.plan.reusedEnrollmentId
|
|
2636
|
+
? "enroll kid into cohort by reusing an existing enrollment (no charge)"
|
|
2637
|
+
: billing.chargesImmediately
|
|
2638
|
+
? `enroll kid into cohort and CHARGE ${amount} NOW${recurrence}`
|
|
2639
|
+
: `enroll kid into cohort with first charge of ${amount} on ${billing.firstChargeAt}${recurrence}`,
|
|
2640
|
+
target: {
|
|
2641
|
+
kid: `${preflight.plan.kid.firstName ?? ""} ${preflight.plan.kid.lastName ?? ""}`.trim(),
|
|
2642
|
+
userId,
|
|
2643
|
+
cohort: preflight.plan.cohort.name,
|
|
2644
|
+
cohortId,
|
|
2645
|
+
course: preflight.plan.cohort.courseName,
|
|
2646
|
+
},
|
|
2647
|
+
request: { ...body, dryRun: false },
|
|
2648
|
+
details: {
|
|
2649
|
+
billing,
|
|
2650
|
+
reusedEnrollmentId: preflight.plan.reusedEnrollmentId,
|
|
2651
|
+
warnings: preflight.warnings,
|
|
2652
|
+
...(preflight.warnings.length > 0 && !body.force
|
|
2653
|
+
? {
|
|
2654
|
+
blocked: "These warnings will reject the write unless you also pass --force.",
|
|
2655
|
+
}
|
|
2656
|
+
: {}),
|
|
2657
|
+
},
|
|
2658
|
+
};
|
|
2659
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/cohorts/enroll/", {
|
|
2660
|
+
body: { ...body, dryRun: false },
|
|
2661
|
+
})));
|
|
2050
2662
|
}
|
|
2051
2663
|
if (noun === "enrollments" && verb === "register-cohort") {
|
|
2052
2664
|
const enrollmentId = flagString(parsed, "enrollment", { required: true });
|
|
@@ -2141,12 +2753,20 @@ export async function runCommand(argv) {
|
|
|
2141
2753
|
const search = flagString(parsed, "search");
|
|
2142
2754
|
const userId = flagString(parsed, "user");
|
|
2143
2755
|
const id = flagString(parsed, "id");
|
|
2756
|
+
const limit = flagNumber(parsed, "limit") ?? 20;
|
|
2757
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
2758
|
+
throw new CliError("invalid_arguments", "--limit must be an integer between 1 and 100.");
|
|
2759
|
+
}
|
|
2144
2760
|
return unwrap(await api.client.GET("/admin/payout/recipient/", {
|
|
2145
2761
|
params: {
|
|
2146
2762
|
query: {
|
|
2147
2763
|
...(search ? { search } : {}),
|
|
2148
2764
|
...(userId ? { userId } : {}),
|
|
2149
2765
|
...(id ? { id } : {}),
|
|
2766
|
+
limit,
|
|
2767
|
+
...(flagString(parsed, "cursor")
|
|
2768
|
+
? { cursor: flagString(parsed, "cursor") }
|
|
2769
|
+
: {}),
|
|
2150
2770
|
},
|
|
2151
2771
|
},
|
|
2152
2772
|
}));
|
|
@@ -2767,6 +3387,19 @@ export async function runCommand(argv) {
|
|
|
2767
3387
|
if (verb === "set-stage") {
|
|
2768
3388
|
const stage = assertChoice(flagString(parsed, "stage", { required: true }), CONTENT_LIBRARY_RESOURCE_STAGES, "--stage");
|
|
2769
3389
|
const input = await readContentLibraryGemTargets(parsed);
|
|
3390
|
+
const wait = hasFlag(parsed, "wait");
|
|
3391
|
+
const timeoutSeconds = flagNumber(parsed, "timeout") ?? 900;
|
|
3392
|
+
if (hasFlag(parsed, "timeout") && !wait) {
|
|
3393
|
+
throw new CliError("invalid_arguments", "--timeout requires --wait.");
|
|
3394
|
+
}
|
|
3395
|
+
if (wait && stage !== "live") {
|
|
3396
|
+
throw new CliError("invalid_arguments", "--wait is supported for content-library set-stage only when --stage live.");
|
|
3397
|
+
}
|
|
3398
|
+
if (!Number.isInteger(timeoutSeconds) ||
|
|
3399
|
+
timeoutSeconds < 10 ||
|
|
3400
|
+
timeoutSeconds > 7200) {
|
|
3401
|
+
throw new CliError("invalid_arguments", "--timeout must be an integer number of seconds from 10 through 7200.");
|
|
3402
|
+
}
|
|
2770
3403
|
const preflightBody = {
|
|
2771
3404
|
gems: input.gems,
|
|
2772
3405
|
stage,
|
|
@@ -2783,16 +3416,39 @@ export async function runCommand(argv) {
|
|
|
2783
3416
|
resources: preflight.results,
|
|
2784
3417
|
lifecycle: "Uses the same lifecycle as Manage. Direct-to-Live never bypasses unfinished polishing; leaving Polishing cancels its bound run before moving.",
|
|
2785
3418
|
concurrency: 3,
|
|
3419
|
+
waitForLive: wait,
|
|
3420
|
+
...(wait ? { timeoutSeconds } : {}),
|
|
2786
3421
|
...(input.file ? { inputFile: input.file } : {}),
|
|
2787
3422
|
},
|
|
2788
3423
|
};
|
|
2789
|
-
return writeCommand(parsed, preview, async () =>
|
|
2790
|
-
|
|
2791
|
-
|
|
3424
|
+
return writeCommand(parsed, preview, async () => {
|
|
3425
|
+
const result = unwrap(await api.client.POST("/admin/content-library/transition", {
|
|
3426
|
+
body: { gems: input.gems, stage, dryRun: false },
|
|
3427
|
+
}));
|
|
3428
|
+
if (!wait)
|
|
3429
|
+
return result;
|
|
3430
|
+
return {
|
|
3431
|
+
...result,
|
|
3432
|
+
wait: await waitForContentLibraryLive(api, input.gems, timeoutSeconds),
|
|
3433
|
+
};
|
|
3434
|
+
});
|
|
2792
3435
|
}
|
|
2793
3436
|
if (verb === "submit") {
|
|
2794
3437
|
const stage = assertChoice(flagString(parsed, "stage") ?? "review", CONTENT_LIBRARY_STAGES, "--stage");
|
|
2795
3438
|
const input = await readContentLibrarySubmitItems(parsed);
|
|
3439
|
+
const wait = hasFlag(parsed, "wait");
|
|
3440
|
+
const timeoutSeconds = flagNumber(parsed, "timeout") ?? 900;
|
|
3441
|
+
if (hasFlag(parsed, "timeout") && !wait) {
|
|
3442
|
+
throw new CliError("invalid_arguments", "--timeout requires --wait.");
|
|
3443
|
+
}
|
|
3444
|
+
if (wait && stage !== "polish") {
|
|
3445
|
+
throw new CliError("invalid_arguments", "--wait is supported for content-library submit only when --stage polish.");
|
|
3446
|
+
}
|
|
3447
|
+
if (!Number.isInteger(timeoutSeconds) ||
|
|
3448
|
+
timeoutSeconds < 10 ||
|
|
3449
|
+
timeoutSeconds > 7200) {
|
|
3450
|
+
throw new CliError("invalid_arguments", "--timeout must be an integer number of seconds from 10 through 7200.");
|
|
3451
|
+
}
|
|
2796
3452
|
const body = { stage, items: input.items };
|
|
2797
3453
|
const preview = {
|
|
2798
3454
|
action: "content-library.submit",
|
|
@@ -2804,51 +3460,57 @@ export async function runCommand(argv) {
|
|
|
2804
3460
|
: "Holds each new resource in REVIEW until an admin approves it.",
|
|
2805
3461
|
duplicateBehavior: "Existing URLs are returned as duplicates and are not overwritten.",
|
|
2806
3462
|
deployOrderFence: "The server verifies the island's review/polish lifecycle capability before its first write.",
|
|
3463
|
+
waitForLive: wait,
|
|
3464
|
+
...(wait ? { timeoutSeconds } : {}),
|
|
2807
3465
|
...(input.file ? { inputFile: input.file } : {}),
|
|
2808
3466
|
},
|
|
2809
3467
|
};
|
|
2810
|
-
return writeCommand(parsed, preview, async () =>
|
|
2811
|
-
|
|
2812
|
-
|
|
3468
|
+
return writeCommand(parsed, preview, async () => {
|
|
3469
|
+
const result = unwrap(await api.client.POST("/admin/content-library/submit", {
|
|
3470
|
+
body,
|
|
3471
|
+
}));
|
|
3472
|
+
if (!wait)
|
|
3473
|
+
return result;
|
|
3474
|
+
return {
|
|
3475
|
+
...result,
|
|
3476
|
+
wait: await waitForContentLibraryLive(api, input.items.map((item) => item.url), timeoutSeconds),
|
|
3477
|
+
};
|
|
3478
|
+
});
|
|
2813
3479
|
}
|
|
2814
3480
|
throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
|
|
2815
3481
|
}
|
|
2816
3482
|
if (noun === "goal-templates") {
|
|
2817
3483
|
if (verb === "list") {
|
|
2818
|
-
const query = flagString(parsed, "query")
|
|
3484
|
+
const query = flagString(parsed, "query");
|
|
2819
3485
|
const kind = flagString(parsed, "kind");
|
|
2820
|
-
const
|
|
3486
|
+
const wantedKind = kind
|
|
3487
|
+
? assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind")
|
|
3488
|
+
: undefined;
|
|
3489
|
+
const limit = flagNumber(parsed, "limit") ?? 20;
|
|
3490
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
3491
|
+
throw new CliError("invalid_arguments", "--limit must be an integer between 1 and 100.");
|
|
3492
|
+
}
|
|
3493
|
+
return unwrap(await api.client.GET("/ai/goal-templates/", {
|
|
2821
3494
|
params: {
|
|
2822
3495
|
query: {
|
|
2823
3496
|
...(flagString(parsed, "category")
|
|
2824
3497
|
? { category: flagString(parsed, "category") }
|
|
2825
3498
|
: {}),
|
|
3499
|
+
...(query ? { query } : {}),
|
|
3500
|
+
...(wantedKind ? { kind: wantedKind } : {}),
|
|
2826
3501
|
includeDeleted: hasFlag(parsed, "include-deleted")
|
|
2827
3502
|
? "true"
|
|
2828
3503
|
: "false",
|
|
2829
3504
|
starterOnly: hasFlag(parsed, "starter-only")
|
|
2830
3505
|
? "true"
|
|
2831
3506
|
: "false",
|
|
3507
|
+
limit,
|
|
3508
|
+
...(flagString(parsed, "cursor")
|
|
3509
|
+
? { cursor: flagString(parsed, "cursor") }
|
|
3510
|
+
: {}),
|
|
2832
3511
|
},
|
|
2833
3512
|
},
|
|
2834
3513
|
}));
|
|
2835
|
-
// The route filters by category/starter/deleted only; `--query` and
|
|
2836
|
-
// `--kind` narrow the returned page here rather than pretending the
|
|
2837
|
-
// backend supports them.
|
|
2838
|
-
const wantedKind = kind
|
|
2839
|
-
? assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind")
|
|
2840
|
-
: undefined;
|
|
2841
|
-
const items = data.items.filter((item) => {
|
|
2842
|
-
if (wantedKind && item.kind !== wantedKind)
|
|
2843
|
-
return false;
|
|
2844
|
-
if (!query)
|
|
2845
|
-
return true;
|
|
2846
|
-
return [item.title, item.slug, item.description, item.category ?? ""]
|
|
2847
|
-
.join("\n")
|
|
2848
|
-
.toLowerCase()
|
|
2849
|
-
.includes(query);
|
|
2850
|
-
});
|
|
2851
|
-
return { items, totalBeforeFilter: data.items.length };
|
|
2852
3514
|
}
|
|
2853
3515
|
if (verb === "get") {
|
|
2854
3516
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -2953,7 +3615,7 @@ export async function runCommand(argv) {
|
|
|
2953
3615
|
// protected-inventory counting, and the token binding the approved loss
|
|
2954
3616
|
// to this exact version + result hash. Always ask it for a fresh preview,
|
|
2955
3617
|
// including on a confirmed run, before permitting the write request.
|
|
2956
|
-
const preflight = unwrap(await api.client.POST("/
|
|
3618
|
+
const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/setup-workflow-spec/patch", {
|
|
2957
3619
|
params: { path: { id } },
|
|
2958
3620
|
body: {
|
|
2959
3621
|
expectedVersion,
|
|
@@ -2991,7 +3653,9 @@ export async function runCommand(argv) {
|
|
|
2991
3653
|
: {}),
|
|
2992
3654
|
},
|
|
2993
3655
|
};
|
|
2994
|
-
|
|
3656
|
+
if (!hasFlag(parsed, "confirm")) {
|
|
3657
|
+
await requirePreviewBoundConfirmation(parsed, preview);
|
|
3658
|
+
}
|
|
2995
3659
|
const destructiveChangeToken = flagString(parsed, "destructive-change-token");
|
|
2996
3660
|
if (preflight.preview.destructiveChanges &&
|
|
2997
3661
|
(!hasFlag(parsed, "confirm-destructive-changes") ||
|
|
@@ -3007,7 +3671,7 @@ export async function runCommand(argv) {
|
|
|
3007
3671
|
expectedDestructiveChangeToken: preflight.preview.destructiveChangeToken,
|
|
3008
3672
|
});
|
|
3009
3673
|
}
|
|
3010
|
-
return unwrap(await api.client.POST("/
|
|
3674
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/setup-workflow-spec/patch", {
|
|
3011
3675
|
params: { path: { id } },
|
|
3012
3676
|
body: {
|
|
3013
3677
|
expectedVersion,
|
|
@@ -3016,7 +3680,7 @@ export async function runCommand(argv) {
|
|
|
3016
3680
|
confirmDestructiveChanges: preflight.preview.destructiveChanges,
|
|
3017
3681
|
...(destructiveChangeToken ? { destructiveChangeToken } : {}),
|
|
3018
3682
|
},
|
|
3019
|
-
}));
|
|
3683
|
+
})));
|
|
3020
3684
|
}
|
|
3021
3685
|
if (verb === "set-metadata") {
|
|
3022
3686
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -3085,7 +3749,7 @@ export async function runCommand(argv) {
|
|
|
3085
3749
|
action: "update goal template metadata (never its setupWorkflowSpec)",
|
|
3086
3750
|
target: { templateId: id, expectedVersion },
|
|
3087
3751
|
request: body,
|
|
3088
|
-
}, async () => unwrap(await api.client.PUT("/
|
|
3752
|
+
}, async () => unwrap(await api.client.PUT("/ai/goal-templates/{id}", {
|
|
3089
3753
|
params: { path: { id } },
|
|
3090
3754
|
body,
|
|
3091
3755
|
})));
|
|
@@ -3096,7 +3760,7 @@ export async function runCommand(argv) {
|
|
|
3096
3760
|
// Read the current version so the gate refuses a stale delete locally,
|
|
3097
3761
|
// and so the preview names the template a human is being asked to approve
|
|
3098
3762
|
// rather than only its UUID.
|
|
3099
|
-
const current = unwrap(await api.client.GET("/
|
|
3763
|
+
const current = unwrap(await api.client.GET("/ai/goal-templates/{id}", {
|
|
3100
3764
|
params: { path: { id } },
|
|
3101
3765
|
}));
|
|
3102
3766
|
if (current.version !== expectedVersion) {
|
|
@@ -3114,7 +3778,7 @@ export async function runCommand(argv) {
|
|
|
3114
3778
|
details: {
|
|
3115
3779
|
note: "Soft delete: the row keeps its deletedAt and drops out of every list. Existing goals already applied from it are unaffected.",
|
|
3116
3780
|
},
|
|
3117
|
-
}, async () => unwrap(await api.client.DELETE("/
|
|
3781
|
+
}, async () => unwrap(await api.client.DELETE("/ai/goal-templates/{id}", {
|
|
3118
3782
|
params: { path: { id } },
|
|
3119
3783
|
})));
|
|
3120
3784
|
}
|
|
@@ -3134,18 +3798,43 @@ export async function runCommand(argv) {
|
|
|
3134
3798
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3135
3799
|
const sourceGoalId = flagString(parsed, "source-goal");
|
|
3136
3800
|
const sourceDraft = flagString(parsed, "source-draft");
|
|
3137
|
-
|
|
3138
|
-
|
|
3801
|
+
const sourceDirectory = flagString(parsed, "source-dir");
|
|
3802
|
+
if ([sourceGoalId, sourceDraft, sourceDirectory].filter(Boolean).length !==
|
|
3803
|
+
1) {
|
|
3804
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --source-goal, --source-draft, or --source-dir.");
|
|
3805
|
+
}
|
|
3806
|
+
const checkout = sourceDirectory
|
|
3807
|
+
? await readRecessGitMetadata(sourceDirectory)
|
|
3808
|
+
: null;
|
|
3809
|
+
const checkoutHead = checkout
|
|
3810
|
+
? await assertCheckoutReadyForMaterialization(checkout)
|
|
3811
|
+
: null;
|
|
3812
|
+
const requestedSourceStudentId = flagString(parsed, "student");
|
|
3813
|
+
if (checkout &&
|
|
3814
|
+
requestedSourceStudentId &&
|
|
3815
|
+
requestedSourceStudentId !== checkout.metadata.studentId) {
|
|
3816
|
+
throw new CliError("invalid_arguments", `--student ${requestedSourceStudentId} does not match checkout owner ${checkout.metadata.studentId}.`);
|
|
3139
3817
|
}
|
|
3140
3818
|
const sourceStudentUserId = sourceDraft
|
|
3141
3819
|
? flagString(parsed, "student", { required: true })
|
|
3142
|
-
:
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3820
|
+
: checkout?.metadata.studentId;
|
|
3821
|
+
let source;
|
|
3822
|
+
if (sourceGoalId) {
|
|
3823
|
+
source = { sourceGoalId };
|
|
3824
|
+
}
|
|
3825
|
+
else if (checkout?.metadata.target.kind === "goal") {
|
|
3826
|
+
source = { sourceGoalId: checkout.metadata.target.goalId };
|
|
3827
|
+
}
|
|
3828
|
+
else {
|
|
3829
|
+
const draftSlug = sourceDraft ??
|
|
3830
|
+
(checkout?.metadata.target.kind === "draft"
|
|
3831
|
+
? checkout.metadata.target.draftSlug
|
|
3832
|
+
: undefined);
|
|
3833
|
+
source = {
|
|
3834
|
+
sourceWorkspacePath: `drafts/${draftSlug}/workspace`,
|
|
3835
|
+
sourceStudentUserId: sourceStudentUserId,
|
|
3148
3836
|
};
|
|
3837
|
+
}
|
|
3149
3838
|
const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
|
|
3150
3839
|
params: { path: { id } },
|
|
3151
3840
|
body: { ...source, dryRun: true },
|
|
@@ -3153,6 +3842,10 @@ export async function runCommand(argv) {
|
|
|
3153
3842
|
if (preflight.action !== "preview_capture_snapshot") {
|
|
3154
3843
|
throw new CliError("unexpected_response", "Snapshot capture did not return a preview; nothing was captured.");
|
|
3155
3844
|
}
|
|
3845
|
+
if (checkout &&
|
|
3846
|
+
preflight.source.changeId !== checkout.metadata.changeId) {
|
|
3847
|
+
throw new CliError("stale_workspace", `Workspace changed from ${checkout.metadata.changeId} to ${preflight.source.changeId}. Check it out again before capturing; nothing was captured.`);
|
|
3848
|
+
}
|
|
3156
3849
|
if (hasFlag(parsed, "dry-run"))
|
|
3157
3850
|
return preflight;
|
|
3158
3851
|
const preview = {
|
|
@@ -3169,6 +3862,13 @@ export async function runCommand(argv) {
|
|
|
3169
3862
|
fileCount: preflight.snapshot.fileCount,
|
|
3170
3863
|
sizeBytes: preflight.snapshot.sizeBytes,
|
|
3171
3864
|
sha256: preflight.snapshot.sha256,
|
|
3865
|
+
...(checkout
|
|
3866
|
+
? {
|
|
3867
|
+
sourceDirectory: checkout.root,
|
|
3868
|
+
localHeadCommit: checkoutHead,
|
|
3869
|
+
mesaChangeId: checkout.metadata.changeId,
|
|
3870
|
+
}
|
|
3871
|
+
: {}),
|
|
3172
3872
|
},
|
|
3173
3873
|
details: {
|
|
3174
3874
|
modules: preflight.snapshot.modules,
|
|
@@ -3177,8 +3877,7 @@ export async function runCommand(argv) {
|
|
|
3177
3877
|
note: "The confirmed request is fenced to both this template version and this exact source revision. Capturing bumps the template version unless the snapshot is unchanged.",
|
|
3178
3878
|
},
|
|
3179
3879
|
};
|
|
3180
|
-
|
|
3181
|
-
return unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
|
|
3880
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
|
|
3182
3881
|
params: { path: { id } },
|
|
3183
3882
|
body: {
|
|
3184
3883
|
...source,
|
|
@@ -3186,7 +3885,7 @@ export async function runCommand(argv) {
|
|
|
3186
3885
|
expectedTemplateVersion: preflight.template.version,
|
|
3187
3886
|
expectedSourceChangeId: preflight.source.changeId,
|
|
3188
3887
|
},
|
|
3189
|
-
}));
|
|
3888
|
+
})));
|
|
3190
3889
|
}
|
|
3191
3890
|
if (verb === "apply") {
|
|
3192
3891
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -3230,15 +3929,14 @@ export async function runCommand(argv) {
|
|
|
3230
3929
|
note: "Counts come from the backend's own dry run. `skipped_existing` items are idempotent — re-applying does not duplicate them.",
|
|
3231
3930
|
},
|
|
3232
3931
|
};
|
|
3233
|
-
|
|
3234
|
-
return unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
|
|
3932
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/apply-workflow", {
|
|
3235
3933
|
params: { path: { id } },
|
|
3236
3934
|
body: {
|
|
3237
3935
|
answers,
|
|
3238
3936
|
dryRun: false,
|
|
3239
3937
|
expectedTemplateVersion: preflight.templateVersion,
|
|
3240
3938
|
},
|
|
3241
|
-
}));
|
|
3939
|
+
})));
|
|
3242
3940
|
}
|
|
3243
3941
|
if (verb === "apply-starter") {
|
|
3244
3942
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -3272,7 +3970,30 @@ export async function runCommand(argv) {
|
|
|
3272
3970
|
}));
|
|
3273
3971
|
}
|
|
3274
3972
|
if (verb === "create") {
|
|
3275
|
-
const
|
|
3973
|
+
const requestedStudentId = flagString(parsed, "student");
|
|
3974
|
+
const sourceDirectory = flagString(parsed, "source-dir");
|
|
3975
|
+
const explicitDraftSlug = flagString(parsed, "draft");
|
|
3976
|
+
if (sourceDirectory && explicitDraftSlug) {
|
|
3977
|
+
throw new CliError("invalid_arguments", "Pass either --source-dir or --draft, not both.");
|
|
3978
|
+
}
|
|
3979
|
+
const checkout = sourceDirectory
|
|
3980
|
+
? await readRecessGitMetadata(sourceDirectory)
|
|
3981
|
+
: null;
|
|
3982
|
+
if (checkout && checkout.metadata.target.kind !== "draft") {
|
|
3983
|
+
throw new CliError("invalid_checkout", "A fresh module goal must be created from a draft checkout, not an existing goal checkout.");
|
|
3984
|
+
}
|
|
3985
|
+
const checkoutHead = checkout
|
|
3986
|
+
? await assertCheckoutReadyForMaterialization(checkout)
|
|
3987
|
+
: null;
|
|
3988
|
+
if (checkout &&
|
|
3989
|
+
requestedStudentId &&
|
|
3990
|
+
requestedStudentId !== checkout.metadata.studentId) {
|
|
3991
|
+
throw new CliError("invalid_arguments", `--student ${requestedStudentId} does not match checkout owner ${checkout.metadata.studentId}.`);
|
|
3992
|
+
}
|
|
3993
|
+
const userId = requestedStudentId ?? checkout?.metadata.studentId;
|
|
3994
|
+
if (!userId) {
|
|
3995
|
+
throw new CliError("invalid_arguments", "Pass --student for a direct goal create, or use a Recess draft --source-dir.");
|
|
3996
|
+
}
|
|
3276
3997
|
const title = flagString(parsed, "title", { required: true });
|
|
3277
3998
|
const descriptionFile = flagString(parsed, "description-file");
|
|
3278
3999
|
const descriptionFlag = flagString(parsed, "description");
|
|
@@ -3288,14 +4009,17 @@ export async function runCommand(argv) {
|
|
|
3288
4009
|
throw new CliError("invalid_arguments", "--target-date must be an ISO 8601 datetime.");
|
|
3289
4010
|
}
|
|
3290
4011
|
const schedule = flagString(parsed, "schedule");
|
|
3291
|
-
const draftSlug =
|
|
4012
|
+
const draftSlug = explicitDraftSlug ??
|
|
4013
|
+
(checkout?.metadata.target.kind === "draft"
|
|
4014
|
+
? checkout.metadata.target.draftSlug
|
|
4015
|
+
: undefined);
|
|
3292
4016
|
const enablesAppletFollowUps = hasFlag(parsed, "enable-applet-follow-ups");
|
|
3293
4017
|
const disablesAppletFollowUps = hasFlag(parsed, "disable-applet-follow-ups");
|
|
3294
4018
|
if (draftSlug && enablesAppletFollowUps === disablesAppletFollowUps) {
|
|
3295
4019
|
throw new CliError("invalid_arguments", "A module-backed goal requires exactly one of --enable-applet-follow-ups or --disable-applet-follow-ups.");
|
|
3296
4020
|
}
|
|
3297
4021
|
if (!draftSlug && (enablesAppletFollowUps || disablesAppletFollowUps)) {
|
|
3298
|
-
throw new CliError("invalid_arguments", "Applet follow-up flags are used with --draft for
|
|
4022
|
+
throw new CliError("invalid_arguments", "Applet follow-up flags are used with --draft or --source-dir for module-backed goals.");
|
|
3299
4023
|
}
|
|
3300
4024
|
if (draftSlug) {
|
|
3301
4025
|
const body = {
|
|
@@ -3313,6 +4037,10 @@ export async function runCommand(argv) {
|
|
|
3313
4037
|
if (preflight.action !== "preview_module_goal_create") {
|
|
3314
4038
|
throw new CliError("unexpected_response", "Direct module goal creation did not return a preview; no goal was created.");
|
|
3315
4039
|
}
|
|
4040
|
+
if (checkout &&
|
|
4041
|
+
preflight.currentChangeId !== checkout.metadata.changeId) {
|
|
4042
|
+
throw new CliError("stale_workspace", `Draft changed from ${checkout.metadata.changeId} to ${preflight.currentChangeId}. Check it out again before creating the goal; nothing was created.`);
|
|
4043
|
+
}
|
|
3316
4044
|
const preview = {
|
|
3317
4045
|
action: "create a module-backed goal directly from a validated draft workspace",
|
|
3318
4046
|
target: preflight.target,
|
|
@@ -3325,6 +4053,13 @@ export async function runCommand(argv) {
|
|
|
3325
4053
|
targetDate: targetDate ?? null,
|
|
3326
4054
|
schedule: schedule ?? null,
|
|
3327
4055
|
enableAppletFollowUps: enablesAppletFollowUps,
|
|
4056
|
+
...(checkout
|
|
4057
|
+
? {
|
|
4058
|
+
sourceDirectory: checkout.root,
|
|
4059
|
+
localHeadCommit: checkoutHead,
|
|
4060
|
+
mesaChangeId: checkout.metadata.changeId,
|
|
4061
|
+
}
|
|
4062
|
+
: {}),
|
|
3328
4063
|
},
|
|
3329
4064
|
details: {
|
|
3330
4065
|
currentChangeId: preflight.currentChangeId,
|
|
@@ -3338,14 +4073,35 @@ export async function runCommand(argv) {
|
|
|
3338
4073
|
note: "Creates a personal module-backed goal directly. It does not create, capture, or apply a reusable template. After success, Recess removes the draft only when no newer Mesa edit has landed; otherwise it preserves the draft.",
|
|
3339
4074
|
},
|
|
3340
4075
|
};
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
4076
|
+
return previewBoundWrite(parsed, preview, async () => {
|
|
4077
|
+
const result = unwrap(await api.client.POST("/tutor/browser/mesa/goals", {
|
|
4078
|
+
body: {
|
|
4079
|
+
...body,
|
|
4080
|
+
dryRun: false,
|
|
4081
|
+
expectedWorkspaceChangeId: preflight.currentChangeId,
|
|
4082
|
+
},
|
|
4083
|
+
}));
|
|
4084
|
+
if (result.action !== "create_module_goal") {
|
|
4085
|
+
throw new CliError("unexpected_response", "Module goal creation returned a preview instead of creating the goal.");
|
|
4086
|
+
}
|
|
4087
|
+
if (checkout && result.mesaChangeId) {
|
|
4088
|
+
await writeRecessGitMetadata(checkout.root, {
|
|
4089
|
+
...checkout.metadata,
|
|
4090
|
+
target: { kind: "goal", goalId: result.goalId },
|
|
4091
|
+
changeId: result.mesaChangeId,
|
|
4092
|
+
upstreamCommit: checkoutHead,
|
|
4093
|
+
});
|
|
4094
|
+
return {
|
|
4095
|
+
...result,
|
|
4096
|
+
local: {
|
|
4097
|
+
directory: checkout.root,
|
|
4098
|
+
target: { kind: "goal", goalId: result.goalId },
|
|
4099
|
+
upstreamCommit: checkoutHead,
|
|
4100
|
+
},
|
|
4101
|
+
};
|
|
4102
|
+
}
|
|
4103
|
+
return result;
|
|
4104
|
+
});
|
|
3349
4105
|
}
|
|
3350
4106
|
const body = {
|
|
3351
4107
|
title,
|
|
@@ -3363,7 +4119,7 @@ export async function runCommand(argv) {
|
|
|
3363
4119
|
schedule: schedule ?? null,
|
|
3364
4120
|
},
|
|
3365
4121
|
details: {
|
|
3366
|
-
note: "Creates a description-only goal with no modules or course workspace. Pass
|
|
4122
|
+
note: "Creates a description-only goal with no modules or course workspace. Pass a draft checkout with --source-dir to create a personal module-backed goal. A 409 GOAL_LIMIT_REACHED means the kid is at capacity and nothing was created.",
|
|
3367
4123
|
},
|
|
3368
4124
|
}, async () => unwrap(await api.client.POST("/tutor/browser/students/{userId}/goals/", {
|
|
3369
4125
|
params: { path: { userId } },
|
|
@@ -3392,11 +4148,39 @@ export async function runCommand(argv) {
|
|
|
3392
4148
|
patch,
|
|
3393
4149
|
},
|
|
3394
4150
|
};
|
|
3395
|
-
|
|
3396
|
-
return unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
|
|
4151
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
|
|
3397
4152
|
params: { path: { goalId } },
|
|
3398
4153
|
body: patch,
|
|
3399
|
-
}));
|
|
4154
|
+
})));
|
|
4155
|
+
}
|
|
4156
|
+
if (verb === "delete") {
|
|
4157
|
+
const goalId = positional(parsed, 2, "goal ID");
|
|
4158
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
4159
|
+
const current = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
4160
|
+
params: { path: { userId: studentId } },
|
|
4161
|
+
})).goals.find((goal) => goal.id === goalId);
|
|
4162
|
+
if (!current) {
|
|
4163
|
+
throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
|
|
4164
|
+
}
|
|
4165
|
+
const preview = {
|
|
4166
|
+
action: "soft-delete a goal for a managed student",
|
|
4167
|
+
target: {
|
|
4168
|
+
goalId,
|
|
4169
|
+
studentUserId: studentId,
|
|
4170
|
+
title: current.title,
|
|
4171
|
+
status: current.status,
|
|
4172
|
+
},
|
|
4173
|
+
request: { expectedUpdatedAt: current.updatedAt },
|
|
4174
|
+
details: {
|
|
4175
|
+
note: "Soft-deletes the goal and its current/future dated todos. Historical todos and the deletion audit remain available.",
|
|
4176
|
+
},
|
|
4177
|
+
};
|
|
4178
|
+
return previewBoundWrite(parsed, preview, async () => {
|
|
4179
|
+
unwrap(await api.client.DELETE("/admin/browser/students/goals/{goalId}/", {
|
|
4180
|
+
params: { path: { goalId } },
|
|
4181
|
+
}));
|
|
4182
|
+
return { deleted: true, goalId, studentUserId: studentId };
|
|
4183
|
+
});
|
|
3400
4184
|
}
|
|
3401
4185
|
if (verb === "queue") {
|
|
3402
4186
|
const subverb = positional(parsed, 2, "queue action (get|set)");
|
|
@@ -3411,7 +4195,7 @@ export async function runCommand(argv) {
|
|
|
3411
4195
|
});
|
|
3412
4196
|
const delta = flagString(parsed, "delta", { required: true });
|
|
3413
4197
|
const replaceDescriptionPointer = hasFlag(parsed, "replace-description-pointer");
|
|
3414
|
-
const { absolutePath, parsed: rawEntries } = await readJsonValue(entriesFile, "Queue entries file");
|
|
4198
|
+
const { absolutePath, raw: rawEntriesText, parsed: rawEntries, } = await readJsonValue(entriesFile, "Queue entries file");
|
|
3415
4199
|
const entries = parseGoalQueueEntries(rawEntries, "Queue entries file");
|
|
3416
4200
|
const goal = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
3417
4201
|
params: { path: { userId: studentId } },
|
|
@@ -3422,15 +4206,36 @@ export async function runCommand(argv) {
|
|
|
3422
4206
|
const current = unwrap(await api.client.GET("/tutor/browser/students/goals/{goalId}/queue/", { params: { path: { goalId } } }));
|
|
3423
4207
|
const currentUrls = new Set(current.queue.map((item) => item.url));
|
|
3424
4208
|
const proposedUrls = new Set(entries.map((entry) => entry.url));
|
|
4209
|
+
const currentByUrl = new Map(current.queue.map((item) => [item.url, item]));
|
|
4210
|
+
const completionChanges = entries.flatMap((entry) => {
|
|
4211
|
+
if (entry.completed === undefined)
|
|
4212
|
+
return [];
|
|
4213
|
+
const existing = currentByUrl.get(entry.url);
|
|
4214
|
+
const currentlyCompleted = existing?.completedAt != null;
|
|
4215
|
+
if (currentlyCompleted === entry.completed)
|
|
4216
|
+
return [];
|
|
4217
|
+
return [
|
|
4218
|
+
{
|
|
4219
|
+
moduleRef: existing?.moduleRef ?? null,
|
|
4220
|
+
url: entry.url,
|
|
4221
|
+
fromCompletedAt: existing?.completedAt ?? null,
|
|
4222
|
+
toCompleted: entry.completed,
|
|
4223
|
+
},
|
|
4224
|
+
];
|
|
4225
|
+
});
|
|
3425
4226
|
const preview = {
|
|
3426
4227
|
action: "replace a goal's URL skill queue for a managed student",
|
|
3427
4228
|
target: { goalId, studentUserId: studentId },
|
|
3428
4229
|
request: {
|
|
3429
4230
|
entriesFile: absolutePath,
|
|
4231
|
+
entriesSha256: createHash("sha256")
|
|
4232
|
+
.update(rawEntriesText)
|
|
4233
|
+
.digest("hex"),
|
|
3430
4234
|
entryCount: entries.length,
|
|
3431
4235
|
delta,
|
|
3432
4236
|
replaceDescriptionPointer,
|
|
3433
4237
|
expectedUpdatedAt: goal.updatedAt,
|
|
4238
|
+
unchangedTitleForConcurrencyFence: goal.title,
|
|
3434
4239
|
},
|
|
3435
4240
|
details: {
|
|
3436
4241
|
currentQueue: current.queue.map((item) => ({
|
|
@@ -3444,26 +4249,31 @@ export async function runCommand(argv) {
|
|
|
3444
4249
|
removed: current.queue
|
|
3445
4250
|
.filter((item) => !proposedUrls.has(item.url))
|
|
3446
4251
|
.map((item) => item.url),
|
|
4252
|
+
completionChanges,
|
|
3447
4253
|
note: "Replaces the entire EXTERNAL_URL queue. Completion is preserved for unchanged URLs; WORKSPACE modules are untouched. Daily generation mints the head entry for a flag-on kid.",
|
|
3448
4254
|
},
|
|
3449
4255
|
};
|
|
3450
|
-
requirePreviewBoundConfirmation(parsed, preview);
|
|
3451
4256
|
const body = {
|
|
3452
4257
|
delta,
|
|
3453
4258
|
expectedUpdatedAt: goal.updatedAt,
|
|
4259
|
+
// The deployed route's queue-only CAS reaches Prisma with an empty
|
|
4260
|
+
// scalar update, which reports zero rows and falsely trips the stale
|
|
4261
|
+
// write fence. Echoing the already-previewed title gives that CAS a
|
|
4262
|
+
// real update field without changing the title.
|
|
4263
|
+
title: goal.title,
|
|
3454
4264
|
queue: entries,
|
|
3455
4265
|
...(replaceDescriptionPointer
|
|
3456
4266
|
? { description: URL_QUEUE_DESCRIPTION_POINTER }
|
|
3457
4267
|
: {}),
|
|
3458
4268
|
};
|
|
3459
|
-
return unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
|
|
4269
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
|
|
3460
4270
|
params: { path: { goalId } },
|
|
3461
4271
|
body,
|
|
3462
|
-
}));
|
|
4272
|
+
})));
|
|
3463
4273
|
}
|
|
3464
4274
|
throw new CliError("invalid_arguments", "Use goals queue get|set.");
|
|
3465
4275
|
}
|
|
3466
|
-
throw new CliError("invalid_arguments", "Use goals list|create|edit|queue|files|pdf.");
|
|
4276
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|queue|files|pdf.");
|
|
3467
4277
|
}
|
|
3468
4278
|
if (noun === "students") {
|
|
3469
4279
|
if (verb === "list") {
|
|
@@ -3559,11 +4369,37 @@ export async function runCommand(argv) {
|
|
|
3559
4369
|
patch: body,
|
|
3560
4370
|
},
|
|
3561
4371
|
};
|
|
3562
|
-
|
|
3563
|
-
return unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
|
|
4372
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/todos/{id}", {
|
|
3564
4373
|
params: { path: { id: todoId } },
|
|
3565
4374
|
body,
|
|
4375
|
+
})));
|
|
4376
|
+
}
|
|
4377
|
+
if (verb === "delete") {
|
|
4378
|
+
const todoId = positional(parsed, 2, "todo ID");
|
|
4379
|
+
const current = unwrap(await api.client.GET("/tutor/browser/todos/{id}/", {
|
|
4380
|
+
params: { path: { id: todoId } },
|
|
3566
4381
|
}));
|
|
4382
|
+
const preview = {
|
|
4383
|
+
action: "permanently delete a todo for a managed student",
|
|
4384
|
+
target: {
|
|
4385
|
+
todoId,
|
|
4386
|
+
studentUserId: current.userId,
|
|
4387
|
+
title: current.title,
|
|
4388
|
+
status: current.status,
|
|
4389
|
+
goalId: current.goal?.id ?? null,
|
|
4390
|
+
},
|
|
4391
|
+
request: { expectedUpdatedAt: current.updatedAt },
|
|
4392
|
+
details: {
|
|
4393
|
+
dueDate: current.dueDate,
|
|
4394
|
+
creationSource: current.creationSource,
|
|
4395
|
+
creationSourceId: current.creationSourceId,
|
|
4396
|
+
url: "url" in current ? current.url : null,
|
|
4397
|
+
note: "Hard-deletes this todo without awarding XP or running todo-completion side effects. This is permanent; use it only when the preview identifies a disposable todo with no work to preserve.",
|
|
4398
|
+
},
|
|
4399
|
+
};
|
|
4400
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.DELETE("/admin/todos/{id}/", {
|
|
4401
|
+
params: { path: { id: todoId } },
|
|
4402
|
+
})));
|
|
3567
4403
|
}
|
|
3568
4404
|
if (verb === "generate-applet") {
|
|
3569
4405
|
const todoId = positional(parsed, 2, "todo ID");
|
|
@@ -3601,13 +4437,12 @@ export async function runCommand(argv) {
|
|
|
3601
4437
|
versionRouting: "The server evaluates applet-gen-v2 for this student and uses v1 when the flag is off or its evaluation fails.",
|
|
3602
4438
|
},
|
|
3603
4439
|
};
|
|
3604
|
-
|
|
3605
|
-
return unwrap(await api.client.POST("/admin/learning-pipeline/{analysisId}/generate-applet/", {
|
|
4440
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/admin/learning-pipeline/{analysisId}/generate-applet/", {
|
|
3606
4441
|
params: { path: { analysisId: analysis.id } },
|
|
3607
4442
|
body: targetDueDateISO ? { targetDueDateISO } : {},
|
|
3608
|
-
}));
|
|
4443
|
+
})));
|
|
3609
4444
|
}
|
|
3610
|
-
throw new CliError("invalid_arguments", "Use todos create|edit|generate-applet.");
|
|
4445
|
+
throw new CliError("invalid_arguments", "Use todos create|edit|delete|generate-applet.");
|
|
3611
4446
|
}
|
|
3612
4447
|
if (noun === "memories") {
|
|
3613
4448
|
const studentId = flagString(parsed, "student", { required: true });
|
|
@@ -3715,7 +4550,7 @@ export async function runCommand(argv) {
|
|
|
3715
4550
|
if (preflight.action !== "preview_workspace_files_write") {
|
|
3716
4551
|
throw new CliError("unexpected_response", "Goal PDF upload did not return a preview; nothing was uploaded.");
|
|
3717
4552
|
}
|
|
3718
|
-
|
|
4553
|
+
const preview = {
|
|
3719
4554
|
action: "upload a textbook PDF and attach it to a student's goal",
|
|
3720
4555
|
target: preflight.target,
|
|
3721
4556
|
request: {
|
|
@@ -3729,111 +4564,114 @@ export async function runCommand(argv) {
|
|
|
3729
4564
|
currentChangeId: preflight.currentChangeId,
|
|
3730
4565
|
note: "The PDF is stored separately and the goal workspace receives a small verified reference, matching planner-authored textbook goals.",
|
|
3731
4566
|
},
|
|
3732
|
-
}
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
conversationId,
|
|
3737
|
-
studentUserId: studentId,
|
|
3738
|
-
fileName,
|
|
3739
|
-
contentType: "application/pdf",
|
|
3740
|
-
sizeBytes: fileStat.size,
|
|
3741
|
-
},
|
|
3742
|
-
}));
|
|
3743
|
-
let uploaded;
|
|
3744
|
-
try {
|
|
3745
|
-
const uploadedSource = await uploadPdfToSignedUrls(absolutePath, "application/pdf", signed);
|
|
3746
|
-
if (uploadedSource.sha256 !== sourceSha256 ||
|
|
3747
|
-
uploadedSource.sizeBytes !== fileStat.size) {
|
|
3748
|
-
throw new CliError("source_changed", "The source PDF changed after approval. The upload was discarded; preview the current file again.");
|
|
3749
|
-
}
|
|
3750
|
-
uploaded =
|
|
3751
|
-
signed.kind === "multipart"
|
|
3752
|
-
? unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/complete", {
|
|
3753
|
-
body: {
|
|
3754
|
-
conversationId,
|
|
3755
|
-
fileName,
|
|
3756
|
-
contentType: "application/pdf",
|
|
3757
|
-
key: signed.key,
|
|
3758
|
-
uploadId: signed.uploadId,
|
|
3759
|
-
sizeBytes: fileStat.size,
|
|
3760
|
-
},
|
|
3761
|
-
}))
|
|
3762
|
-
: unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/verify", {
|
|
3763
|
-
body: {
|
|
3764
|
-
conversationId,
|
|
3765
|
-
fileName,
|
|
3766
|
-
contentType: "application/pdf",
|
|
3767
|
-
key: signed.key,
|
|
3768
|
-
sizeBytes: fileStat.size,
|
|
3769
|
-
},
|
|
3770
|
-
}));
|
|
3771
|
-
}
|
|
3772
|
-
catch (error) {
|
|
3773
|
-
// A failed multipart complete may already have materialized the object,
|
|
3774
|
-
// so attempt both abort and delete. These are compensation attempts; the
|
|
3775
|
-
// original upload error remains authoritative.
|
|
3776
|
-
await cleanupPlannerUpload(api, conversationId, signed, true);
|
|
3777
|
-
throw error;
|
|
3778
|
-
}
|
|
3779
|
-
const manifest = JSON.stringify({
|
|
3780
|
-
kind: "r2-pdf",
|
|
3781
|
-
version: 2,
|
|
3782
|
-
r2Key: uploaded.key,
|
|
3783
|
-
signature: uploaded.manifestSignature,
|
|
3784
|
-
fileName,
|
|
3785
|
-
contentType: uploaded.contentType,
|
|
3786
|
-
sizeBytes: uploaded.sizeBytes,
|
|
3787
|
-
});
|
|
3788
|
-
let workspace;
|
|
3789
|
-
try {
|
|
3790
|
-
workspace = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
4567
|
+
};
|
|
4568
|
+
return previewBoundWrite(parsed, preview, async () => {
|
|
4569
|
+
const conversationId = randomUUID();
|
|
4570
|
+
const signed = unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/sign", {
|
|
3791
4571
|
body: {
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
contentEncoding: "utf8",
|
|
3798
|
-
},
|
|
3799
|
-
],
|
|
3800
|
-
dryRun: false,
|
|
3801
|
-
expectedChangeId: preflight.currentChangeId,
|
|
4572
|
+
conversationId,
|
|
4573
|
+
studentUserId: studentId,
|
|
4574
|
+
fileName,
|
|
4575
|
+
contentType: "application/pdf",
|
|
4576
|
+
sizeBytes: fileStat.size,
|
|
3802
4577
|
},
|
|
3803
4578
|
}));
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
4579
|
+
let uploaded;
|
|
4580
|
+
try {
|
|
4581
|
+
const uploadedSource = await uploadPdfToSignedUrls(absolutePath, "application/pdf", signed);
|
|
4582
|
+
if (uploadedSource.sha256 !== sourceSha256 ||
|
|
4583
|
+
uploadedSource.sizeBytes !== fileStat.size) {
|
|
4584
|
+
throw new CliError("source_changed", "The source PDF changed after approval. The upload was discarded; preview the current file again.");
|
|
4585
|
+
}
|
|
4586
|
+
uploaded =
|
|
4587
|
+
signed.kind === "multipart"
|
|
4588
|
+
? unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/complete", {
|
|
4589
|
+
body: {
|
|
4590
|
+
conversationId,
|
|
4591
|
+
fileName,
|
|
4592
|
+
contentType: "application/pdf",
|
|
4593
|
+
key: signed.key,
|
|
4594
|
+
uploadId: signed.uploadId,
|
|
4595
|
+
sizeBytes: fileStat.size,
|
|
4596
|
+
},
|
|
4597
|
+
}))
|
|
4598
|
+
: unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/verify", {
|
|
4599
|
+
body: {
|
|
4600
|
+
conversationId,
|
|
4601
|
+
fileName,
|
|
4602
|
+
contentType: "application/pdf",
|
|
4603
|
+
key: signed.key,
|
|
4604
|
+
sizeBytes: fileStat.size,
|
|
4605
|
+
},
|
|
4606
|
+
}));
|
|
4607
|
+
}
|
|
4608
|
+
catch (error) {
|
|
4609
|
+
// A failed multipart complete may already have materialized the object,
|
|
4610
|
+
// so attempt both abort and delete. These are compensation attempts; the
|
|
4611
|
+
// original upload error remains authoritative.
|
|
3816
4612
|
await cleanupPlannerUpload(api, conversationId, signed, true);
|
|
4613
|
+
throw error;
|
|
3817
4614
|
}
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
4615
|
+
const manifest = JSON.stringify({
|
|
4616
|
+
kind: "r2-pdf",
|
|
4617
|
+
version: 2,
|
|
4618
|
+
r2Key: uploaded.key,
|
|
4619
|
+
signature: uploaded.manifestSignature,
|
|
4620
|
+
fileName,
|
|
4621
|
+
contentType: uploaded.contentType,
|
|
4622
|
+
sizeBytes: uploaded.sizeBytes,
|
|
4623
|
+
});
|
|
4624
|
+
let workspace;
|
|
4625
|
+
try {
|
|
4626
|
+
workspace = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
4627
|
+
body: {
|
|
4628
|
+
...body,
|
|
4629
|
+
files: [
|
|
4630
|
+
{
|
|
4631
|
+
path: workspacePath,
|
|
4632
|
+
content: manifest,
|
|
4633
|
+
contentEncoding: "utf8",
|
|
4634
|
+
},
|
|
4635
|
+
],
|
|
4636
|
+
dryRun: false,
|
|
4637
|
+
expectedChangeId: preflight.currentChangeId,
|
|
4638
|
+
},
|
|
4639
|
+
}));
|
|
4640
|
+
}
|
|
4641
|
+
catch (error) {
|
|
4642
|
+
// Only a returned 4xx from this route is a definitive no-write response.
|
|
4643
|
+
// A transport/5xx failure is ambiguous: the workspace commit may have landed,
|
|
4644
|
+
// so keep the object rather than creating a dangling manifest.
|
|
4645
|
+
const status = error instanceof CliError &&
|
|
4646
|
+
error.details &&
|
|
4647
|
+
typeof error.details === "object" &&
|
|
4648
|
+
"status" in error.details
|
|
4649
|
+
? Number(error.details.status)
|
|
4650
|
+
: 0;
|
|
4651
|
+
if (status >= 400 && status < 500) {
|
|
4652
|
+
await cleanupPlannerUpload(api, conversationId, signed, true);
|
|
4653
|
+
}
|
|
4654
|
+
throw error;
|
|
4655
|
+
}
|
|
4656
|
+
return {
|
|
4657
|
+
action: "upload_textbook_pdf",
|
|
4658
|
+
sourceSha256,
|
|
4659
|
+
fileName,
|
|
4660
|
+
sizeBytes: uploaded.sizeBytes,
|
|
4661
|
+
workspacePath,
|
|
4662
|
+
workspace,
|
|
4663
|
+
};
|
|
4664
|
+
});
|
|
3828
4665
|
}
|
|
3829
4666
|
if (noun === "goals" && verb === "files") {
|
|
3830
4667
|
const action = positional(parsed, 2, "goals files action");
|
|
3831
|
-
const studentId = flagString(parsed, "student", { required: true });
|
|
3832
4668
|
if (action === "list") {
|
|
4669
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3833
4670
|
const goalId = flagString(parsed, "goal", { required: true });
|
|
3834
4671
|
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/files", { params: { path: { studentId, goalId } } }));
|
|
3835
4672
|
}
|
|
3836
4673
|
if (action === "read") {
|
|
4674
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3837
4675
|
const goalId = flagString(parsed, "goal", { required: true });
|
|
3838
4676
|
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/file", {
|
|
3839
4677
|
params: {
|
|
@@ -3842,7 +4680,115 @@ export async function runCommand(argv) {
|
|
|
3842
4680
|
},
|
|
3843
4681
|
}));
|
|
3844
4682
|
}
|
|
4683
|
+
if (action === "init" || action === "checkout") {
|
|
4684
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
4685
|
+
const goalId = flagString(parsed, "goal");
|
|
4686
|
+
const draftSlug = flagString(parsed, "draft");
|
|
4687
|
+
if (action === "init" && (goalId || !draftSlug)) {
|
|
4688
|
+
throw new CliError("invalid_arguments", "goals files init requires --draft and does not accept --goal.");
|
|
4689
|
+
}
|
|
4690
|
+
if (Boolean(goalId) === Boolean(draftSlug)) {
|
|
4691
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --goal or --draft for goals files checkout.");
|
|
4692
|
+
}
|
|
4693
|
+
const outputDirectory = flagString(parsed, "output-dir", {
|
|
4694
|
+
required: true,
|
|
4695
|
+
});
|
|
4696
|
+
const target = goalId
|
|
4697
|
+
? { kind: "goal", goalId }
|
|
4698
|
+
: { kind: "draft", draftSlug: draftSlug };
|
|
4699
|
+
const response = await readRemoteWorkspaceSnapshot(api, studentId, target);
|
|
4700
|
+
if (action === "init" && response.snapshot.fileCount > 0) {
|
|
4701
|
+
throw new CliError("draft_exists", `Draft ${draftSlug} already has files. Use goals files checkout --draft to preserve them.`);
|
|
4702
|
+
}
|
|
4703
|
+
return checkoutGoalWorkspace({
|
|
4704
|
+
outputDirectory,
|
|
4705
|
+
studentId,
|
|
4706
|
+
target,
|
|
4707
|
+
snapshot: response.snapshot,
|
|
4708
|
+
});
|
|
4709
|
+
}
|
|
4710
|
+
if (action === "push") {
|
|
4711
|
+
const sourceDirectory = flagString(parsed, "source-dir", {
|
|
4712
|
+
required: true,
|
|
4713
|
+
});
|
|
4714
|
+
const checkout = await readRecessGitMetadata(sourceDirectory);
|
|
4715
|
+
const local = await readGoalWorkspaceGitChanges(checkout.root, checkout.metadata.upstreamCommit);
|
|
4716
|
+
const defaultMessage = local.commits.length === 1
|
|
4717
|
+
? local.commits[0].subject
|
|
4718
|
+
: `recess-cli: push ${local.commits.length} commits (${local.commits.at(-1).subject})`;
|
|
4719
|
+
const message = (flagString(parsed, "message") ?? defaultMessage).trim();
|
|
4720
|
+
if (!message || message.length > 200) {
|
|
4721
|
+
throw new CliError("invalid_arguments", "--message (or the derived local commit message) must be 1 to 200 characters.");
|
|
4722
|
+
}
|
|
4723
|
+
const files = local.changes.map((change) => change.action === "delete"
|
|
4724
|
+
? { path: change.path, action: "delete" }
|
|
4725
|
+
: {
|
|
4726
|
+
path: change.path,
|
|
4727
|
+
action: "upsert",
|
|
4728
|
+
content: change.content,
|
|
4729
|
+
contentEncoding: change.contentEncoding,
|
|
4730
|
+
});
|
|
4731
|
+
const body = {
|
|
4732
|
+
studentUserId: checkout.metadata.studentId,
|
|
4733
|
+
target: checkout.metadata.target,
|
|
4734
|
+
message,
|
|
4735
|
+
files,
|
|
4736
|
+
expectedChangeId: checkout.metadata.changeId,
|
|
4737
|
+
};
|
|
4738
|
+
const preflight = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
4739
|
+
body: { ...body, dryRun: true },
|
|
4740
|
+
}));
|
|
4741
|
+
if (preflight.action !== "preview_workspace_files_write") {
|
|
4742
|
+
throw new CliError("unexpected_response", "Goal workspace push did not return a preview; nothing was written.");
|
|
4743
|
+
}
|
|
4744
|
+
const preview = {
|
|
4745
|
+
action: `push committed Git changes to a student's Mesa ${checkout.metadata.target.kind} workspace`,
|
|
4746
|
+
target: preflight.target,
|
|
4747
|
+
request: {
|
|
4748
|
+
sourceDirectory: checkout.root,
|
|
4749
|
+
mesaBaseChangeId: checkout.metadata.changeId,
|
|
4750
|
+
localBaseCommit: checkout.metadata.upstreamCommit,
|
|
4751
|
+
localHeadCommit: local.head,
|
|
4752
|
+
commits: local.commits,
|
|
4753
|
+
message,
|
|
4754
|
+
changes: local.changes.map((change) => change.action === "delete"
|
|
4755
|
+
? change
|
|
4756
|
+
: {
|
|
4757
|
+
path: change.path,
|
|
4758
|
+
action: change.action,
|
|
4759
|
+
sizeBytes: change.sizeBytes,
|
|
4760
|
+
sha256: change.sha256,
|
|
4761
|
+
}),
|
|
4762
|
+
sizeBytes: local.sizeBytes,
|
|
4763
|
+
},
|
|
4764
|
+
details: {
|
|
4765
|
+
currentChangeId: preflight.currentChangeId,
|
|
4766
|
+
note: "The committed local diff is published as one atomic Mesa change. Deletes and renames follow Git's diff. A changed Mesa tip refuses the push.",
|
|
4767
|
+
},
|
|
4768
|
+
};
|
|
4769
|
+
return previewBoundWrite(parsed, preview, async () => {
|
|
4770
|
+
const result = unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
4771
|
+
body: { ...body, dryRun: false },
|
|
4772
|
+
}));
|
|
4773
|
+
if (result.action !== "write_workspace_files") {
|
|
4774
|
+
throw new CliError("unexpected_response", "Goal workspace push returned an unexpected response.");
|
|
4775
|
+
}
|
|
4776
|
+
await writeRecessGitMetadata(checkout.root, {
|
|
4777
|
+
...checkout.metadata,
|
|
4778
|
+
changeId: result.changeId,
|
|
4779
|
+
upstreamCommit: local.head,
|
|
4780
|
+
});
|
|
4781
|
+
return {
|
|
4782
|
+
...result,
|
|
4783
|
+
local: {
|
|
4784
|
+
directory: checkout.root,
|
|
4785
|
+
upstreamCommit: local.head,
|
|
4786
|
+
},
|
|
4787
|
+
};
|
|
4788
|
+
});
|
|
4789
|
+
}
|
|
3845
4790
|
if (action === "write") {
|
|
4791
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3846
4792
|
const goalId = flagString(parsed, "goal");
|
|
3847
4793
|
const draftSlug = flagString(parsed, "draft");
|
|
3848
4794
|
if (Boolean(goalId) === Boolean(draftSlug)) {
|
|
@@ -3881,19 +4827,18 @@ export async function runCommand(argv) {
|
|
|
3881
4827
|
files: preflight.files,
|
|
3882
4828
|
note: target.kind === "draft"
|
|
3883
4829
|
? "This writes a complete authoring tree under drafts/<slug>/workspace. Capture it only after the server's goal-workspace validation passes."
|
|
3884
|
-
: "
|
|
4830
|
+
: "This directly edits the live goal workspace, including modules/ and state/. The write remains bound to the previewed workspace revision.",
|
|
3885
4831
|
},
|
|
3886
4832
|
};
|
|
3887
|
-
|
|
3888
|
-
return unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
4833
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
3889
4834
|
body: {
|
|
3890
4835
|
...body,
|
|
3891
4836
|
dryRun: false,
|
|
3892
4837
|
expectedChangeId: preflight.currentChangeId,
|
|
3893
4838
|
},
|
|
3894
|
-
}));
|
|
4839
|
+
})));
|
|
3895
4840
|
}
|
|
3896
|
-
throw new CliError("invalid_arguments", "Use goals files list|read|write.");
|
|
4841
|
+
throw new CliError("invalid_arguments", "Use goals files list|read|init|checkout|push|write.");
|
|
3897
4842
|
}
|
|
3898
4843
|
if (noun === "request" && verb === "get") {
|
|
3899
4844
|
return api.rawGet(positional(parsed, 2, "request path"));
|