FL-02 — Node link & runtime foundations · Task breakdown
Ordered, executable tasks derived from
plan.md. Each carries explicit file paths and a test. Every task ships with its tests (Constitution VI). Work top to bottom; tasks marked(parallel)may run alongside the task immediately above them.
Epic ID: FL-02-node-link
Spec: ./spec.md · Plan: ./plan.md
Status: Draft
Last updated: 2026-10-18
How to use
- Paths are repo-relative;
pnpmfrom the root. Runners:packages/contracts,apps/node→ Vitest;packages/agent,apps/api→ Jest;apps/webe2e → Playwright. - The existing computer-session leg (
apps/node/src/core/screen/node-leg.ts) and both gateways must stay green after every task — their specs are the regression floor for this epic. - Migration authored from
apps/api/; re-stamp the slot ifdevelopmoved (README rule 10). - Nothing is removed: the legacy leg stays as the fallback; retiring it is a separate change the owner approves, not a task here.
- Commit style:
feat(fleet-link): …,feat(node): …,test(fleet-link): ….
P1.A — Contracts
- T-01. Add
packages/contracts/src/fleet/fleet-link.types.ts(constants, frame kinds, families, control message union,FleetLinkError,FleetConsents,effectiveConsents,FLEET_LINK_FAMILY_CONSENT,FLEET_LINK_STATE_CHANGING_FAMILIES) and export frompackages/contracts/src/fleet/index.ts. Test:packages/contracts/src/fleet/__tests__/fleet-link.types.spec.ts— every family has a consent;effectiveConsentsfor null/partial/full; constants equal CONTRACTS §10. - T-02. Add
packages/contracts/src/fleet/fleet-link.codec.ts(encodeLinkFrame,decodeLinkFrame,encodeControl,decodeControl; null-never-throw; 16 MiB cap before parse). Test:packages/contracts/src/fleet/__tests__/fleet-link.codec.spec.ts— round-trip per kind, cap + 1 refused, truncated header,__proto__stripped, 10 000-buffer fuzz never throws. - T-03 (parallel). Extend
packages/contracts/src/fleet/fleet-node.types.ts:FleetNodeSelfDescription.consents?,FleetNodeView.consents?,FleetNodeView.link?,FleetHeartbeatResponse.recommendedNodeVersion?. Addpackages/contracts/src/fleet/fleet-operations.types.ts. Addnode.consent.change,node.link.connect,node.link.disconnect,node.link.channel-opentopackages/contracts/src/fleet/fleet-panic.types.ts(:60-131). Test: extendfleet-node.spec.ts,fleet-panic.spec.ts;apps/api/src/fleet/__tests__/node-contract.conformance.spec.tsstill green (optional fields only). - T-04. Add fixture
packages/contracts/fixtures/fleet-link-contract.v1.json(hello, ready, channel.open/ready/refused/close, operation.progress/done, one frame per kind as hex). Test:packages/contracts/src/fleet/__tests__/fleet-link.fixture.spec.ts— codec decodes every fixture entry to the typed shape.
P1.B — Node: consent, socket extraction, link client
- T-05. Extract socket mechanics from
apps/node/src/core/screen/node-leg.ts(:76-89,:107-116,:142-148,:171-177) intoapps/node/src/core/link/link-socket.ts(isSecureLegOrigin,toWsUrl,createReconnectingSocket({ baseMs, maxMs, jitter, acceptedAfterMs }));node-leg.tsimports it with its 2 s base unchanged. Test:apps/node/src/core/link/link-socket.spec.ts(ladder 1 s → 30 s with jitter bounds, reset only on accept);apps/node/src/core/screen/node-leg.spec.tspasses unchanged. - T-06.
--manageonstartinapps/node/src/cli/program.ts(beside--attend:1549-1552;manageEnabledbesideattendEnabled:464);createNodeRuntimeoptionmanageEnabledinapps/node/src/core/runtime.ts(besideattendEnabled:480);CapabilityEnvironment.managedand themanagedtag inapps/node/src/core/capabilities.ts(detectCapabilities:277-330).status/doctorprint consents. Test: extendapps/node/src/core/capabilities.spec.ts(managedonly with the flag) andapps/node/src/cli/program.spec.ts(flag parsed, independent of--work/--attend). - T-07. Heartbeat
consents:describe()inruntime.tsadds{ work, attend, manage, usageScan: false }(CONTRACTS §1.1; FL-20 wiresusageScan);OPTIONAL_DESCRIPTION_FIELD_TIERSinapps/node/src/core/heartbeat.ts:72-90gainsconsentsin the newest tier;apps/node/src/core/types.tsNodeSelfDescription.consents?. Test: extendapps/node/src/core/heartbeat.spec.ts— sent on every beat; dropped first on a 400 from an older platform;node-contract.conformance.spec.tsfixture updated. - T-08. Module registry:
apps/node/src/core/link/modules/registry.ts(NodeModule,composeModules),modules/control.ts(ch 0: ping/pong, pause/resume/drain flags),modules/machine.ts(lazy channel, verb router,unsupported_on_node),modules/index.ts.detectCapabilitiesappendscomposeModules(env).tags. Test:apps/node/src/core/link/modules/registry.spec.ts— tag composition; unknown verb; consent refusal on the node; pause blocks new input, not replies. - T-09. Link client:
apps/node/src/core/link/link-client.ts(hello/ready, generation, resume list, 15 s ping / 45 s silence,refusedUntil5 min on flag-off close) andapps/node/src/core/link/channel-mux.ts(64 channels, per-channel queues, slow-reader drop, ACK credit). Wire intoruntime.tsafteruntilHeartbeatAccepted(heartbeat.ts:127) when any consent is on; expose link state tostatus/doctor; export fromapps/node/src/core/index.ts. Test:link-client.spec.ts(hello shape, ready, ping timing ±1 ms, resume, pause/resume/drain, refused-until),channel-mux.spec.ts(65th refused, number not reused, credit).
P1.C — API: token, gateway, registry, authoriser, operations
- T-10. Migration
apps/api/src/migrations/1796020000000-AddFleetNodeConsents.ts(guardedADD COLUMN consents text NULLandADD COLUMN previewProfileEnabled boolean NOT NULL DEFAULT false— CONTRACTS §13 puts both in this slot; FL-10 owns the behaviour); entity columns inpackages/agent/src/entities/fleet-node.entity.ts;FleetService.heartbeat(packages/agent/src/fleet/fleet.service.ts:534-630) storesconsents, emitsnode.consent.changethroughFleetAuditService, and calls aConsentChangeListenerport. DTO field inapps/api/src/fleet/dto/fleet.dto.ts. Test:packages/agent/src/fleet/__tests__/fleet.service.consents.spec.ts; migration applies twice;apps/api/src/fleet/dto/fleet-heartbeat-consents.dto.spec.ts(shape accepted; unknown key still 400). - T-11. Create
apps/api/src/fleet-link/module:fleet-link.module.ts(registered inapps/api/src/api.module.tsbesideTerminalModule:72,292),fleet-link-token.service.ts(HMAC viaTerminalAttachServicesigner family,jtiseen-set with Valkey/in-process),fleet-link.controller.ts(POST /api/fleet/link-tokenunderFleetEnabledGuard+FleetNodeAuthGuard, 404 whenFLEET_NODE_LINK_ENABLEDis off). Test:apps/api/src/fleet-link/fleet-link.controller.spec.ts— guard reuse, undifferentiated 401, single use, 5 min TTL, flag off. - T-12.
apps/api/src/fleet-link/fleet-link-gateway.service.tson/ws/fleet/link(shape ofapps/api/src/computer/computer-ws.service.ts:27-69); add/ws/fleet/toTERMINAL_WS_FOREIGN_PATH_PREFIXES(apps/api/src/terminal/terminal-ws.service.ts:50) and to the computer gateway's ignore list.fleet-link.registry.ts(links, generations, channels, resume). Test:fleet-link-gateway.spec.ts— query string destroyed; 4001 after 5 s; replay refused; foreign node refused; 10 hellos/min; generation increments; resumed/closed lists; 45 s silence. - T-13.
fleet-link.authorizer.ts(kill switch → consent →FleetAccessService.assertFamily; auditnode.link.channel-openforFLEET_LINK_STATE_CHANGING_FAMILIES) andfleet-link.service.ts(request(nodeId, family, verb, params, caller), channel reuse,requestIdcorrelation, typed errors with CONTRACTS §8 hints). Test:fleet-link.authorizer.spec.ts(order; no node contact on refusal; audit only for state-changing);fleet-link.service.spec.ts(unreachablewhen no link;limit_exceededon the 65th;unsupported_on_nodepassthrough). - T-14. Operations:
fleet-operations.service.ts(in-memory + Valkey TTL 1 h) andGET /api/fleet/operations/:id+DELETE /api/fleet/operations/:id(sendsoperation.cancel, CONTRACTS §3.3 / §11.2) infleet-link.controller.ts; ch 0operation.progress/donehandling in the registry. Test:fleet-operations.service.spec.ts— progress, done, cancel →cancelled, expiry, foreign caller 404. - T-15. Kill switch and panic:
fleet-link.registry.tssubscribes toFleetKillSwitchServicechanges (poll each ping tick, as the computer relay does) →link.pause/link.resumebroadcast;apps/api/src/fleet/fleet-panic.service.tsdrain-all →link.drain { graceSec: 60 }. Consent withdrawal (T-10 listener) closes affected channels withconsent_missing. Test: extendfleet-link.registry.spec.ts— pause within 5 s, resume without reconnect, drain warning, channels closed on consent loss.
P1.D — Web and i18n
- T-16. Link line in
apps/web/src/components/fleet/computer/ComputerHeader.tsxfromnode.link;apps/web/src/components/fleet/link/ConsentsCard.tsxregistered withregisterOverviewBlock(FL-01);link.shared.ts(state word, missing-consent command, bytes). Registry placeholder resolution readseffectiveConsents. Test:link.shared.unit.spec.ts,ConsentsCard.unit.spec.tsx(command for each missing consent; kind-cannot variant). - T-17. i18n
fleet.link.*,fleet.consents.*,fleet.errors.*additions inapps/web/messages/en.json. Test: no dotted leaf; no literal string inapps/web/src/components/fleet/link/**. - T-18. Playwright
apps/web/e2e/flow-fleet-link-contract.spec.ts(token shape, hello/ready fixture via the node harness, refusal codes) andflow-fleet-consents.spec.ts. Test: itself; AW-11flow-agent-computer-*.spec.tsunchanged and green.
P2 — Relay bus and viewer bridge
- T-19.
apps/api/src/relay/relay-bus.ts(RelayBusinterface = the existingTerminalFanoutBusplus subscribe),relay-bus.valkey.ts(ioredis pub/sub, delivery answer),relay-bus.in-process.ts,relay-bus.module.tsselecting Valkey exactly whengetRedisUrl()answers (CONTRACTS R-7 — no mode setting); bind toTERMINAL_FANOUT_BUS(terminal-relay.registry.ts:130) and the computer relay's bus token (computer.module.ts:62-70); boot check forFLEET_RELAY_REQUIRE_BUS. Test:relay-bus.valkey.spec.tsagainst a test Valkey (round-trip, delivery answer, degraded on loss);relay-bus.module.spec.ts(selection byREDIS_URL; boot failure message namesFLEET_RELAY_REQUIRE_BUS). - T-20. Health:
fleet_relayindicator inapps/api/src/health/service-detection.ts/health.controller.ts:85-100(configured,mode: 'valkey'|'single-replica',status) on the existingGET /api/health/ready. Test: extendapps/api/src/health/health.controller.spec.ts— both modes and the degraded status. - T-21. Viewer side:
fleet-link-attach.service.ts(mint(nodeId, family, target, role, caller); channel claimfleet-link, roleviewer|driver, 60 s, single use) overTerminalAttachService, authoriser applied — no HTTP route of its own: each epic calls it from its CONTRACTS §11.2 attach-token path;fleet-link-viewer-gateway.service.tson/ws/fleet/:family/:id;fleet-link-relay.tsbridging viewer sockets ↔ node channels through the bus. Test:fleet-link-relay.spec.ts(viewer input → error frame, never forwarded; driver forwarded; cross-replica via bus mock);fleet-link-viewer-gateway.spec.ts(token single use, wrong-channel token refused). - T-22. Computer-session leg over the link:
apps/node/src/core/executors/computer-session.tsuses adesktop { source: 'browser' }channel whenruntime.linkis connected, elseopenNodeLeg(unchanged);EVER_WORKS_NODE_LINK_LEGACY_LEG=1forces the old path. API side:computer-relay.registry.tsaccepts frames from the bridge as theworkerrole. Test:computer-session.spec.tsextend (both paths); AW-11 e2e run twice in CI (env matrix). - T-23. Integration
apps/api/test/fleet-link.two-replicas.e2e-spec.ts— two API processes, one Valkey, node harness on A, viewer on B; frames both ways; bus loss degrades health. Test: itself.
P3 — Lifecycle, management lane and parity
- T-24. Upgrade-required surfaces:
apps/web/src/components/fleet/link/UpgradeRequiredSection.tsxon the Settings tab; card/header word from FL-03's state mapping (node.upgradeRequired);recommendedNodeVersion(CONTRACTS §1.1) fromFLEET_RECOMMENDED_NODE_VERSIONinpackages/agent/src/config/andFleetService.heartbeatresult (fleet.service.ts:620-630); producerpackages/agent/src/fleet/needs-a-look/producers/node-upgrade-required.producer.tsregistered under FL-01'sNEEDS_A_LOOK_PRODUCERS. Test:UpgradeRequiredSection.unit.spec.tsx; extendconfig.spec.tsand the heartbeat spec;packages/agent/src/fleet/__tests__/node-upgrade-required.producer.spec.ts(oneinfoitem per below-floor node, stable id, none at or above the floor). - T-25. Node self-update:
apps/node/src/core/link/self-update.ts(compare, installer detection reusingdoctor's install-path logic, run, exit 75, failure log + heartbeatselfUpdateFailedAt);--auto-updateonstartinprogram.ts;node-lifecycle.tsrecord gains the verdict. Test:self-update.spec.ts(ladder, refuse unknown installer, failure keeps old version); container integration inapps/node/packaging/smoke script. - T-26. Desktop shell: consents in
apps/desktop-node/src/main/main.ts:138options,apps/desktop-node/src/main/tray.tstoggles, wizard step inapps/desktop-node/src/renderer/; config persisted beside the enrolment. Test:apps/desktop-node/src/main/start-order.spec.tsextend (consents passed through); renderer unit spec for the step. - T-27. Management lane (CONTRACTS R-34):
FLEET_MANAGEMENT_LANE_KINDS(empty) andFLEET_MANAGEMENT_LANE_CONSENTinpackages/contracts/src/fleet/fleet-jobs.types.ts;createManagementLanebesidecreateAttendedLaneinapps/node/src/core/runtime.ts(:1026-1070), started only when a registered kind's consent is on, concurrency 1 outside--concurrency; the work lane's kind filter (runtime.ts:657-661) excludes the lane's kinds. Test:apps/node/src/core/runtime.management-lane.spec.ts— with a test kind: leased only with its consent, never by the work lane, work-lane slots unchanged; empty set → no lane started. - T-28. Docs:
docs/features/fleet-node-link.md(consents, what the link is, what it never does, operator relay settings, self-update); add toapps/docs/sidebarsPlatform.tsbeside'features/fleet'(:183); extenddocs/features/fleet.md§"Keeping nodes current" and §"Connect a machine";docs/specs/security/fleet-session-trust-model.mdgains a link section; updatedocs/specs/features/fleet/TRACKER.mdrow FL-02. Test:pnpm --filter ever-works-docs build— no broken-link warning. - T-29.
pnpm format && pnpm lint && pnpm test && pnpm buildgreen; AW-11 and fleet e2e green withFLEET_NODE_LINK_ENABLEDon and off and on both legs.
Definition of done
- Every checkbox ticked; every named test exists and passes locally and in CI.
FLEET_NODE_LINK_ENABLED=falsereproduces today's behaviour; the legacy leg is intact.- Conformance fixtures (
fleet-node-contract.v1.json,fleet-link-contract.v1.json) are the wire truth and both are green. - Banned-terms grep over this folder and the code prints nothing (program rule 16).
- Spec §9 criteria checked by a non-author; constitution gates in spec §10 confirmed.