recess-cli 3.0.0 → 3.2.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 +7 -1
- package/dist/args.js +1 -0
- package/dist/auth.js +8 -3
- package/dist/cli.js +615 -48
- package/dist/command-schema.js +20 -9
- package/dist/commands/apps.js +10 -1
- package/dist/commands/goal-curriculum.js +187 -0
- package/dist/help.js +56 -7
- package/package.json +1 -1
- package/skill/recess-cli/SKILL.md +3 -2
- package/skill/recess-cli/agents/version.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { clearStoredSession, deleteProfile, listProfiles, resolveConfig, savePro
|
|
|
9
9
|
import { agentContext, buildCommandSchema, findCommandSchema, remoteCommands, scopedHelp, validateInvocation, } from "./command-schema.js";
|
|
10
10
|
import { runApplicationsCommand } from "./commands/applications.js";
|
|
11
11
|
import { runAppsCommand } from "./commands/apps.js";
|
|
12
|
+
import { readAssignmentSelection, runGoalCurriculumCommand, } from "./commands/goal-curriculum.js";
|
|
12
13
|
import { runChatLogsCommand } from "./commands/chat-logs.js";
|
|
13
14
|
import { runMasteryCommand } from "./commands/mastery.js";
|
|
14
15
|
import { runOnboardingCommand } from "./commands/onboarding.js";
|
|
@@ -615,7 +616,7 @@ function assertOperationKeyMatchesPreview(operationKey, fingerprint, preview) {
|
|
|
615
616
|
});
|
|
616
617
|
}
|
|
617
618
|
}
|
|
618
|
-
async function requirePreviewBoundConfirmation(parsed, preview) {
|
|
619
|
+
async function requirePreviewBoundConfirmation(parsed, preview, persistPreview = false) {
|
|
619
620
|
const approvalToken = approvalTokenFor(preview);
|
|
620
621
|
const suppliedOperationKey = flagString(parsed, "operation-key");
|
|
621
622
|
const boundPreview = {
|
|
@@ -634,6 +635,9 @@ async function requirePreviewBoundConfirmation(parsed, preview) {
|
|
|
634
635
|
action: preview.action,
|
|
635
636
|
fingerprint: approvalToken,
|
|
636
637
|
target: preview.target,
|
|
638
|
+
// Opted-in operations retain the approved receipt in the 0600 ledger so
|
|
639
|
+
// retries can reach server idempotency after their own write changed state.
|
|
640
|
+
...(persistPreview ? { preview } : {}),
|
|
637
641
|
});
|
|
638
642
|
requireConfirmation(false, boundPreview);
|
|
639
643
|
}
|
|
@@ -655,8 +659,46 @@ async function requirePreviewBoundConfirmation(parsed, preview) {
|
|
|
655
659
|
});
|
|
656
660
|
return { operationKey: suppliedOperationKey, fingerprint: approvalToken };
|
|
657
661
|
}
|
|
658
|
-
|
|
659
|
-
|
|
662
|
+
/**
|
|
663
|
+
* A tutor template's `body` is the `## Subjects` block markdown (no `###`
|
|
664
|
+
* heading — the server renders that from `title` + `cadence`).
|
|
665
|
+
*/
|
|
666
|
+
async function readTutorTemplateBody(filePath) {
|
|
667
|
+
const absolutePath = path.resolve(filePath);
|
|
668
|
+
const contents = await fs.readFile(absolutePath, "utf8").catch(() => null);
|
|
669
|
+
if (contents === null) {
|
|
670
|
+
throw new CliError("invalid_arguments", `--body-file is not readable: ${absolutePath}`);
|
|
671
|
+
}
|
|
672
|
+
if (contents.trim().length === 0) {
|
|
673
|
+
throw new CliError("invalid_arguments", "--body-file is empty.");
|
|
674
|
+
}
|
|
675
|
+
return contents;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Every `*.md` in the directory becomes one `lessons[name]` entry, referenced
|
|
679
|
+
* from a module line as `lesson:<slug>/<name>`.
|
|
680
|
+
*/
|
|
681
|
+
async function readTutorTemplateLessons(directory) {
|
|
682
|
+
const absolutePath = path.resolve(directory);
|
|
683
|
+
const entries = await fs
|
|
684
|
+
.readdir(absolutePath, { withFileTypes: true })
|
|
685
|
+
.catch(() => null);
|
|
686
|
+
if (entries === null) {
|
|
687
|
+
throw new CliError("invalid_arguments", `--lessons-dir is not a readable directory: ${absolutePath}`);
|
|
688
|
+
}
|
|
689
|
+
const lessons = {};
|
|
690
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
691
|
+
if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md"))
|
|
692
|
+
continue;
|
|
693
|
+
lessons[entry.name] = await fs.readFile(path.join(absolutePath, entry.name), "utf8");
|
|
694
|
+
}
|
|
695
|
+
if (Object.keys(lessons).length === 0) {
|
|
696
|
+
throw new CliError("invalid_arguments", `--lessons-dir contains no .md files: ${absolutePath}`);
|
|
697
|
+
}
|
|
698
|
+
return lessons;
|
|
699
|
+
}
|
|
700
|
+
async function previewBoundWrite(parsed, preview, execute, persistPreview = false) {
|
|
701
|
+
const context = await requirePreviewBoundConfirmation(parsed, preview, persistPreview);
|
|
660
702
|
try {
|
|
661
703
|
const result = await withIdempotencyContext(context, execute);
|
|
662
704
|
await appendJobEvent({
|
|
@@ -688,6 +730,41 @@ async function previewBoundWrite(parsed, preview, execute) {
|
|
|
688
730
|
throw error;
|
|
689
731
|
}
|
|
690
732
|
}
|
|
733
|
+
async function readExecutedPreview(parsed) {
|
|
734
|
+
const key = flagString(parsed, "operation-key");
|
|
735
|
+
if (!hasFlag(parsed, "confirm") || !key)
|
|
736
|
+
return null;
|
|
737
|
+
const { history } = await getJob(key);
|
|
738
|
+
const receipt = [...history].reverse().find((event) => event.preview);
|
|
739
|
+
if (!receipt?.preview)
|
|
740
|
+
return null;
|
|
741
|
+
// First confirmation still refreshes server effects. A prior execution
|
|
742
|
+
// attempt is what makes a stale preflight an idempotent-recovery problem.
|
|
743
|
+
if (!history.some((event) => event.status !== "awaiting_confirmation" &&
|
|
744
|
+
event.fingerprint === receipt.fingerprint &&
|
|
745
|
+
event.action === receipt.action))
|
|
746
|
+
return null;
|
|
747
|
+
const saved = receipt.preview;
|
|
748
|
+
const fingerprint = approvalTokenFor(saved);
|
|
749
|
+
if (receipt.fingerprint !== fingerprint ||
|
|
750
|
+
flagString(parsed, "approval-token") !== fingerprint) {
|
|
751
|
+
throw new CliError("approval_mismatch", "This operation key does not match the approved command. Retry the original command unchanged, or review a new preview.");
|
|
752
|
+
}
|
|
753
|
+
assertOperationKeyMatchesPreview(key, fingerprint, saved);
|
|
754
|
+
return saved;
|
|
755
|
+
}
|
|
756
|
+
/** A retried write must retain its approved fence after changing its own source state. */
|
|
757
|
+
async function recoverExecutedPreview(parsed, expected) {
|
|
758
|
+
const saved = await readExecutedPreview(parsed);
|
|
759
|
+
if (!saved)
|
|
760
|
+
return null;
|
|
761
|
+
if (saved.action !== expected.action ||
|
|
762
|
+
JSON.stringify(saved.target) !== JSON.stringify(expected.target) ||
|
|
763
|
+
JSON.stringify(saved.request) !== JSON.stringify(expected.request)) {
|
|
764
|
+
throw new CliError("approval_mismatch", "This operation key does not match the approved command. Retry the original command unchanged, or review a new preview.");
|
|
765
|
+
}
|
|
766
|
+
return saved;
|
|
767
|
+
}
|
|
691
768
|
async function cleanupPlannerUpload(api, conversationId, signed, includeDelete) {
|
|
692
769
|
if (signed.kind === "multipart") {
|
|
693
770
|
await api.client
|
|
@@ -809,6 +886,9 @@ async function readGoalWorkspaceWriteSource(parsed) {
|
|
|
809
886
|
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
810
887
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
811
888
|
for (const entry of entries) {
|
|
889
|
+
// Git metadata is local checkout state, including worktree .git files.
|
|
890
|
+
if (entry.name === ".git")
|
|
891
|
+
continue;
|
|
812
892
|
const relativePath = relativeRoot
|
|
813
893
|
? path.join(relativeRoot, entry.name)
|
|
814
894
|
: entry.name;
|
|
@@ -1877,6 +1957,10 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
1877
1957
|
};
|
|
1878
1958
|
}
|
|
1879
1959
|
if (noun === "auth") {
|
|
1960
|
+
const duration = flagString(parsed, "duration");
|
|
1961
|
+
if (duration !== undefined && duration !== "12h" && duration !== "30d") {
|
|
1962
|
+
throw new CliError("invalid_arguments", "--duration must be 12h or 30d (admins only).");
|
|
1963
|
+
}
|
|
1880
1964
|
if (verb === "login") {
|
|
1881
1965
|
const oauthClientId = flagString(parsed, "client-id") ?? config.oauthClientId;
|
|
1882
1966
|
const callbackPort = flagNumber(parsed, "callback-port") ?? 8765;
|
|
@@ -1885,11 +1969,15 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
1885
1969
|
callbackPort > 65535) {
|
|
1886
1970
|
throw new CliError("invalid_arguments", "--callback-port must be an integer from 1024 through 65535.");
|
|
1887
1971
|
}
|
|
1888
|
-
return login(config, {
|
|
1972
|
+
return login(config, {
|
|
1973
|
+
callbackPort,
|
|
1974
|
+
oauthClientId,
|
|
1975
|
+
sessionDuration: duration,
|
|
1976
|
+
});
|
|
1889
1977
|
}
|
|
1890
1978
|
if (verb === "request") {
|
|
1891
1979
|
const label = flagString(parsed, "label");
|
|
1892
|
-
return requestDeviceAuth(config,
|
|
1980
|
+
return requestDeviceAuth(config, { label, sessionDuration: duration });
|
|
1893
1981
|
}
|
|
1894
1982
|
if (verb === "poll") {
|
|
1895
1983
|
const timeoutSeconds = flagNumber(parsed, "timeout") ?? 300;
|
|
@@ -3460,6 +3548,16 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
3460
3548
|
throw new CliError("invalid_arguments", "Use content-library search, status, set-stage, or submit.");
|
|
3461
3549
|
}
|
|
3462
3550
|
if (noun === "goal-templates") {
|
|
3551
|
+
if (verb === "curriculum") {
|
|
3552
|
+
return runGoalCurriculumCommand({
|
|
3553
|
+
parsed,
|
|
3554
|
+
api,
|
|
3555
|
+
writeCommand,
|
|
3556
|
+
resolveTemplateId: (value) => resolveGoalTemplateId(api, value),
|
|
3557
|
+
previewBoundWrite: (args, preview, execute) => previewBoundWrite(args, preview, execute, true),
|
|
3558
|
+
recoverExecutedPreview: (expected) => recoverExecutedPreview(parsed, expected),
|
|
3559
|
+
});
|
|
3560
|
+
}
|
|
3463
3561
|
const readTemplateDocument = async () => {
|
|
3464
3562
|
if (!isRemote) {
|
|
3465
3563
|
return readJsonFile(flagString(parsed, "file", { required: true }), "Template file");
|
|
@@ -3618,6 +3716,174 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
3618
3716
|
body: document,
|
|
3619
3717
|
})));
|
|
3620
3718
|
}
|
|
3719
|
+
if (verb === "policy") {
|
|
3720
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3721
|
+
return unwrap(await api.client.GET("/ai/goal-templates/{id}/policy", {
|
|
3722
|
+
params: { path: { id } },
|
|
3723
|
+
}));
|
|
3724
|
+
}
|
|
3725
|
+
if (verb === "set-policy") {
|
|
3726
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3727
|
+
const target = flagString(parsed, "target-modules");
|
|
3728
|
+
const minimum = flagString(parsed, "required-modules");
|
|
3729
|
+
for (const [flag, value] of [
|
|
3730
|
+
["target-modules", target],
|
|
3731
|
+
["required-modules", minimum],
|
|
3732
|
+
]) {
|
|
3733
|
+
if (value === undefined ||
|
|
3734
|
+
(flag === "required-modules" && value === "all"))
|
|
3735
|
+
continue;
|
|
3736
|
+
const count = Number(value);
|
|
3737
|
+
if (!Number.isInteger(count) || count < 1 || count > 20)
|
|
3738
|
+
throw new CliError("invalid_arguments", `--${flag} must be an integer from 1 through 20${flag === "required-modules" ? " or all" : ""}.`);
|
|
3739
|
+
}
|
|
3740
|
+
if (target === undefined && minimum === undefined)
|
|
3741
|
+
throw new CliError("invalid_arguments", "Provide --target-modules or --required-modules N|all");
|
|
3742
|
+
const body = {
|
|
3743
|
+
recipeKey: flagString(parsed, "recipe", { required: true }),
|
|
3744
|
+
...(target === undefined ? {} : { targetModuleCount: Number(target) }),
|
|
3745
|
+
...(minimum === undefined
|
|
3746
|
+
? {}
|
|
3747
|
+
: {
|
|
3748
|
+
requiredModuleCount: minimum === "all" ? null : Number(minimum),
|
|
3749
|
+
}),
|
|
3750
|
+
expectedVersion: requiredExpectedVersion(parsed),
|
|
3751
|
+
};
|
|
3752
|
+
const requestPreview = {
|
|
3753
|
+
action: "edit shared goal-template assigned target or completion minimum",
|
|
3754
|
+
target: { templateId: id, apiOrigin: api.config.apiOrigin },
|
|
3755
|
+
request: body,
|
|
3756
|
+
};
|
|
3757
|
+
const receipt = await recoverExecutedPreview(parsed, requestPreview);
|
|
3758
|
+
const preview = receipt ?? {
|
|
3759
|
+
...requestPreview,
|
|
3760
|
+
details: unwrap(await api.client.POST("/ai/goal-templates/{id}/policy", {
|
|
3761
|
+
params: { path: { id } },
|
|
3762
|
+
body: { ...body, dryRun: true },
|
|
3763
|
+
})),
|
|
3764
|
+
};
|
|
3765
|
+
// A recovered receipt only skips the now-stale preview. The actual write
|
|
3766
|
+
// still reaches server authorization, idempotency, and expectedVersion.
|
|
3767
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/policy", {
|
|
3768
|
+
params: { path: { id } },
|
|
3769
|
+
body: { ...body, dryRun: false },
|
|
3770
|
+
})), true);
|
|
3771
|
+
}
|
|
3772
|
+
if (verb === "migrate-policy") {
|
|
3773
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3774
|
+
const studentIds = flagString(parsed, "students", { required: true })
|
|
3775
|
+
.split(",")
|
|
3776
|
+
.map((value) => value.trim());
|
|
3777
|
+
const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/policy-migration", {
|
|
3778
|
+
params: { path: { id } },
|
|
3779
|
+
body: { studentIds, dryRun: true },
|
|
3780
|
+
}));
|
|
3781
|
+
const preview = {
|
|
3782
|
+
action: "backfill reviewed goal policy identities and migration holds",
|
|
3783
|
+
target: { templateId: id, studentIds },
|
|
3784
|
+
request: { studentIds, fingerprint: preflight.fingerprint },
|
|
3785
|
+
details: preflight,
|
|
3786
|
+
};
|
|
3787
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/policy-migration", {
|
|
3788
|
+
params: { path: { id } },
|
|
3789
|
+
body: {
|
|
3790
|
+
studentIds,
|
|
3791
|
+
fingerprint: preflight.fingerprint,
|
|
3792
|
+
dryRun: false,
|
|
3793
|
+
},
|
|
3794
|
+
})));
|
|
3795
|
+
}
|
|
3796
|
+
if (verb === "retire-assignments") {
|
|
3797
|
+
const [id, session] = await Promise.all([
|
|
3798
|
+
resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug")),
|
|
3799
|
+
api.client.GET("/auth/admin-cli/session/").then(unwrap),
|
|
3800
|
+
]);
|
|
3801
|
+
if (session.user.role !== "ADMIN" || session.cliScope !== "full_admin")
|
|
3802
|
+
throw new CliError("forbidden", "Assignment retirement requires a full-admin session.");
|
|
3803
|
+
const goalId = flagString(parsed, "goal", { required: true });
|
|
3804
|
+
const rawIds = flagString(parsed, "todos");
|
|
3805
|
+
const selectionPath = flagString(parsed, "todos-file");
|
|
3806
|
+
if (Boolean(rawIds) === Boolean(selectionPath))
|
|
3807
|
+
throw new CliError("invalid_arguments", "Choose exactly one of --todos or --todos-file with explicit assignment IDs.");
|
|
3808
|
+
const selection = selectionPath
|
|
3809
|
+
? await readAssignmentSelection(selectionPath)
|
|
3810
|
+
: null;
|
|
3811
|
+
const todoIds = selection?.todoIds ?? rawIds.split(",").map((id) => id.trim());
|
|
3812
|
+
if (!todoIds.length ||
|
|
3813
|
+
todoIds.length > 500 ||
|
|
3814
|
+
todoIds.some((id) => !id) ||
|
|
3815
|
+
new Set(todoIds).size !== todoIds.length)
|
|
3816
|
+
throw new CliError("invalid_arguments", "Choose 1–500 distinct assignment IDs explicitly.");
|
|
3817
|
+
const reason = flagString(parsed, "retirement-reason", {
|
|
3818
|
+
required: true,
|
|
3819
|
+
});
|
|
3820
|
+
const requestPreview = {
|
|
3821
|
+
action: "retire the selected curriculum assignments without completing lessons",
|
|
3822
|
+
target: {
|
|
3823
|
+
templateId: id,
|
|
3824
|
+
goalId,
|
|
3825
|
+
actorId: session.user.id,
|
|
3826
|
+
apiOrigin: api.config.apiOrigin,
|
|
3827
|
+
},
|
|
3828
|
+
request: { todoIds, reason, selectionFile: selection?.file ?? null },
|
|
3829
|
+
};
|
|
3830
|
+
const receipt = await recoverExecutedPreview(parsed, requestPreview);
|
|
3831
|
+
const preflight = receipt
|
|
3832
|
+
? receipt.details
|
|
3833
|
+
: unwrap(await api.client.POST("/ai/goal-templates/{id}/goals/{goalId}/assignments/retire", {
|
|
3834
|
+
params: { path: { id, goalId } },
|
|
3835
|
+
body: { todoIds, reason, dryRun: true },
|
|
3836
|
+
}));
|
|
3837
|
+
const preview = receipt ?? {
|
|
3838
|
+
...requestPreview,
|
|
3839
|
+
details: preflight,
|
|
3840
|
+
};
|
|
3841
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/goals/{goalId}/assignments/retire", {
|
|
3842
|
+
params: { path: { id, goalId } },
|
|
3843
|
+
body: {
|
|
3844
|
+
todoIds,
|
|
3845
|
+
reason,
|
|
3846
|
+
fingerprint: preflight.fingerprint,
|
|
3847
|
+
dryRun: false,
|
|
3848
|
+
},
|
|
3849
|
+
})));
|
|
3850
|
+
}
|
|
3851
|
+
if (verb === "student-policy") {
|
|
3852
|
+
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3853
|
+
const goalId = flagString(parsed, "goal", { required: true });
|
|
3854
|
+
const filePath = path.resolve(flagString(parsed, "file", { required: true }));
|
|
3855
|
+
const bytes = await fs.readFile(filePath);
|
|
3856
|
+
let raw;
|
|
3857
|
+
try {
|
|
3858
|
+
raw = JSON.parse(bytes.toString("utf8"));
|
|
3859
|
+
}
|
|
3860
|
+
catch {
|
|
3861
|
+
throw new CliError("invalid_arguments", "Student policy edit must contain valid JSON");
|
|
3862
|
+
}
|
|
3863
|
+
// Backend Zod is authoritative; preserve exact file bytes in the approval preview.
|
|
3864
|
+
const body = raw;
|
|
3865
|
+
const preflight = unwrap(await api.client.POST("/ai/goal-templates/{id}/goals/{goalId}/policy", {
|
|
3866
|
+
params: { path: { id, goalId } },
|
|
3867
|
+
body: { ...body, dryRun: true },
|
|
3868
|
+
}));
|
|
3869
|
+
const preview = {
|
|
3870
|
+
action: "review a student policy override or migration hold",
|
|
3871
|
+
target: { templateId: id, goalId },
|
|
3872
|
+
request: body,
|
|
3873
|
+
details: {
|
|
3874
|
+
...preflight,
|
|
3875
|
+
file: {
|
|
3876
|
+
absolutePath: filePath,
|
|
3877
|
+
sizeBytes: bytes.byteLength,
|
|
3878
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
3879
|
+
},
|
|
3880
|
+
},
|
|
3881
|
+
};
|
|
3882
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.POST("/ai/goal-templates/{id}/goals/{goalId}/policy", {
|
|
3883
|
+
params: { path: { id, goalId } },
|
|
3884
|
+
body: { ...body, dryRun: false },
|
|
3885
|
+
})));
|
|
3886
|
+
}
|
|
3621
3887
|
if (verb === "patch-spec") {
|
|
3622
3888
|
const id = await resolveGoalTemplateId(api, positional(parsed, 2, "template ID or slug"));
|
|
3623
3889
|
const expectedVersion = requiredExpectedVersion(parsed);
|
|
@@ -4090,6 +4356,65 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
4090
4356
|
.includes(query))),
|
|
4091
4357
|
};
|
|
4092
4358
|
}
|
|
4359
|
+
// Moves ONE kid's lesson, not the template's. The daily queue serves
|
|
4360
|
+
// modules by `ordinal ASC`, so pushing the one a kid is stuck on past the
|
|
4361
|
+
// rest is what hands them the next lesson tomorrow — no template version,
|
|
4362
|
+
// no retiring the work they have already done, nobody else's goal touched.
|
|
4363
|
+
if (verb === "move-module") {
|
|
4364
|
+
const moduleRef = positional(parsed, 2, "module ref (e.g. Q006)");
|
|
4365
|
+
const studentId = flagString(parsed, "student", { required: true });
|
|
4366
|
+
const goalId = flagString(parsed, "goal", { required: true });
|
|
4367
|
+
const toEnd = hasFlag(parsed, "to-end");
|
|
4368
|
+
const position = flagNumber(parsed, "position");
|
|
4369
|
+
if (toEnd === (position !== undefined)) {
|
|
4370
|
+
throw new CliError("invalid_arguments", "Pass exactly one of --to-end or --position <n>.");
|
|
4371
|
+
}
|
|
4372
|
+
const goal = unwrap(await api.client.GET("/tutor/browser/students/{userId}/goals/", {
|
|
4373
|
+
params: { path: { userId: studentId } },
|
|
4374
|
+
})).goals.find((candidate) => candidate.id === goalId);
|
|
4375
|
+
const modules = goal?.osV2?.modules ?? [];
|
|
4376
|
+
const target = modules.find((module) => module.moduleRef.toLocaleLowerCase() === moduleRef.toLocaleLowerCase());
|
|
4377
|
+
if (!target) {
|
|
4378
|
+
throw new CliError("not_found", `Module ${moduleRef} was not found on goal ${goalId}. Known refs: ${modules.map((m) => m.moduleRef).join(", ") || "none"}.`);
|
|
4379
|
+
}
|
|
4380
|
+
// Sparse past the current maximum, so no sibling has to be renumbered.
|
|
4381
|
+
const last = modules.reduce((max, m) => Math.max(max, m.ordinal), 0);
|
|
4382
|
+
const ordinal = toEnd ? last + 1 : position;
|
|
4383
|
+
if (ordinal === target.ordinal) {
|
|
4384
|
+
throw new CliError("already_positioned", `Module ${target.moduleRef} ("${target.title}") is already at position ${ordinal}.`);
|
|
4385
|
+
}
|
|
4386
|
+
const collision = modules.find((m) => m.id !== target.id && m.ordinal === ordinal);
|
|
4387
|
+
const preview = {
|
|
4388
|
+
action: toEnd
|
|
4389
|
+
? "move a lesson to the end of this kid's curriculum"
|
|
4390
|
+
: "move a lesson to a specific position in this kid's curriculum",
|
|
4391
|
+
target: {
|
|
4392
|
+
studentUserId: studentId,
|
|
4393
|
+
goalId,
|
|
4394
|
+
goalTitle: goal?.title ?? null,
|
|
4395
|
+
moduleRef: target.moduleRef,
|
|
4396
|
+
title: target.title,
|
|
4397
|
+
from: target.ordinal,
|
|
4398
|
+
to: ordinal,
|
|
4399
|
+
completed: target.completedAt !== null,
|
|
4400
|
+
},
|
|
4401
|
+
request: { moduleId: target.id, ordinal },
|
|
4402
|
+
details: {
|
|
4403
|
+
note: "Affects only this student's copy of the curriculum — the goal template and every other kid on it are untouched. Completion state is preserved: an unfinished lesson stays unfinished and is simply served later.",
|
|
4404
|
+
...(collision
|
|
4405
|
+
? {
|
|
4406
|
+
warning: `Position ${ordinal} is already held by ${collision.moduleRef} ("${collision.title}"). Both will sit at the same position and the queue will order them by creation date — pass --to-end instead if you just want this one out of the way.`,
|
|
4407
|
+
}
|
|
4408
|
+
: {}),
|
|
4409
|
+
},
|
|
4410
|
+
};
|
|
4411
|
+
return previewBoundWrite(parsed, preview, async () => unwrap(await api.client.PATCH("/tutor/students/{studentId}/goals/{goalId}/modules/{moduleId}", {
|
|
4412
|
+
params: {
|
|
4413
|
+
path: { studentId, goalId, moduleId: target.id },
|
|
4414
|
+
},
|
|
4415
|
+
body: { ordinal },
|
|
4416
|
+
})));
|
|
4417
|
+
}
|
|
4093
4418
|
if (verb === "create") {
|
|
4094
4419
|
const requestedStudentId = flagString(parsed, "student");
|
|
4095
4420
|
const sourceDirectory = flagString(parsed, "source-dir");
|
|
@@ -4437,7 +4762,7 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
4437
4762
|
}
|
|
4438
4763
|
throw new CliError("invalid_arguments", "Use goals queue get|set.");
|
|
4439
4764
|
}
|
|
4440
|
-
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|queue|files|pdf.");
|
|
4765
|
+
throw new CliError("invalid_arguments", "Use goals list|create|edit|delete|restore|archive|unarchive|complete|undo-completion|move-module|queue|files|pdf.");
|
|
4441
4766
|
}
|
|
4442
4767
|
if (noun === "chat-logs") {
|
|
4443
4768
|
return runChatLogsCommand({ parsed, api, writeCommand });
|
|
@@ -4864,6 +5189,156 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
4864
5189
|
}
|
|
4865
5190
|
throw new CliError("invalid_arguments", "Use rocky get|set.");
|
|
4866
5191
|
}
|
|
5192
|
+
if (noun === "tutor-templates") {
|
|
5193
|
+
if (verb === "list") {
|
|
5194
|
+
const grade = flagNumber(parsed, "grade");
|
|
5195
|
+
return unwrap(await api.client.GET("/tutor-templates/", {
|
|
5196
|
+
params: {
|
|
5197
|
+
query: {
|
|
5198
|
+
q: flagString(parsed, "q"),
|
|
5199
|
+
subject: flagString(parsed, "subject"),
|
|
5200
|
+
...(grade === undefined ? {} : { grade }),
|
|
5201
|
+
},
|
|
5202
|
+
},
|
|
5203
|
+
}));
|
|
5204
|
+
}
|
|
5205
|
+
if (verb === "get") {
|
|
5206
|
+
const slug = positional(parsed, 2, "template slug");
|
|
5207
|
+
return unwrap(await api.client.GET("/tutor-templates/{slug}", {
|
|
5208
|
+
params: { path: { slug } },
|
|
5209
|
+
}));
|
|
5210
|
+
}
|
|
5211
|
+
if (verb === "assign" || verb === "unassign") {
|
|
5212
|
+
const slug = positional(parsed, 2, "template slug");
|
|
5213
|
+
const studentId = flagString(parsed, "student");
|
|
5214
|
+
const body = studentId ? { studentId } : {};
|
|
5215
|
+
const template = unwrap(await api.client.GET("/tutor-templates/{slug}", {
|
|
5216
|
+
params: { path: { slug } },
|
|
5217
|
+
}));
|
|
5218
|
+
return writeCommand(parsed, {
|
|
5219
|
+
action: verb === "assign"
|
|
5220
|
+
? "add a tutor template's subject block to a kid's plan"
|
|
5221
|
+
: "remove a tutor template's subject block from a kid's plan",
|
|
5222
|
+
target: { slug, studentUserId: studentId ?? "the signed-in kid" },
|
|
5223
|
+
request: body,
|
|
5224
|
+
details: {
|
|
5225
|
+
title: template.title,
|
|
5226
|
+
cadence: template.cadence,
|
|
5227
|
+
consequence: verb === "assign"
|
|
5228
|
+
? "Appends `### <title> — <cadence>` to rocky-v3/plan.md, rebuilds Today, and writes an ACTIVE assignment row."
|
|
5229
|
+
: "Deletes that subject block from rocky-v3/plan.md and marks the assignment REMOVED.",
|
|
5230
|
+
},
|
|
5231
|
+
}, async () => verb === "assign"
|
|
5232
|
+
? unwrap(await api.client.POST("/tutor-templates/{slug}/assign", {
|
|
5233
|
+
params: { path: { slug } },
|
|
5234
|
+
body,
|
|
5235
|
+
}))
|
|
5236
|
+
: unwrap(await api.client.POST("/tutor-templates/{slug}/unassign", {
|
|
5237
|
+
params: { path: { slug } },
|
|
5238
|
+
body,
|
|
5239
|
+
})));
|
|
5240
|
+
}
|
|
5241
|
+
if (verb === "create" || verb === "update") {
|
|
5242
|
+
const bodyFile = flagString(parsed, "body-file", {
|
|
5243
|
+
required: verb === "create",
|
|
5244
|
+
});
|
|
5245
|
+
const lessonsDir = flagString(parsed, "lessons-dir");
|
|
5246
|
+
const visibility = flagString(parsed, "visibility");
|
|
5247
|
+
const emoji = flagString(parsed, "emoji");
|
|
5248
|
+
const gradeMin = flagNumber(parsed, "grade-min");
|
|
5249
|
+
const gradeMax = flagNumber(parsed, "grade-max");
|
|
5250
|
+
const shared = {
|
|
5251
|
+
...(flagString(parsed, "title") === undefined
|
|
5252
|
+
? {}
|
|
5253
|
+
: { title: flagString(parsed, "title") }),
|
|
5254
|
+
...(flagString(parsed, "summary") === undefined
|
|
5255
|
+
? {}
|
|
5256
|
+
: { summary: flagString(parsed, "summary") }),
|
|
5257
|
+
...(flagString(parsed, "subject") === undefined
|
|
5258
|
+
? {}
|
|
5259
|
+
: { subject: flagString(parsed, "subject") }),
|
|
5260
|
+
...(flagString(parsed, "cadence") === undefined
|
|
5261
|
+
? {}
|
|
5262
|
+
: { cadence: flagString(parsed, "cadence") }),
|
|
5263
|
+
...(emoji === undefined ? {} : { emoji }),
|
|
5264
|
+
...(gradeMin === undefined ? {} : { gradeMin }),
|
|
5265
|
+
...(gradeMax === undefined ? {} : { gradeMax }),
|
|
5266
|
+
...(visibility === undefined
|
|
5267
|
+
? {}
|
|
5268
|
+
: {
|
|
5269
|
+
visibility: assertChoice(visibility, ["PUBLIC", "STAFF"], "--visibility"),
|
|
5270
|
+
}),
|
|
5271
|
+
...(bodyFile === undefined
|
|
5272
|
+
? {}
|
|
5273
|
+
: { body: await readTutorTemplateBody(bodyFile) }),
|
|
5274
|
+
...(lessonsDir === undefined
|
|
5275
|
+
? {}
|
|
5276
|
+
: { lessons: await readTutorTemplateLessons(lessonsDir) }),
|
|
5277
|
+
};
|
|
5278
|
+
if (verb === "create") {
|
|
5279
|
+
const missing = ["title", "summary", "subject", "cadence"].filter((name) => !(name in shared));
|
|
5280
|
+
if (missing.length > 0) {
|
|
5281
|
+
throw new CliError("invalid_arguments", `tutor-templates create requires ${missing
|
|
5282
|
+
.map((name) => `--${name}`)
|
|
5283
|
+
.join(", ")}.`);
|
|
5284
|
+
}
|
|
5285
|
+
const slug = flagString(parsed, "slug", { required: true });
|
|
5286
|
+
const body = { slug, ...shared };
|
|
5287
|
+
return writeCommand(parsed, {
|
|
5288
|
+
action: "create a tutor template in the subject catalog",
|
|
5289
|
+
target: { slug },
|
|
5290
|
+
request: {
|
|
5291
|
+
...body,
|
|
5292
|
+
bodyFile: bodyFile ? path.resolve(bodyFile) : null,
|
|
5293
|
+
lessonsDir: lessonsDir ? path.resolve(lessonsDir) : null,
|
|
5294
|
+
},
|
|
5295
|
+
details: {
|
|
5296
|
+
lessonNames: Object.keys(shared.lessons ?? {}),
|
|
5297
|
+
renamingNote: "`title` is the `### <heading>` join key in every kid's plan.md — renaming later orphans existing plans.",
|
|
5298
|
+
},
|
|
5299
|
+
}, async () => unwrap(await api.client.POST("/tutor-templates/", { body })));
|
|
5300
|
+
}
|
|
5301
|
+
const slug = positional(parsed, 2, "template slug");
|
|
5302
|
+
const body = shared;
|
|
5303
|
+
if (Object.keys(body).length === 0) {
|
|
5304
|
+
throw new CliError("invalid_arguments", "tutor-templates update needs at least one field to change.");
|
|
5305
|
+
}
|
|
5306
|
+
return writeCommand(parsed, {
|
|
5307
|
+
action: "update a tutor template in the subject catalog",
|
|
5308
|
+
target: { slug },
|
|
5309
|
+
request: {
|
|
5310
|
+
...body,
|
|
5311
|
+
bodyFile: bodyFile ? path.resolve(bodyFile) : null,
|
|
5312
|
+
lessonsDir: lessonsDir ? path.resolve(lessonsDir) : null,
|
|
5313
|
+
},
|
|
5314
|
+
details: {
|
|
5315
|
+
fields: Object.keys(body),
|
|
5316
|
+
renamingNote: "Changing `title` orphans the `### <heading>` in every plan.md already carrying this template.",
|
|
5317
|
+
},
|
|
5318
|
+
}, async () => unwrap(await api.client.PATCH("/tutor-templates/{slug}", {
|
|
5319
|
+
params: { path: { slug } },
|
|
5320
|
+
body,
|
|
5321
|
+
})));
|
|
5322
|
+
}
|
|
5323
|
+
if (verb === "delete") {
|
|
5324
|
+
const slug = positional(parsed, 2, "template slug");
|
|
5325
|
+
const template = unwrap(await api.client.GET("/tutor-templates/{slug}", {
|
|
5326
|
+
params: { path: { slug } },
|
|
5327
|
+
}));
|
|
5328
|
+
return writeCommand(parsed, {
|
|
5329
|
+
action: "soft-delete a tutor template from the subject catalog",
|
|
5330
|
+
target: { slug },
|
|
5331
|
+
request: {},
|
|
5332
|
+
details: {
|
|
5333
|
+
title: template.title,
|
|
5334
|
+
consequence: "The row is soft-deleted; subject blocks already written into kids' plan.md stay where they are.",
|
|
5335
|
+
},
|
|
5336
|
+
}, async () => unwrap(await api.client.DELETE("/tutor-templates/{slug}", {
|
|
5337
|
+
params: { path: { slug } },
|
|
5338
|
+
})));
|
|
5339
|
+
}
|
|
5340
|
+
throw new CliError("invalid_arguments", "Use tutor-templates list|get|create|update|delete|assign|unassign.");
|
|
5341
|
+
}
|
|
4867
5342
|
if (noun === "goals" && verb === "pdf") {
|
|
4868
5343
|
const action = positional(parsed, 2, "goals pdf action");
|
|
4869
5344
|
if (action !== "upload") {
|
|
@@ -5085,7 +5560,40 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
5085
5560
|
required: true,
|
|
5086
5561
|
});
|
|
5087
5562
|
const checkout = await readRecessGitMetadata(sourceDirectory);
|
|
5088
|
-
const
|
|
5563
|
+
const isGoal = checkout.metadata.target.kind === "goal";
|
|
5564
|
+
const session = isGoal
|
|
5565
|
+
? unwrap(await api.client.GET("/auth/admin-cli/session/"))
|
|
5566
|
+
: null;
|
|
5567
|
+
const actionDescription = `push committed Git changes to a student's Mesa ${checkout.metadata.target.kind} workspace`;
|
|
5568
|
+
const boundTarget = session
|
|
5569
|
+
? {
|
|
5570
|
+
studentId: checkout.metadata.studentId,
|
|
5571
|
+
workspace: checkout.metadata.target,
|
|
5572
|
+
actorId: session.user.id,
|
|
5573
|
+
apiOrigin: api.config.apiOrigin,
|
|
5574
|
+
}
|
|
5575
|
+
: null;
|
|
5576
|
+
const receipt = isGoal ? await readExecutedPreview(parsed) : null;
|
|
5577
|
+
if (receipt &&
|
|
5578
|
+
(receipt.action !== actionDescription ||
|
|
5579
|
+
JSON.stringify(receipt.target) !== JSON.stringify(boundTarget) ||
|
|
5580
|
+
receipt.request.sourceDirectory !== checkout.root)) {
|
|
5581
|
+
throw new CliError("approval_mismatch", "The approved push belongs to a different checkout, account, or API.");
|
|
5582
|
+
}
|
|
5583
|
+
const localBaseCommit = receipt
|
|
5584
|
+
? String(receipt.request.localBaseCommit)
|
|
5585
|
+
: checkout.metadata.upstreamCommit;
|
|
5586
|
+
const mesaBaseChangeId = receipt
|
|
5587
|
+
? String(receipt.request.mesaBaseChangeId)
|
|
5588
|
+
: checkout.metadata.changeId;
|
|
5589
|
+
const local = await readGoalWorkspaceGitChanges(checkout.root, localBaseCommit);
|
|
5590
|
+
if (receipt &&
|
|
5591
|
+
((checkout.metadata.upstreamCommit !== localBaseCommit &&
|
|
5592
|
+
checkout.metadata.upstreamCommit !== local.head) ||
|
|
5593
|
+
(checkout.metadata.upstreamCommit === localBaseCommit &&
|
|
5594
|
+
checkout.metadata.changeId !== mesaBaseChangeId))) {
|
|
5595
|
+
throw new CliError("approval_mismatch", "The checkout's upstream metadata moved outside the approved push.");
|
|
5596
|
+
}
|
|
5089
5597
|
const defaultMessage = local.commits.length === 1
|
|
5090
5598
|
? local.commits[0].subject
|
|
5091
5599
|
: `recess-cli: push ${local.commits.length} commits (${local.commits.at(-1).subject})`;
|
|
@@ -5101,41 +5609,61 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
5101
5609
|
content: change.content,
|
|
5102
5610
|
contentEncoding: change.contentEncoding,
|
|
5103
5611
|
});
|
|
5612
|
+
const assignedEdits = hasFlag(parsed, "allow-assigned-edits")
|
|
5613
|
+
? { allowAssignedEdits: true }
|
|
5614
|
+
: {};
|
|
5615
|
+
const stateModification = hasFlag(parsed, "allow-state-modification")
|
|
5616
|
+
? { allowStateModification: true }
|
|
5617
|
+
: {};
|
|
5104
5618
|
const body = {
|
|
5619
|
+
...stateModification,
|
|
5620
|
+
...assignedEdits,
|
|
5105
5621
|
studentUserId: checkout.metadata.studentId,
|
|
5106
5622
|
target: checkout.metadata.target,
|
|
5107
5623
|
message,
|
|
5108
5624
|
files,
|
|
5109
|
-
expectedChangeId:
|
|
5625
|
+
expectedChangeId: mesaBaseChangeId,
|
|
5110
5626
|
};
|
|
5111
|
-
const preflight =
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
|
|
5627
|
+
const preflight = receipt
|
|
5628
|
+
? null
|
|
5629
|
+
: unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
5630
|
+
body: { ...body, dryRun: true },
|
|
5631
|
+
}));
|
|
5632
|
+
if (preflight && preflight.action !== "preview_workspace_files_write") {
|
|
5115
5633
|
throw new CliError("unexpected_response", "Goal workspace push did not return a preview; nothing was written.");
|
|
5116
5634
|
}
|
|
5117
|
-
const
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
:
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5635
|
+
const request = {
|
|
5636
|
+
...stateModification,
|
|
5637
|
+
...assignedEdits,
|
|
5638
|
+
sourceDirectory: checkout.root,
|
|
5639
|
+
mesaBaseChangeId,
|
|
5640
|
+
localBaseCommit,
|
|
5641
|
+
localHeadCommit: local.head,
|
|
5642
|
+
commits: local.commits,
|
|
5643
|
+
message,
|
|
5644
|
+
changes: local.changes.map((change) => change.action === "delete"
|
|
5645
|
+
? change
|
|
5646
|
+
: {
|
|
5647
|
+
path: change.path,
|
|
5648
|
+
action: change.action,
|
|
5649
|
+
sizeBytes: change.sizeBytes,
|
|
5650
|
+
sha256: change.sha256,
|
|
5651
|
+
}),
|
|
5652
|
+
sizeBytes: local.sizeBytes,
|
|
5653
|
+
};
|
|
5654
|
+
if (receipt &&
|
|
5655
|
+
JSON.stringify(receipt.request) !== JSON.stringify(request)) {
|
|
5656
|
+
throw new CliError("approval_mismatch", "The command or committed file bytes differ from the approved push.");
|
|
5657
|
+
}
|
|
5658
|
+
const preview = receipt ?? {
|
|
5659
|
+
action: actionDescription,
|
|
5660
|
+
target: boundTarget ?? preflight.target,
|
|
5661
|
+
request,
|
|
5137
5662
|
details: {
|
|
5663
|
+
stateModificationPaths: preflight.stateModificationPaths ?? [],
|
|
5664
|
+
warnings: preflight.warnings ?? [],
|
|
5138
5665
|
currentChangeId: preflight.currentChangeId,
|
|
5666
|
+
resolvedTarget: preflight.target,
|
|
5139
5667
|
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.",
|
|
5140
5668
|
},
|
|
5141
5669
|
};
|
|
@@ -5146,6 +5674,11 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
5146
5674
|
if (result.action !== "write_workspace_files") {
|
|
5147
5675
|
throw new CliError("unexpected_response", "Goal workspace push returned an unexpected response.");
|
|
5148
5676
|
}
|
|
5677
|
+
if (receipt &&
|
|
5678
|
+
checkout.metadata.upstreamCommit === local.head &&
|
|
5679
|
+
checkout.metadata.changeId !== result.changeId) {
|
|
5680
|
+
throw new CliError("approval_mismatch", "The acknowledged checkout revision does not match this recovered push. Its local metadata was preserved.");
|
|
5681
|
+
}
|
|
5149
5682
|
await writeRecessGitMetadata(checkout.root, {
|
|
5150
5683
|
...checkout.metadata,
|
|
5151
5684
|
changeId: result.changeId,
|
|
@@ -5158,7 +5691,7 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
5158
5691
|
upstreamCommit: local.head,
|
|
5159
5692
|
},
|
|
5160
5693
|
};
|
|
5161
|
-
});
|
|
5694
|
+
}, isGoal);
|
|
5162
5695
|
}
|
|
5163
5696
|
if (action === "write") {
|
|
5164
5697
|
const studentId = flagString(parsed, "student", { required: true });
|
|
@@ -5173,31 +5706,65 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
5173
5706
|
: { kind: "draft", draftSlug: draftSlug };
|
|
5174
5707
|
const message = flagString(parsed, "message") ??
|
|
5175
5708
|
`recess-cli: write ${input.files.length} workspace file${input.files.length === 1 ? "" : "s"}`;
|
|
5709
|
+
const assignedEdits = hasFlag(parsed, "allow-assigned-edits")
|
|
5710
|
+
? { allowAssignedEdits: true }
|
|
5711
|
+
: {};
|
|
5712
|
+
const stateModification = hasFlag(parsed, "allow-state-modification")
|
|
5713
|
+
? { allowStateModification: true }
|
|
5714
|
+
: {};
|
|
5176
5715
|
const body = {
|
|
5716
|
+
...stateModification,
|
|
5717
|
+
...assignedEdits,
|
|
5177
5718
|
studentUserId: studentId,
|
|
5178
5719
|
target,
|
|
5179
5720
|
message,
|
|
5180
5721
|
files: input.files,
|
|
5181
5722
|
};
|
|
5182
|
-
const
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5723
|
+
const session = goalId
|
|
5724
|
+
? unwrap(await api.client.GET("/auth/admin-cli/session/"))
|
|
5725
|
+
: null;
|
|
5726
|
+
const request = {
|
|
5727
|
+
...stateModification,
|
|
5728
|
+
...assignedEdits,
|
|
5729
|
+
source: input.source,
|
|
5730
|
+
sourceSha256: input.sha256,
|
|
5731
|
+
fileCount: input.files.length,
|
|
5732
|
+
sizeBytes: input.sizeBytes,
|
|
5733
|
+
message,
|
|
5734
|
+
};
|
|
5735
|
+
const boundTarget = session
|
|
5736
|
+
? {
|
|
5737
|
+
studentId,
|
|
5738
|
+
goalId: goalId,
|
|
5739
|
+
actorId: session.user.id,
|
|
5740
|
+
apiOrigin: api.config.apiOrigin,
|
|
5741
|
+
}
|
|
5742
|
+
: null;
|
|
5743
|
+
const receipt = boundTarget
|
|
5744
|
+
? await recoverExecutedPreview(parsed, {
|
|
5745
|
+
action: "batch-upsert files in a student's goal workspace",
|
|
5746
|
+
target: boundTarget,
|
|
5747
|
+
request,
|
|
5748
|
+
})
|
|
5749
|
+
: null;
|
|
5750
|
+
const preflight = receipt
|
|
5751
|
+
? null
|
|
5752
|
+
: unwrap(await api.client.POST("/tutor/browser/mesa/workspace-files", {
|
|
5753
|
+
body: { ...body, dryRun: true },
|
|
5754
|
+
}));
|
|
5755
|
+
if (preflight && preflight.action !== "preview_workspace_files_write") {
|
|
5186
5756
|
throw new CliError("unexpected_response", "Goal workspace write did not return a preview; nothing was written.");
|
|
5187
5757
|
}
|
|
5188
|
-
const preview = {
|
|
5758
|
+
const preview = receipt ?? {
|
|
5189
5759
|
action: "batch-upsert files in a student's goal workspace",
|
|
5190
|
-
target: preflight.target,
|
|
5191
|
-
request
|
|
5192
|
-
source: input.source,
|
|
5193
|
-
sourceSha256: input.sha256,
|
|
5194
|
-
fileCount: input.files.length,
|
|
5195
|
-
sizeBytes: input.sizeBytes,
|
|
5196
|
-
message,
|
|
5197
|
-
},
|
|
5760
|
+
target: boundTarget ?? preflight.target,
|
|
5761
|
+
request,
|
|
5198
5762
|
details: {
|
|
5763
|
+
stateModificationPaths: preflight.stateModificationPaths ?? [],
|
|
5764
|
+
warnings: preflight.warnings ?? [],
|
|
5199
5765
|
currentChangeId: preflight.currentChangeId,
|
|
5200
5766
|
files: preflight.files,
|
|
5767
|
+
resolvedTarget: preflight.target,
|
|
5201
5768
|
note: target.kind === "draft"
|
|
5202
5769
|
? "This writes a complete authoring tree under drafts/<slug>/workspace. Capture it only after the server's goal-workspace validation passes."
|
|
5203
5770
|
: "This directly edits the live goal workspace, including modules/ and state/. The write remains bound to the previewed workspace revision.",
|
|
@@ -5207,9 +5774,9 @@ export async function executeRecessCommand(argv, options = {}) {
|
|
|
5207
5774
|
body: {
|
|
5208
5775
|
...body,
|
|
5209
5776
|
dryRun: false,
|
|
5210
|
-
expectedChangeId:
|
|
5777
|
+
expectedChangeId: String(preview.details.currentChangeId),
|
|
5211
5778
|
},
|
|
5212
|
-
})));
|
|
5779
|
+
})), Boolean(goalId));
|
|
5213
5780
|
}
|
|
5214
5781
|
throw new CliError("invalid_arguments", "Use goals files list|read|init|checkout|push|write.");
|
|
5215
5782
|
}
|