Skip to content

docs(tutorial): add a course-author guide for the class framework - #3495

Draft
Ethanlita wants to merge 27 commits into
goplus:issue-3403-tutorial-v2from
Ethanlita:issue-3417-docs
Draft

Ethanlita wants to merge 27 commits into
goplus:issue-3403-tutorial-v2from
Ethanlita:issue-3417-docs

Conversation

@Ethanlita

Copy link
Copy Markdown
Collaborator

Part of #3417. Adds a course-author guide for the Tutorial Class Framework, as suggested by @go-wyvern in the #3442 review.

Important

Stacked on #3442. This branch is #3442's branch plus one commit, so until #3442 merges the diff also shows its changes (a goplus/builder PR cannot be based on a fork branch). Review only the last commit, docs(tutorial): add a course-author guide for the class framework. The guide documents the API that #3442 implements, so it should merge after #3442.

What it adds

  • tools/tutorial/README.md and tools/tutorial/README.zh.md, next to the implementation, following the tools/xgoexec/README.md precedent.
  • One sentence in docs/develop/tutorial-v2/index.md linking to the guide.

The contracts in docs/develop/tutorial-v2 (tutorial-class-framework.go, module_TutorialFramework.ts) stay the source of truth. The guide says so up front: where they disagree, the guide is out of date.

Contents

Section Covers
Course project layout index.json fields, main_course.gox, project/, declared videos under assets/videos/<name>/
A first course The example course, walked through, and the "agreed log line + onLog" pattern
Calling conventions Bare course methods, capitalized namespaces, command style vs. parentheses when a result is used
Top-level code and course start Registration first, then course start; registering callbacks from callbacks
Execution model One callback at a time, waiting calls yield, no re-entry, independent callbacks, serialized presentations, no timers
Busy events The 1024-trigger queue per callback, what happens when it fills, and the "return early" guidance
Completion What complete / completeWith stop, what still runs, idempotence
API reference Every call per namespace, when it returns, and its constraints (including generateJSON field rules and Spotlight selector status, #3441)
When a call fails Which failures stop the course and why; the one non-fatal Spotlight case
Trying a course locally The /debug/tutorial-courses page

Verification

  • Every go code block in both files (11 each) was compiled through the real classfile pipeline (xgobuild + ixgo load, the same path as tools/xgoexec-bundle's build tests). The two files carry identical code blocks, and the first block is the example course's main_course.gox verbatim.
  • Every statement in the guide was checked against the implementation and the contracts, including the queue limit, the delivery behavior when a queue fills, and the generateJSON type rules.
  • All relative links and in-page anchors resolve.

Ethanlita and others added 27 commits August 21, 2026 15:17
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
'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>
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>
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>
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).
tools/tutorial/README.md and README.zh.md describe the framework from a
course author's point of view: the project layout, calling conventions,
the execution model (one callback at a time, waiting calls yield, no
re-entry, independent callbacks, serialized presentations, no timers), the
per-callback queue limit, completion semantics, an API reference by
namespace, failure behavior and the local debug page. The contracts in
docs/develop/tutorial-v2 stay the source of truth; the design index links
to the guide.

Every go code block in both files compiles through the real classfile
pipeline, the two files carry identical code blocks, and the first block is
the example course verbatim.

Suggested in review (go-wyvern).

This branch had an error being deployed

1 failed deployment
Preview – builder c4f85f54 Deployed Sep 11, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant