Schedules — Implementation Plan
Status: Draft v1 · Owner: Engineering · Date: 2026-07-18 Spec: spec.md · Tasks: tasks.md
This plan is additive. It adds one read-only endpoint, one in-page toggle, one new view, and a set of activity-log emitters on cron paths that currently emit nothing. It removes and renames nothing internal — the
ActivityLogentity,activity_logtable,ActivityLogService, and/activity-logroutes are untouched. The only "rename" is two user-facing i18n string values.
The plan is 3 phases matching the spec's P1/P2/P3. Each phase ships as one PR against develop (P1 may split into an API PR + a web PR if review-friendly).
Phase 1 (P1) — Rename + toggle + aggregation endpoint + Schedules list
Goal: A working Log | Schedules toggle on the (renamed) Activity page, with a read-only Schedules list backed by GET /api/schedules.
1a. Rename (i18n values only)
- Edit
apps/web/messages/en.json:dashboard.activity.title(line ~1219):"Activity Log"→"Activity".metadata.pages.activity(line ~5053):"Activity Log"→"Activity".
- Mirror the same value change into all 20 sibling locales (
ar, bg, de, es, fr, he, hi, id, it, ja, ko, nl, pl, pt, ru, th, tr, uk, vi, zh), translating to each locale's word for "Activity" (drop the "Log" qualifier). Keys unchanged. - Do not touch entity/table/service class names,
/activity-logAPI paths, i18n keys, or theactivity-log.csvfilename (spec.md §3.2).
1b. Contracts
- New
packages/contracts/src/api/schedule/:schedule.enum.ts—ScheduleSourceType(6 members per spec.md §4.2).schedule.dto.ts—ScheduleDto,ScheduleControls,SchedulesResponseDto.- Export from
packages/contracts/src/api/index.ts.
1c. API — aggregation endpoint
- New module
apps/api/src/schedules/:schedules.module.ts— imports the repositories/services for tasks, agents, work-schedules, missions, works.schedules.controller.ts—GET /api/schedules(auth-guarded, scope-aware), optional query paramssourceType,entityKind,enabledOnly. OpenAPI annotations so the MCP server picks it up.schedules.service.ts—getSchedules(userId, scope, filters):- Run six scoped source queries via the existing repositories (spec.md §4.3) —
TaskRepository,AgentRepository,WorkScheduleRepository(joinworksfor name),MissionRepository,WorkRepository(×2 for source-validation + data-sync). - Map each row to
ScheduleDto(status normalization per spec.md §4.5; links per spec.md §5.4). - Compute
nextRunAtfor cron sources viacomputeNextCronFireandcadenceHumanviadescribeCadence(spec.md §4.4). - Sort by
nextRunAtasc (null last); buildcountsByType; returnSchedulesResponseDto. controlsreturns all-falsein P1.
- Run six scoped source queries via the existing repositories (spec.md §4.3) —
- Scope: consume the request-scoped
ScopeContext(from the Tenants work); filteruserIdalways,organizationIdwhen an Org scope is active,organizationId IS NULL(+ legacytenantId IS NULL) in bare-Tenant scope. If the Tenants scope plumbing is not yet deployed in the target environment, fall back touserId-only (both are correct supersets for a single-scope user). - Helpers:
- Extend
packages/agent/src/missions/cron-matcher.tswithcomputeNextCronFire(expr, from)(bounded forward walk reusingmatchesCron). describeCadence(sourceType, raw)inschedules.service.ts— RRULE→text via the existingrecurrence.tshelper, cron→text,WorkScheduleCadence→label, interval-minutes→"Every N minutes".
- Extend
1d. Web — toggle + Schedules view
activity-client.tsx:- Add
activeTab: 'log' | 'schedules'state +activity-tablocalStorage persistence (mirror theviewModepattern at lines 60–68). - Render the segmented control above
PageHeader. - Gate the body:
log→ today's tree unchanged;schedules→<SchedulesView />, hiding the Log-specific chrome (status cards, filters,ViewModeSwitch, Export, 5s activity poll). - Optional
?tab=schedulesreflection via the existingrouter.replaceblock (lines 72–84).
- Add
apps/web/src/app/actions/schedules.ts—getSchedules(filters)server action (auth-guarded; mirrorapp/actions/activity-log.ts).apps/web/src/lib/api/schedules.ts—schedulesAPI.getAll(filters)viaserverFetch(server-only; attachX-Scope-Slug).apps/web/src/components/activity-log/schedules/:SchedulesView.tsx(fetch + summary strip + list/cards + filter state).ScheduleRow.tsx,ScheduleCard.tsx.SchedulesFilters.tsx(source-type chips + "Active only").SchedulesEmptyState.tsx.
- i18n: additive keys
dashboard.activity.tabs.{log,schedules}anddashboard.activity.schedules.*across all locales.
Out of scope this phase: any mutation from the Schedules view (P3); any activity emitter changes (P2).
Tests:
schedules.service.spec.ts— each source maps correctly; scope filter applied; status normalization; sort order (null-last);countsByType.schedules.controller.spec.ts— auth guard; query-param filters.- Web unit: toggle persistence;
schedulestab hides Log chrome; empty state. - E2E: seed one of each source type →
/activity, switch to Schedules → all six rows render with cadence + next-run.
Phase 2 (P2) — Activity-gap emitters
Goal: Automated cron fires write activity_log rows so the Log tab (and the per-Work activity feed) show them — not just manual CRUD.
2a. Enum
- Append
MISSION_TICK = 'mission_tick'andIDEA_GENERATED = 'idea_generated'toActivityActionTypeinpackages/agent/src/entities/activity-log.types.ts(additive;actionTypeisvarchar(50), no migration).
2b. Emitters (spec.md §8)
schedule_executedon the cron path —packages/agent/src/services/work-schedule-dispatcher.service.ts(dispatchDuearoundrunScheduledUpdate); mirror in the NestJS fallbackapps/api/src/works/tasks/work-schedule-dispatcher-cron.service.ts. DeterministicingestEventId = schedule-exec:${workId}:${generationHistoryId}.agent_heartbeat_started/completed/failed—packages/agent/src/agents/agent-run.service.tsrun lifecycle, guarded bytriggerKind === 'heartbeat'; optionalstartedat claim inagent-schedule-dispatcher.service.ts.ingestEventIdkeyed onagentRunId.mission_tick—packages/agent/src/missions/mission-tick.service.ts(tickDue, per matched Mission).ingestEventId = mission-tick:${missionId}:${tickTimestamp}.idea_generated—packages/agent/src/user-research/work-proposal.service.tsgenerate(covers both Mission-tick generation and the scheduled user-research rerun).ingestEventIdkeyed on the new WorkProposal id.- (optional)
task_recurrence_fired—packages/agent/src/tasks-domain/task-recurrence-dispatcher.service.tsspawn path. Include if the diff stays small; else defer.
2c. Scope + idempotency
- Every emitter stamps
userId,workId(where applicable),tenantId,organizationIdfrom the parent entity being processed (Tier C scope-stamping rule). Never NULL. - Deterministic
ingestEventIdon every emitter so Trigger.dev + NestJS-fallback double-fires dedupe on theactivity_logpartial-unique(workId, ingestEventId)index (spec.md §7).
Out of scope this phase: any UI change; any new endpoint; missions.lastTickAt column (open question §11.1).
Tests:
- Per-emitter unit test: one
activity_logrow written with the rightactionType, scope columns set, and stableingestEventId. - Idempotency test: a second dispatch with the same
ingestEventIddoes not create a duplicate row. - Feed test:
activity-feed.servicesurfaces the new cron-emittedschedule_executed(whitelist already includes it).
Phase 3 (P3) — Controls polish
Goal: Run-now / pause-resume / change-period directly from the Schedules view, each delegating to an existing per-entity endpoint.
3a. Contracts / API
- Populate
ScheduleDto.controlsper source inschedules.service.ts(spec.md §4.6) — e.g.agent_heartbeat→{ runNow: true, pauseResume: true, changePeriod: true };recurring_task→{ runNow: false, pauseResume: false, changePeriod: true }; etc. - No new mutation endpoints. All controls map to existing routes (spec.md §4.6 table).
3b. Web
ScheduleControlsMenu.tsx— trailing "⋯" menu rendering only enabled controls.- Run-now: POST the mapped endpoint; toast; optimistic "running" state.
- Pause/Resume: POST pause/resume (or
PUT works/:id/schedulestatus); flip the status pill. - Change-period: dialog reusing the per-entity cadence editor (RRULE builder for tasks; cron/interval input otherwise); PATCH the owning entity.
- Reuse existing server actions / BFF routes where present (e.g.
app/actions/dashboard/work-schedule.ts); add thin server actions for agents/missions run-now/pause/resume if none exist. - After any mutation, re-fetch
GET /api/schedules.
Out of scope this phase: schedule creation/deletion from this view (users do that on the entity's own page); run-now for recurring_task / source_validation (no endpoint today — open question §11.3).
Tests:
- Unit: controls menu renders only enabled controls per
ScheduleDto.controls. - E2E: pause a Work schedule from the Schedules view → status pill flips → row reflects
pausedon refetch → the underlyingwork_schedules.statusispaused. - E2E: run-now an Agent heartbeat → toast → an
agent_heartbeat_startedactivity row appears in the Log tab (depends on P2).
Cross-cutting concerns
Additive-only / DB safety
- No new tables, no new columns for the read model. The only schema-adjacent change is two additive TypeScript enum members (no migration —
actionTypeisvarchar(50)). - No DROP / ALTER / data deletion anywhere.
Scope correctness
- The aggregation and every emitter respect the Tenant/Org scope columns (Tier A on the owning entities; Tier C stamping on emitted
activity_logrows). ReuseScopeContext; do not invent a parallel scoping path.
Tests
Each phase ships with unit + integration tests; P1/P3 add E2E flows on the real Activity page. The bar: the existing agent test suites and the e2e suite stay green.
Rollout
- No feature flag needed. P1 is a read-only view behind an in-page toggle defaulting to
log; P2 adds rows to an existing feed; P3 reuses guarded endpoints. Deploy in phase order via the standarddevelop → stage → maincascade.
Sequencing summary
| Phase | Title | Depends on | PR target |
|---|---|---|---|
| P1 | Rename + toggle + GET /api/schedules + Schedules list | — (Tenants scope columns already landed) | develop |
| P2 | Activity-gap emitters | — (independent of P1 UI) | develop |
| P3 | Controls polish | P1 | develop |
P1 and P2 can proceed in parallel. P3 depends on P1's view shell; P3's run-now E2E assertion depends on P2's emitters.