feat(tutorial): implement the Course-author-facing framework - #3442
Conversation
There was a problem hiding this comment.
Review: Tutorial ("Course") class framework
This is a solid, well-documented build-out of the Course framework. The concurrency model is deliberately narrow (one program, mutex-guarded state, single consumer goroutine draining a bounded event channel), the doc comments explain why rather than restate code, and the tests cover the important lifecycle edges: idempotent completion, in-order log delivery, post-completion drop, queue overflow, and capability failure. Removing the //go:build js && wasm constraint from tools/xgoexec-bundle/tutorial.go is correct — the host-only tutorial_test.go needs those symbols and the file pulls in no syscall/js.
A few findings below; the schema-recursion one is the only thing I'd want addressed before merge since it is reachable from Course-author input.
Additional notes (no reliable inline anchor)
- Panic recovery for interpreted Course code.
mustCallCapabilityandGenerateJSONpanic on failure.TestCapabilityFailureStopsTheCourseverifies the panic propagates out ofGopt_Course_Mainwhen called directly, but not that it is recovered when the Course runs inside the ixgo interpreter goroutine (tools/xgoexec/runtime.goRunuses a barego func()with onlyctx.SetPanic, nodefer/recover). Worth confirming an author-triggered panic degrades to a reported error rather than crashing the host worker. - Stale design docs vs. shipped API (out of PR scope).
docs/develop/tutorial-v2/tutorial-class-framework.goputsgetCode()(no args) onCodeEditorand givesProjectno methods, while the implementation shipsProject.GetCode(sprite)/Project.ListSprites().docs/develop/tutorial-v2/example-tutorial-course/main_course.goxcallseditor.codeEditor.getCode()and will not compile against the shipped API. Consider a follow-up so the example doesn't drift. - Minor: every namespace receiver is named
p(e.g.func (p *Spotlight)), which is non-idiomatic Go; and the drain loop inStarttakes the global mutex on every iteration (completed()) — anatomic.Boolfor the completed flag would remove that per-event contention. Low priority.
Grow the placeholder Tutorial class framework into the API a Playground Course is written against: Course presentation and completion, the Editor namespaces (Project, Runtime, CodeEditor, Ruler), Copilot text and structured generation, and Spotlight reveal. Course callbacks now run on the Course program's own goroutine, one at a time, in the order the host dispatched the events, instead of each event starting its own goroutine — Course code is written as ordinary sequential code, and runtime logs are a judging channel whose order carries meaning. The framework registers every host event up front, so dispatching an event the Course did not subscribe to is accepted and dropped rather than reported as an unknown event, and it bounds the pending queue so a Course blocked in a slow capability tells the host it is not keeping up instead of silently losing events. complete and completeWith end the program from within: the current callback runs to its end, queued events are abandoned and Start returns, so the executor reports the run as completed. Repeated completions are ignored. generateJSON derives its JSON Schema from the struct the Course passed and decodes the response back into it. A struct with no exported fields is rejected, since encoding/json could never fill it and the Course would silently read zero values. A failed capability panics: the Course asked the editor, Copilot or the learner's UI for something that did not happen, and continuing would judge the learner on a false premise. Validation: unit tests drive the framework through a fake host, and a bundle-level test builds a Course program that uses the whole API against the real class-framework binding, which also fails if the ixgo exports were not regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ivation - ShowVideo takes a declared video resource name, matching the contract merged in goplus#3437: videos are declared under assets/videos/<name>/, and the Course writes showVideo "step-to" rather than a file path - Reject recursive result types in generateJSON: a self-referential or mutually referential struct made schema derivation recurse without bound, which crashes the interpreter instead of reporting a mistake - Raise the event queue bound: presentation capabilities block until the learner acts, which can take minutes, and the runtime-log contract promises one event per appended entry, so the bound has to sit beyond what a running project can produce meanwhile - Say "serializable exported fields" where json:"-" also excludes a field Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
657cadc to
0c752ce
Compare
The namespaces reach their course's run state through this field, and "program" sat one letter away from the unrelated Project namespace. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Tutorial lifecycle prototype (goplus#3445) reads the completion feedback from a feedback field, and the name also says what the value is. Align the wire shape before either side ships. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
createTutorialFramework wraps the host's typed methods into the capability table the executor consumes, mirroring the Go half's wire names and request shapes. It lives in tools/tutorial next to the Go package so the framework stays one self-contained unit, and spx-gui consumes it through a symlink the same way it consumes the spxls type declarations. The adapter deliberately imports nothing from spx-gui: the executor's framework shape is declared structurally, which keeps the whole directory extractable as its own repository. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The example course used a parenthesis-free command-style call as an expression (feedback := Copilot.generateText "..."), which XGo only allows at statement level, so the example never compiled. Fix the call and add a bundle test that compiles the example through the real classfile pipeline so the docs example cannot drift from the framework again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reveal the API References panel right after filterAPIs narrows it to stepTo, using the panel's real Radar node name. The exact selector syntax is pending goplus#3441; a TODO marks the line for revisiting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the hand-rolled single-capability framework stub with createTutorialFramework wired to a full mock TutorialFrameworkHost that logs every capability call and answers with canned values. The course source is the docs example imported via ?raw, so the debug page always exercises the exact example we document. Verified in the browser against the freshly built wasm: filterAPIs, showPrelude, showVideo and spotlight_reveal (with Go-side defaults mask=true/duration=0) run on start; dispatching the reached-target log drives getCode -> generateText -> completeWith and the executor exits with reason 'completed'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Extract the mock TutorialFrameworkHost into tutorial-mock-host.ts so both debug pages share it; it logs every capability call and answers with canned values. - xgoexec debug page: the course source is now an editable textarea (prefilled with the docs example, with a button to load it back), and the status line turns green on 'exited: completed' / red on errors. - New /debug/tutorial-courses page: scripted course cases run one by one against the real executor wasm; each case declares a course source and the events to dispatch, and passes (green) when the run exits as 'completed'. Seeded with four cases: the docs example, a full-API course adapted from the bundle integration test, completion during onStart, and unsubscribed-event tolerance. Verified in the browser: all four cases pass, including the reflection-derived generateJSON schema round-tripping the bridge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Review summary
Reviewed the tutorial Course-author framework across four dimensions (quality, performance, security, docs). This is a carefully-reasoned, well-tested PR: the single-consumer event loop, idempotent completion (markCompleted sets the flag before the capability so a panicking capability can't re-open the gate), snapshot-under-lock handler iteration, and the reflection schema derivation (rejecting recursive types and zero-serializable-field structs) are all sound. The two-language wire contract is guarded on both sides (tutorial-framework.test.ts locks the capability names; program_test.go asserts request shapes), and I verified every request shape matches the Go json tags (content, videoName, feedback, apis, sprite, {content,schema}, {target,tip,options}, and SpotlightOptions{mask,duration}).
The removal of //go:build js && wasm from tools/xgoexec-bundle/tutorial.go was confirmed intentional and correct — it lets tutorial_test.go run under host go test, while main.go (the only code touching syscall/js) still carries the wasm tag and off-wasm CallCapability resolves to the erroring stub. Not a defect.
One inline suggestion below. A few non-blocking notes:
- Contract param-name drift (docs, not in this diff):
docs/develop/tutorial-v2/module_TutorialFramework.ts:94andtutorial-class-framework.go:33declarecompleteWith(message …), but the code deliberately usesfeedback(client.ts, Gojson:"feedback"). The wire is consistent; only the contract's parameter name lags. Worth aligning the contract tofeedbackso it stays the source of truthtutorial.goclaims it to be. dispatchWithRetryretries on any error (tutorial-courses.vue): the loop retries every thrown error for up to 2s, so a genuine queue-full/decode rejection is masked and then rethrown with the transient cause obscured. Narrowing to the known "not running" registration-race message would surface real failures faster. Debug-only; the same broad-catch assumption exists in the Go test helperdispatch.- Reminder: both
/debug/xgoexecand the new/debug/tutorial-coursesroutes run arbitrary XGo through the real executor and carry the "remove before merging to dev" TODO — just make sure that removal/gating is enforced before this reaches a non-dev build.
No correctness-breaking bug or security issue found.
The contract (tutorial-class-framework.go / module_TutorialFramework.ts)
declares completeWith(message); the earlier switch to a {feedback} wire
field (b3784e9) followed the goplus#3445 prototype instead of the contract.
The contract is the source of truth, so revert to the {content} shape
shared by the other presentation capabilities and name the parameter
message; the prototype side should align with the contract instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
When a case exits before the deadline the 15s setTimeout kept running until it fired into the already-settled Promise.race; clear it once the case is over. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
对 review summary 里三条 non-blocking notes 的处理:
|
Add a tools-tutorial-test job following the tools-ai-test precedent: it verifies the committed qexp export file matches the tutorial package's public API (build-wasm.sh silently regenerates it, so staleness never failed a build before — it only broke course execution inside the wasm interpreter), then runs the tutorial unit tests with -race and the executor bundle's classfile-binding tests. Also let the spx-gui job's Go cache cover the executor bundle modules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| if dead { | ||
| return | ||
| } | ||
| go lane.run(p) |
There was a problem hiding this comment.
我们每一个 OnXXX 对应一个 addLane,产生一个goroutine,也就是一个事件对应一个 goroutine,这样其实不好,建议,每种事件一个 goroutine:同类事件顺序处理的规则容易解释。
比如 两段 OnLog:A 会弹出提示并等待关闭,B 判断是否完成课程。改为每种事件一个 goroutine 后,A 等待期间,B 和后续所有日志都会被堵住。等等,这种存在同一类型,存在先后注册顺序的,按注册顺序执行的需求是合理的
There was a problem hiding this comment.
这个地方我们之前有一个讨论:
#3442 (comment)
嗯,所以我稍微放宽了一点。目前是每调用一次 onXXX就建一条 lane,即一个队列加一个专属 goroutine。一条日志到达时投给所有订阅 lane,各自独立处理。
嘛,实际上这样子可能也比较符合spx里面那种感觉,就如同现在的https://x.qiniu.com/editor/curator/Coding-Course-39/sprites/Kiko/code这一课,用户注册了两个onStart回调的时候其实有可能在某种程度上是期待这两个函数在“同时运行”的,如果B 和后续所有日志都会被阻塞的话,可能和spx那边的使用习惯会差的有点远。
There was a problem hiding this comment.
其实不会同时运行,wasm 是单线程的,看起来并发,但不并行
There was a problem hiding this comment.
嗯好吧,我想一下是不是要简化一下这块的设计……?@nighca 寒星会有什么想法吗?
There was a problem hiding this comment.
tutorial 通过假设自己一定运行在浏览器的 wasm 环境中,利用这个环境的行为来简化自己的实现,这个我没问题
回到这里,你们说的好像不是一个事情
其实不会同时运行,wasm 是单线程的,看起来并发,但不并行
这边 Lita 说的“同时运行”就是并发而不是真正的并行,因为事件 handler 中可能存在可被调度的阻塞调用,比如 showMessage,它可能会打开一个 message modal 并等到用户“确认”后才 return,在此期间它不会一直占用 CPU 的。
There was a problem hiding this comment.
这种会卡着的命令,调度中是要让出调度权的?runtime.Sched ?
There was a problem hiding this comment.
是的,调用会阻塞的Capability的时候是会释放token,这样其他的handler就会执行
There was a problem hiding this comment.
execToken 之前没 review 到,按目前设计,改造成是否 按注册顺序 复杂度确实比较高,按课程需求,这个需求可能并不太重要。可以忽略:
目前的调度方式:
M -> 调度到 G1 : callCapability 内部有一个 <- response ,这个异步操作,会让 golang 调度器调度到 G2,但 G2 的 execToken 拿不到( <- execToken 卡住),继续让出调度权。如果 G1 调用 callCapability 前,判断 式 call 是 kindSlow 或者 kindPresentation 会临时让出 execToken,G2就可以继续走下去。
按目前的设计,我觉得也没问题。我觉得可以先合入。
There was a problem hiding this comment.
有关于不可重入的问题,计划采用“可取消”的事件模型替代,并且改用“提供能力”的方式,允许作者根据其需求自行设计。
func longTask(ctx context.Context) {
doTask1()
if (ctx.Done()) {
return
}
doTask2()
if (ctx.Done()) {
return
}
doTask3()
}
var ctrl tutorial.TaskController
// var currentId = 0
// course.onExit => {
// var confirmed = ui.confirm("Are you sure you want to exit?")
// if !confirmed {
// return
// }
// var confirmed = ui.confirm("Are you sure you want to exit again?")
// if !confirmed {
// return
// }
// complete
// }
onLog log => {
ctrl.trackRoutine() // 1. cancel previous 2. watch and abort current routine on next cancel
// ctrl.cancelPrevious()
doCheck
doCheck2
complete
}
onExit code => {
// ctrl.trackFrame()
doCheck
complete
}
// ==== 1 ====
onLog log => {
if log.level == "error" {
processError(log)
} else if log.level == "warning" {
processWarning(log)
}
}
// ==== 2 ====
onLog log => {
if log.level == "error" {
processError(log)
}
}
onLog log => {
if log.level == "warning" {
processWarning(log)
}
}…lers Split terminated into a locked wrapper and terminatedLocked, and let addLane's start reuse the latter inside the region that also does laneWorkers.Add. The check and the Add stay under one lock on purpose: after the terminal state, awaitShutdown may already be in Wait, and an Add landing after that is a WaitGroup misuse. Today both sides run inside token-holding frames and cannot interleave; keeping them under one lock means this spot does not depend on that discipline. Requested by review (go-wyvern). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rker A course may register a callback from inside another callback, for example subscribing to runtime logs only after the prelude was dismissed. That is the lanesStarted branch of addLane, which no test exercised: disabling it left the whole suite green. The new test registers onLog inside onStart and expects a later log to complete the course; with the branch disabled it times out. Requested by review (go-wyvern). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Brings in goplus#3445 (Tutorial v2 module). tools/tutorial/tutorial.go is taken from this branch as agreed in that PR's review: the skeleton changes there only served its mock session and goplus#3442 is authoritative.
The executor's run() resolves as soon as the interpreter goroutine is launched, before the course program has registered its event handlers, and xgoexec now ignores events with no handler instead of erroring. An event dispatched in that window was silently lost; the debug page and the Go tests only survived it by retrying on the old error. Register the four handlers once, in package init: this package is compiled natively into xgoexec.wasm, so init runs at process start, before any build or run. The handlers route to a process-level event registry that knows the attached program and whether it is live. XGot_Course_Main attaches the program before MainEntry; Start switches it live only after the course-start delivery, replaying everything held until then in arrival order, and keeps holding events that arrive during the replay so nothing overtakes a held event. The pending buffer is bounded like a lane queue and errors beyond that. The contract now states the guarantee: dispatching is safe as soon as run() resolves. The invariants checker pins the new shape: handlers are registered only in init, attach/goLive only from their entry points, and registryMu regions may only touch fields (len/append/fmt.Errorf/ json.RawMessage). The debug page and the test dispatch helper drop their retry loops; the one test that dispatched from outside without a readiness signal now resets the registry first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compare the keys of eventDeliverers with the TutorialEvent union in module_TutorialFramework.ts. With the executor ignoring unregistered event names, forgetting to register a new event no longer produces any error at runtime, so the mismatch has to surface here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the per-callback serial unit with runs: every trigger starts a new run of each registered callback, and runs may overlap. How runs relate is decided by a run group's RunPolicy (CancelPrevious, OneAtATime, SkipWhileBusy): a policy given at registration puts the callback's runs in a private group from their first statement, while newRunGroup plus enter() lets a run join a group after filtering its trigger and lets several callbacks, such as an onLog and an onExit that both judge completion, share one policy. A cancelled run ends at its next waiting point and the result of a call it was waiting on is discarded; the host settles calls as usual and needs no cancellation protocol. Runs start in a fixed order: triggers in arrival order, and within one trigger the callbacks in registration order, each to its first wait before the next starts. Presentation is no longer serialized by the framework: overlapping course.show* calls are the host capability's policy, as agreed in the review call. Align CopilotRound's field names with how Course code reads them (XGo lowercases method calls, not field access). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every trigger now starts a new run of each registered callback on its
own goroutine, so runs of one callback may overlap, as agreed in the
review call: the old per-callback serial unit handled the stale-result
race by queueing, and queueing gave poor results. How runs relate is
now the author's choice through a run group's RunPolicy:
CancelPrevious the joining run cancels the run holding the group;
a cancelled run ends at its next waiting point and
the result it was waiting on is discarded
OneAtATime the joining run yields and waits for its turn
SkipWhileBusy the joining run ends on the spot
A policy given at registration (OnLog__1 and friends, resolved by XGo's
__N overloading) puts the callback's runs in a private group from their
first statement; newRunGroup plus enter() lets a run join after
filtering its trigger and lets callbacks of different events share one
policy. The author never checks a result: skipping and cancellation end
the run through a sentinel that runFrame absorbs silently.
Runs start in a fixed order. A single dispatcher goroutine (the former
pre-ready buffer became the one dispatch queue) handles triggers in
arrival order, and startRuns starts the callbacks of one trigger in
registration order, each to its first yield before the next, the
JoinYieldedOrDone shape spx uses for onStart. yieldWhile is the only
yield point and the only place cancellation takes effect, so a
cancelled run never touches a pending result.
The framework no longer serializes presentation calls: overlapping
course.show* calls are the host capability's policy per the contract.
The invariants checker pins the new shape (token ops in runFrame and
yieldWhile only, one start path, cancel only in join, leaf-lock call
allowlists); the docs example and the debug seed course use a group;
the bundle build test exercises all three registration forms.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keep the overlapping-run model from the previous commit: every trigger starts a new run of each registered callback, nothing queues, and the host owns presentation overlap. Take the run policies out of this PR so it can merge and unblock the rest of Tutorial v2: RunPolicy, RunGroup, newRunGroup and enter(), the registration overloads, cancellation and the group lock all go. The review call left the concept, its naming and whether to ship it unsettled; goplus#3509 carries that discussion, and the full implementation stays reachable as 07ed874 for a follow-up PR. What remains is exactly what the contract now says: runs of one callback may overlap and how they relate is unspecified (TODO in the contract and in program.go pointing at goplus#3509). The dispatcher still starts runs in arrival and registration order, each to its first yield before the next, because that is what lets course start precede host events and keeps the queue draining; the contract does not promise it, and the regression test says so. The invariants checker drops the policy rules; the docs example, the debug seed course and the bundle build test use plain registrations again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
| @@ -0,0 +1,127 @@ | |||
| /** | |||
| * Tutorial Class Framework 的前端半边。 | |||
There was a problem hiding this comment.
之前 comment 里说合入前最后一个 commit 把注释统一为英文,别忘了
Requested by review: the project writes comments in English, and this package was the last one in tools/ still commenting in Chinese. Every comment in tools/tutorial and in the bundle's build test is translated rather than trimmed, so the design rationale each one carries survives. Three comments were stale and are corrected while being translated: GenerateText pointed at program.go's eventQueueSize, which is now pendingEventLimit in events.go; the late-registration test still described addLane and lanesStarted, which registration no longer has; and GenerateJSON repeated, at length, why reflection works on Course types, which deriveSchema already explains, so it now points there instead. No code changes: the only lines that differ outside comment blocks are trailing comments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ssue-3417-framework
… runs Since goplus#3442, every trigger starts a new run of each registered callback and runs may overlap while one of them waits on the learner or on generation. That reproduces the stale-result race the review call discussed, and the agreed answer was cancelling or aborting stale processing rather than queueing. This lets the author say how runs of one callback, or of several callbacks, relate: CancelPrevious the joining run cancels the run holding the group; a cancelled run ends at its next waiting point and the result it was waiting on is discarded OneAtATime the joining run yields and waits for its turn SkipWhileBusy the joining run ends on the spot newRunGroup plus enter() lets a run join after filtering its trigger and lets callbacks of different events share one policy; a policy or a group given at registration joins from the first statement, through the __N overloads XGo groups. The author never checks a result: skipping and cancellation end the run through a sentinel that runFrame absorbs silently. yieldWhile is the only yield point and the only place cancellation takes effect, so a cancelled run never touches a pending result. Group state lives behind a leaf lock. The invariants checker pins the shape, the docs example and the debug seed course use a group, and the bundle build test exercises all three registration forms. The design is still under discussion in goplus#3509; this branch carries the implementation for that follow-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…3442) * feat(tutorial): implement the Course-author-facing framework Grow the placeholder Tutorial class framework into the API a Playground Course is written against: Course presentation and completion, the Editor namespaces (Project, Runtime, CodeEditor, Ruler), Copilot text and structured generation, and Spotlight reveal. Course callbacks now run on the Course program's own goroutine, one at a time, in the order the host dispatched the events, instead of each event starting its own goroutine — Course code is written as ordinary sequential code, and runtime logs are a judging channel whose order carries meaning. The framework registers every host event up front, so dispatching an event the Course did not subscribe to is accepted and dropped rather than reported as an unknown event, and it bounds the pending queue so a Course blocked in a slow capability tells the host it is not keeping up instead of silently losing events. complete and completeWith end the program from within: the current callback runs to its end, queued events are abandoned and Start returns, so the executor reports the run as completed. Repeated completions are ignored. generateJSON derives its JSON Schema from the struct the Course passed and decodes the response back into it. A struct with no exported fields is rejected, since encoding/json could never fill it and the Course would silently read zero values. A failed capability panics: the Course asked the editor, Copilot or the learner's UI for something that did not happen, and continuing would judge the learner on a false premise. Validation: unit tests drive the framework through a fake host, and a bundle-level test builds a Course program that uses the whole API against the real class-framework binding, which also fails if the ixgo exports were not regenerated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: Course ClassFramework * fix(tutorial): follow the merged video contract and harden schema derivation - ShowVideo takes a declared video resource name, matching the contract merged in goplus#3437: videos are declared under assets/videos/<name>/, and the Course writes showVideo "step-to" rather than a file path - Reject recursive result types in generateJSON: a self-referential or mutually referential struct made schema derivation recurse without bound, which crashes the interpreter instead of reporting a mistake - Raise the event queue bound: presentation capabilities block until the learner acts, which can take minutes, and the runtime-log contract promises one event per appended entry, so the bound has to sit beyond what a running project can produce meanwhile - Say "serializable exported fields" where json:"-" also excludes a field Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(tutorial): name the shared state field courseProgram The namespaces reach their course's run state through this field, and "program" sat one letter away from the unrelated Project namespace. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tutorial): send completeWith feedback as the feedback field The Tutorial lifecycle prototype (goplus#3445) reads the completion feedback from a feedback field, and the name also says what the value is. Align the wire shape before either side ships. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tutorial): add the frontend framework adapter createTutorialFramework wraps the host's typed methods into the capability table the executor consumes, mirroring the Go half's wire names and request shapes. It lives in tools/tutorial next to the Go package so the framework stays one self-contained unit, and spx-gui consumes it through a symlink the same way it consumes the spxls type declarations. The adapter deliberately imports nothing from spx-gui: the executor's framework shape is declared structurally, which keeps the whole directory extractable as its own repository. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tutorial): make the docs example course compile and guard it The example course used a parenthesis-free command-style call as an expression (feedback := Copilot.generateText "..."), which XGo only allows at statement level, so the example never compiled. Fix the call and add a bundle test that compiles the example through the real classfile pipeline so the docs example cannot drift from the framework again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(tutorial): demonstrate spotlight in the example course Reveal the API References panel right after filterAPIs narrows it to stepTo, using the panel's real Radar node name. The exact selector syntax is pending goplus#3441; a TODO marks the line for revisiting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tutorial): run the docs example course in the xgoexec debug page Replace the hand-rolled single-capability framework stub with createTutorialFramework wired to a full mock TutorialFrameworkHost that logs every capability call and answers with canned values. The course source is the docs example imported via ?raw, so the debug page always exercises the exact example we document. Verified in the browser against the freshly built wasm: filterAPIs, showPrelude, showVideo and spotlight_reveal (with Go-side defaults mask=true/duration=0) run on start; dispatching the reached-target log drives getCode -> generateText -> completeWith and the executor exits with reason 'completed'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tutorial): turn the debug pages into course test infrastructure - Extract the mock TutorialFrameworkHost into tutorial-mock-host.ts so both debug pages share it; it logs every capability call and answers with canned values. - xgoexec debug page: the course source is now an editable textarea (prefilled with the docs example, with a button to load it back), and the status line turns green on 'exited: completed' / red on errors. - New /debug/tutorial-courses page: scripted course cases run one by one against the real executor wasm; each case declares a course source and the events to dispatch, and passes (green) when the run exits as 'completed'. Seeded with four cases: the docs example, a full-API course adapted from the bundle integration test, completion during onStart, and unsubscribed-event tolerance. Verified in the browser: all four cases pass, including the reflection-derived generateJSON schema round-tripping the bridge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tutorial): align completeWith with the contract's naming The contract (tutorial-class-framework.go / module_TutorialFramework.ts) declares completeWith(message); the earlier switch to a {feedback} wire field (b3784e9) followed the goplus#3445 prototype instead of the contract. The contract is the source of truth, so revert to the {content} shape shared by the other presentation capabilities and name the parameter message; the prototype side should align with the contract instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tutorial): clear the per-case timeout timer in the course runner When a case exits before the deadline the 15s setTimeout kept running until it fired into the already-settled Promise.race; clear it once the case is over. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ci(tutorial): test the tutorial framework and guard qexp exports Add a tools-tutorial-test job following the tools-ai-test precedent: it verifies the committed qexp export file matches the tutorial package's public API (build-wasm.sh silently regenerates it, so staleness never failed a build before — it only broke course execution inside the wasm interpreter), then runs the tutorial unit tests with -race and the executor bundle's classfile-binding tests. Also let the spx-gui job's Go cache cover the executor bundle modules. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(tutorial): group TutorialFrameworkHost by the API tree Restructure the host interface from flat underscore-joined methods into course / editor / copilot / spotlight namespaces mirroring the author-facing API tree, per review feedback on goplus#3442 — friendlier for the host implementer. The wire stays flat: createTutorialFramework still maps each capability name onto the nested host method. The contract (module_TutorialFramework.ts) changes together with the code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(tutorial): consolidate the debug pages Per review feedback on goplus#3442: inline a copy of the docs example course instead of importing it from outside the frontend directory (the docs copy stays compile-guarded by tools/xgoexec-bundle), and fold the former /debug/xgoexec page into /debug/tutorial-courses as a free-form runner card (editable course source prefilled with the example, plain-XGo run, runtime-log dispatch), removing the old page and route. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(tutorial): replace the adapter vitest with a wire-contract check Per review feedback on goplus#3442: drop spx-gui's tutorial-framework.test.ts (the adapter is maintained in tools/tutorial, so spx-gui testing it was misplaced, and its hand-written wire-name list stayed green when both sides forgot an update). The guard moves into the framework's own directory as a Go test that extracts capability names from the Go sources (mustCallCapability call sites) and from client.ts's capabilities table, then asserts the two sets match — renaming either side now fails tools-tutorial-test in CI without any manual list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tutorial): let waiting capabilities yield to other events Replace the single run-to-completion event loop with a token-scheduled execution model, following the review discussion on the event model: - Callbacks run as frames holding a cap-1 execution token, so exactly one piece of course code executes at any instant and shared variables in course code still never race (the token's release/acquire chain provides the happens-before edges a single loop used to). - Capabilities are classified in capabilityKinds by what they wait on. Calls waiting on an external party — the learner (course_show*) or the LLM (copilot_generate*) — release the token for the duration, so events keep being handled while a dialog is open or a generation is pending, and state observed after such a call is fresh. Calls that only wait on host computation keep the token; an unregistered name degrades to non-yielding, the conservative direction. - Each event gets its own lane (FIFO queue + single worker): triggers of one event stay strictly ordered and its handlers never interleave or re-enter, so check-then-act judgment code needs no guards. Only different events interleave, at waiting points. - Presentation calls from different frames are serialized on a dedicated lane, entered only after releasing the token (lock order matters: waiting for it while holding the token would freeze all frames), so the host never sees concurrent dialogs. - Completion closes intake; running and suspended frames finish their remaining statements (the host no-ops presentation after completion) and the program then exits. Frame panics are recorded and re-thrown on the main goroutine so the executor still reports exit 'error'. Contract docs gain the author-visible semantics and the host-visible note that non-presentation calls may now be pending concurrently. New tests cover yielding during a held dialog (logs processed meanwhile, fresh state observed after), same-event serialization, presentation serialization, and two generations pending concurrently; the wire-name guard now also checks capabilityKinds keys are real capabilities. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(tutorial): serialize per registered callback, not per event Change the serial unit from one lane per event to one lane per registered callback, per design discussion: when an author registers several callbacks on one event, expecting them to run independently is the natural reading. Each callback now owns its queue and worker — its triggers stay strictly ordered and it never re-enters, while different callbacks (including several on the same event) may interleave at waiting points. Callbacks registered after the program started get their worker immediately. Contract wording updated accordingly; tests drop cross-callback ordering assumptions and add coverage for two same-event callbacks running independently while one waits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tutorial): close the review findings on the yielding model Address the external review of the execution model (4 High, 2 Medium, 1 nit — all confirmed): - Decode waiting-capability responses under the token: the bridge used to write straight into the caller's result (an author-shared struct for generateJSON) while the token was released — a race outside the serialization guarantee. Responses now land in a private buffer and are unmarshalled after the token is reacquired. - Admission moved to after token acquisition: a dequeued event's pre-check could go stale while its frame waited for the token, letting a callback start after completion or a fatal error. runFrame now re-checks the terminal state once it holds the token. - awaitEnd re-checks the fatal slot after the completion-path Wait: a failing course_complete (or a late frame panic) used to be swallowed and reported as completed. - Event delivery is all-or-none: deliverAll preflights every lane's capacity under a delivery lock before sending, so one full queue no longer leaves the event partially delivered; delivery after a fatal error now returns an error instead of feeding dead queues. - Worker startup checks the terminal state under the same lock as workers.Add, closing an Add-vs-Wait window for callbacks registered while the program is winding down. - The token comment no longer claims FIFO wakeup (Go does not promise it), and a classification test forces every capability into either capabilityKinds or an explicit fast list — the safe runtime default can no longer hide an unclassified capability. The host contract gains the matching obligation: on completion the host must promptly settle every still-pending call, since the program exits only after pending callbacks finish. Four regression tests pin the fixed behaviors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(tutorial): machine-check the scheduler's locking discipline The rules that keep the yielding model deadlock-free lived only in comments; this turns them into checks that fail in CI: - An AST-based test asserts: the token moves only through acquire/release, and only runFrame and mustCallCapability may operate it; presentationMu and the capability bridge are touched only inside mustCallCapability (whose fixed order releases the token before any long wait); mu- and deliverMu-held regions contain no blocking operation (channel ops, select, Wait, token ops, bridge calls), with deliverMu -> mu as the only permitted nesting and deliverAll's preflighted queue send as the one documented exemption. - release() gains a dynamic assertion: a non-blocking send that panics on a release without a matching acquire, so a pairing bug surfaces immediately instead of silently minting a second token. - init fills the token before entering the mu region, so the invariant holds without exceptions. Injecting violations (token op inside a mu region, presentationMu use outside its home) was verified to turn the check red. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(tutorial): name the scheduler's primitives by domain Rename the synchronization primitives so each name carries its domain and its object, per design discussion: - token -> execToken, acquire/release -> acquireExec/releaseExec: the execution-right token, with the object visible at every call site. - mu -> schedulerMu: it guards the scheduler's own bookkeeping (handler registry, lifecycle flags, lane starters). 'state'/'program' were rejected as ambiguous — 'program' collides with the learner project's running program; spx's scheduler uses the same name for the same role. - deliverMu -> eventDeliveryMu: says what is being delivered. - ending/endOnce/signalEnd/awaitEnd -> shutdown/shutdownOnce/ beginShutdown/awaitShutdown: the channel signals that wind-down has begun, not that everything has finished. Pure rename, no semantic change; the invariant checker's name-based rules are updated in lockstep. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(tutorial): reject untagged embedded fields in generateJSON schemas encoding/json promotes an untagged anonymous field's exported members into the outer object, but the derived schema nested them under the type name — a host result conforming to that schema was then ignored during unmarshalling, leaving the embedded fields at zero values (external review finding). Mirroring the full promotion/conflict rules is not worth it for an LLM-facing schema, so such fields are rejected with a clear error, consistent with how recursive types are handled; an anonymous field with an explicit json name is treated by encoding/json as a regular named field and keeps working, now pinned by a test that round-trips against encoding/json itself. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(tutorial): rename workers to laneWorkers 'worker' collides with the browser Web Worker that hosts the whole wasm instance one container level up; the WaitGroup counts lane worker goroutines inside it. The new name carries its qualifier, and the field comment now states the distinction explicitly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(tutorial): add AGENTS.md with the scheduler's rules of change Capture the discipline that keeps the yielding execution model correct as directory-scoped agent guidance, following the tools/ai precedent: locking rules (and their machine-checked enforcement), capability classification and contract-sync obligations, package testing pitfalls (process-global event registry, token-holding blocks), and the qexp/symlink build-chain constraints. Rules only — design rationale stays in program.go's comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(tutorial): deliver course start as an event Course.OnStart used to append to a plain slice that Start ran one after another on the main goroutine, so a second onStart could not begin until the first returned — including its waits on the learner. Nothing in the design asked for that; it was left over from the fully serial model and was never revisited when event callbacks moved to per-callback lanes. Course start now goes through the same path as every host event: each onStart registers a lane, and Start delivers the single course-start trigger through deliverAll. Several onStart callbacks run independently and may interleave at waiting points, exactly like several callbacks on one event; there is no ordering promise between them. Author callbacks now have exactly two execution paths: a lane worker's frame and the one MainEntry frame. A rejected delivery can only mean MainEntry already recorded a fatal error, which recordFatal keeps as the first one. Contract wording and AGENTS.md updated. TestHandlersAccumulate drops its start-order assumption and completes only once all four callbacks ran; TestCourseStartHandlersRunIndependently pins that a second onStart does not wait for the first one's showPrelude to return. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(tutorial)!: name the classfile entry XGot_Course_Main XGot_ is the current prefix for a template receiver method; Gopt_ is the legacy spelling that gogen still accepts (isXGoCommon takes both "XGo" and "Gop"), which is why the old name kept working. spx v3 already uses XGot_Game_Main, so stop relying on the compatibility path. The generated qexp export follows. tools/xgoexec-bundle's build test covers this: with an unrecognized prefix the classfile binding fails with "Course has no method Main". Requested by review (go-wyvern). * refactor(tutorial): share the terminal-state check between locked callers Split terminated into a locked wrapper and terminatedLocked, and let addLane's start reuse the latter inside the region that also does laneWorkers.Add. The check and the Add stay under one lock on purpose: after the terminal state, awaitShutdown may already be in Wait, and an Add landing after that is a WaitGroup misuse. Today both sides run inside token-holding frames and cannot interleave; keeping them under one lock means this spot does not depend on that discipline. Requested by review (go-wyvern). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(tutorial): pin that callbacks registered during the run get a worker A course may register a callback from inside another callback, for example subscribing to runtime logs only after the prelude was dismissed. That is the lanesStarted branch of addLane, which no test exercised: disabling it left the whole suite green. The new test registers onLog inside onStart and expects a later log to complete the course; with the branch disabled it times out. Requested by review (go-wyvern). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tutorial): hold events dispatched before the program is ready The executor's run() resolves as soon as the interpreter goroutine is launched, before the course program has registered its event handlers, and xgoexec now ignores events with no handler instead of erroring. An event dispatched in that window was silently lost; the debug page and the Go tests only survived it by retrying on the old error. Register the four handlers once, in package init: this package is compiled natively into xgoexec.wasm, so init runs at process start, before any build or run. The handlers route to a process-level event registry that knows the attached program and whether it is live. XGot_Course_Main attaches the program before MainEntry; Start switches it live only after the course-start delivery, replaying everything held until then in arrival order, and keeps holding events that arrive during the replay so nothing overtakes a held event. The pending buffer is bounded like a lane queue and errors beyond that. The contract now states the guarantee: dispatching is safe as soon as run() resolves. The invariants checker pins the new shape: handlers are registered only in init, attach/goLive only from their entry points, and registryMu regions may only touch fields (len/append/fmt.Errorf/ json.RawMessage). The debug page and the test dispatch helper drop their retry loops; the one test that dispatched from outside without a readiness signal now resets the registry first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(tutorial): guard registered event names against the contract Compare the keys of eventDeliverers with the TutorialEvent union in module_TutorialFramework.ts. With the executor ignoring unregistered event names, forgetting to register a new event no longer produces any error at runtime, so the mismatch has to surface here. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(tutorial): contract for run policies and host-owned presentation Replace the per-callback serial unit with runs: every trigger starts a new run of each registered callback, and runs may overlap. How runs relate is decided by a run group's RunPolicy (CancelPrevious, OneAtATime, SkipWhileBusy): a policy given at registration puts the callback's runs in a private group from their first statement, while newRunGroup plus enter() lets a run join a group after filtering its trigger and lets several callbacks, such as an onLog and an onExit that both judge completion, share one policy. A cancelled run ends at its next waiting point and the result of a call it was waiting on is discarded; the host settles calls as usual and needs no cancellation protocol. Runs start in a fixed order: triggers in arrival order, and within one trigger the callbacks in registration order, each to its first wait before the next starts. Presentation is no longer serialized by the framework: overlapping course.show* calls are the host capability's policy, as agreed in the review call. Align CopilotRound's field names with how Course code reads them (XGo lowercases method calls, not field access). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(tutorial)!: run policies and run groups replace per-callback lanes Every trigger now starts a new run of each registered callback on its own goroutine, so runs of one callback may overlap, as agreed in the review call: the old per-callback serial unit handled the stale-result race by queueing, and queueing gave poor results. How runs relate is now the author's choice through a run group's RunPolicy: CancelPrevious the joining run cancels the run holding the group; a cancelled run ends at its next waiting point and the result it was waiting on is discarded OneAtATime the joining run yields and waits for its turn SkipWhileBusy the joining run ends on the spot A policy given at registration (OnLog__1 and friends, resolved by XGo's __N overloading) puts the callback's runs in a private group from their first statement; newRunGroup plus enter() lets a run join after filtering its trigger and lets callbacks of different events share one policy. The author never checks a result: skipping and cancellation end the run through a sentinel that runFrame absorbs silently. Runs start in a fixed order. A single dispatcher goroutine (the former pre-ready buffer became the one dispatch queue) handles triggers in arrival order, and startRuns starts the callbacks of one trigger in registration order, each to its first yield before the next, the JoinYieldedOrDone shape spx uses for onStart. yieldWhile is the only yield point and the only place cancellation takes effect, so a cancelled run never touches a pending result. The framework no longer serializes presentation calls: overlapping course.show* calls are the host capability's policy per the contract. The invariants checker pins the new shape (token ops in runFrame and yieldWhile only, one start path, cancel only in join, leaf-lock call allowlists); the docs example and the debug seed course use a group; the bundle build test exercises all three registration forms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(tutorial): defer run policies to goplus#3509 Keep the overlapping-run model from the previous commit: every trigger starts a new run of each registered callback, nothing queues, and the host owns presentation overlap. Take the run policies out of this PR so it can merge and unblock the rest of Tutorial v2: RunPolicy, RunGroup, newRunGroup and enter(), the registration overloads, cancellation and the group lock all go. The review call left the concept, its naming and whether to ship it unsettled; goplus#3509 carries that discussion, and the full implementation stays reachable as 07ed874 for a follow-up PR. What remains is exactly what the contract now says: runs of one callback may overlap and how they relate is unspecified (TODO in the contract and in program.go pointing at goplus#3509). The dispatcher still starts runs in arrival and registration order, each to its first yield before the next, because that is what lets course start precede host events and keeps the queue draining; the contract does not promise it, and the regression test says so. The invariants checker drops the policy rules; the docs example, the debug seed course and the bundle build test use plain registrations again. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(tutorial): write the framework's comments in English Requested by review: the project writes comments in English, and this package was the last one in tools/ still commenting in Chinese. Every comment in tools/tutorial and in the bundle's build test is translated rather than trimmed, so the design rationale each one carries survives. Three comments were stale and are corrected while being translated: GenerateText pointed at program.go's eventQueueSize, which is now pendingEventLimit in events.go; the late-registration test still described addLane and lanesStarted, which registration no longer has; and GenerateJSON repeated, at length, why reflection works on Course types, which deriveSchema already explains, so it now points there instead. No code changes: the only lines that differ outside comment blocks are trailing comments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Refs #3417 (part of #3403). Implements the contract documented in #3439, which should land first.
Summary
Grows the placeholder Tutorial class framework from #3424 into the API a Playground Course is written against: Course presentation and completion (
showPrelude/showMessage/showVideo/complete/completeWith), the Editor namespaces (Project,Runtime,CodeEditor,Ruler), Copilot text and structured generation, and Spotlight reveal.Behavioral decisions worth reviewing:
complete/completeWithend the program from within: the current callback runs to its end, queued events are abandoned,Startreturns and the executor reports the run ascompleted. Repeated completions are ignored.generateJSONderives its schema by reflection and decodes the response back into the Course's struct. A struct with no exported fields is rejected:encoding/jsoncould never fill it, and the Course would silently read zero values. (An XGo author lowercasing field names out of habit is the likely case.)Validation
go test ./...intools/tutorial(event ordering, completion and idempotency, post-completion events, unsubscribed events, queue overflow, capability failure, schema derivation and write-back)go test ./...intools/xgoexec-bundle: builds a Course program that uses the whole API against the real class-framework binding, which also fails if the ixgo exports were not regeneratedGOOS=js GOARCH=wasm go build ./...intools/xgoexec-bundlego generate ./...intools/xgoexec-bundle(regenerated exports are included)/debug/xgoexec)/debug/tutorial-courses)🤖 Generated with Claude Code