feat(FN-3448): normalize AgentsView button utility classes
The merge normalizes button utility classes in AgentsView, swapping 12 lines of CSS class references for their standardized counterparts for consistency and maintainability. Fusion-Task-Id: FN-3448
This commit is contained in:
@@ -48,6 +48,7 @@ For a full walkthrough (installation, onboarding, first task, and daily workflow
|
|||||||
| [Storage](./storage.md) | Storage architecture, migration, archive system, and SQLite schema |
|
| [Storage](./storage.md) | Storage architecture, migration, archive system, and SQLite schema |
|
||||||
| [Dev Server Module Audit](./dev-server-modules.md) | Analysis of parallel dashboard dev-server module families, production wiring, and consolidation guidance |
|
| [Dev Server Module Audit](./dev-server-modules.md) | Analysis of parallel dashboard dev-server module families, production wiring, and consolidation guidance |
|
||||||
| [Beads and Dolt Evaluation for Fusion Node Sync](./beads-dolt-sync-evaluation.md) | Evaluation of Beads and Dolt for node sync, with a recommendation for Fusion-native sync design |
|
| [Beads and Dolt Evaluation for Fusion Node Sync](./beads-dolt-sync-evaluation.md) | Evaluation of Beads and Dolt for node sync, with a recommendation for Fusion-native sync design |
|
||||||
|
| [Shared Mesh Replication Protocol](./shared-mesh-protocol.md) | Canonical multi-leader replication/write-coordination contract (versioning, quorum, leases/fencing, queue/replay, reconciliation, and degraded-read semantics) |
|
||||||
| [Contributing](./contributing.md) | Local development setup, testing, release flow, and contributor conventions |
|
| [Contributing](./contributing.md) | Local development setup, testing, release flow, and contributor conventions |
|
||||||
| [Docker](./docker.md) | Container builds, deployment, and persistence configuration |
|
| [Docker](./docker.md) | Container builds, deployment, and persistence configuration |
|
||||||
| [Code Signing](./CODE_SIGNING.md) | macOS and Windows code signing configuration for release binaries |
|
| [Code Signing](./CODE_SIGNING.md) | macOS and Windows code signing configuration for release binaries |
|
||||||
|
|||||||
@@ -425,6 +425,9 @@ Implemented in `agent-heartbeat.ts`:
|
|||||||
### Node/mesh runtime services
|
### Node/mesh runtime services
|
||||||
- `NodeHealthMonitor` (`node-health-monitor.ts`) — remote node liveness/metrics checks
|
- `NodeHealthMonitor` (`node-health-monitor.ts`) — remote node liveness/metrics checks
|
||||||
- `PeerExchangeService` (`peer-exchange-service.ts`) — peer sync orchestration
|
- `PeerExchangeService` (`peer-exchange-service.ts`) — peer sync orchestration
|
||||||
|
- Canonical replication/write-coordination contract: [`docs/shared-mesh-protocol.md`](./shared-mesh-protocol.md)
|
||||||
|
- Defines protocol versioning, write classes, quorum/ack semantics, lease epochs/fencing, offline queue/replay, reconciliation outcomes, restart recovery hooks, and degraded-read staleness metadata.
|
||||||
|
- Existing `/api/mesh/sync` and settings-sync payloads remain the active exchange primitives while follow-on runtime tasks implement full v1 coordinator/quorum behavior.
|
||||||
- Process lifecycle ownership:
|
- Process lifecycle ownership:
|
||||||
- `fn serve` / `fn dashboard` start a single process-level `PeerExchangeService` and stop it during shutdown.
|
- `fn serve` / `fn dashboard` start a single process-level `PeerExchangeService` and stop it during shutdown.
|
||||||
- `CentralCore.startDiscovery()` is invoked from CLI startup only after HTTP bind completes so discovery advertises the actual listening port.
|
- `CentralCore.startDiscovery()` is invoked from CLI startup only after HTTP bind completes so discovery advertises the actual listening port.
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ Per-project task data remains in each repo’s `.fusion/fusion.db`.
|
|||||||
Peer/mesh coordination spans core + engine, with startup ownership in CLI process entrypoints:
|
Peer/mesh coordination spans core + engine, with startup ownership in CLI process entrypoints:
|
||||||
- `NodeDiscovery` and `NodeConnection` in `@fusion/core` handle discovery and remote node connectivity/auth primitives.
|
- `NodeDiscovery` and `NodeConnection` in `@fusion/core` handle discovery and remote node connectivity/auth primitives.
|
||||||
- `PeerExchangeService` in `@fusion/engine` coordinates node-to-node sync/exchange workflows.
|
- `PeerExchangeService` in `@fusion/engine` coordinates node-to-node sync/exchange workflows.
|
||||||
|
- Canonical replication semantics live in [`docs/shared-mesh-protocol.md`](./shared-mesh-protocol.md). That protocol separates strongly coordinated shared state from append-only streams, queued replay classes, and node-local runtime state.
|
||||||
- `runServe()` and `runDashboard()` (CLI) own process-level mesh service lifecycle:
|
- `runServe()` and `runDashboard()` (CLI) own process-level mesh service lifecycle:
|
||||||
- start one process-wide `PeerExchangeService` instance
|
- start one process-wide `PeerExchangeService` instance
|
||||||
- call `CentralCore.startDiscovery()` only after the HTTP server is listening and the real bound port is known
|
- call `CentralCore.startDiscovery()` only after the HTTP server is listening and the real bound port is known
|
||||||
|
|||||||
164
docs/shared-mesh-protocol.md
Normal file
164
docs/shared-mesh-protocol.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Shared Mesh Replication Protocol (v1)
|
||||||
|
|
||||||
|
[← Docs index](./README.md)
|
||||||
|
|
||||||
|
This document is the canonical contract for Fusion multi-leader mesh replication.
|
||||||
|
|
||||||
|
## 1. Goals and non-goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
- Preserve one shared durable project state across multiple nodes.
|
||||||
|
- Keep task and planning state strongly coordinated by default.
|
||||||
|
- Allow local progress during peer outages via durable queues.
|
||||||
|
- Support deterministic replay/reconciliation after recovery.
|
||||||
|
- Expose read staleness so clients can decide whether to trust last-known global state.
|
||||||
|
|
||||||
|
### Non-goals (for v1)
|
||||||
|
- Full runtime scheduler failover.
|
||||||
|
- Full live-process state migration.
|
||||||
|
- Immediate global consistency for every data class.
|
||||||
|
|
||||||
|
## 2. Terms
|
||||||
|
|
||||||
|
- **Node**: A Fusion runtime instance participating in mesh sync.
|
||||||
|
- **Coordinator**: Node currently responsible for committing a write intent.
|
||||||
|
- **Intent**: Durable write proposal before global ack quorum completes.
|
||||||
|
- **Envelope**: Wire record carrying replication metadata + payload.
|
||||||
|
- **Epoch**: Monotonic lease/fencing generation for coordinator authority.
|
||||||
|
- **Fence token**: `epoch + coordinatorNodeId + sequence` token that invalidates stale coordinators.
|
||||||
|
- **Queue entry**: Durable locally-accepted write waiting for replay.
|
||||||
|
|
||||||
|
## 3. Versioning
|
||||||
|
|
||||||
|
- Protocol id: `fusion.shared-mesh`
|
||||||
|
- Initial version: `1.0`
|
||||||
|
- All envelopes must include `{ protocol, version }`.
|
||||||
|
- Minor versions (`1.x`) are backward-compatible additive.
|
||||||
|
- Major versions (`2.0+`) may change semantics and require explicit compatibility checks.
|
||||||
|
|
||||||
|
## 4. Data-class coordination matrix
|
||||||
|
|
||||||
|
| Data class | Mode | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Tasks (core fields, deps, steps, column transitions) | Strongly coordinated | Quorum-acked intent/commit path; replayable with fencing |
|
||||||
|
| Task metadata (priority, model overrides, docs metadata refs) | Strongly coordinated | Same write path as tasks |
|
||||||
|
| Missions/milestones/slices/features | Strongly coordinated | Ordered writes preserve hierarchy invariants |
|
||||||
|
| Agent definitions/configuration | Strongly coordinated | Durable config replicated; runtime process handles excluded |
|
||||||
|
| Agent runtime state (heartbeat ticks, local process internals, worktree paths) | Node-local only | Exposed as local telemetry, not global truth |
|
||||||
|
| Project settings | Strongly coordinated | Existing settings payloads remain canonical payload shape |
|
||||||
|
| Auth material / provider credentials | Queued-for-later (secured transport only) | Explicit auth channel; never merged as ordinary settings data |
|
||||||
|
| Execution runs / live activity streams | Node-local + queued summary | Live events local; durable run outcomes appended later |
|
||||||
|
| Audit / event streams (`activityLog`, `runAuditEvents`) | Append-only replicated | Immutable event replication with origin metadata |
|
||||||
|
| Filesystem blobs (`.fusion/tasks/*` prompts/logs/attachments) | Queued-for-later | Metadata in replicated records, blob transfer out-of-band |
|
||||||
|
|
||||||
|
## 5. Write classes
|
||||||
|
|
||||||
|
- **`strong`**: Requires coordinator fence + quorum ack before `committed`.
|
||||||
|
- **`append-only`**: Event-style immutable replication; dedupe by event id.
|
||||||
|
- **`queued`**: Accept locally when peers unavailable; replay later.
|
||||||
|
- **`local`**: Never replicated globally.
|
||||||
|
|
||||||
|
## 6. Replication envelope
|
||||||
|
|
||||||
|
Every replicated record uses:
|
||||||
|
- `protocol`, `version`
|
||||||
|
- `recordId`, `entityType`, `entityId`
|
||||||
|
- `originNodeId`, `originSeq`
|
||||||
|
- `writeClass`
|
||||||
|
- `leaseEpoch`, `fenceToken`
|
||||||
|
- `intentId` and `state` (`intent` | `committed` | `rejected` | `queued` | `reconciled`)
|
||||||
|
- `createdAt`, `committedAt?`
|
||||||
|
- `payload`
|
||||||
|
- `precondition?` (base revision / expected epoch)
|
||||||
|
|
||||||
|
`PeerSyncRequest` / `PeerSyncResponse` remain mesh exchange carriers. v1 envelopes are payloads exchanged through current mesh sync infrastructure and follow-on sync endpoints.
|
||||||
|
|
||||||
|
## 7. Quorum and acknowledgements
|
||||||
|
|
||||||
|
For `strong` writes:
|
||||||
|
1. Coordinator accepts intent locally.
|
||||||
|
2. Coordinator requests acknowledgements from peers in current membership view.
|
||||||
|
3. Commit requires `quorum = floor(eligibleVoters / 2) + 1` including coordinator.
|
||||||
|
4. If quorum fails before timeout, intent becomes `queued` with retry metadata.
|
||||||
|
|
||||||
|
`append-only` writes can be accepted locally and replicated asynchronously, but must preserve origin ordering `(originNodeId, originSeq)`.
|
||||||
|
|
||||||
|
## 8. Lease epochs and fencing
|
||||||
|
|
||||||
|
- Coordinator authority is leased with a monotonic `leaseEpoch`.
|
||||||
|
- Any write with stale epoch/fence must be rejected (`fenced`).
|
||||||
|
- Restarted nodes must reacquire lease and increment epoch before coordinating strong writes.
|
||||||
|
- Replay workers must carry original fence metadata; reconciler can reject stale queued entries after epoch advancement.
|
||||||
|
|
||||||
|
## 9. Offline queueing and replay
|
||||||
|
|
||||||
|
When a strong/queued write cannot reach quorum:
|
||||||
|
- Persist queue entry durably with:
|
||||||
|
- `intentId`, `entityType`, `entityId`, `writeClass`
|
||||||
|
- `originNodeId`, `originSeq`, `leaseEpoch`, `fenceToken`
|
||||||
|
- retry counters, first/last attempt timestamps, next attempt time
|
||||||
|
- Local node may expose optimistic local result as `queued` only (not globally committed).
|
||||||
|
|
||||||
|
Replay ordering:
|
||||||
|
1. Sort by `(leaseEpoch asc, originSeq asc, createdAt asc, intentId asc)`.
|
||||||
|
2. Re-validate preconditions and fence tokens.
|
||||||
|
3. Commit, reject, or reconcile with deterministic outcome.
|
||||||
|
|
||||||
|
## 10. Reconciliation
|
||||||
|
|
||||||
|
Reconciliation outcomes are explicit:
|
||||||
|
- `applied` — replayed successfully.
|
||||||
|
- `noop_already_applied` — idempotent duplicate.
|
||||||
|
- `superseded` — newer committed revision already exists.
|
||||||
|
- `conflict_requires_merge` — semantic conflict; requires policy/agent/manual resolution.
|
||||||
|
- `rejected_fenced` — stale epoch/fence.
|
||||||
|
|
||||||
|
Conflict policy must never silently downgrade strong writes to local-only updates.
|
||||||
|
|
||||||
|
## 11. Restart recovery hooks
|
||||||
|
|
||||||
|
On node startup:
|
||||||
|
1. Load durable queue.
|
||||||
|
2. Rebuild last known lease epoch / origin sequence.
|
||||||
|
3. Mark in-flight intents without terminal state as `queued` recovery candidates.
|
||||||
|
4. Start replay loop only after mesh membership snapshot and lease status are known.
|
||||||
|
|
||||||
|
## 12. Degraded reads and staleness
|
||||||
|
|
||||||
|
Read responses for shared entities include staleness metadata:
|
||||||
|
- `source`: `local-committed` | `local-queued` | `replica`
|
||||||
|
- `lastGlobalCommitAt`
|
||||||
|
- `replicationLagMs`
|
||||||
|
- `queueDepth`
|
||||||
|
- `isStale`
|
||||||
|
|
||||||
|
In degraded mode, clients may read last-known global state plus queued-local overlays, but must be able to distinguish them.
|
||||||
|
|
||||||
|
## 13. End-to-end v1 write path
|
||||||
|
|
||||||
|
1. **Intent creation**: Node creates write intent + envelope.
|
||||||
|
2. **Coordinator selection**: Node routes to current coordinator lease holder for the entity scope.
|
||||||
|
3. **Commit/ack**:
|
||||||
|
- strong: quorum commit
|
||||||
|
- append-only: local append + async replication
|
||||||
|
4. **Fallback**: if unreachable/quorum-fail, persist queue entry (`queued`).
|
||||||
|
5. **Replay**: on recovery, replay durable queue in canonical order with fencing checks.
|
||||||
|
6. **Reconciliation**: produce explicit outcome and update entity revision state.
|
||||||
|
|
||||||
|
## 14. Contract for FN-3449 through FN-3456
|
||||||
|
|
||||||
|
Follow-on tasks must implement against this contract and not redefine it:
|
||||||
|
- **FN-3449**: distributed ids/origin sequence allocation + monotonic ordering.
|
||||||
|
- **FN-3450**: coordinator selection and lease management runtime.
|
||||||
|
- **FN-3451**: strong-write commit path + quorum ack handling.
|
||||||
|
- **FN-3452**: durable offline queue persistence and replay engine.
|
||||||
|
- **FN-3453**: reconciliation executor + conflict outcome handling.
|
||||||
|
- **FN-3454**: restart recovery bootstrap and in-flight intent recovery.
|
||||||
|
- **FN-3455**: degraded-read APIs exposing staleness metadata.
|
||||||
|
- **FN-3456**: partition behavior policy, observability, and operator controls.
|
||||||
|
|
||||||
|
## 15. Security boundary
|
||||||
|
|
||||||
|
- Mesh transport authentication (node API keys / trust) is mandatory for replication traffic.
|
||||||
|
- Auth credential replication is explicit and separately controlled from ordinary settings replication.
|
||||||
|
- Sensitive payloads must be redacted from non-secure logs and diagnostics.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
SHARED_MESH_PROTOCOL_ID,
|
||||||
|
SHARED_MESH_PROTOCOL_VERSION,
|
||||||
|
classifyReadStaleness,
|
||||||
|
createFenceToken,
|
||||||
|
getCoordinationModeForEntity,
|
||||||
|
getDefaultWriteClassForEntity,
|
||||||
|
getQuorumRequirement,
|
||||||
|
isMeshIntentState,
|
||||||
|
isMeshWriteClass,
|
||||||
|
isProtocolRef,
|
||||||
|
isQuorumSatisfied,
|
||||||
|
} from "../mesh-replication-protocol.js";
|
||||||
|
|
||||||
|
describe("mesh-replication-protocol", () => {
|
||||||
|
it("exposes protocol identity", () => {
|
||||||
|
expect(SHARED_MESH_PROTOCOL_ID).toBe("fusion.shared-mesh");
|
||||||
|
expect(SHARED_MESH_PROTOCOL_VERSION).toBe("1.0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies entity coordination modes", () => {
|
||||||
|
expect(getCoordinationModeForEntity("task")).toBe("strongly-coordinated");
|
||||||
|
expect(getCoordinationModeForEntity("audit-event")).toBe("append-only-replicated");
|
||||||
|
expect(getCoordinationModeForEntity("filesystem-blob")).toBe("queued-for-later");
|
||||||
|
expect(getCoordinationModeForEntity("agent-runtime")).toBe("node-local-only");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps entity coordination mode to default write class", () => {
|
||||||
|
expect(getDefaultWriteClassForEntity("task")).toBe("strong");
|
||||||
|
expect(getDefaultWriteClassForEntity("audit-event")).toBe("append-only");
|
||||||
|
expect(getDefaultWriteClassForEntity("filesystem-blob")).toBe("queued");
|
||||||
|
expect(getDefaultWriteClassForEntity("agent-runtime")).toBe("local");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("computes quorum requirements and satisfies majority rule", () => {
|
||||||
|
expect(getQuorumRequirement(1)).toEqual({ eligibleVoters: 1, requiredAcks: 1 });
|
||||||
|
expect(getQuorumRequirement(2)).toEqual({ eligibleVoters: 2, requiredAcks: 2 });
|
||||||
|
expect(getQuorumRequirement(3)).toEqual({ eligibleVoters: 3, requiredAcks: 2 });
|
||||||
|
expect(isQuorumSatisfied(5, 2)).toBe(false);
|
||||||
|
expect(isQuorumSatisfied(5, 3)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates write class and intent state discriminators", () => {
|
||||||
|
expect(isMeshWriteClass("strong")).toBe(true);
|
||||||
|
expect(isMeshWriteClass("invalid")).toBe(false);
|
||||||
|
expect(isMeshIntentState("committed")).toBe(true);
|
||||||
|
expect(isMeshIntentState("waiting")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates deterministic fence tokens", () => {
|
||||||
|
expect(createFenceToken(7, "node_a", 99)).toBe("7:node_a:99");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("validates protocol refs", () => {
|
||||||
|
expect(isProtocolRef({ protocol: "fusion.shared-mesh", version: "1.0" })).toBe(true);
|
||||||
|
expect(isProtocolRef({ protocol: "fusion.shared-mesh", version: "2.0" })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies read staleness from queue depth and lag", () => {
|
||||||
|
const fresh = classifyReadStaleness({ queueDepth: 0, observedAt: "2026-05-05T00:00:10.000Z", lastGlobalCommitAt: "2026-05-05T00:00:10.000Z" });
|
||||||
|
expect(fresh.isStale).toBe(false);
|
||||||
|
expect(fresh.source).toBe("local-committed");
|
||||||
|
|
||||||
|
const queued = classifyReadStaleness({ queueDepth: 2, observedAt: "2026-05-05T00:00:10.000Z", lastGlobalCommitAt: "2026-05-05T00:00:09.000Z" });
|
||||||
|
expect(queued.isStale).toBe(true);
|
||||||
|
expect(queued.source).toBe("local-queued");
|
||||||
|
expect(queued.replicationLagMs).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey } from "./types.js";
|
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, getDefaultHeartbeatProcedurePath, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, validateDockerNodeConfig, sanitizeDockerNodeConfigForResponse, normalizeMergeConflictStrategy, buildResearchDocumentKey } from "./types.js";
|
||||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, WebSearchBackend, ResearchEnabledSources, ResearchGlobalDefaults, ResearchProjectLimits, ResearchProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||||
|
export * from "./mesh-replication-protocol.js";
|
||||||
export {
|
export {
|
||||||
BUILTIN_AGENT_PROMPTS,
|
BUILTIN_AGENT_PROMPTS,
|
||||||
resolveAgentPrompt,
|
resolveAgentPrompt,
|
||||||
|
|||||||
179
packages/core/src/mesh-replication-protocol.ts
Normal file
179
packages/core/src/mesh-replication-protocol.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import type { NodeMeshState, PeerSyncRequest, PeerSyncResponse, SettingsSyncPayload } from "./types.js";
|
||||||
|
|
||||||
|
export const SHARED_MESH_PROTOCOL_ID = "fusion.shared-mesh" as const;
|
||||||
|
export const SHARED_MESH_PROTOCOL_VERSION = "1.0" as const;
|
||||||
|
|
||||||
|
export const MESH_WRITE_CLASSES = ["strong", "append-only", "queued", "local"] as const;
|
||||||
|
export type MeshWriteClass = (typeof MESH_WRITE_CLASSES)[number];
|
||||||
|
|
||||||
|
export const MESH_INTENT_STATES = ["intent", "committed", "rejected", "queued", "reconciled"] as const;
|
||||||
|
export type MeshIntentState = (typeof MESH_INTENT_STATES)[number];
|
||||||
|
|
||||||
|
export const MESH_RECONCILIATION_OUTCOMES = [
|
||||||
|
"applied",
|
||||||
|
"noop_already_applied",
|
||||||
|
"superseded",
|
||||||
|
"conflict_requires_merge",
|
||||||
|
"rejected_fenced",
|
||||||
|
] as const;
|
||||||
|
export type MeshReconciliationOutcome = (typeof MESH_RECONCILIATION_OUTCOMES)[number];
|
||||||
|
|
||||||
|
export type SharedMeshEntityType =
|
||||||
|
| "task"
|
||||||
|
| "task-metadata"
|
||||||
|
| "mission"
|
||||||
|
| "agent-config"
|
||||||
|
| "agent-runtime"
|
||||||
|
| "project-settings"
|
||||||
|
| "auth-material"
|
||||||
|
| "execution-run"
|
||||||
|
| "audit-event"
|
||||||
|
| "filesystem-blob";
|
||||||
|
|
||||||
|
export type SharedMeshCoordinationMode = "strongly-coordinated" | "append-only-replicated" | "queued-for-later" | "node-local-only";
|
||||||
|
|
||||||
|
export interface SharedMeshProtocolRef {
|
||||||
|
protocol: typeof SHARED_MESH_PROTOCOL_ID;
|
||||||
|
version: typeof SHARED_MESH_PROTOCOL_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedMeshLeaseRef {
|
||||||
|
leaseEpoch: number;
|
||||||
|
fenceToken: string;
|
||||||
|
coordinatorNodeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedMeshWritePrecondition {
|
||||||
|
expectedBaseRevision?: string;
|
||||||
|
expectedLeaseEpoch?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedMeshReplicationEnvelope<TPayload = unknown> extends SharedMeshProtocolRef, SharedMeshLeaseRef {
|
||||||
|
recordId: string;
|
||||||
|
intentId: string;
|
||||||
|
entityType: SharedMeshEntityType;
|
||||||
|
entityId: string;
|
||||||
|
originNodeId: string;
|
||||||
|
originSeq: number;
|
||||||
|
writeClass: MeshWriteClass;
|
||||||
|
state: MeshIntentState;
|
||||||
|
createdAt: string;
|
||||||
|
committedAt?: string;
|
||||||
|
precondition?: SharedMeshWritePrecondition;
|
||||||
|
payload: TPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedMeshQueueEntryMeta {
|
||||||
|
firstAttemptAt: string;
|
||||||
|
lastAttemptAt?: string;
|
||||||
|
nextAttemptAt?: string;
|
||||||
|
retryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedMeshReconciliationResult {
|
||||||
|
intentId: string;
|
||||||
|
outcome: MeshReconciliationOutcome;
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedMeshReadStaleness {
|
||||||
|
source: "local-committed" | "local-queued" | "replica";
|
||||||
|
lastGlobalCommitAt?: string;
|
||||||
|
replicationLagMs?: number;
|
||||||
|
queueDepth: number;
|
||||||
|
isStale: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedMeshQuorumRequirement {
|
||||||
|
eligibleVoters: number;
|
||||||
|
requiredAcks: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SharedMeshSyncRequestEnvelope<TPayload = unknown> = PeerSyncRequest & {
|
||||||
|
replication?: SharedMeshReplicationEnvelope<TPayload>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SharedMeshSyncResponseEnvelope<TPayload = unknown> = PeerSyncResponse & {
|
||||||
|
replication?: SharedMeshReplicationEnvelope<TPayload>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SharedMeshSettingsRecord {
|
||||||
|
settings: SettingsSyncPayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedMeshSnapshot {
|
||||||
|
mesh: NodeMeshState;
|
||||||
|
staleness: SharedMeshReadStaleness;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COORDINATION_BY_ENTITY: Record<SharedMeshEntityType, SharedMeshCoordinationMode> = {
|
||||||
|
task: "strongly-coordinated",
|
||||||
|
"task-metadata": "strongly-coordinated",
|
||||||
|
mission: "strongly-coordinated",
|
||||||
|
"agent-config": "strongly-coordinated",
|
||||||
|
"agent-runtime": "node-local-only",
|
||||||
|
"project-settings": "strongly-coordinated",
|
||||||
|
"auth-material": "queued-for-later",
|
||||||
|
"execution-run": "queued-for-later",
|
||||||
|
"audit-event": "append-only-replicated",
|
||||||
|
"filesystem-blob": "queued-for-later",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isMeshWriteClass(value: string): value is MeshWriteClass {
|
||||||
|
return MESH_WRITE_CLASSES.includes(value as MeshWriteClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMeshIntentState(value: string): value is MeshIntentState {
|
||||||
|
return MESH_INTENT_STATES.includes(value as MeshIntentState);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCoordinationModeForEntity(entityType: SharedMeshEntityType): SharedMeshCoordinationMode {
|
||||||
|
return COORDINATION_BY_ENTITY[entityType];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultWriteClassForEntity(entityType: SharedMeshEntityType): MeshWriteClass {
|
||||||
|
const mode = getCoordinationModeForEntity(entityType);
|
||||||
|
if (mode === "append-only-replicated") return "append-only";
|
||||||
|
if (mode === "queued-for-later") return "queued";
|
||||||
|
if (mode === "node-local-only") return "local";
|
||||||
|
return "strong";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQuorumRequirement(eligibleVoters: number): SharedMeshQuorumRequirement {
|
||||||
|
const normalized = Math.max(1, Math.floor(eligibleVoters));
|
||||||
|
return {
|
||||||
|
eligibleVoters: normalized,
|
||||||
|
requiredAcks: Math.floor(normalized / 2) + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isQuorumSatisfied(eligibleVoters: number, ackCount: number): boolean {
|
||||||
|
return ackCount >= getQuorumRequirement(eligibleVoters).requiredAcks;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFenceToken(leaseEpoch: number, coordinatorNodeId: string, originSeq: number): string {
|
||||||
|
return `${leaseEpoch}:${coordinatorNodeId}:${originSeq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProtocolRef(value: { protocol?: string; version?: string } | null | undefined): value is SharedMeshProtocolRef {
|
||||||
|
return value?.protocol === SHARED_MESH_PROTOCOL_ID && value.version === SHARED_MESH_PROTOCOL_VERSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyReadStaleness(params: {
|
||||||
|
queueDepth: number;
|
||||||
|
lastGlobalCommitAt?: string;
|
||||||
|
observedAt?: string;
|
||||||
|
}): SharedMeshReadStaleness {
|
||||||
|
const observedAt = params.observedAt ? Date.parse(params.observedAt) : Date.now();
|
||||||
|
const lastGlobal = params.lastGlobalCommitAt ? Date.parse(params.lastGlobalCommitAt) : undefined;
|
||||||
|
const lag = lastGlobal !== undefined && Number.isFinite(lastGlobal) ? Math.max(0, observedAt - lastGlobal) : undefined;
|
||||||
|
const queueDepth = Math.max(0, params.queueDepth);
|
||||||
|
|
||||||
|
return {
|
||||||
|
source: queueDepth > 0 ? "local-queued" : "local-committed",
|
||||||
|
lastGlobalCommitAt: params.lastGlobalCommitAt,
|
||||||
|
replicationLagMs: lag,
|
||||||
|
queueDepth,
|
||||||
|
isStale: queueDepth > 0 || (lag ?? 0) > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1251,7 +1251,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
/>
|
/>
|
||||||
<span className="text-secondary">min</span>
|
<span className="text-secondary">min</span>
|
||||||
<button
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn-sm"
|
||||||
onClick={() => void handleCustomHeartbeatSave(agent)}
|
onClick={() => void handleCustomHeartbeatSave(agent)}
|
||||||
disabled={isUpdatingHeartbeat}
|
disabled={isUpdatingHeartbeat}
|
||||||
title="Save custom interval"
|
title="Save custom interval"
|
||||||
@@ -1259,7 +1259,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCustomHeartbeatAgentId(null);
|
setCustomHeartbeatAgentId(null);
|
||||||
setCustomHeartbeatMinutes((prev) => {
|
setCustomHeartbeatMinutes((prev) => {
|
||||||
@@ -1327,7 +1327,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
<div className="agent-card-actions">
|
<div className="agent-card-actions">
|
||||||
{agent.state === "idle" && (
|
{agent.state === "idle" && (
|
||||||
<button
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn-sm"
|
||||||
onClick={() => void handleStateChange(agent.id, "active")}
|
onClick={() => void handleStateChange(agent.id, "active")}
|
||||||
disabled={transitioningAgentIds.has(agent.id)}
|
disabled={transitioningAgentIds.has(agent.id)}
|
||||||
title="Activate"
|
title="Activate"
|
||||||
@@ -1338,7 +1338,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
{agent.state === "active" && (
|
{agent.state === "active" && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn-sm"
|
||||||
onClick={() => void handleRunHeartbeat(agent.id, agent.name)}
|
onClick={() => void handleRunHeartbeat(agent.id, agent.name)}
|
||||||
disabled={transitioningAgentIds.has(agent.id)}
|
disabled={transitioningAgentIds.has(agent.id)}
|
||||||
title="Run Now"
|
title="Run Now"
|
||||||
@@ -1347,7 +1347,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
<Activity size={14} /> Run Now
|
<Activity size={14} /> Run Now
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn-sm"
|
||||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||||
disabled={transitioningAgentIds.has(agent.id)}
|
disabled={transitioningAgentIds.has(agent.id)}
|
||||||
title="Pause"
|
title="Pause"
|
||||||
@@ -1358,7 +1358,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
)}
|
)}
|
||||||
{agent.state === "paused" && (
|
{agent.state === "paused" && (
|
||||||
<button
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn-sm"
|
||||||
onClick={() => void handleStateChange(agent.id, "active")}
|
onClick={() => void handleStateChange(agent.id, "active")}
|
||||||
disabled={transitioningAgentIds.has(agent.id)}
|
disabled={transitioningAgentIds.has(agent.id)}
|
||||||
title="Resume"
|
title="Resume"
|
||||||
@@ -1369,7 +1369,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
{agent.state === "running" && (
|
{agent.state === "running" && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn-sm"
|
||||||
onClick={() => openAgentDetail(agent.id, { initialTab: "runs", initialRunId: null, preferActiveRun: true })}
|
onClick={() => openAgentDetail(agent.id, { initialTab: "runs", initialRunId: null, preferActiveRun: true })}
|
||||||
title="View live run details"
|
title="View live run details"
|
||||||
aria-label={`View live run details for ${agent.name}`}
|
aria-label={`View live run details for ${agent.name}`}
|
||||||
@@ -1377,7 +1377,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
<Activity size={14} /> Running
|
<Activity size={14} /> Running
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn-sm"
|
||||||
onClick={() => void handleStateChange(agent.id, "paused")}
|
onClick={() => void handleStateChange(agent.id, "paused")}
|
||||||
disabled={transitioningAgentIds.has(agent.id)}
|
disabled={transitioningAgentIds.has(agent.id)}
|
||||||
title="Pause"
|
title="Pause"
|
||||||
@@ -1388,7 +1388,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
)}
|
)}
|
||||||
{agent.state === "error" && (
|
{agent.state === "error" && (
|
||||||
<button
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn-sm"
|
||||||
onClick={() => void handleStateChange(agent.id, "active")}
|
onClick={() => void handleStateChange(agent.id, "active")}
|
||||||
disabled={transitioningAgentIds.has(agent.id)}
|
disabled={transitioningAgentIds.has(agent.id)}
|
||||||
title="Retry"
|
title="Retry"
|
||||||
@@ -1398,7 +1398,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
)}
|
)}
|
||||||
{agent.state === "terminated" && (
|
{agent.state === "terminated" && (
|
||||||
<button
|
<button
|
||||||
className="btn btn--sm"
|
className="btn btn-sm"
|
||||||
onClick={() => void handleStateChange(agent.id, "active")}
|
onClick={() => void handleStateChange(agent.id, "active")}
|
||||||
disabled={transitioningAgentIds.has(agent.id)}
|
disabled={transitioningAgentIds.has(agent.id)}
|
||||||
title="Start"
|
title="Start"
|
||||||
@@ -1407,7 +1407,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="btn btn--sm agent-card-details-btn"
|
className="btn btn-sm agent-card-details-btn"
|
||||||
onClick={() => openAgentDetail(agent.id)}
|
onClick={() => openAgentDetail(agent.id)}
|
||||||
title={`View details for ${agent.name}`}
|
title={`View details for ${agent.name}`}
|
||||||
aria-label={`View details for ${agent.name}`}
|
aria-label={`View details for ${agent.name}`}
|
||||||
@@ -1416,7 +1416,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
</button>
|
</button>
|
||||||
{(agent.state === "idle" || agent.state === "terminated" || agent.state === "paused") && (
|
{(agent.state === "idle" || agent.state === "terminated" || agent.state === "paused") && (
|
||||||
<button
|
<button
|
||||||
className="btn btn--sm btn--danger"
|
className="btn btn-sm btn-danger"
|
||||||
onClick={() => void handleDelete(agent.id, agent.name)}
|
onClick={() => void handleDelete(agent.id, agent.name)}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user