Skip to content
Open
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
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
engine-strict=true
legacy-peer-deps=true
30 changes: 16 additions & 14 deletions package-lock.json

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

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@
"@aws-sdk/client-s3": "^3.44.0",
"@aws-sdk/s3-request-presigner": "^3.44.0",
"@concord-consortium/dynamic-text": "^1.0.6",
"@concord-consortium/interactive-api-host": "0.10.0",
"@concord-consortium/lara-interactive-api": "1.11.0",
"@concord-consortium/interactive-api-host": "^0.11.0-pre.0",
"@concord-consortium/lara-interactive-api": "1.13.0-pre.2",
"@concord-consortium/object-storage": "1.0.0-pre.8",
"@concord-consortium/text-decorator": "^1.0.2",
"@concord-consortium/token-service": "^2.1.0",
Expand Down
67 changes: 67 additions & 0 deletions specs/AP-68-add-job-manager-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Add Job Manager Support to Activity Player

**Jira**: https://concord-consortium.atlassian.net/browse/AP-68

**Status**: **Closed**

## Overview

Add host-side job manager support to the Activity Player so that iframed interactives (starting with the button interactive) can create and track background jobs via the LARA interactive API. The Activity Player implements `IJobExecutor`, which calls a Firebase Cloud Function per-environment and listens for real-time status updates via Firestore.

## Requirements

- The Activity Player must implement `IJobExecutor` in a new `src/firebase-job-executor.ts` file; all Firebase SDK access goes through `firebase-db.ts` imports — no direct Firebase SDK calls in the executor
- `firebase-db.ts` must export a `getFirestoreDb()` helper that returns `app.firestore()`, so the executor can access the already-initialized Firestore instance without coupling to the Firebase SDK directly
- `createJob(request, context?)` must call the Firebase Cloud Function at `https://us-central1-${projectId}.cloudfunctions.net/submitTask` via HTTP POST with a JSON body containing both the `request` fields (task + task params) and the `context` fields (user identity); for authenticated users it must send a Firebase JWT as a Bearer token in the Authorization header; for anonymous users the Authorization header is omitted and the Cloud Function verifies identity via `run_key` in the POST body context; both calls set `Content-Type: application/json`
- The Firebase Cloud Function name is `submitTask` — a named constant in `firebase-job-executor.ts`
- `createJob` must never reject — on any error, it must return `IJobInfo` with `status: "failure"` and a descriptive `result.message`; if the executor has not been configured yet, `createJob` must immediately return a failure job with a clear message rather than throwing or hanging
- The Cloud Function must return a full `IJobInfo` JSON response (at minimum `{ version, id, status, request, createdAt }`) — coordination requirement with the backend team; the executor returns this object directly as the resolved value of `createJob` and uses `id` to construct the Firestore listener path
- After calling the Cloud Function, `createJob` must set up a Firestore document listener at `sources/{sourceKey}/jobs/{id}` (where `id` comes from the Cloud Function response) to receive real-time status updates; the job info is stored under the `jobInfo` key within the document (i.e. `snapshot.data().jobInfo`); updates must be delivered to the registered `onJobUpdate` callback
- If the Firestore document listener fails to establish or encounters an error, the executor must emit a `"failure"` job update for the affected job so the interactive does not hang indefinitely waiting for a status update
- `getJobs(context?)` must return an empty array (not throw) if the executor has not yet been configured, or if `context` is missing or lacks an `interactiveId`; otherwise it must query the `sources/{sourceKey}/jobs` Firestore collection filtered by `interactiveId` plus the appropriate user identity fields from the context to backfill the interactive after page reload; after returning, `getJobs` must set up Firestore listeners for any backfilled jobs with non-final status (`"queued"` or `"running"`) and populate the `jobId → interactiveId` mapping for those jobs using the context passed to `getJobs`; results must be sorted by `jobInfo.createdAt` ascending
- The Firestore job document written by the Cloud Function must have the structure `{ jobInfo: IJobInfo, interactiveId, ...userIdentityFields }` — `jobInfo` holds the full job object; `interactiveId` and user identity fields (`platform_user_id` or `run_key`) are stored at the root for Firestore query filtering — backend coordination requirement
- The Firestore security rules for `sources/{sourceKey}/jobs` must follow the same `studentWorkCreate/Read` pattern as answers: authenticated learners verified via Firebase JWT claims, and anonymous users verified via `run_key` presence — backend coordination requirement
- `cancelJob(jobId)` must call the same Firebase Function URL via HTTP POST with a JSON body `{ action: "cancel", jobId, context }`; `context` is built from the stored `jobId → interactiveId` mapping and `portalData`; `cancelJob` is fire-and-forget and must not reject
- `onJobUpdate(callback)` must register the callback so the executor calls it whenever any job's Firestore document changes
- The executor must be a module-level singleton configured via a `configure({ portalData, getFirebaseJWT })` call when portal data resolves — before any interactive loads; `configure()` is idempotent after the first call — subsequent calls are ignored
- A `JobManager` instance must be created as a module-level singleton in `iframe-runtime.tsx`, wired with the executor, following the `PubSubManager` pattern; `addInteractive(id, phone, context)` must always be called (not guarded by a null check) so job message handlers are always registered
- `addInteractive` must be called in both runtime and report mode so that job info is available for display in report views
- The executor must maintain an internal `jobId → interactiveId` mapping, populated when `createJob` is called, so it can identify and clean up the correct Firestore listeners when `removeInteractive(id)` is called
- `removeInteractive(id)` must be called in the `useEffect` cleanup — the executor must clean up any Firestore listeners for jobs owned by that interactive
- `configure()` must be called for both authenticated learners (with a portal-minted Firebase JWT) and anonymous users (with an empty-string JWT, relying on `run_key` for identity); teacher preview is intentionally excluded — teachers view read-only student work and do not need job execution
- `@concord-consortium/interactive-api-host` must be updated to `^0.11.0-pre.0` and `@concord-consortium/lara-interactive-api` to `1.13.0-pre.2`; when stable versions ship, `package.json` must be manually updated (pre-release `^` semver does not auto-resolve to stable)

## Technical Notes

- **Firebase project names**: `"report-service-dev"` (staging) and `"report-service-pro"` (production) — see `FirebaseAppName` in `src/firebase-db.ts`
- **Cloud Function URL**: `https://us-central1-${appName}.cloudfunctions.net/submitTask` (prod) or `http://localhost:5001/${appName}/us-central1/submitTask` (emulator via `?emulator=true` query param)
- **Firebase emulator**: Firestore on port 9090, Functions on port 5001, enabled via `?emulator=true`; `firebase-db.ts` conditionally calls `app.firestore().useEmulator("localhost", 9090)` when the param is set
- **Firestore document path**: `sources/{sourceKey}/jobs/{jobId}` — consistent with existing source-namespaced pattern; job info stored at `jobInfo` key, user identity fields at root
- **User identity context** — based on `createAnswerDoc` fields, with `user_type` added:
```typescript
// authenticated
{ interactiveId, user_type: "authenticated", source_key, resource_url, tool_id,
platform_id, platform_user_id, context_id, resource_link_id, remote_endpoint }
// anonymous
{ interactiveId, user_type: "anonymous", source_key, resource_url, tool_id,
run_key, tool_user_id: "anonymous", platform_user_id: portalData.runKey }
```
- **`buildJobContext`**: exported from `firebase-job-executor.ts`; imported by `iframe-runtime.tsx` — service module dependency direction (UI depends on service, not vice versa)
- **`handleGetFirebaseJWT`** from `portal-utils.ts` is the correct export for fetching a portal-minted Firebase JWT (not `getFirebaseJwtFromPortal` in `plugin-context.ts`, which is module-private)
- **Composite Firestore indexes required**: `(interactiveId, platform_user_id)` and `(interactiveId, run_key)` in both Firebase projects — backend team must create these for `getJobs` queries to work
- **Relevant files**:
- `src/firebase-job-executor.ts` — new file: `FirebaseJobExecutor` implementing `IJobExecutor`
- `src/firebase-job-executor.test.ts` — new file: 21 unit tests
- `src/components/activity-page/managed-interactive/iframe-runtime.tsx` — `JobManager` wiring
- `src/firebase-db.ts` — added `getFirestoreDb()` export and emulator support
- `src/components/app.tsx` — `configure()` calls after portal data resolves

## Out of Scope

- The Firebase Cloud Function itself — this story is host-side wiring only
- UI changes in the Activity Player for job status display (handled by the button interactive)
- The mock/fake executor in the QI demo harness (already implemented in question-interactives)
- Job persistence / retrieval history beyond what the Firebase function provides
- Authorization beyond what the existing portal JWT flow provides
- Host-side gating of `createJob`/`cancelJob` in report mode — interactives are responsible for not calling these in report mode
- Changes to the LARA interactive API client or `JobManager` routing logic (those belong in LARA-210)
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
ITextDecorationInfo, ITextDecorationHandlerInfo, IAttachmentUrlRequest, IAttachmentUrlResponse, IGetInteractiveState, AttachmentInfoMap,
IPubSubCreateChannel, IPubSubSubscribe, IPubSubUnsubscribe, IPubSubPublish
} from "@concord-consortium/lara-interactive-api";
import { PubSubManager } from "@concord-consortium/interactive-api-host";
import { PubSubManager, JobManager } from "@concord-consortium/interactive-api-host";
import { firebaseJobExecutor, buildJobContext } from "../../../firebase-job-executor";
import { DynamicTextCustomMessageType, DynamicTextMessage, useDynamicTextContext } from "@concord-consortium/dynamic-text";
import { FirebaseObjectStorageConfig, FirebaseObjectStorageUser } from "@concord-consortium/object-storage";
import Shutterbug from "shutterbug";
Expand Down Expand Up @@ -90,8 +91,9 @@ interface IProps {
log: (logData: any) => void;
}

// this is managed outside of the component to persist across component unmount/mount cycles
// these are managed outside of the component to persist across component unmount/mount cycles
const pubSubManager = new PubSubManager();
const jobManager = new JobManager(firebaseJobExecutor);

export const IframeRuntime: React.ForwardRefExoticComponent<IProps> = forwardRef((props, ref) => {
const { url, id, authoredState, initialInteractiveState, legacyLinkedInteractiveState, setInteractiveState, linkedInteractives, report,
Expand Down Expand Up @@ -127,7 +129,10 @@ export const IframeRuntime: React.ForwardRefExoticComponent<IProps> = forwardRef
return;
}

const preview = queryValueBoolean("preview") || queryValue("mode")?.toLowerCase() === "teacher-edition";

pubSubManager.addInteractive(id, phone);
jobManager.addInteractive(id, phone, buildJobContext(id, portalData ?? anonymousPortalData(preview)));

// Just to add some type checking to phone post (ServerMessage).
const post = (type: ServerMessage, data: any) => phone.post(type, data);
Expand Down Expand Up @@ -317,7 +322,6 @@ export const IframeRuntime: React.ForwardRefExoticComponent<IProps> = forwardRef
});

// create object storage config
const preview = queryValueBoolean("preview") || queryValue("mode")?.toLowerCase() === "teacher-edition";
const objectStorePortalData = portalData ?? anonymousPortalData(preview);

const objectStorageUser: FirebaseObjectStorageUser =
Expand Down Expand Up @@ -439,6 +443,8 @@ export const IframeRuntime: React.ForwardRefExoticComponent<IProps> = forwardRef
});

pubSubManager.removeInteractive(id);
jobManager.removeInteractive(id);
firebaseJobExecutor.removeInteractive(id);

if (phoneRef.current) {
phoneRef.current.disconnect();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,16 @@ jest.mock("@concord-consortium/interactive-api-host", () => {
publish = mockPublish;
}

class MockJobManager {
addInteractive = jest.fn();
removeInteractive = jest.fn();
onJobUpdate = jest.fn();
}

return {
handleGetAttachmentUrl: (...args: any) => mockHandleGetAttachmentUrl(...args),
PubSubManager: MockPubSubManager
PubSubManager: MockPubSubManager,
JobManager: MockJobManager
};
});

Expand Down
10 changes: 10 additions & 0 deletions src/components/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import { LaraDataContext } from "./lara-data-context";
import { __closeAllPopUps } from "../lara-plugin/plugin-api/popup";
import { IPageChangeNotification, PageChangeNotificationErrorTimeout, PageChangeNotificationStartTimeout } from "./activity-page/page-change-notification";
import { getBearerToken } from "../utilities/auth-utils";
import { configure as configureJobExecutor } from "../firebase-job-executor";
import { handleGetFirebaseJWT } from "../portal-utils";
import { ReadAloudContext } from "./read-aloud-context";
import { AccessibilityContext, FontSize, FontType, getFamilyForFontType, getFontSize, getFontSizeInPx, getFontType } from "./accessibility-context";
import { MediaLibraryContext } from "./media-library-context";
Expand Down Expand Up @@ -236,6 +238,10 @@ export class App extends React.PureComponent<IProps, IState> {
await signInWithToken(portalData.database.rawFirebaseJWT);
this.setState({ portalData });
setPortalData(portalData);
configureJobExecutor({
portalData,
getFirebaseJWT: (appName: string) => handleGetFirebaseJWT({ firebase_app: appName }, portalData),
});
} else if (portalJWT.user_type === "teacher" || portalJWT.user_type === undefined) {
// Logged-in user who is not launching an offering from Portal, most likely teacher.
// As of 08/2022, portalJWT doesn't provide user_type when JWT is obtained using token coming from OAuth.
Expand All @@ -255,6 +261,10 @@ export class App extends React.PureComponent<IProps, IState> {
try {
// Anonymous user running AP using a direct link most likely.
await initializeAnonymousDB(preview);
configureJobExecutor({
portalData: getPortalData() as IPortalDataUnion,
getFirebaseJWT: () => Promise.resolve(""),
});
} catch (err) {
this.setError("auth", err);
}
Expand Down
12 changes: 12 additions & 0 deletions src/firebase-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,13 @@ export const onFirestoreSaveAfterTimeout = (handler: () => void) => {
};
let app: firebase.app.App;

export const getFirestoreDb = (): firebase.firestore.Firestore => {
if (!app) {
throw new Error("Firebase app has not been initialized. Call initializeDB before calling getFirestoreDb.");
}
return app.firestore();
};

export const getConfiguration = (name?: FirebaseAppName): IConfig => {
return name ? configurations[name] : configurations["report-service-dev"];
};
Expand All @@ -126,6 +133,11 @@ export async function initializeDB({ name, preview }: { name: FirebaseAppName, p
ignoreUndefinedProperties: true,
});

if (queryValueBoolean("emulator")) {
console.warn("[FirebaseDB] Using Firebase Firestore emulator at localhost:9090");
app.firestore().useEmulator("localhost", 9090);
}

// The following flags are useful for tests. It makes it possible to clear the persistence
// at the beginning of a test, and enable perisistence on each visit call
// this way the tests can run offline but still share firestore state across visits
Expand Down
Loading
Loading