Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .agent/knowledge/data-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ Document API and data-shape assumptions that must stay compatible over time.

## Contracts

- Date: 2026-08-01
- Surface: REST | Learn SyncDeck resource status
- Contract: An active `GET /api/integrations/learn/v1/activities/syncdeck/resources/:resourceLinkId/status` response exposes `joinCode` (the active SyncDeck session ID), `participantCount`, and `instructorCount` alongside the existing active-session status fields.
- Compatibility constraints: Existing `activeSessionId`, `studentLaunchUrl`, `connectedParticipantCount`, and `connectedInstructorCount` fields remain available. The new counts are live websocket connection counts, not attendance totals.
- Validation rules: The route authenticates the request and derives the join code only from the active server-side entry mapping. Participants are deduplicated by student ID; instructor sockets are counted individually.
- Evidence (schema/tests/path): `activities/syncdeck/server/learnIntegration.ts`; `activities/syncdeck/server/learnIntegration.test.ts`; `.agent/plans/learn-syncdeck-session-integration.md`.
- Follow-up action: Retain both field sets until Learn has migrated all consumers to the concise status shape.
- Owner: Codex

- Date: 2026-07-23
- Surface: REST | browser handoff | SyncDeck waiting room
- Contract: Learn-managed instructor sessions use a dedicated HMAC-authenticated API and a temporary `(activityId, provider, resourceLinkId)` entry mapping. The activity ID is a required URL path segment; the first implementation accepts `syncdeck`. A `student-entry` request returns a short-lived, single-use ActiveBits browser URL; consuming it establishes an httpOnly waiting-room handoff. Learn `start` transitions the mapping from waiting to active and returns a distinct single-use instructor manager handoff. `stop` broadcasts session end, clears the mapping, and leaves the stopped session to normal ActiveBits TTL cleanup.
Expand Down
15 changes: 9 additions & 6 deletions .agent/plans/learn-syncdeck-session-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,19 +273,22 @@ For an active session:
{
"resourceLinkId": "opaque-resource-id",
"state": "active",
"joinCode": "activebits-session-id",
"participantCount": 24,
"instructorCount": 1,
"activeSessionId": "activebits-session-id",
"studentLaunchUrl": "/<session-id>",
"connectedParticipantCount": 24,
"connectedInstructorCount": 1
}
```

`studentLaunchUrl` is a navigation URL, not an API credential. It may be omitted from
the status response if Learn instead asks ActiveBits for a redirect response at student
launch time. `connectedParticipantCount` is the number of unique, currently connected
student participants; `connectedInstructorCount` is the number of currently connected
instructors. These are live connection counts, not attendance or historical enrollment
totals.
`joinCode` is the active SyncDeck session ID that students can enter directly.
`participantCount` is the number of unique, currently connected student participants;
`instructorCount` is the number of currently connected instructors. These are live
connection counts, not attendance or historical enrollment totals. `activeSessionId`,
`studentLaunchUrl`, `connectedParticipantCount`, and `connectedInstructorCount` remain
available for existing Learn clients.

Learn may poll status while rendering its activity. The first implementation should poll
at a modest interval (for example, every 15–30 seconds while the activity page is
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ Additional operational docs:
- Use only for nested sandbox tooling inside the devcontainer, such as agent/debug environments that launch their own sandbox layer.
- Do not use this profile for routine development unless you specifically need those tools.

## Development

Run `npm run dev` to start the client and server. When a root `.env` file is present,
the command loads it for both processes; it is optional, so a fresh checkout still
starts without one. For an externally reachable development server, set
`HOST=0.0.0.0` and `PORT=3000` in that file.

## Access

- Student site: <https://bits.mycode.run>
Expand Down
32 changes: 32 additions & 0 deletions activities/syncdeck/server/learnIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,38 @@ void test('Learn routes transition a one-time waiting-room entry into an active
assert.equal(startResponse.statusCode, 200)
assert.equal((startResponse.body as { activeSessionId?: unknown }).activeSessionId, createdSessionId)

ws.wss.clients.add({ readyState: 1, sessionId: createdSessionId, isInstructor: true } as unknown as ActiveBitsWebSocket)
ws.wss.clients.add({ readyState: 1, sessionId: createdSessionId, studentId: 'student-1' } as unknown as ActiveBitsWebSocket)
ws.wss.clients.add({ readyState: 1, sessionId: createdSessionId, studentId: 'student-1' } as unknown as ActiveBitsWebSocket)
const activeStatusResponse = response()
await getHandlers.get('/api/integrations/learn/v1/activities/:activityId/resources/:resourceLinkId/status')!(
{ params: { activityId: 'syncdeck', resourceLinkId: resourceId }, ...signedRequest('GET', statusPath, {}, 'active-status-nonce') },
activeStatusResponse,
)
assert.equal(activeStatusResponse.statusCode, 200)
assert.deepEqual(
{
state: (activeStatusResponse.body as { state: unknown }).state,
joinCode: (activeStatusResponse.body as { joinCode: unknown }).joinCode,
participantCount: (activeStatusResponse.body as { participantCount: unknown }).participantCount,
instructorCount: (activeStatusResponse.body as { instructorCount: unknown }).instructorCount,
activeSessionId: (activeStatusResponse.body as { activeSessionId: unknown }).activeSessionId,
studentLaunchUrl: (activeStatusResponse.body as { studentLaunchUrl: unknown }).studentLaunchUrl,
connectedParticipantCount: (activeStatusResponse.body as { connectedParticipantCount: unknown }).connectedParticipantCount,
connectedInstructorCount: (activeStatusResponse.body as { connectedInstructorCount: unknown }).connectedInstructorCount,
},
{
state: 'active',
joinCode: createdSessionId,
participantCount: 1,
instructorCount: 1,
activeSessionId: createdSessionId,
studentLaunchUrl: `/${createdSessionId}`,
connectedParticipantCount: 1,
connectedInstructorCount: 1,
},
)

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const substituteLaunch = substituteInstructorLink(resourceId, 'https://slides.example/deck')
const substituteLaunchResponse = response()
await getHandlers.get('/api/syncdeck/learn/substitute')!(
Expand Down
12 changes: 11 additions & 1 deletion activities/syncdeck/server/learnIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -461,7 +461,17 @@ export function registerLearnSyncDeckRoutes(options: LearnSyncDeckRouteOptions):
return void res.json({ resourceLinkId, state: 'waiting', activeSessionId: null, studentLaunchUrl: null, connectedParticipantCount: 0, connectedInstructorCount: 0 })
}
const counts = countConnections(ws, entry.data.activeSessionId)
res.json({ resourceLinkId, state: 'active', activeSessionId: entry.data.activeSessionId, studentLaunchUrl: `/${encodeURIComponent(entry.data.activeSessionId)}`, connectedParticipantCount: counts.participants, connectedInstructorCount: counts.instructors })
res.json({
resourceLinkId,
state: 'active',
joinCode: entry.data.activeSessionId,
participantCount: counts.participants,
instructorCount: counts.instructors,
activeSessionId: entry.data.activeSessionId,
studentLaunchUrl: `/${encodeURIComponent(entry.data.activeSessionId)}`,
connectedParticipantCount: counts.participants,
connectedInstructorCount: counts.instructors,
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})

app.post(`${INTEGRATION_PREFIX}/activities/:activityId/resources/:resourceLinkId/student-entry`, async (req, res) => {
Expand Down
26 changes: 26 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
"build": "npm run typecheck && echo 'Quick build (assumes dependencies installed). Use npm run deploy for full install+build.' && npm run build --workspace client",
"deploy": "npm install --include=dev --workspaces --include-workspace-root && npm run build --workspace client",
"start": "npm start --prefix server",
"dev": "export NODE_ENV='dev' && concurrently \"npm run dev --prefix client\" \"npm run start --prefix server\"",
"dev": "cross-env NODE_ENV=dev node --env-file-if-exists=.env ./node_modules/concurrently/dist/bin/index.js \"npm run dev --prefix client\" \"npm run start --prefix server\"",
"lint": "npm --workspace client run lint && npm --workspace server run lint && npm --workspace activities run lint",
"lint:activities": "npm --workspace activities run lint",
"lint:activities:scope": "npm --workspace activities run lint:scope",
Expand Down Expand Up @@ -56,6 +56,7 @@
"@playwright/test": "^1.60.0",
"@types/node": "^24.13.2",
"concurrently": "^10.0.4",
"cross-env": "^10.1.0",
"tsx": "^4.22.4",
"typescript": "^6.0.3",
"vite": "^8.1.0"
Expand Down