-
Notifications
You must be signed in to change notification settings - Fork 194
feat(stepper): integrate Conductor plugin tabs and dynamic loading of web plugins #3959
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -46,9 +46,9 @@ | |||||||||
| "@sentry/react": "^10.5.0", | ||||||||||
| "@sourceacademy/autocomplete": "github:source-academy/autocomplete#e669d9ed98753350a3c8433a92985227eb789663", | ||||||||||
| "@sourceacademy/c-slang": "^1.0.21", | ||||||||||
| "@sourceacademy/conductor": "https://github.com/source-academy/conductor.git#0.4.0", | ||||||||||
| "@sourceacademy/conductor": "portal:../conductor", | ||||||||||
| "@sourceacademy/language-directory": "https://github.com/source-academy/language-directory.git#0.0.6", | ||||||||||
| "@sourceacademy/plugin-directory": "https://github.com/source-academy/plugin-directory.git#0.0.2", | ||||||||||
| "@sourceacademy/plugin-directory": "portal:../plugin-directory", | ||||||||||
| "@sourceacademy/sharedb-ace": "2.1.1", | ||||||||||
| "@sourceacademy/sling-client": "^0.1.0", | ||||||||||
| "@szhsin/react-menu": "^4.0.0", | ||||||||||
|
|
@@ -196,6 +196,7 @@ | |||||||||
| }, | ||||||||||
| "resolutions": { | ||||||||||
| "@types/estree": "1.0.9", | ||||||||||
| "vite": "^8.0.0" | ||||||||||
| "vite": "^8.0.0", | ||||||||||
| "js-slang": "portal:/Users/shreyjain/Downloads/source-academy/js-slang" | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The absolute local path
Suggested change
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove hardcoded absolute path to js-slang. The 🔧 Recommended fixesOption 1 (preferred if js-slang is in a sibling directory): Use a relative portal path: - "js-slang": "portal:/Users/shreyjain/Downloads/source-academy/js-slang"
+ "js-slang": "portal:../js-slang"Option 2 (if this override was only for local testing): Remove the resolution entirely and use the version from dependencies: "resolutions": {
"`@types/estree`": "1.0.9",
"vite": "^8.0.0",
- "js-slang": "portal:/Users/shreyjain/Downloads/source-academy/js-slang"
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| } | ||||||||||
| } | ||||||||||
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do the evaluators need to be added to the frontend?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Shrey5132 I was confused bout this as well. They can be built from js-slang just like how py-slang does it. py-slang CI:
So the evaluator .js files live at a GitHub Pages URL like https://source-academy.github.io/py-slang/PyCseEvaluator1.js. The frontend's language-directory (or local-directory.json) references those URLs — it doesn't need the files locally at all. |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| [ | ||
| { | ||
| "id": "source-1", | ||
| "name": "Source 1 (Stepper)", | ||
| "evaluators": [ | ||
| { | ||
| "id": "default", | ||
| "name": "Default", | ||
| "path": "/evaluators/js-slang/SourceStepperEvaluator1.js", | ||
| "capabilities": [] | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "id": "source-2", | ||
| "name": "Source 2 (Stepper)", | ||
| "evaluators": [ | ||
| { | ||
| "id": "default", | ||
| "name": "Default", | ||
| "path": "/evaluators/js-slang/SourceStepperEvaluator2.js", | ||
| "capabilities": [] | ||
| } | ||
| ] | ||
| }, | ||
| { | ||
| "id": "python-1", | ||
| "name": "Python 1 (Stepper)", | ||
| "evaluators": [ | ||
| { | ||
| "id": "default", | ||
| "name": "Default", | ||
| "path": "/evaluators/py-slang/PythonStepperEvaluator1.js", | ||
| "capabilities": [] | ||
| } | ||
| ] | ||
| } | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| [ | ||
| { | ||
| "id": "stepper", | ||
| "name": "Stepper", | ||
| "description": "Visualises the step-by-step substitution evaluation of a program.", | ||
| "resolutions": { | ||
| "web": "/plugins/stepper/index.mjs" | ||
| } | ||
| } | ||
| ] |
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| // Import-map shim: resolves a plugin bundle's `import ... from "@blueprintjs/core"` to the host | ||
| // frontend's Blueprint build (exposed on globalThis by src/bootstrap/conductorSharedDeps.ts), so the | ||
| // plugin's Blueprint components share the host's CSS and React instance. | ||
| // | ||
| // Re-exports the surface used by the bundled plugins; extend this list if a plugin needs more. | ||
| const Blueprint = globalThis.__SA_BLUEPRINT__; | ||
| export const { | ||
| Button, | ||
| ButtonGroup, | ||
| Card, | ||
| Classes, | ||
| Divider, | ||
| Icon, | ||
| Popover, | ||
| Pre, | ||
| Slider, | ||
| } = Blueprint; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| // Import-map shim: resolves a plugin bundle's `import ... from "react/jsx-runtime"` to the host | ||
| // frontend's React jsx-runtime (exposed on globalThis by src/bootstrap/conductorSharedDeps.ts). | ||
| const jsxRuntime = globalThis.__SA_REACT_JSX__; | ||
| export const { jsx, jsxs, Fragment } = jsxRuntime; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| // Import-map shim: resolves a plugin bundle's `import ... from "react"` to the host frontend's | ||
| // single React instance (exposed on globalThis by src/bootstrap/conductorSharedDeps.ts). | ||
| const React = globalThis.__SA_REACT__; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. All three shim modules lack defensive checks for missing globals. The shim files 🤖 Prompt for AI Agents |
||
| export default React.default ?? React; | ||
| export const { | ||
| Children, | ||
| Component, | ||
| Fragment, | ||
| Profiler, | ||
| PureComponent, | ||
| StrictMode, | ||
| Suspense, | ||
| cloneElement, | ||
| createContext, | ||
| createElement, | ||
| createRef, | ||
| forwardRef, | ||
| isValidElement, | ||
| lazy, | ||
| memo, | ||
| startTransition, | ||
| use, | ||
| useActionState, | ||
| useCallback, | ||
| useContext, | ||
| useDebugValue, | ||
| useDeferredValue, | ||
| useEffect, | ||
| useId, | ||
| useImperativeHandle, | ||
| useInsertionEffect, | ||
| useLayoutEffect, | ||
| useMemo, | ||
| useOptimistic, | ||
| useReducer, | ||
| useRef, | ||
| useState, | ||
| useSyncExternalStore, | ||
| useTransition, | ||
| version, | ||
| } = React; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| /** | ||
| * Exposes the frontend's singleton library instances so that dynamically-imported Conductor web | ||
| * plugin bundles can share them, rather than bundling (and duplicating) their own copies. | ||
| * | ||
| * Plugin bundles import `react`, `react/jsx-runtime` and `@blueprintjs/core` as bare specifiers. | ||
| * The import map in `public/index.html` maps those specifiers to the shim modules in `public/shims`, | ||
| * which re-export the globals set here. The result: the plugin renders inside the host's single | ||
| * React tree and uses the host's exact Blueprint build (so styling is identical). | ||
| * | ||
| * This module must be imported before any plugin is loaded; it is imported first from `index.tsx`. | ||
| */ | ||
| import * as Blueprint from '@blueprintjs/core'; | ||
| // eslint-disable-next-line no-restricted-imports | ||
| import * as React from 'react'; | ||
| import * as ReactJsxRuntime from 'react/jsx-runtime'; | ||
|
|
||
| const globals = globalThis as Record<string, unknown>; | ||
| globals.__SA_REACT__ = React; | ||
| globals.__SA_REACT_JSX__ = ReactJsxRuntime; | ||
| globals.__SA_BLUEPRINT__ = Blueprint; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -448,9 +448,14 @@ function* handleResults( | |
| workspaceLocation: WorkspaceLocation, | ||
| ): SagaIterator { | ||
| const resultChan = eventChannel(emitter => { | ||
| hostPlugin.receiveResult = emitter; | ||
| // redux-saga's eventChannel throws on `undefined`; an evaluator may legitimately produce an | ||
| // undefined result, so drop it rather than crash the channel. | ||
| const receive = (result: any) => { | ||
| if (result !== undefined) emitter(result); | ||
| }; | ||
| hostPlugin.receiveResult = receive; | ||
| return () => { | ||
| if (hostPlugin.receiveResult === emitter) delete hostPlugin.receiveResult; | ||
| if (hostPlugin.receiveResult === receive) delete hostPlugin.receiveResult; | ||
| }; | ||
| }); | ||
| try { | ||
|
|
@@ -468,6 +473,7 @@ function* handleResults( | |
| function* handleErrors( | ||
| hostPlugin: BrowserHostPlugin, | ||
| workspaceLocation: WorkspaceLocation, | ||
| onTerminate: () => void, | ||
| ): SagaIterator { | ||
| const errorChan = eventChannel(emitter => { | ||
| hostPlugin.receiveError = emitter; | ||
|
|
@@ -479,6 +485,11 @@ function* handleErrors( | |
| while (true) { | ||
| const error = yield take(errorChan); | ||
| yield put(actions.appendInterpreterError([toConductorSourceError(error)], workspaceLocation)); | ||
| // Signal the REPL loop that evaluation has ended due to an error. | ||
| // We call onTerminate() here as a safety net: the runner should also | ||
| // send a terminal status (STOPPED/ERROR) which handleStatuses will catch, | ||
| // but if it doesn't (e.g. older evaluator build), this ensures we unblock. | ||
| onTerminate(); | ||
|
Comment on lines
+488
to
+492
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t terminate the whole conductor session on any error event.
🤖 Prompt for AI Agents |
||
| } | ||
| } finally { | ||
| if (yield cancelled()) { | ||
|
|
@@ -490,6 +501,7 @@ function* handleErrors( | |
| function* handleStatuses( | ||
| hostPlugin: BrowserHostPlugin, | ||
| workspaceLocation: WorkspaceLocation, | ||
| onTerminate: () => void, | ||
| ): SagaIterator { | ||
| const statusChan = eventChannel<{ status: RunnerStatus; isActive: boolean }>(emitter => { | ||
| const onStatusUpdate = (status: RunnerStatus, isActive: boolean) => | ||
|
|
@@ -510,7 +522,8 @@ function* handleStatuses( | |
| isActive && (status === RunnerStatus.STOPPED || status === RunnerStatus.ERROR); | ||
|
|
||
| if (isTerminalStatus) { | ||
| yield put(actions.beginInterruptExecution(workspaceLocation)); | ||
| // Unblock the REPL loop via the shared termination signal. | ||
| onTerminate(); | ||
| } | ||
| } | ||
| } finally { | ||
|
|
@@ -551,35 +564,57 @@ export function* evalCodeConductorSaga( | |
| { files, consume: true }, | ||
| ); | ||
|
|
||
| // A one-shot Promise that resolves when the evaluator signals it has finished | ||
| // (either via a terminal status or via an error with no follow-up status). | ||
| // Using a Promise + eventChannel means the signal is completely decoupled from | ||
| // Redux action ordering, avoiding the race condition where beginInterruptExecution | ||
| // dispatched by evalEditorSaga is buffered and consumed by this loop before the | ||
| // evaluator even has a chance to report an error. | ||
| let resolveTerminated!: () => void; | ||
| const terminatedChan = eventChannel<true>(emitter => { | ||
| resolveTerminated = () => emitter(true); | ||
| return () => {}; | ||
| }); | ||
|
|
||
|
|
||
| // Begin evaluation | ||
| const stdoutTask = yield fork(handleStdout, hostPlugin, workspaceLocation); | ||
| const resultTask = yield fork(handleResults, hostPlugin, workspaceLocation); | ||
| const errorTask = yield fork(handleErrors, hostPlugin, workspaceLocation); | ||
| const statusTask = yield fork(handleStatuses, hostPlugin, workspaceLocation); | ||
| yield call([hostPlugin, 'startEvaluator'], entrypointFilePath); | ||
|
|
||
| // This exit logic of this while loop might be causing an unintended infinite loop in the REPL | ||
| while (true) { | ||
| const { stop } = yield race({ | ||
| repl: take(actions.evalRepl.type), | ||
| stop: take(actions.beginInterruptExecution.type), | ||
| }); | ||
| if (stop) break; | ||
| const code: string = yield select( | ||
| (state: OverallState) => state.workspaces[workspaceLocation].replValue, | ||
| ); | ||
| yield put(actions.sendReplInputToOutput(code, workspaceLocation)); | ||
| yield put(actions.clearReplInput(workspaceLocation)); | ||
| yield call([hostPlugin, 'sendChunk'], code); | ||
| const errorTask = yield fork(handleErrors, hostPlugin, workspaceLocation, resolveTerminated); | ||
| const statusTask = yield fork(handleStatuses, hostPlugin, workspaceLocation, resolveTerminated); | ||
|
|
||
| try { | ||
| yield call([hostPlugin, 'startEvaluator'], entrypointFilePath); | ||
|
|
||
| // The REPL loop processes REPL inputs until the evaluator signals termination. | ||
| // Using a dedicated termination channel (not beginInterruptExecution) avoids the | ||
| // race condition where a pre-dispatched beginInterruptExecution from evalEditorSaga | ||
| // is buffered and consumed by this loop prematurely. | ||
| while (true) { | ||
| const { stop } = yield race({ | ||
| repl: take(actions.evalRepl.type), | ||
| stop: take(terminatedChan), | ||
| }); | ||
| if (stop) break; | ||
| const code: string = yield select( | ||
| (state: OverallState) => state.workspaces[workspaceLocation].replValue, | ||
| ); | ||
| yield put(actions.sendReplInputToOutput(code, workspaceLocation)); | ||
| yield put(actions.clearReplInput(workspaceLocation)); | ||
| yield call([hostPlugin, 'sendChunk'], code); | ||
| } | ||
| } finally { | ||
| // Always clean up — runs on normal exit, error, or saga cancellation (takeLatest). | ||
| terminatedChan.close(); | ||
| yield cancel(statusTask); | ||
| yield cancel(stdoutTask); | ||
| yield cancel(resultTask); | ||
| yield cancel(errorTask); | ||
| yield call([conduit, 'terminate']); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
try {
yield call([conduit, 'terminate']);
} catch (e) {
console.warn('[conductor] failed to terminate conduit', e);
} |
||
| yield put(actions.endInterruptExecution(workspaceLocation)); | ||
| yield put(actions.setIsRunning(false, workspaceLocation)); | ||
| console.log('[conductor] saga terminated'); | ||
| } | ||
| yield cancel(statusTask); | ||
| yield call([conduit, 'terminate']); | ||
| yield cancel(stdoutTask); | ||
| yield cancel(resultTask); | ||
| yield cancel(errorTask); | ||
| //yield put(actions.debuggerReset(workspaceLocation)); | ||
| yield put(actions.endInterruptExecution(workspaceLocation)); | ||
| console.log('killed'); | ||
| } | ||
|
|
||
| // Special module errors | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -136,8 +136,10 @@ const WorkspaceSaga = combineSagaHandlers({ | |
| } | ||
| }); | ||
| }, | ||
| [WorkspaceActions.evalEditor.type]: ({ payload: { workspaceLocation } }) => | ||
| evalEditorSaga(workspaceLocation), | ||
| [WorkspaceActions.evalEditor.type]: { | ||
| takeLatest: ({ payload: { workspaceLocation } }) => evalEditorSaga(workspaceLocation), | ||
| }, | ||
|
Comment on lines
+139
to
+141
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== combineSagaHandlers / takeLatest implementation =="
rg -n -C4 '\bcombineSagaHandlers\b|\btakeLatest\b' src/commons/redux
echo
echo "== evalEditor / evalRepl action creators =="
rg -n -C2 '\bevalEditor:\s*\(|\bevalRepl:\s*\(' src/commons/workspace/WorkspaceActions.ts
echo
echo "== WorkspaceLocation definition =="
rg -n -C2 '\b(type|export type)\s+WorkspaceLocation\b' src/commons/workspace/WorkspaceTypes.ts
echo
echo "== evalEditor / evalRepl dispatch sites =="
rg -n -C2 '\b(WorkspaceActions\.)?(evalEditor|evalRepl)\s*\(' --type=ts --type=tsx srcRepository: source-academy/frontend Length of output: 5532 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== combineSagaHandlers / takeLatest implementation =="
rg -n -C4 '\bcombineSagaHandlers\b|\btakeLatest\b' src/commons/redux
echo
echo "== WorkspaceActions action creators (workspaceLocation payload) =="
rg -n -C2 'evalEditor:\s*\(|evalRepl:\s*\(' src/commons/workspace/WorkspaceActions.ts
echo
echo "== WorkspaceLocation type definition =="
rg -n -C2 '\btype\s+WorkspaceLocation\b|\bexport\s+type\s+WorkspaceLocation\b' src/commons/workspace/WorkspaceTypes.ts
echo
echo "== Inspect WorkspaceSaga index.ts around lines 139-141 =="
nl -ba src/commons/sagas/WorkspaceSaga/index.ts | sed -n '120,170p'
echo
echo "== Inspect evalCode.ts around lines 594-604 =="
nl -ba src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts | sed -n '540,660p'
echo
echo "== Find dispatch sites for evalEditor/evalRepl =="
rg -n --hidden --glob='*.ts' --glob='*.tsx' '(WorkspaceActions\.)?(evalEditor|evalRepl)\s*\(' src | head -n 200Repository: source-academy/frontend Length of output: 5591 🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== WorkspaceSaga/index.ts around requested lines =="
echo "--- src/commons/sagas/WorkspaceSaga/index.ts (130-155) ---"
sed -n '130,155p' src/commons/sagas/WorkspaceSaga/index.ts
echo
echo "== WorkspaceSaga/helpers/evalCode.ts around requested lines =="
echo "--- src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts (570-640) ---"
sed -n '570,640p' src/commons/sagas/WorkspaceSaga/helpers/evalCode.ts
echo
echo "== Find definition/usages of take(actions.evalRepl.type / evalEditor Saga mapping) =="
rg -n --glob='*.ts' --glob='*.tsx' \
"(take\\(actions\\.evalRepl\\.type\\)|actions\\.evalRepl\\.type|evalRepl\\.type)" \
src/commons/sagas/WorkspaceSaga
echo
echo "== Find evalEditor/evalRepl dispatch sites =="
rg -n --glob='*.ts' --glob='*.tsx' \
'\b(WorkspaceActions\.)?(evalEditor|evalRepl)\s*\(' src | head -n 200Repository: source-academy/frontend Length of output: 6064 Fix saga concurrency: key
In 🤖 Prompt for AI Agents |
||
|
|
||
| [WorkspaceActions.promptAutocomplete.type]: function* (action) { | ||
| const workspaceLocation = action.payload.workspaceLocation; | ||
| const { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: source-academy/frontend
Length of output: 738
🏁 Script executed:
Repository: source-academy/frontend
Length of output: 2018
🏁 Script executed:
Repository: source-academy/frontend
Length of output: 191
🏁 Script executed:
Repository: source-academy/frontend
Length of output: 409
🏁 Script executed:
Repository: source-academy/frontend
Length of output: 2862
🏁 Script executed:
Repository: source-academy/frontend
Length of output: 8441
Fix CI to satisfy
portal:dependency targets (or update the portal paths).package.jsonpoints to@sourceacademy/conductor(portal:../conductor) and@sourceacademy/plugin-directory(portal:../plugin-directory), which requireconductor/andplugin-directory/to exist as sibling directories of the frontend checkout..github/workflows/ci.ymlandbuild-development.yml) only checks out this single repo and runsyarn install --immutable, without fetching/creating those sibling directories—so theportal:targets will be missing in CI.../conductorand../plugin-directorydo not exist, so resolution can’t work.🤖 Prompt for AI Agents
Source: Pipeline failures