diff --git a/.agent/knowledge/data-contracts.md b/.agent/knowledge/data-contracts.md index a991defd..fec907b5 100644 --- a/.agent/knowledge/data-contracts.md +++ b/.agent/knowledge/data-contracts.md @@ -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. diff --git a/.agent/plans/learn-syncdeck-session-integration.md b/.agent/plans/learn-syncdeck-session-integration.md index 61f74010..8fe14edb 100644 --- a/.agent/plans/learn-syncdeck-session-integration.md +++ b/.agent/plans/learn-syncdeck-session-integration.md @@ -273,6 +273,9 @@ For an active session: { "resourceLinkId": "opaque-resource-id", "state": "active", + "joinCode": "activebits-session-id", + "participantCount": 24, + "instructorCount": 1, "activeSessionId": "activebits-session-id", "studentLaunchUrl": "/", "connectedParticipantCount": 24, @@ -280,12 +283,12 @@ For an active session: } ``` -`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 diff --git a/README.md b/README.md index 1d1f1764..5523b177 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/activities/syncdeck/server/learnIntegration.test.ts b/activities/syncdeck/server/learnIntegration.test.ts index 70d47b70..061f9fe3 100644 --- a/activities/syncdeck/server/learnIntegration.test.ts +++ b/activities/syncdeck/server/learnIntegration.test.ts @@ -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, + }, + ) + const substituteLaunch = substituteInstructorLink(resourceId, 'https://slides.example/deck') const substituteLaunchResponse = response() await getHandlers.get('/api/syncdeck/learn/substitute')!( diff --git a/activities/syncdeck/server/learnIntegration.ts b/activities/syncdeck/server/learnIntegration.ts index 93751955..ec2b308d 100644 --- a/activities/syncdeck/server/learnIntegration.ts +++ b/activities/syncdeck/server/learnIntegration.ts @@ -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, + }) }) app.post(`${INTEGRATION_PREFIX}/activities/:activityId/resources/:resourceLinkId/student-entry`, async (req, res) => { diff --git a/package-lock.json b/package-lock.json index 2a41c910..19c78bdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,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" @@ -861,6 +862,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@es-joy/jsdoccomment": { "version": "0.87.0", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.87.0.tgz", @@ -3515,6 +3523,24 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index f882d710..308c5348 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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"