recess-cli 1.9.1 → 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 +17 -12
- package/dist/cli.js +1312 -241
- 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,24 +182,33 @@ 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]
|
|
202
|
+
recess [--json] goals queue get <goal-id> --student <kid-id>
|
|
203
|
+
recess [--json] goals queue set <goal-id> --student <kid-id>
|
|
204
|
+
--entries-file <path.json> --delta TEXT [--replace-description-pointer]
|
|
205
|
+
[--confirm --approval-token TOKEN]
|
|
179
206
|
recess [--json] todos create --student <kid-id> --title TEXT
|
|
180
207
|
[--due-date YYYY-MM-DD] [--estimated-minutes N] [--url URL] [--confirm]
|
|
181
208
|
recess [--json] todos edit <todo-id> --patch-file <path/patch.json>
|
|
182
209
|
[--confirm --approval-token TOKEN]
|
|
210
|
+
recess [--json] todos delete <todo-id>
|
|
211
|
+
[--confirm --approval-token TOKEN]
|
|
183
212
|
recess [--json] todos generate-applet <todo-id> --student <kid-id>
|
|
184
213
|
[--due-date YYYY-MM-DD] [--confirm --approval-token TOKEN]
|
|
185
214
|
recess [--json] memories context --student <kid-id>
|
|
@@ -189,6 +218,13 @@ Usage:
|
|
|
189
218
|
--expected-updated-at ISO|none [--confirm]
|
|
190
219
|
recess [--json] goals files list --student <goal-owner-id> --goal <goal-id>
|
|
191
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]
|
|
192
228
|
recess [--json] goals files write --student <goal-owner-id>
|
|
193
229
|
(--goal <goal-id> | --draft <draft-slug>)
|
|
194
230
|
(--source-dir <local-dir> | --source-file <local-file> --path P)
|
|
@@ -207,10 +243,10 @@ standard. Responses cache under ~/.recess-cli/skills-cache/ (--refresh re-fetche
|
|
|
207
243
|
For goal workspace and PDF commands, --student names the goal owner. A
|
|
208
244
|
full_admin session may use a KID or ADMIN user id, including its own ADMIN id;
|
|
209
245
|
family_ai sessions remain limited to managed KID profiles.
|
|
210
|
-
"goals create --
|
|
211
|
-
module-backed goal
|
|
212
|
-
|
|
213
|
-
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.
|
|
214
250
|
Every template created here is setupMode DETERMINISTIC_WORKFLOW and CANNOT be
|
|
215
251
|
converted back, so "validate-spec" against the same file until it passes, then
|
|
216
252
|
"create". "create" runs a real server-side validation before its gate, so the
|
|
@@ -257,8 +293,14 @@ bundled copy, and an unreachable server is not an error. "doctor" reports whethe
|
|
|
257
293
|
a newer skill exists and names the command; it never writes.
|
|
258
294
|
|
|
259
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.
|
|
260
298
|
Environment overrides: RECESS_CLI_API_ORIGIN, RECESS_CLI_WEB_ORIGIN,
|
|
261
|
-
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.`;
|
|
262
304
|
function positional(parsed, index, label) {
|
|
263
305
|
const value = parsed.positionals[index];
|
|
264
306
|
if (!value) {
|
|
@@ -266,6 +308,9 @@ function positional(parsed, index, label) {
|
|
|
266
308
|
}
|
|
267
309
|
return value;
|
|
268
310
|
}
|
|
311
|
+
function requiredRequestReason(parsed) {
|
|
312
|
+
return requireCliRequestReason(flagString(parsed, "reason", { required: true }));
|
|
313
|
+
}
|
|
269
314
|
async function sessionStatus(config) {
|
|
270
315
|
if (!config.sessionCookie) {
|
|
271
316
|
return {
|
|
@@ -287,18 +332,20 @@ async function sessionStatus(config) {
|
|
|
287
332
|
}
|
|
288
333
|
return { ...result.data, authSource: config.authSource };
|
|
289
334
|
}
|
|
290
|
-
async function doctor(config) {
|
|
335
|
+
async function doctor(config, reason) {
|
|
291
336
|
const checks = {
|
|
292
337
|
config: {
|
|
293
338
|
path: config.configPath,
|
|
294
339
|
apiOrigin: config.apiOrigin,
|
|
295
340
|
webOrigin: config.webOrigin,
|
|
296
341
|
oauthClientIdConfigured: Boolean(config.oauthClientId),
|
|
342
|
+
profile: config.profileName ?? null,
|
|
297
343
|
},
|
|
298
344
|
auth: await sessionStatus(config),
|
|
299
345
|
};
|
|
300
346
|
try {
|
|
301
347
|
const response = await fetch(new URL("/health", config.apiOrigin), {
|
|
348
|
+
headers: cliRequestHeaders(undefined, reason),
|
|
302
349
|
signal: AbortSignal.timeout(5000),
|
|
303
350
|
});
|
|
304
351
|
checks.api = { reachable: response.ok, status: response.status };
|
|
@@ -309,7 +356,7 @@ async function doctor(config) {
|
|
|
309
356
|
message: error instanceof Error ? error.message : String(error),
|
|
310
357
|
};
|
|
311
358
|
}
|
|
312
|
-
checks.skill = await skillStatus(config);
|
|
359
|
+
checks.skill = await skillStatus(config, reason);
|
|
313
360
|
return checks;
|
|
314
361
|
}
|
|
315
362
|
/**
|
|
@@ -318,7 +365,7 @@ async function doctor(config) {
|
|
|
318
365
|
* silently rewrote `~/.claude/skills` mid-session would change what the agent is
|
|
319
366
|
* reading out from under it. It names the command instead.
|
|
320
367
|
*/
|
|
321
|
-
async function skillStatus(config) {
|
|
368
|
+
async function skillStatus(config, reason) {
|
|
322
369
|
const cliVersion = await readCliVersion();
|
|
323
370
|
const installedVersion = await readBundledSkillVersion();
|
|
324
371
|
const base = { cliVersion, bundledSkillVersion: installedVersion };
|
|
@@ -327,7 +374,7 @@ async function skillStatus(config) {
|
|
|
327
374
|
}
|
|
328
375
|
try {
|
|
329
376
|
const response = await fetch(new URL("/auth/admin-cli/skill/", config.apiOrigin), {
|
|
330
|
-
headers: { cookie: config.sessionCookie },
|
|
377
|
+
headers: cliRequestHeaders({ cookie: config.sessionCookie }, reason),
|
|
331
378
|
signal: AbortSignal.timeout(5000),
|
|
332
379
|
});
|
|
333
380
|
if (!response.ok) {
|
|
@@ -362,28 +409,433 @@ async function skillStatus(config) {
|
|
|
362
409
|
}
|
|
363
410
|
}
|
|
364
411
|
async function writeCommand(parsed, preview, execute) {
|
|
365
|
-
|
|
366
|
-
|
|
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
|
+
}
|
|
367
471
|
}
|
|
368
472
|
const GOAL_WORKSPACE_WRITE_MAX_FILES = 1_000;
|
|
369
473
|
const GOAL_WORKSPACE_WRITE_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
|
|
370
474
|
const GOAL_PDF_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
|
475
|
+
const GOAL_QUEUE_MAX_ENTRIES = 500;
|
|
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
|
+
}
|
|
371
761
|
function approvalTokenFor(preview) {
|
|
372
762
|
return createHash("sha256").update(JSON.stringify(preview)).digest("hex");
|
|
373
763
|
}
|
|
374
|
-
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) {
|
|
375
773
|
const approvalToken = approvalTokenFor(preview);
|
|
774
|
+
const suppliedOperationKey = flagString(parsed, "operation-key");
|
|
376
775
|
const boundPreview = {
|
|
377
776
|
...preview,
|
|
378
|
-
details: {
|
|
777
|
+
details: {
|
|
778
|
+
...preview.details,
|
|
779
|
+
approvalToken,
|
|
780
|
+
operationKey: suppliedOperationKey ?? operationKeyFor(approvalToken),
|
|
781
|
+
},
|
|
379
782
|
};
|
|
380
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
|
+
});
|
|
381
792
|
requireConfirmation(false, boundPreview);
|
|
382
793
|
}
|
|
383
794
|
const supplied = flagString(parsed, "approval-token");
|
|
384
795
|
if (supplied !== approvalToken) {
|
|
385
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" });
|
|
386
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
|
+
}
|
|
387
839
|
}
|
|
388
840
|
async function cleanupPlannerUpload(api, conversationId, signed, includeDelete) {
|
|
389
841
|
if (signed.kind === "multipart") {
|
|
@@ -693,6 +1145,61 @@ async function readJsonFile(filePath, label) {
|
|
|
693
1145
|
}
|
|
694
1146
|
return parsed;
|
|
695
1147
|
}
|
|
1148
|
+
function parseGoalQueueEntries(value, label) {
|
|
1149
|
+
if (!Array.isArray(value)) {
|
|
1150
|
+
throw new CliError("invalid_arguments", `${label} must be a JSON array of queue entries.`);
|
|
1151
|
+
}
|
|
1152
|
+
if (value.length > GOAL_QUEUE_MAX_ENTRIES) {
|
|
1153
|
+
throw new CliError("invalid_arguments", `${label} has ${value.length} entries; the maximum is ${GOAL_QUEUE_MAX_ENTRIES}.`);
|
|
1154
|
+
}
|
|
1155
|
+
return value.map((raw, index) => parseGoalQueueEntry(raw, `${label}[${index}]`));
|
|
1156
|
+
}
|
|
1157
|
+
function parseGoalQueueEntry(value, label) {
|
|
1158
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1159
|
+
throw new CliError("invalid_arguments", `${label} must be an object with title and url.`);
|
|
1160
|
+
}
|
|
1161
|
+
const record = value;
|
|
1162
|
+
const { title, url } = record;
|
|
1163
|
+
if (typeof title !== "string" || !title.trim()) {
|
|
1164
|
+
throw new CliError("invalid_arguments", `${label}.title is required.`);
|
|
1165
|
+
}
|
|
1166
|
+
if (typeof url !== "string" || !/^https:\/\//i.test(url.trim())) {
|
|
1167
|
+
throw new CliError("invalid_arguments", `${label}.url must be an https URL.`);
|
|
1168
|
+
}
|
|
1169
|
+
const entry = { title: title.trim(), url: url.trim() };
|
|
1170
|
+
if (record.completed !== undefined) {
|
|
1171
|
+
if (typeof record.completed !== "boolean") {
|
|
1172
|
+
throw new CliError("invalid_arguments", `${label}.completed must be a boolean.`);
|
|
1173
|
+
}
|
|
1174
|
+
entry.completed = record.completed;
|
|
1175
|
+
}
|
|
1176
|
+
if (record.sourceMeta !== undefined) {
|
|
1177
|
+
if (!record.sourceMeta ||
|
|
1178
|
+
typeof record.sourceMeta !== "object" ||
|
|
1179
|
+
Array.isArray(record.sourceMeta)) {
|
|
1180
|
+
throw new CliError("invalid_arguments", `${label}.sourceMeta must be an object.`);
|
|
1181
|
+
}
|
|
1182
|
+
const meta = record.sourceMeta;
|
|
1183
|
+
const sourceMeta = {};
|
|
1184
|
+
for (const key of [
|
|
1185
|
+
"permacode",
|
|
1186
|
+
"skillId",
|
|
1187
|
+
"section",
|
|
1188
|
+
"subjectKey",
|
|
1189
|
+
"planKey",
|
|
1190
|
+
]) {
|
|
1191
|
+
const raw = meta[key];
|
|
1192
|
+
if (raw === undefined)
|
|
1193
|
+
continue;
|
|
1194
|
+
if (typeof raw !== "string") {
|
|
1195
|
+
throw new CliError("invalid_arguments", `${label}.sourceMeta.${key} must be a string.`);
|
|
1196
|
+
}
|
|
1197
|
+
sourceMeta[key] = raw;
|
|
1198
|
+
}
|
|
1199
|
+
entry.sourceMeta = sourceMeta;
|
|
1200
|
+
}
|
|
1201
|
+
return entry;
|
|
1202
|
+
}
|
|
696
1203
|
function contentLibrarySubmitItem(value, label) {
|
|
697
1204
|
const record = typeof value === "string"
|
|
698
1205
|
? { url: value }
|
|
@@ -964,7 +1471,7 @@ function optionalDocString(doc, key) {
|
|
|
964
1471
|
return value;
|
|
965
1472
|
}
|
|
966
1473
|
/**
|
|
967
|
-
* Coerce an authored template file into the exact `POST /
|
|
1474
|
+
* Coerce an authored template file into the exact `POST /ai/goal-templates/`
|
|
968
1475
|
* body. Every unknown key is dropped rather than forwarded, so a stale field
|
|
969
1476
|
* copied from an old export cannot ride along into a create, and the shape
|
|
970
1477
|
* failures an agent actually makes (missing slug, spec as a string, tags as a
|
|
@@ -1192,6 +1699,48 @@ function flagDateOnly(parsed, name) {
|
|
|
1192
1699
|
return raw;
|
|
1193
1700
|
}
|
|
1194
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
|
+
}
|
|
1195
1744
|
// The cohort event routes take zoneless local wall-clock datetimes
|
|
1196
1745
|
// (interpreted in the cohort's timezone server-side).
|
|
1197
1746
|
function flagLocalDateTime(parsed, name, options = {}) {
|
|
@@ -1264,20 +1813,148 @@ function tierSlots(parsed) {
|
|
|
1264
1813
|
export async function runCommand(argv) {
|
|
1265
1814
|
const parsed = parseArgs(argv);
|
|
1266
1815
|
const [noun, verb] = parsed.positionals;
|
|
1816
|
+
const commands = buildCommandSchema(HELP);
|
|
1267
1817
|
// Before the help branch: `--version` parses as a FLAG, so `noun` is
|
|
1268
1818
|
// undefined and `!noun` would return help instead. (Found by running it.)
|
|
1269
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
|
+
}
|
|
1270
1826
|
return {
|
|
1271
1827
|
cliVersion: await readCliVersion(),
|
|
1272
1828
|
skillVersion: await readBundledSkillVersion(),
|
|
1273
1829
|
};
|
|
1274
1830
|
}
|
|
1275
|
-
if (!noun
|
|
1276
|
-
|
|
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
|
+
}
|
|
1899
|
+
}
|
|
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
|
+
}
|
|
1277
1953
|
}
|
|
1278
|
-
const config = await resolveConfig();
|
|
1954
|
+
const config = await resolveConfig(flagString(parsed, "profile"));
|
|
1955
|
+
const requestReason = noun === "auth" ? undefined : requiredRequestReason(parsed);
|
|
1279
1956
|
if (noun === "doctor")
|
|
1280
|
-
return doctor(config);
|
|
1957
|
+
return doctor(config, requestReason);
|
|
1281
1958
|
if (noun === "setup") {
|
|
1282
1959
|
// Always lay down the bundled copy first: it is the floor, and it is the
|
|
1283
1960
|
// only copy guaranteed to match this binary. The served upgrade below is
|
|
@@ -1287,6 +1964,7 @@ export async function runCommand(argv) {
|
|
|
1287
1964
|
apiOrigin: config.apiOrigin,
|
|
1288
1965
|
sessionCookie: config.sessionCookie,
|
|
1289
1966
|
cliVersion: await readCliVersion(),
|
|
1967
|
+
reason: requestReason,
|
|
1290
1968
|
});
|
|
1291
1969
|
const ephemeral = isEphemeralInstall();
|
|
1292
1970
|
// Reuse a session that is still live; a missing or already-expired one is
|
|
@@ -1339,12 +2017,12 @@ export async function runCommand(argv) {
|
|
|
1339
2017
|
if (verb === "status")
|
|
1340
2018
|
return sessionStatus(config);
|
|
1341
2019
|
if (verb === "logout") {
|
|
1342
|
-
await clearStoredSession();
|
|
2020
|
+
await clearStoredSession(config.profileName);
|
|
1343
2021
|
return { loggedOut: true };
|
|
1344
2022
|
}
|
|
1345
2023
|
throw new CliError("invalid_arguments", "Use auth login, request, poll, status, or logout.");
|
|
1346
2024
|
}
|
|
1347
|
-
const api = new RecessAdminApi(config);
|
|
2025
|
+
const api = new RecessAdminApi(config, requestReason);
|
|
1348
2026
|
api.requireAuth();
|
|
1349
2027
|
if (noun === "village") {
|
|
1350
2028
|
const targetWorldId = flagString(parsed, "world") ?? "village-1";
|
|
@@ -1940,52 +2618,47 @@ export async function runCommand(argv) {
|
|
|
1940
2618
|
? { firstChargeAt: new Date(firstChargeAt).toISOString() }
|
|
1941
2619
|
: {}),
|
|
1942
2620
|
};
|
|
1943
|
-
//
|
|
1944
|
-
//
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
// the human before the confirmation gate.
|
|
1948
|
-
if (!hasFlag(parsed, "confirm")) {
|
|
1949
|
-
const preview = unwrap(await api.client.POST("/admin/cohorts/enroll/", {
|
|
1950
|
-
body: { ...body, dryRun: true },
|
|
1951
|
-
}));
|
|
1952
|
-
const b = preview.plan.billing;
|
|
1953
|
-
const usd = (cents) => `${(cents / 100).toFixed(2)} USD`;
|
|
1954
|
-
const recurrence = b.isRecurring ? `, recurring per ${b.interval}` : "";
|
|
1955
|
-
// effectivePriceCents already has the course discount applied; credits are
|
|
1956
|
-
// deducted later as an invoice line, so this is an upper bound.
|
|
1957
|
-
const amount = b.discountKind || b.creditBalanceCents > 0
|
|
1958
|
-
? `${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` : ""})`
|
|
1959
|
-
: usd(b.effectivePriceCents);
|
|
1960
|
-
requireConfirmation(false, {
|
|
1961
|
-
action: preview.plan.reusedEnrollmentId
|
|
1962
|
-
? "enroll kid into cohort by reusing an existing enrollment (no charge)"
|
|
1963
|
-
: b.chargesImmediately
|
|
1964
|
-
? `enroll kid into cohort and CHARGE ${amount} NOW${recurrence}`
|
|
1965
|
-
: `enroll kid into cohort with first charge of ${amount} on ${b.firstChargeAt}${recurrence}`,
|
|
1966
|
-
target: {
|
|
1967
|
-
kid: `${preview.plan.kid.firstName ?? ""} ${preview.plan.kid.lastName ?? ""}`.trim(),
|
|
1968
|
-
userId,
|
|
1969
|
-
cohort: preview.plan.cohort.name,
|
|
1970
|
-
cohortId,
|
|
1971
|
-
course: preview.plan.cohort.courseName,
|
|
1972
|
-
},
|
|
1973
|
-
request: { ...body, dryRun: false },
|
|
1974
|
-
details: {
|
|
1975
|
-
billing: b,
|
|
1976
|
-
reusedEnrollmentId: preview.plan.reusedEnrollmentId,
|
|
1977
|
-
warnings: preview.warnings,
|
|
1978
|
-
...(preview.warnings.length > 0 && !body.force
|
|
1979
|
-
? {
|
|
1980
|
-
blocked: "These warnings will reject the write unless you also pass --force.",
|
|
1981
|
-
}
|
|
1982
|
-
: {}),
|
|
1983
|
-
},
|
|
1984
|
-
});
|
|
1985
|
-
}
|
|
1986
|
-
return unwrap(await api.client.POST("/admin/cohorts/enroll/", {
|
|
1987
|
-
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 },
|
|
1988
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
|
+
})));
|
|
1989
2662
|
}
|
|
1990
2663
|
if (noun === "enrollments" && verb === "register-cohort") {
|
|
1991
2664
|
const enrollmentId = flagString(parsed, "enrollment", { required: true });
|
|
@@ -2080,12 +2753,20 @@ export async function runCommand(argv) {
|
|
|
2080
2753
|
const search = flagString(parsed, "search");
|
|
2081
2754
|
const userId = flagString(parsed, "user");
|
|
2082
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
|
+
}
|
|
2083
2760
|
return unwrap(await api.client.GET("/admin/payout/recipient/", {
|
|
2084
2761
|
params: {
|
|
2085
2762
|
query: {
|
|
2086
2763
|
...(search ? { search } : {}),
|
|
2087
2764
|
...(userId ? { userId } : {}),
|
|
2088
2765
|
...(id ? { id } : {}),
|
|
2766
|
+
limit,
|
|
2767
|
+
...(flagString(parsed, "cursor")
|
|
2768
|
+
? { cursor: flagString(parsed, "cursor") }
|
|
2769
|
+
: {}),
|
|
2089
2770
|
},
|
|
2090
2771
|
},
|
|
2091
2772
|
}));
|
|
@@ -2706,6 +3387,19 @@ export async function runCommand(argv) {
|
|
|
2706
3387
|
if (verb === "set-stage") {
|
|
2707
3388
|
const stage = assertChoice(flagString(parsed, "stage", { required: true }), CONTENT_LIBRARY_RESOURCE_STAGES, "--stage");
|
|
2708
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
|
+
}
|
|
2709
3403
|
const preflightBody = {
|
|
2710
3404
|
gems: input.gems,
|
|
2711
3405
|
stage,
|
|
@@ -2722,16 +3416,39 @@ export async function runCommand(argv) {
|
|
|
2722
3416
|
resources: preflight.results,
|
|
2723
3417
|
lifecycle: "Uses the same lifecycle as Manage. Direct-to-Live never bypasses unfinished polishing; leaving Polishing cancels its bound run before moving.",
|
|
2724
3418
|
concurrency: 3,
|
|
3419
|
+
waitForLive: wait,
|
|
3420
|
+
...(wait ? { timeoutSeconds } : {}),
|
|
2725
3421
|
...(input.file ? { inputFile: input.file } : {}),
|
|
2726
3422
|
},
|
|
2727
3423
|
};
|
|
2728
|
-
return writeCommand(parsed, preview, async () =>
|
|
2729
|
-
|
|
2730
|
-
|
|
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
|
+
});
|
|
2731
3435
|
}
|
|
2732
3436
|
if (verb === "submit") {
|
|
2733
3437
|
const stage = assertChoice(flagString(parsed, "stage") ?? "review", CONTENT_LIBRARY_STAGES, "--stage");
|
|
2734
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
|
+
}
|
|
2735
3452
|
const body = { stage, items: input.items };
|
|
2736
3453
|
const preview = {
|
|
2737
3454
|
action: "content-library.submit",
|
|
@@ -2743,51 +3460,57 @@ export async function runCommand(argv) {
|
|
|
2743
3460
|
: "Holds each new resource in REVIEW until an admin approves it.",
|
|
2744
3461
|
duplicateBehavior: "Existing URLs are returned as duplicates and are not overwritten.",
|
|
2745
3462
|
deployOrderFence: "The server verifies the island's review/polish lifecycle capability before its first write.",
|
|
3463
|
+
waitForLive: wait,
|
|
3464
|
+
...(wait ? { timeoutSeconds } : {}),
|
|
2746
3465
|
...(input.file ? { inputFile: input.file } : {}),
|
|
2747
3466
|
},
|
|
2748
3467
|
};
|
|
2749
|
-
return writeCommand(parsed, preview, async () =>
|
|
2750
|
-
|
|
2751
|
-
|
|
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
|
+
});
|
|
2752
3479
|
}
|
|
2753
3480
|
throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
|
|
2754
3481
|
}
|
|
2755
3482
|
if (noun === "goal-templates") {
|
|
2756
3483
|
if (verb === "list") {
|
|
2757
|
-
const query = flagString(parsed, "query")
|
|
3484
|
+
const query = flagString(parsed, "query");
|
|
2758
3485
|
const kind = flagString(parsed, "kind");
|
|
2759
|
-
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/", {
|
|
2760
3494
|
params: {
|
|
2761
3495
|
query: {
|
|
2762
3496
|
...(flagString(parsed, "category")
|
|
2763
3497
|
? { category: flagString(parsed, "category") }
|
|
2764
3498
|
: {}),
|
|
3499
|
+
...(query ? { query } : {}),
|
|
3500
|
+
...(wantedKind ? { kind: wantedKind } : {}),
|
|
2765
3501
|
includeDeleted: hasFlag(parsed, "include-deleted")
|
|
2766
3502
|
? "true"
|
|
2767
3503
|
: "false",
|
|
2768
3504
|
starterOnly: hasFlag(parsed, "starter-only")
|
|
2769
3505
|
? "true"
|
|
2770
3506
|
: "false",
|
|
3507
|
+
limit,
|
|
3508
|
+
...(flagString(parsed, "cursor")
|
|
3509
|
+
? { cursor: flagString(parsed, "cursor") }
|
|
3510
|
+
: {}),
|
|
2771
3511
|
},
|
|
2772
3512
|
},
|
|
2773
3513
|
}));
|
|
2774
|
-
// The route filters by category/starter/deleted only; `--query` and
|
|
2775
|
-
// `--kind` narrow the returned page here rather than pretending the
|
|
2776
|
-
// backend supports them.
|
|
2777
|
-
const wantedKind = kind
|
|
2778
|
-
? assertChoice(kind, GOAL_TEMPLATE_KINDS, "--kind")
|
|
2779
|
-
: undefined;
|
|
2780
|
-
const items = data.items.filter((item) => {
|
|
2781
|
-
if (wantedKind && item.kind !== wantedKind)
|
|
2782
|
-
return false;
|
|
2783
|
-
if (!query)
|
|
2784
|
-
return true;
|
|
2785
|
-
return [item.title, item.slug, item.description, item.category ?? ""]
|
|
2786
|
-
.join("\n")
|
|
2787
|
-
.toLowerCase()
|
|
2788
|
-
.includes(query);
|
|
2789
|
-
});
|
|
2790
|
-
return { items, totalBeforeFilter: data.items.length };
|
|
2791
3514
|
}
|
|
2792
3515
|
if (verb === "get") {
|
|
2793
3516
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -2892,7 +3615,7 @@ export async function runCommand(argv) {
|
|
|
2892
3615
|
// protected-inventory counting, and the token binding the approved loss
|
|
2893
3616
|
// to this exact version + result hash. Always ask it for a fresh preview,
|
|
2894
3617
|
// including on a confirmed run, before permitting the write request.
|
|
2895
|
-
const preflight = unwrap(await api.client.POST("/
|
|
3618
|
+
const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/setup-workflow-spec/patch", {
|
|
2896
3619
|
params: { path: { id } },
|
|
2897
3620
|
body: {
|
|
2898
3621
|
expectedVersion,
|
|
@@ -2930,7 +3653,9 @@ export async function runCommand(argv) {
|
|
|
2930
3653
|
: {}),
|
|
2931
3654
|
},
|
|
2932
3655
|
};
|
|
2933
|
-
|
|
3656
|
+
if (!hasFlag(parsed, "confirm")) {
|
|
3657
|
+
await requirePreviewBoundConfirmation(parsed, preview);
|
|
3658
|
+
}
|
|
2934
3659
|
const destructiveChangeToken = flagString(parsed, "destructive-change-token");
|
|
2935
3660
|
if (preflight.preview.destructiveChanges &&
|
|
2936
3661
|
(!hasFlag(parsed, "confirm-destructive-changes") ||
|
|
@@ -2946,7 +3671,7 @@ export async function runCommand(argv) {
|
|
|
2946
3671
|
expectedDestructiveChangeToken: preflight.preview.destructiveChangeToken,
|
|
2947
3672
|
});
|
|
2948
3673
|
}
|
|
2949
|
-
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", {
|
|
2950
3675
|
params: { path: { id } },
|
|
2951
3676
|
body: {
|
|
2952
3677
|
expectedVersion,
|
|
@@ -2955,7 +3680,7 @@ export async function runCommand(argv) {
|
|
|
2955
3680
|
confirmDestructiveChanges: preflight.preview.destructiveChanges,
|
|
2956
3681
|
...(destructiveChangeToken ? { destructiveChangeToken } : {}),
|
|
2957
3682
|
},
|
|
2958
|
-
}));
|
|
3683
|
+
})));
|
|
2959
3684
|
}
|
|
2960
3685
|
if (verb === "set-metadata") {
|
|
2961
3686
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -3024,7 +3749,7 @@ export async function runCommand(argv) {
|
|
|
3024
3749
|
action: "update goal template metadata (never its setupWorkflowSpec)",
|
|
3025
3750
|
target: { templateId: id, expectedVersion },
|
|
3026
3751
|
request: body,
|
|
3027
|
-
}, async () => unwrap(await api.client.PUT("/
|
|
3752
|
+
}, async () => unwrap(await api.client.PUT("/ai/goal-templates/{id}", {
|
|
3028
3753
|
params: { path: { id } },
|
|
3029
3754
|
body,
|
|
3030
3755
|
})));
|
|
@@ -3035,7 +3760,7 @@ export async function runCommand(argv) {
|
|
|
3035
3760
|
// Read the current version so the gate refuses a stale delete locally,
|
|
3036
3761
|
// and so the preview names the template a human is being asked to approve
|
|
3037
3762
|
// rather than only its UUID.
|
|
3038
|
-
const current = unwrap(await api.client.GET("/
|
|
3763
|
+
const current = unwrap(await api.client.GET("/ai/goal-templates/{id}", {
|
|
3039
3764
|
params: { path: { id } },
|
|
3040
3765
|
}));
|
|
3041
3766
|
if (current.version !== expectedVersion) {
|
|
@@ -3053,7 +3778,7 @@ export async function runCommand(argv) {
|
|
|
3053
3778
|
details: {
|
|
3054
3779
|
note: "Soft delete: the row keeps its deletedAt and drops out of every list. Existing goals already applied from it are unaffected.",
|
|
3055
3780
|
},
|
|
3056
|
-
}, async () => unwrap(await api.client.DELETE("/
|
|
3781
|
+
}, async () => unwrap(await api.client.DELETE("/ai/goal-templates/{id}", {
|
|
3057
3782
|
params: { path: { id } },
|
|
3058
3783
|
})));
|
|
3059
3784
|
}
|
|
@@ -3073,18 +3798,43 @@ export async function runCommand(argv) {
|
|
|
3073
3798
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3074
3799
|
const sourceGoalId = flagString(parsed, "source-goal");
|
|
3075
3800
|
const sourceDraft = flagString(parsed, "source-draft");
|
|
3076
|
-
|
|
3077
|
-
|
|
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}.`);
|
|
3078
3817
|
}
|
|
3079
3818
|
const sourceStudentUserId = sourceDraft
|
|
3080
3819
|
? flagString(parsed, "student", { required: true })
|
|
3081
|
-
:
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
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,
|
|
3087
3836
|
};
|
|
3837
|
+
}
|
|
3088
3838
|
const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/snapshot/capture", {
|
|
3089
3839
|
params: { path: { id } },
|
|
3090
3840
|
body: { ...source, dryRun: true },
|
|
@@ -3092,6 +3842,10 @@ export async function runCommand(argv) {
|
|
|
3092
3842
|
if (preflight.action !== "preview_capture_snapshot") {
|
|
3093
3843
|
throw new CliError("unexpected_response", "Snapshot capture did not return a preview; nothing was captured.");
|
|
3094
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
|
+
}
|
|
3095
3849
|
if (hasFlag(parsed, "dry-run"))
|
|
3096
3850
|
return preflight;
|
|
3097
3851
|
const preview = {
|
|
@@ -3108,6 +3862,13 @@ export async function runCommand(argv) {
|
|
|
3108
3862
|
fileCount: preflight.snapshot.fileCount,
|
|
3109
3863
|
sizeBytes: preflight.snapshot.sizeBytes,
|
|
3110
3864
|
sha256: preflight.snapshot.sha256,
|
|
3865
|
+
...(checkout
|
|
3866
|
+
? {
|
|
3867
|
+
sourceDirectory: checkout.root,
|
|
3868
|
+
localHeadCommit: checkoutHead,
|
|
3869
|
+
mesaChangeId: checkout.metadata.changeId,
|
|
3870
|
+
}
|
|
3871
|
+
: {}),
|
|
3111
3872
|
},
|
|
3112
3873
|
details: {
|
|
3113
3874
|
modules: preflight.snapshot.modules,
|
|
@@ -3116,8 +3877,7 @@ export async function runCommand(argv) {
|
|
|
3116
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.",
|
|
3117
3878
|
},
|
|
3118
3879
|
};
|
|
3119
|
-
|
|
3120
|
-
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", {
|
|
3121
3881
|
params: { path: { id } },
|
|
3122
3882
|
body: {
|
|
3123
3883
|
...source,
|
|
@@ -3125,7 +3885,7 @@ export async function runCommand(argv) {
|
|
|
3125
3885
|
expectedTemplateVersion: preflight.template.version,
|
|
3126
3886
|
expectedSourceChangeId: preflight.source.changeId,
|
|
3127
3887
|
},
|
|
3128
|
-
}));
|
|
3888
|
+
})));
|
|
3129
3889
|
}
|
|
3130
3890
|
if (verb === "apply") {
|
|
3131
3891
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -3169,15 +3929,14 @@ export async function runCommand(argv) {
|
|
|
3169
3929
|
note: "Counts come from the backend's own dry run. `skipped_existing` items are idempotent — re-applying does not duplicate them.",
|
|
3170
3930
|
},
|
|
3171
3931
|
};
|
|
3172
|
-
|
|
3173
|
-
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", {
|
|
3174
3933
|
params: { path: { id } },
|
|
3175
3934
|
body: {
|
|
3176
3935
|
answers,
|
|
3177
3936
|
dryRun: false,
|
|
3178
3937
|
expectedTemplateVersion: preflight.templateVersion,
|
|
3179
3938
|
},
|
|
3180
|
-
}));
|
|
3939
|
+
})));
|
|
3181
3940
|
}
|
|
3182
3941
|
if (verb === "apply-starter") {
|
|
3183
3942
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
@@ -3211,7 +3970,30 @@ export async function runCommand(argv) {
|
|
|
3211
3970
|
}));
|
|
3212
3971
|
}
|
|
3213
3972
|
if (verb === "create") {
|
|
3214
|
-
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
|
+
}
|
|
3215
3997
|
const title = flagString(parsed, "title", { required: true });
|
|
3216
3998
|
const descriptionFile = flagString(parsed, "description-file");
|
|
3217
3999
|
const descriptionFlag = flagString(parsed, "description");
|
|
@@ -3227,14 +4009,17 @@ export async function runCommand(argv) {
|
|
|
3227
4009
|
throw new CliError("invalid_arguments", "--target-date must be an ISO 8601 datetime.");
|
|
3228
4010
|
}
|
|
3229
4011
|
const schedule = flagString(parsed, "schedule");
|
|
3230
|
-
const draftSlug =
|
|
4012
|
+
const draftSlug = explicitDraftSlug ??
|
|
4013
|
+
(checkout?.metadata.target.kind === "draft"
|
|
4014
|
+
? checkout.metadata.target.draftSlug
|
|
4015
|
+
: undefined);
|
|
3231
4016
|
const enablesAppletFollowUps = hasFlag(parsed, "enable-applet-follow-ups");
|
|
3232
4017
|
const disablesAppletFollowUps = hasFlag(parsed, "disable-applet-follow-ups");
|
|
3233
4018
|
if (draftSlug && enablesAppletFollowUps === disablesAppletFollowUps) {
|
|
3234
4019
|
throw new CliError("invalid_arguments", "A module-backed goal requires exactly one of --enable-applet-follow-ups or --disable-applet-follow-ups.");
|
|
3235
4020
|
}
|
|
3236
4021
|
if (!draftSlug && (enablesAppletFollowUps || disablesAppletFollowUps)) {
|
|
3237
|
-
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.");
|
|
3238
4023
|
}
|
|
3239
4024
|
if (draftSlug) {
|
|
3240
4025
|
const body = {
|
|
@@ -3252,6 +4037,10 @@ export async function runCommand(argv) {
|
|
|
3252
4037
|
if (preflight.action !== "preview_module_goal_create") {
|
|
3253
4038
|
throw new CliError("unexpected_response", "Direct module goal creation did not return a preview; no goal was created.");
|
|
3254
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
|
+
}
|
|
3255
4044
|
const preview = {
|
|
3256
4045
|
action: "create a module-backed goal directly from a validated draft workspace",
|
|
3257
4046
|
target: preflight.target,
|
|
@@ -3264,6 +4053,13 @@ export async function runCommand(argv) {
|
|
|
3264
4053
|
targetDate: targetDate ?? null,
|
|
3265
4054
|
schedule: schedule ?? null,
|
|
3266
4055
|
enableAppletFollowUps: enablesAppletFollowUps,
|
|
4056
|
+
...(checkout
|
|
4057
|
+
? {
|
|
4058
|
+
sourceDirectory: checkout.root,
|
|
4059
|
+
localHeadCommit: checkoutHead,
|
|
4060
|
+
mesaChangeId: checkout.metadata.changeId,
|
|
4061
|
+
}
|
|
4062
|
+
: {}),
|
|
3267
4063
|
},
|
|
3268
4064
|
details: {
|
|
3269
4065
|
currentChangeId: preflight.currentChangeId,
|
|
@@ -3277,14 +4073,35 @@ export async function runCommand(argv) {
|
|
|
3277
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.",
|
|
3278
4074
|
},
|
|
3279
4075
|
};
|
|
3280
|
-
|
|
3281
|
-
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
|
|
3287
|
-
|
|
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
|
+
});
|
|
3288
4105
|
}
|
|
3289
4106
|
const body = {
|
|
3290
4107
|
title,
|
|
@@ -3302,7 +4119,7 @@ export async function runCommand(argv) {
|
|
|
3302
4119
|
schedule: schedule ?? null,
|
|
3303
4120
|
},
|
|
3304
4121
|
details: {
|
|
3305
|
-
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.",
|
|
3306
4123
|
},
|
|
3307
4124
|
}, async () => unwrap(await api.client.POST("/tutor/browser/students/{userId}/goals/", {
|
|
3308
4125
|
params: { path: { userId } },
|
|
@@ -3331,13 +4148,132 @@ export async function runCommand(argv) {
|
|
|
3331
4148
|
patch,
|
|
3332
4149
|
},
|
|
3333
4150
|
};
|
|
3334
|
-
|
|
3335
|
-
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}/", {
|
|
3336
4152
|
params: { path: { goalId } },
|
|
3337
4153
|
body: patch,
|
|
3338
|
-
}));
|
|
4154
|
+
})));
|
|
3339
4155
|
}
|
|
3340
|
-
|
|
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
|
+
});
|
|
4184
|
+
}
|
|
4185
|
+
if (verb === "queue") {
|
|
4186
|
+
const subverb = positional(parsed, 2, "queue action (get|set)");
|
|
4187
|
+
const goalId = positional(parsed, 3, "goal ID");
|
|
4188
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
4189
|
+
if (subverb === "get") {
|
|
4190
|
+
return unwrap(await api.client.GET("/tutor/browser/students/goals/{goalId}/queue/", { params: { path: { goalId } } }));
|
|
4191
|
+
}
|
|
4192
|
+
if (subverb === "set") {
|
|
4193
|
+
const entriesFile = flagString(parsed, "entries-file", {
|
|
4194
|
+
required: true,
|
|
4195
|
+
});
|
|
4196
|
+
const delta = flagString(parsed, "delta", { required: true });
|
|
4197
|
+
const replaceDescriptionPointer = hasFlag(parsed, "replace-description-pointer");
|
|
4198
|
+
const { absolutePath, raw: rawEntriesText, parsed: rawEntries, } = await readJsonValue(entriesFile, "Queue entries file");
|
|
4199
|
+
const entries = parseGoalQueueEntries(rawEntries, "Queue entries file");
|
|
4200
|
+
const goal = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
4201
|
+
params: { path: { userId: studentId } },
|
|
4202
|
+
})).goals.find((candidate) => candidate.id === goalId);
|
|
4203
|
+
if (!goal) {
|
|
4204
|
+
throw new CliError("not_found", `Goal ${goalId} was not found for student ${studentId}.`);
|
|
4205
|
+
}
|
|
4206
|
+
const current = unwrap(await api.client.GET("/tutor/browser/students/goals/{goalId}/queue/", { params: { path: { goalId } } }));
|
|
4207
|
+
const currentUrls = new Set(current.queue.map((item) => item.url));
|
|
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
|
+
});
|
|
4226
|
+
const preview = {
|
|
4227
|
+
action: "replace a goal's URL skill queue for a managed student",
|
|
4228
|
+
target: { goalId, studentUserId: studentId },
|
|
4229
|
+
request: {
|
|
4230
|
+
entriesFile: absolutePath,
|
|
4231
|
+
entriesSha256: createHash("sha256")
|
|
4232
|
+
.update(rawEntriesText)
|
|
4233
|
+
.digest("hex"),
|
|
4234
|
+
entryCount: entries.length,
|
|
4235
|
+
delta,
|
|
4236
|
+
replaceDescriptionPointer,
|
|
4237
|
+
expectedUpdatedAt: goal.updatedAt,
|
|
4238
|
+
unchangedTitleForConcurrencyFence: goal.title,
|
|
4239
|
+
},
|
|
4240
|
+
details: {
|
|
4241
|
+
currentQueue: current.queue.map((item) => ({
|
|
4242
|
+
moduleRef: item.moduleRef,
|
|
4243
|
+
url: item.url,
|
|
4244
|
+
completedAt: item.completedAt,
|
|
4245
|
+
})),
|
|
4246
|
+
added: entries
|
|
4247
|
+
.filter((entry) => !currentUrls.has(entry.url))
|
|
4248
|
+
.map((entry) => entry.url),
|
|
4249
|
+
removed: current.queue
|
|
4250
|
+
.filter((item) => !proposedUrls.has(item.url))
|
|
4251
|
+
.map((item) => item.url),
|
|
4252
|
+
completionChanges,
|
|
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.",
|
|
4254
|
+
},
|
|
4255
|
+
};
|
|
4256
|
+
const body = {
|
|
4257
|
+
delta,
|
|
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,
|
|
4264
|
+
queue: entries,
|
|
4265
|
+
...(replaceDescriptionPointer
|
|
4266
|
+
? { description: URL_QUEUE_DESCRIPTION_POINTER }
|
|
4267
|
+
: {}),
|
|
4268
|
+
};
|
|
4269
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/browser/students/goals/{goalId}/", {
|
|
4270
|
+
params: { path: { goalId } },
|
|
4271
|
+
body,
|
|
4272
|
+
})));
|
|
4273
|
+
}
|
|
4274
|
+
throw new CliError("invalid_arguments", "Use goals queue get|set.");
|
|
4275
|
+
}
|
|
4276
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|queue|files|pdf.");
|
|
3341
4277
|
}
|
|
3342
4278
|
if (noun === "students") {
|
|
3343
4279
|
if (verb === "list") {
|
|
@@ -3433,11 +4369,37 @@ export async function runCommand(argv) {
|
|
|
3433
4369
|
patch: body,
|
|
3434
4370
|
},
|
|
3435
4371
|
};
|
|
3436
|
-
|
|
3437
|
-
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}", {
|
|
3438
4373
|
params: { path: { id: todoId } },
|
|
3439
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 } },
|
|
3440
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
|
+
})));
|
|
3441
4403
|
}
|
|
3442
4404
|
if (verb === "generate-applet") {
|
|
3443
4405
|
const todoId = positional(parsed, 2, "todo ID");
|
|
@@ -3475,13 +4437,12 @@ export async function runCommand(argv) {
|
|
|
3475
4437
|
versionRouting: "The server evaluates applet-gen-v2 for this student and uses v1 when the flag is off or its evaluation fails.",
|
|
3476
4438
|
},
|
|
3477
4439
|
};
|
|
3478
|
-
|
|
3479
|
-
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/", {
|
|
3480
4441
|
params: { path: { analysisId: analysis.id } },
|
|
3481
4442
|
body: targetDueDateISO ? { targetDueDateISO } : {},
|
|
3482
|
-
}));
|
|
4443
|
+
})));
|
|
3483
4444
|
}
|
|
3484
|
-
throw new CliError("invalid_arguments", "Use todos create|edit|generate-applet.");
|
|
4445
|
+
throw new CliError("invalid_arguments", "Use todos create|edit|delete|generate-applet.");
|
|
3485
4446
|
}
|
|
3486
4447
|
if (noun === "memories") {
|
|
3487
4448
|
const studentId = flagString(parsed, "student", { required: true });
|
|
@@ -3589,7 +4550,7 @@ export async function runCommand(argv) {
|
|
|
3589
4550
|
if (preflight.action !== "preview_workspace_files_write") {
|
|
3590
4551
|
throw new CliError("unexpected_response", "Goal PDF upload did not return a preview; nothing was uploaded.");
|
|
3591
4552
|
}
|
|
3592
|
-
|
|
4553
|
+
const preview = {
|
|
3593
4554
|
action: "upload a textbook PDF and attach it to a student's goal",
|
|
3594
4555
|
target: preflight.target,
|
|
3595
4556
|
request: {
|
|
@@ -3603,111 +4564,114 @@ export async function runCommand(argv) {
|
|
|
3603
4564
|
currentChangeId: preflight.currentChangeId,
|
|
3604
4565
|
note: "The PDF is stored separately and the goal workspace receives a small verified reference, matching planner-authored textbook goals.",
|
|
3605
4566
|
},
|
|
3606
|
-
}
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
conversationId,
|
|
3611
|
-
studentUserId: studentId,
|
|
3612
|
-
fileName,
|
|
3613
|
-
contentType: "application/pdf",
|
|
3614
|
-
sizeBytes: fileStat.size,
|
|
3615
|
-
},
|
|
3616
|
-
}));
|
|
3617
|
-
let uploaded;
|
|
3618
|
-
try {
|
|
3619
|
-
const uploadedSource = await uploadPdfToSignedUrls(absolutePath, "application/pdf", signed);
|
|
3620
|
-
if (uploadedSource.sha256 !== sourceSha256 ||
|
|
3621
|
-
uploadedSource.sizeBytes !== fileStat.size) {
|
|
3622
|
-
throw new CliError("source_changed", "The source PDF changed after approval. The upload was discarded; preview the current file again.");
|
|
3623
|
-
}
|
|
3624
|
-
uploaded =
|
|
3625
|
-
signed.kind === "multipart"
|
|
3626
|
-
? unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/complete", {
|
|
3627
|
-
body: {
|
|
3628
|
-
conversationId,
|
|
3629
|
-
fileName,
|
|
3630
|
-
contentType: "application/pdf",
|
|
3631
|
-
key: signed.key,
|
|
3632
|
-
uploadId: signed.uploadId,
|
|
3633
|
-
sizeBytes: fileStat.size,
|
|
3634
|
-
},
|
|
3635
|
-
}))
|
|
3636
|
-
: unwrap(await api.client.POST("/os-v2-mesa/planner/uploads/verify", {
|
|
3637
|
-
body: {
|
|
3638
|
-
conversationId,
|
|
3639
|
-
fileName,
|
|
3640
|
-
contentType: "application/pdf",
|
|
3641
|
-
key: signed.key,
|
|
3642
|
-
sizeBytes: fileStat.size,
|
|
3643
|
-
},
|
|
3644
|
-
}));
|
|
3645
|
-
}
|
|
3646
|
-
catch (error) {
|
|
3647
|
-
// A failed multipart complete may already have materialized the object,
|
|
3648
|
-
// so attempt both abort and delete. These are compensation attempts; the
|
|
3649
|
-
// original upload error remains authoritative.
|
|
3650
|
-
await cleanupPlannerUpload(api, conversationId, signed, true);
|
|
3651
|
-
throw error;
|
|
3652
|
-
}
|
|
3653
|
-
const manifest = JSON.stringify({
|
|
3654
|
-
kind: "r2-pdf",
|
|
3655
|
-
version: 2,
|
|
3656
|
-
r2Key: uploaded.key,
|
|
3657
|
-
signature: uploaded.manifestSignature,
|
|
3658
|
-
fileName,
|
|
3659
|
-
contentType: uploaded.contentType,
|
|
3660
|
-
sizeBytes: uploaded.sizeBytes,
|
|
3661
|
-
});
|
|
3662
|
-
let workspace;
|
|
3663
|
-
try {
|
|
3664
|
-
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", {
|
|
3665
4571
|
body: {
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
contentEncoding: "utf8",
|
|
3672
|
-
},
|
|
3673
|
-
],
|
|
3674
|
-
dryRun: false,
|
|
3675
|
-
expectedChangeId: preflight.currentChangeId,
|
|
4572
|
+
conversationId,
|
|
4573
|
+
studentUserId: studentId,
|
|
4574
|
+
fileName,
|
|
4575
|
+
contentType: "application/pdf",
|
|
4576
|
+
sizeBytes: fileStat.size,
|
|
3676
4577
|
},
|
|
3677
4578
|
}));
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
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.
|
|
3690
4612
|
await cleanupPlannerUpload(api, conversationId, signed, true);
|
|
4613
|
+
throw error;
|
|
3691
4614
|
}
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
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
|
+
});
|
|
3702
4665
|
}
|
|
3703
4666
|
if (noun === "goals" && verb === "files") {
|
|
3704
4667
|
const action = positional(parsed, 2, "goals files action");
|
|
3705
|
-
const studentId = flagString(parsed, "student", { required: true });
|
|
3706
4668
|
if (action === "list") {
|
|
4669
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3707
4670
|
const goalId = flagString(parsed, "goal", { required: true });
|
|
3708
4671
|
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/files", { params: { path: { studentId, goalId } } }));
|
|
3709
4672
|
}
|
|
3710
4673
|
if (action === "read") {
|
|
4674
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3711
4675
|
const goalId = flagString(parsed, "goal", { required: true });
|
|
3712
4676
|
return unwrap(await api.client.GET("/tutor/students/{studentId}/goals/{goalId}/workspace/file", {
|
|
3713
4677
|
params: {
|
|
@@ -3716,7 +4680,115 @@ export async function runCommand(argv) {
|
|
|
3716
4680
|
},
|
|
3717
4681
|
}));
|
|
3718
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
|
+
}
|
|
3719
4790
|
if (action === "write") {
|
|
4791
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
3720
4792
|
const goalId = flagString(parsed, "goal");
|
|
3721
4793
|
const draftSlug = flagString(parsed, "draft");
|
|
3722
4794
|
if (Boolean(goalId) === Boolean(draftSlug)) {
|
|
@@ -3755,19 +4827,18 @@ export async function runCommand(argv) {
|
|
|
3755
4827
|
files: preflight.files,
|
|
3756
4828
|
note: target.kind === "draft"
|
|
3757
4829
|
? "This writes a complete authoring tree under drafts/<slug>/workspace. Capture it only after the server's goal-workspace validation passes."
|
|
3758
|
-
: "
|
|
4830
|
+
: "This directly edits the live goal workspace, including modules/ and state/. The write remains bound to the previewed workspace revision.",
|
|
3759
4831
|
},
|
|
3760
4832
|
};
|
|
3761
|
-
|
|
3762
|
-
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", {
|
|
3763
4834
|
body: {
|
|
3764
4835
|
...body,
|
|
3765
4836
|
dryRun: false,
|
|
3766
4837
|
expectedChangeId: preflight.currentChangeId,
|
|
3767
4838
|
},
|
|
3768
|
-
}));
|
|
4839
|
+
})));
|
|
3769
4840
|
}
|
|
3770
|
-
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.");
|
|
3771
4842
|
}
|
|
3772
4843
|
if (noun === "request" && verb === "get") {
|
|
3773
4844
|
return api.rawGet(positional(parsed, 2, "request path"));
|