Compare commits
3 Commits
docs/mvp-m
...
feat/feder
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8895b0df83 | ||
| 9c89c32684 | |||
| 8aabb8c5b2 |
60
docker-compose.federated.yml
Normal file
60
docker-compose.federated.yml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# docker-compose.federated.yml — Federated tier overlay
|
||||||
|
#
|
||||||
|
# USAGE:
|
||||||
|
# docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
#
|
||||||
|
# This file is a standalone overlay for the Mosaic federated tier.
|
||||||
|
# It is NOT an extension of docker-compose.yml — it defines its own services
|
||||||
|
# and named volumes so it can run independently of the base dev stack.
|
||||||
|
#
|
||||||
|
# IMPORTANT — HOST PORT CONFLICTS:
|
||||||
|
# The federated services bind the same host ports as the base dev stack
|
||||||
|
# (5433 for Postgres, 6380 for Valkey). You must stop the base dev stack
|
||||||
|
# before starting the federated stack on the same machine:
|
||||||
|
# docker compose down
|
||||||
|
# docker compose -f docker-compose.federated.yml --profile federated up -d
|
||||||
|
#
|
||||||
|
# pgvector extension:
|
||||||
|
# The vector extension is created automatically at first boot via
|
||||||
|
# ./infra/pg-init/01-extensions.sql (CREATE EXTENSION IF NOT EXISTS vector).
|
||||||
|
#
|
||||||
|
# Tier configuration:
|
||||||
|
# Used by `mosaic` instances configured with `tier: federated`.
|
||||||
|
# DEFAULT_FEDERATED_CONFIG points at:
|
||||||
|
# postgresql://mosaic:mosaic@localhost:5433/mosaic
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres-federated:
|
||||||
|
image: pgvector/pgvector:pg17
|
||||||
|
profiles: [federated]
|
||||||
|
ports:
|
||||||
|
- '${PG_FEDERATED_HOST_PORT:-5433}:5432'
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: mosaic
|
||||||
|
POSTGRES_PASSWORD: mosaic
|
||||||
|
POSTGRES_DB: mosaic
|
||||||
|
volumes:
|
||||||
|
- pg_federated_data:/var/lib/postgresql/data
|
||||||
|
- ./infra/pg-init:/docker-entrypoint-initdb.d:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U mosaic']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
valkey-federated:
|
||||||
|
image: valkey/valkey:8-alpine
|
||||||
|
profiles: [federated]
|
||||||
|
ports:
|
||||||
|
- '${VALKEY_FEDERATED_HOST_PORT:-6380}:6379'
|
||||||
|
volumes:
|
||||||
|
- valkey_federated_data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'valkey-cli', 'ping']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pg_federated_data:
|
||||||
|
valkey_federated_data:
|
||||||
@@ -28,7 +28,7 @@ These are MVP-level checks that don't belong to any single workstream. Updated b
|
|||||||
| MVP-T02 | done | Archive install-ux-v2 mission state to `docs/archive/missions/install-ux-v2-20260405/` | IUV-M03 retroactively closed (shipped via PR #446 + releases 0.0.27→0.0.29) |
|
| MVP-T02 | done | Archive install-ux-v2 mission state to `docs/archive/missions/install-ux-v2-20260405/` | IUV-M03 retroactively closed (shipped via PR #446 + releases 0.0.27→0.0.29) |
|
||||||
| MVP-T03 | done | Land federation v1 planning artifacts on `main` | PR #468 merged 2026-04-19 (commit `66512550`) |
|
| MVP-T03 | done | Land federation v1 planning artifacts on `main` | PR #468 merged 2026-04-19 (commit `66512550`) |
|
||||||
| MVP-T04 | not-started | Sync `.mosaic/orchestrator/mission.json` MVP slot with this manifest (milestone enumeration, etc.) | Coord state file; consider whether to repopulate via `mosaic coord` or accept hand-edit |
|
| MVP-T04 | not-started | Sync `.mosaic/orchestrator/mission.json` MVP slot with this manifest (milestone enumeration, etc.) | Coord state file; consider whether to repopulate via `mosaic coord` or accept hand-edit |
|
||||||
| MVP-T05 | not-started | Kick off W1 / FED-M1 — federated tier infrastructure | First execution task in MVP |
|
| MVP-T05 | in-progress | Kick off W1 / FED-M1 — federated tier infrastructure | Session 16 (2026-04-19): FED-M1-01 in-progress on `feat/federation-m1-tier-config` |
|
||||||
| MVP-T06 | not-started | Declare additional workstreams (web dashboard, TUI/CLI parity, remote control, etc.) as scope solidifies | Track each new workstream by adding a row to the Workstream Rollup |
|
| MVP-T06 | not-started | Declare additional workstreams (web dashboard, TUI/CLI parity, remote control, etc.) as scope solidifies | Track each new workstream by adding a row to the Workstream Rollup |
|
||||||
|
|
||||||
## Pointer to Active Workstream
|
## Pointer to Active Workstream
|
||||||
|
|||||||
@@ -15,20 +15,20 @@
|
|||||||
|
|
||||||
Goal: Gateway runs in `federated` tier with containerized PG+pgvector+Valkey. No federation logic yet. Existing standalone behavior does not regress.
|
Goal: Gateway runs in `federated` tier with containerized PG+pgvector+Valkey. No federation logic yet. Existing standalone behavior does not regress.
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------- | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------- | ---------- | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| FED-M1-01 | not-started | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | codex | feat/federation-m1-tier-config | — | 4K | Schema lives in `packages/types`; validator in gateway bootstrap. No behavior change yet — enum only. |
|
| FED-M1-01 | done | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | sonnet | feat/federation-m1-tier-config | — | 4K | Shipped in PR #470. Renamed `team` → `standalone`; added `team` deprecation alias; added `DEFAULT_FEDERATED_CONFIG`. |
|
||||||
| FED-M1-02 | not-started | Author `docker-compose.federated.yml` as an overlay profile: Postgres 16 + pgvector extension (port 5433), Valkey (6380), named volumes, healthchecks. Compose-up should boot cleanly on a clean machine. | #460 | codex | feat/federation-m1-compose | FED-M1-01 | 5K | Overlay on existing `docker-compose.yml`; no changes to base file. Add `profile: federated` gating. |
|
| FED-M1-02 | in-progress | Author `docker-compose.federated.yml` as an overlay profile: Postgres 17 + pgvector extension (port 5433), Valkey (6380), named volumes, healthchecks. Compose-up should boot cleanly on a clean machine. | #460 | sonnet | feat/federation-m1-compose | FED-M1-01 | 5K | Bumped PG16→PG17 to match base compose. Overlay defines distinct `postgres-federated`/`valkey-federated` services, profile-gated. |
|
||||||
| FED-M1-03 | not-started | Add pgvector support to `packages/storage/src/adapters/postgres.ts`: create extension on init (idempotent), expose vector column type in schema helpers. No adapter changes for non-federated tiers. | #460 | codex | feat/federation-m1-pgvector | FED-M1-02 | 8K | Extension create is idempotent `CREATE EXTENSION IF NOT EXISTS vector`. Gate on tier = federated. |
|
| FED-M1-03 | not-started | Add pgvector support to `packages/storage/src/adapters/postgres.ts`: create extension on init (idempotent), expose vector column type in schema helpers. No adapter changes for non-federated tiers. | #460 | codex | feat/federation-m1-pgvector | FED-M1-02 | 8K | Extension create is idempotent `CREATE EXTENSION IF NOT EXISTS vector`. Gate on tier = federated. |
|
||||||
| FED-M1-04 | not-started | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | codex | feat/federation-m1-detector | FED-M1-03 | 8K | Structured error type with remediation hints. Logs which service failed, with host:port attempted. |
|
| FED-M1-04 | not-started | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | codex | feat/federation-m1-detector | FED-M1-03 | 8K | Structured error type with remediation hints. Logs which service failed, with host:port attempted. |
|
||||||
| FED-M1-05 | not-started | Write `scripts/migrate-to-federated.ts`: one-way migration from `local` (PGlite) / `standalone` (PG without pgvector) → `federated`. Dumps, transforms, loads; dry-run + confirm UX. Idempotent on re-run. | #460 | codex | feat/federation-m1-migrate | FED-M1-04 | 10K | Do NOT run automatically. CLI subcommand `mosaic migrate tier --to federated --dry-run`. Safety rails. |
|
| FED-M1-05 | not-started | Write `scripts/migrate-to-federated.ts`: one-way migration from `local` (PGlite) / `standalone` (PG without pgvector) → `federated`. Dumps, transforms, loads; dry-run + confirm UX. Idempotent on re-run. | #460 | codex | feat/federation-m1-migrate | FED-M1-04 | 10K | Do NOT run automatically. CLI subcommand `mosaic migrate tier --to federated --dry-run`. Safety rails. |
|
||||||
| FED-M1-06 | not-started | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Existing doctor output evolves; add `--json` flag. Green/yellow/red + remediation suggestions per issue. |
|
| FED-M1-06 | not-started | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Existing doctor output evolves; add `--json` flag. Green/yellow/red + remediation suggestions per issue. |
|
||||||
| FED-M1-07 | not-started | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Vitest + docker-compose test profile. One test file per assertion; real services, no mocks. |
|
| FED-M1-07 | not-started | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Vitest + docker-compose test profile. One test file per assertion; real services, no mocks. |
|
||||||
| FED-M1-08 | not-started | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Runs against docker-compose federated profile; uses temp PGlite file; deterministic seed. |
|
| FED-M1-08 | not-started | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Runs against docker-compose federated profile; uses temp PGlite file; deterministic seed. |
|
||||||
| FED-M1-09 | not-started | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | haiku | feat/federation-m1-regression | FED-M1-07 | 4K | Reuse existing e2e harness; just re-point at the federation branch build. Canary that we didn't break it. |
|
| FED-M1-09 | not-started | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | haiku | feat/federation-m1-regression | FED-M1-07 | 4K | Reuse existing e2e harness; just re-point at the federation branch build. Canary that we didn't break it. |
|
||||||
| FED-M1-10 | not-started | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | — | FED-M1-09 | 8K | Use `feature-dev:code-reviewer` agent. Specifically: no secrets in error messages; no partial-migration footguns. |
|
| FED-M1-10 | not-started | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | — | FED-M1-09 | 8K | Use `feature-dev:code-reviewer` agent. Specifically: no secrets in error messages; no partial-migration footguns. |
|
||||||
| FED-M1-11 | not-started | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Short, actionable. Link from MISSION-MANIFEST. No decisions captured here — those belong in PRD. |
|
| FED-M1-11 | not-started | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Short, actionable. Link from MISSION-MANIFEST. No decisions captured here — those belong in PRD. |
|
||||||
| FED-M1-12 | not-started | PR, CI green, merge to main, close #460. | #460 | — | (aggregate) | FED-M1-11 | 3K | Queue-guard before push; wait for green; merge squashed; tea `issue-close` #460. |
|
| FED-M1-12 | not-started | PR, CI green, merge to main, close #460. | #460 | — | (aggregate) | FED-M1-11 | 3K | Queue-guard before push; wait for green; merge squashed; tea `issue-close` #460. |
|
||||||
|
|
||||||
**M1 total estimate:** ~74K tokens (over-budget vs 20K PRD estimate — explanation below)
|
**M1 total estimate:** ~74K tokens (over-budget vs 20K PRD estimate — explanation below)
|
||||||
|
|
||||||
|
|||||||
@@ -305,3 +305,41 @@ Issues closed: #52, #55, #57, #58, #120-#134
|
|||||||
| Install-ux-v2 manifest + tasks + scratchpad + iuv-m03-design | moved to `docs/archive/missions/install-ux-v2-20260405/` with status corrected to complete |
|
| Install-ux-v2 manifest + tasks + scratchpad + iuv-m03-design | moved to `docs/archive/missions/install-ux-v2-20260405/` with status corrected to complete |
|
||||||
|
|
||||||
**Next:** PR `docs/mvp-mission-manifest` → merge to `main` → next session begins W1 / FED-M1 from clean state.
|
**Next:** PR `docs/mvp-mission-manifest` → merge to `main` → next session begins W1 / FED-M1 from clean state.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Session 16 — 2026-04-19 — claude
|
||||||
|
|
||||||
|
**Mode:** Delivery (W1 / FED-M1 execution)
|
||||||
|
**Branch:** `feat/federation-m1-tier-config`
|
||||||
|
**Context budget:** 200K, currently ~45% used (compaction-aware)
|
||||||
|
|
||||||
|
**Goal:** FED-M1-01 — extend `mosaic.config.json` schema: add `"federated"` to tier enum.
|
||||||
|
|
||||||
|
**Critical reconciliation surfaced during pre-flight:**
|
||||||
|
|
||||||
|
The federation PRD (`docs/federation/PRD.md` line 247) defines three tiers: `local | standalone | federated`.
|
||||||
|
The existing code (`packages/config/src/mosaic-config.ts`, `packages/mosaic/src/types.ts`, `packages/mosaic/src/stages/gateway-config.ts`) uses `local | team`.
|
||||||
|
|
||||||
|
`team` is the same conceptual tier as PRD `standalone` (Postgres + Valkey, no pgvector). Rather than carrying a confusing alias forever, FED-M1-01 will rename `team` → `standalone` and add `federated` as a third value, so all downstream federation work has a coherent vocabulary.
|
||||||
|
|
||||||
|
Affected files (storage-tier semantics only — Team/workspace usages unaffected):
|
||||||
|
|
||||||
|
- `packages/config/src/mosaic-config.ts` (StorageTier type, validator enum, defaults)
|
||||||
|
- `packages/mosaic/src/types.ts` (GatewayStorageTier)
|
||||||
|
- `packages/mosaic/src/stages/gateway-config.ts` (~10 references)
|
||||||
|
- `packages/mosaic/src/stages/gateway-config.spec.ts` (test references)
|
||||||
|
- Possibly `tools/e2e-install-test.sh` (referenced grep) and headless env hint string
|
||||||
|
|
||||||
|
**Worker plan:**
|
||||||
|
|
||||||
|
1. Spawn sonnet subagent with explicit task spec + the reconciliation context above.
|
||||||
|
2. Worker delivers diff; orchestrator runs `pnpm typecheck && pnpm lint && pnpm format:check`.
|
||||||
|
3. Independent `feature-dev:code-reviewer` subagent reviews diff.
|
||||||
|
4. Second independent verification subagent (general-purpose, sonnet) verifies reviewer's claims and confirms all `'team'` storage-tier references migrated, no `Team`/workspace bleed.
|
||||||
|
5. Open PR via tea CLI; wait for CI; queue-guard; squash merge; record actuals.
|
||||||
|
|
||||||
|
**Open items:**
|
||||||
|
|
||||||
|
- `MVP-T04` (sync `.mosaic/orchestrator/mission.json`) still deferred.
|
||||||
|
- `team` tier rename touches install wizard headless env vars (`MOSAIC_STORAGE_TIER=team`); will need 0.0.x deprecation note in scratchpad if release notes are written this milestone.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export type { MosaicConfig, StorageTier, MemoryConfigRef } from './mosaic-config.js';
|
export type { MosaicConfig, StorageTier, MemoryConfigRef } from './mosaic-config.js';
|
||||||
export {
|
export {
|
||||||
DEFAULT_LOCAL_CONFIG,
|
DEFAULT_LOCAL_CONFIG,
|
||||||
DEFAULT_TEAM_CONFIG,
|
DEFAULT_STANDALONE_CONFIG,
|
||||||
|
DEFAULT_FEDERATED_CONFIG,
|
||||||
loadConfig,
|
loadConfig,
|
||||||
validateConfig,
|
validateConfig,
|
||||||
} from './mosaic-config.js';
|
} from './mosaic-config.js';
|
||||||
|
|||||||
109
packages/config/src/mosaic-config.spec.ts
Normal file
109
packages/config/src/mosaic-config.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
validateConfig,
|
||||||
|
DEFAULT_LOCAL_CONFIG,
|
||||||
|
DEFAULT_STANDALONE_CONFIG,
|
||||||
|
DEFAULT_FEDERATED_CONFIG,
|
||||||
|
} from './mosaic-config.js';
|
||||||
|
|
||||||
|
describe('validateConfig — tier enum', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let stderrSpy: any;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
stderrSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts tier="local"', () => {
|
||||||
|
const result = validateConfig({
|
||||||
|
tier: 'local',
|
||||||
|
storage: { type: 'pglite', dataDir: '.mosaic/storage-pglite' },
|
||||||
|
queue: { type: 'local', dataDir: '.mosaic/queue' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
});
|
||||||
|
expect(result.tier).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts tier="standalone"', () => {
|
||||||
|
const result = validateConfig({
|
||||||
|
tier: 'standalone',
|
||||||
|
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5432/mosaic' },
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
});
|
||||||
|
expect(result.tier).toBe('standalone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts tier="federated"', () => {
|
||||||
|
const result = validateConfig({
|
||||||
|
tier: 'federated',
|
||||||
|
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5433/mosaic' },
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'pgvector' },
|
||||||
|
});
|
||||||
|
expect(result.tier).toBe('federated');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts deprecated tier="team" as alias for "standalone" and emits a deprecation warning', () => {
|
||||||
|
const result = validateConfig({
|
||||||
|
tier: 'team',
|
||||||
|
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5432/mosaic' },
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
});
|
||||||
|
expect(result.tier).toBe('standalone');
|
||||||
|
expect(stderrSpy).toHaveBeenCalledWith(expect.stringContaining('DEPRECATED'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid tier with an error listing all three valid values', () => {
|
||||||
|
expect(() =>
|
||||||
|
validateConfig({
|
||||||
|
tier: 'invalid',
|
||||||
|
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5432/mosaic' },
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
}),
|
||||||
|
).toThrow(/local.*standalone.*federated|federated.*standalone.*local/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error message for invalid tier mentions all three valid values', () => {
|
||||||
|
let message = '';
|
||||||
|
try {
|
||||||
|
validateConfig({
|
||||||
|
tier: 'invalid',
|
||||||
|
storage: { type: 'postgres', url: 'postgresql://...' },
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
message = err instanceof Error ? err.message : String(err);
|
||||||
|
}
|
||||||
|
expect(message).toContain('"local"');
|
||||||
|
expect(message).toContain('"standalone"');
|
||||||
|
expect(message).toContain('"federated"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DEFAULT_* config constants', () => {
|
||||||
|
it('DEFAULT_LOCAL_CONFIG has tier="local"', () => {
|
||||||
|
expect(DEFAULT_LOCAL_CONFIG.tier).toBe('local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFAULT_STANDALONE_CONFIG has tier="standalone"', () => {
|
||||||
|
expect(DEFAULT_STANDALONE_CONFIG.tier).toBe('standalone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFAULT_FEDERATED_CONFIG has tier="federated" and pgvector memory', () => {
|
||||||
|
expect(DEFAULT_FEDERATED_CONFIG.tier).toBe('federated');
|
||||||
|
expect(DEFAULT_FEDERATED_CONFIG.memory.type).toBe('pgvector');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DEFAULT_FEDERATED_CONFIG uses port 5433 (distinct from standalone 5432)', () => {
|
||||||
|
const url = (DEFAULT_FEDERATED_CONFIG.storage as { url: string }).url;
|
||||||
|
expect(url).toContain('5433');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ import type { QueueAdapterConfig as QueueConfig } from '@mosaicstack/queue';
|
|||||||
/* Types */
|
/* Types */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
export type StorageTier = 'local' | 'team';
|
export type StorageTier = 'local' | 'standalone' | 'federated';
|
||||||
|
|
||||||
export interface MemoryConfigRef {
|
export interface MemoryConfigRef {
|
||||||
type: 'pgvector' | 'sqlite-vec' | 'keyword';
|
type: 'pgvector' | 'sqlite-vec' | 'keyword';
|
||||||
@@ -31,10 +31,17 @@ export const DEFAULT_LOCAL_CONFIG: MosaicConfig = {
|
|||||||
memory: { type: 'keyword' },
|
memory: { type: 'keyword' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_TEAM_CONFIG: MosaicConfig = {
|
export const DEFAULT_STANDALONE_CONFIG: MosaicConfig = {
|
||||||
tier: 'team',
|
tier: 'standalone',
|
||||||
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5432/mosaic' },
|
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5432/mosaic' },
|
||||||
queue: { type: 'bullmq' },
|
queue: { type: 'bullmq' },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_FEDERATED_CONFIG: MosaicConfig = {
|
||||||
|
tier: 'federated',
|
||||||
|
storage: { type: 'postgres', url: 'postgresql://mosaic:mosaic@localhost:5433/mosaic' },
|
||||||
|
queue: { type: 'bullmq' },
|
||||||
memory: { type: 'pgvector' },
|
memory: { type: 'pgvector' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,7 +49,7 @@ export const DEFAULT_TEAM_CONFIG: MosaicConfig = {
|
|||||||
/* Validation */
|
/* Validation */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
const VALID_TIERS = new Set<string>(['local', 'team']);
|
const VALID_TIERS = new Set<string>(['local', 'standalone', 'federated']);
|
||||||
const VALID_STORAGE_TYPES = new Set<string>(['postgres', 'pglite', 'files']);
|
const VALID_STORAGE_TYPES = new Set<string>(['postgres', 'pglite', 'files']);
|
||||||
const VALID_QUEUE_TYPES = new Set<string>(['bullmq', 'local']);
|
const VALID_QUEUE_TYPES = new Set<string>(['bullmq', 'local']);
|
||||||
const VALID_MEMORY_TYPES = new Set<string>(['pgvector', 'sqlite-vec', 'keyword']);
|
const VALID_MEMORY_TYPES = new Set<string>(['pgvector', 'sqlite-vec', 'keyword']);
|
||||||
@@ -55,9 +62,19 @@ export function validateConfig(raw: unknown): MosaicConfig {
|
|||||||
const obj = raw as Record<string, unknown>;
|
const obj = raw as Record<string, unknown>;
|
||||||
|
|
||||||
// tier
|
// tier
|
||||||
const tier = obj['tier'];
|
let tier = obj['tier'];
|
||||||
|
// Deprecated alias: 'team' → 'standalone' (kept for backward-compat with 0.0.x installs)
|
||||||
|
if (tier === 'team') {
|
||||||
|
process.stderr.write(
|
||||||
|
'[mosaic] DEPRECATED: tier="team" is deprecated — use "standalone" instead. ' +
|
||||||
|
'Update your mosaic.config.json.\n',
|
||||||
|
);
|
||||||
|
tier = 'standalone';
|
||||||
|
}
|
||||||
if (typeof tier !== 'string' || !VALID_TIERS.has(tier)) {
|
if (typeof tier !== 'string' || !VALID_TIERS.has(tier)) {
|
||||||
throw new Error(`Invalid tier "${String(tier)}" — expected "local" or "team"`);
|
throw new Error(
|
||||||
|
`Invalid tier "${String(tier)}" — expected "local", "standalone", or "federated"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// storage
|
// storage
|
||||||
@@ -105,7 +122,7 @@ export function validateConfig(raw: unknown): MosaicConfig {
|
|||||||
function detectFromEnv(): MosaicConfig {
|
function detectFromEnv(): MosaicConfig {
|
||||||
if (process.env['DATABASE_URL']) {
|
if (process.env['DATABASE_URL']) {
|
||||||
return {
|
return {
|
||||||
...DEFAULT_TEAM_CONFIG,
|
...DEFAULT_STANDALONE_CONFIG,
|
||||||
storage: {
|
storage: {
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
url: process.env['DATABASE_URL'],
|
url: process.env['DATABASE_URL'],
|
||||||
|
|||||||
@@ -216,8 +216,8 @@ describe('gatewayConfigStage', () => {
|
|||||||
expect(daemonState.startCalled).toBe(0);
|
expect(daemonState.startCalled).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('honors MOSAIC_STORAGE_TIER=team in headless path', async () => {
|
it('honors MOSAIC_STORAGE_TIER=standalone in headless path', async () => {
|
||||||
process.env['MOSAIC_STORAGE_TIER'] = 'team';
|
process.env['MOSAIC_STORAGE_TIER'] = 'standalone';
|
||||||
process.env['MOSAIC_DATABASE_URL'] = 'postgresql://test/db';
|
process.env['MOSAIC_DATABASE_URL'] = 'postgresql://test/db';
|
||||||
process.env['MOSAIC_VALKEY_URL'] = 'redis://test:6379';
|
process.env['MOSAIC_VALKEY_URL'] = 'redis://test:6379';
|
||||||
|
|
||||||
@@ -231,12 +231,75 @@ describe('gatewayConfigStage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.ready).toBe(true);
|
expect(result.ready).toBe(true);
|
||||||
expect(state.gateway?.tier).toBe('team');
|
expect(state.gateway?.tier).toBe('standalone');
|
||||||
const envContents = readFileSync(daemonState.envFile, 'utf-8');
|
const envContents = readFileSync(daemonState.envFile, 'utf-8');
|
||||||
expect(envContents).toContain('DATABASE_URL=postgresql://test/db');
|
expect(envContents).toContain('DATABASE_URL=postgresql://test/db');
|
||||||
expect(envContents).toContain('VALKEY_URL=redis://test:6379');
|
expect(envContents).toContain('VALKEY_URL=redis://test:6379');
|
||||||
const mosaicConfig = JSON.parse(readFileSync(daemonState.mosaicConfigFile, 'utf-8'));
|
const mosaicConfig = JSON.parse(readFileSync(daemonState.mosaicConfigFile, 'utf-8'));
|
||||||
expect(mosaicConfig.tier).toBe('team');
|
expect(mosaicConfig.tier).toBe('standalone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts deprecated MOSAIC_STORAGE_TIER=team as alias for standalone', async () => {
|
||||||
|
process.env['MOSAIC_STORAGE_TIER'] = 'team';
|
||||||
|
process.env['MOSAIC_DATABASE_URL'] = 'postgresql://test/db';
|
||||||
|
process.env['MOSAIC_VALKEY_URL'] = 'redis://test:6379';
|
||||||
|
|
||||||
|
const p = buildPrompter();
|
||||||
|
const state = makeState('/home/user/.config/mosaic');
|
||||||
|
|
||||||
|
const result = await gatewayConfigStage(p, state, {
|
||||||
|
host: 'localhost',
|
||||||
|
defaultPort: 14242,
|
||||||
|
skipInstall: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deprecated alias 'team' maps to 'standalone'
|
||||||
|
expect(result.ready).toBe(true);
|
||||||
|
expect(state.gateway?.tier).toBe('standalone');
|
||||||
|
const mosaicConfig = JSON.parse(readFileSync(daemonState.mosaicConfigFile, 'utf-8'));
|
||||||
|
expect(mosaicConfig.tier).toBe('standalone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors MOSAIC_STORAGE_TIER=federated in headless path', async () => {
|
||||||
|
process.env['MOSAIC_STORAGE_TIER'] = 'federated';
|
||||||
|
process.env['MOSAIC_DATABASE_URL'] = 'postgresql://test/feddb';
|
||||||
|
process.env['MOSAIC_VALKEY_URL'] = 'redis://test:6379';
|
||||||
|
|
||||||
|
const p = buildPrompter();
|
||||||
|
const state = makeState('/home/user/.config/mosaic');
|
||||||
|
|
||||||
|
const result = await gatewayConfigStage(p, state, {
|
||||||
|
host: 'localhost',
|
||||||
|
defaultPort: 14242,
|
||||||
|
skipInstall: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ready).toBe(true);
|
||||||
|
expect(state.gateway?.tier).toBe('federated');
|
||||||
|
const envContents = readFileSync(daemonState.envFile, 'utf-8');
|
||||||
|
expect(envContents).toContain('DATABASE_URL=postgresql://test/feddb');
|
||||||
|
const mosaicConfig = JSON.parse(readFileSync(daemonState.mosaicConfigFile, 'utf-8'));
|
||||||
|
expect(mosaicConfig.tier).toBe('federated');
|
||||||
|
expect(mosaicConfig.memory.type).toBe('pgvector');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown MOSAIC_STORAGE_TIER value in headless mode with a descriptive warning', async () => {
|
||||||
|
process.env['MOSAIC_STORAGE_TIER'] = 'federatd'; // deliberate typo
|
||||||
|
|
||||||
|
const warnFn = vi.fn();
|
||||||
|
const p = buildPrompter({ warn: warnFn });
|
||||||
|
const state = makeState('/home/user/.config/mosaic');
|
||||||
|
|
||||||
|
const result = await gatewayConfigStage(p, state, {
|
||||||
|
host: 'localhost',
|
||||||
|
defaultPort: 14242,
|
||||||
|
skipInstall: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// The stage surfaces validation errors as ready:false (warning is shown to the user).
|
||||||
|
expect(result.ready).toBe(false);
|
||||||
|
// The warning message must name all three valid values.
|
||||||
|
expect(warnFn).toHaveBeenCalledWith(expect.stringMatching(/local.*standalone.*federated/i));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('regenerates config when portOverride differs from saved GATEWAY_PORT', async () => {
|
it('regenerates config when portOverride differs from saved GATEWAY_PORT', async () => {
|
||||||
|
|||||||
@@ -84,10 +84,15 @@ async function promptTier(p: WizardPrompter): Promise<GatewayStorageTier> {
|
|||||||
hint: 'embedded database, no dependencies',
|
hint: 'embedded database, no dependencies',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'team',
|
value: 'standalone',
|
||||||
label: 'Team',
|
label: 'Standalone',
|
||||||
hint: 'PostgreSQL + Valkey required',
|
hint: 'PostgreSQL + Valkey required',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: 'federated',
|
||||||
|
label: 'Federated',
|
||||||
|
hint: 'PostgreSQL + Valkey + pgvector, federation server+client',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
return tier;
|
return tier;
|
||||||
@@ -437,7 +442,21 @@ async function collectAndWriteConfig(
|
|||||||
p.log('Headless mode detected — reading configuration from environment variables.');
|
p.log('Headless mode detected — reading configuration from environment variables.');
|
||||||
|
|
||||||
const storageTierEnv = process.env['MOSAIC_STORAGE_TIER'] ?? 'local';
|
const storageTierEnv = process.env['MOSAIC_STORAGE_TIER'] ?? 'local';
|
||||||
tier = storageTierEnv === 'team' ? 'team' : 'local';
|
if (storageTierEnv === 'team') {
|
||||||
|
// Deprecated alias — warn and treat as standalone
|
||||||
|
process.stderr.write(
|
||||||
|
'[mosaic] DEPRECATED: MOSAIC_STORAGE_TIER=team is deprecated — use "standalone" instead.\n',
|
||||||
|
);
|
||||||
|
tier = 'standalone';
|
||||||
|
} else if (storageTierEnv === 'standalone' || storageTierEnv === 'federated') {
|
||||||
|
tier = storageTierEnv;
|
||||||
|
} else if (storageTierEnv !== '' && storageTierEnv !== 'local') {
|
||||||
|
throw new GatewayConfigValidationError(
|
||||||
|
`Invalid MOSAIC_STORAGE_TIER="${storageTierEnv}" — expected "local", "standalone", or "federated" (deprecated alias "team" also accepted)`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
tier = 'local';
|
||||||
|
}
|
||||||
|
|
||||||
const portEnv = process.env['MOSAIC_GATEWAY_PORT'];
|
const portEnv = process.env['MOSAIC_GATEWAY_PORT'];
|
||||||
port = portEnv ? parseInt(portEnv, 10) : opts.defaultPort;
|
port = portEnv ? parseInt(portEnv, 10) : opts.defaultPort;
|
||||||
@@ -453,13 +472,13 @@ async function collectAndWriteConfig(
|
|||||||
hostname = hostnameEnv;
|
hostname = hostnameEnv;
|
||||||
corsOrigin = corsOverride ?? deriveCorsOrigin(hostnameEnv, 3000);
|
corsOrigin = corsOverride ?? deriveCorsOrigin(hostnameEnv, 3000);
|
||||||
|
|
||||||
if (tier === 'team') {
|
if (tier === 'standalone' || tier === 'federated') {
|
||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
if (!databaseUrl) missing.push('MOSAIC_DATABASE_URL');
|
if (!databaseUrl) missing.push('MOSAIC_DATABASE_URL');
|
||||||
if (!valkeyUrl) missing.push('MOSAIC_VALKEY_URL');
|
if (!valkeyUrl) missing.push('MOSAIC_VALKEY_URL');
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new GatewayConfigValidationError(
|
throw new GatewayConfigValidationError(
|
||||||
'Headless install with tier=team requires env vars: ' + missing.join(', '),
|
`Headless install with tier=${tier} requires env vars: ` + missing.join(', '),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,11 +486,15 @@ async function collectAndWriteConfig(
|
|||||||
tier = await promptTier(p);
|
tier = await promptTier(p);
|
||||||
port = await promptPort(p, opts.defaultPort);
|
port = await promptPort(p, opts.defaultPort);
|
||||||
|
|
||||||
if (tier === 'team') {
|
if (tier === 'standalone' || tier === 'federated') {
|
||||||
|
const defaultDbUrl =
|
||||||
|
tier === 'federated'
|
||||||
|
? 'postgresql://mosaic:mosaic@localhost:5433/mosaic'
|
||||||
|
: 'postgresql://mosaic:mosaic@localhost:5432/mosaic';
|
||||||
databaseUrl = await p.text({
|
databaseUrl = await p.text({
|
||||||
message: 'DATABASE_URL',
|
message: 'DATABASE_URL',
|
||||||
initialValue: 'postgresql://mosaic:mosaic@localhost:5433/mosaic',
|
initialValue: defaultDbUrl,
|
||||||
defaultValue: 'postgresql://mosaic:mosaic@localhost:5433/mosaic',
|
defaultValue: defaultDbUrl,
|
||||||
});
|
});
|
||||||
valkeyUrl = await p.text({
|
valkeyUrl = await p.text({
|
||||||
message: 'VALKEY_URL',
|
message: 'VALKEY_URL',
|
||||||
@@ -521,7 +544,7 @@ async function collectAndWriteConfig(
|
|||||||
`OTEL_SERVICE_NAME=mosaic-gateway`,
|
`OTEL_SERVICE_NAME=mosaic-gateway`,
|
||||||
];
|
];
|
||||||
|
|
||||||
if (tier === 'team' && databaseUrl && valkeyUrl) {
|
if ((tier === 'standalone' || tier === 'federated') && databaseUrl && valkeyUrl) {
|
||||||
envLines.push(`DATABASE_URL=${databaseUrl}`);
|
envLines.push(`DATABASE_URL=${databaseUrl}`);
|
||||||
envLines.push(`VALKEY_URL=${valkeyUrl}`);
|
envLines.push(`VALKEY_URL=${valkeyUrl}`);
|
||||||
}
|
}
|
||||||
@@ -545,12 +568,19 @@ async function collectAndWriteConfig(
|
|||||||
queue: { type: 'local', dataDir: join(opts.gatewayHome, 'queue') },
|
queue: { type: 'local', dataDir: join(opts.gatewayHome, 'queue') },
|
||||||
memory: { type: 'keyword' },
|
memory: { type: 'keyword' },
|
||||||
}
|
}
|
||||||
: {
|
: tier === 'federated'
|
||||||
tier: 'team',
|
? {
|
||||||
storage: { type: 'postgres', url: databaseUrl },
|
tier: 'federated',
|
||||||
queue: { type: 'bullmq', url: valkeyUrl },
|
storage: { type: 'postgres', url: databaseUrl },
|
||||||
memory: { type: 'pgvector' },
|
queue: { type: 'bullmq', url: valkeyUrl },
|
||||||
};
|
memory: { type: 'pgvector' },
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
tier: 'standalone',
|
||||||
|
storage: { type: 'postgres', url: databaseUrl },
|
||||||
|
queue: { type: 'bullmq', url: valkeyUrl },
|
||||||
|
memory: { type: 'keyword' },
|
||||||
|
};
|
||||||
|
|
||||||
writeFileSync(opts.mosaicConfigFile, JSON.stringify(mosaicConfig, null, 2) + '\n', {
|
writeFileSync(opts.mosaicConfigFile, JSON.stringify(mosaicConfig, null, 2) + '\n', {
|
||||||
mode: 0o600,
|
mode: 0o600,
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export interface HooksState {
|
|||||||
acceptedAt?: string;
|
acceptedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GatewayStorageTier = 'local' | 'team';
|
export type GatewayStorageTier = 'local' | 'standalone' | 'federated';
|
||||||
|
|
||||||
export interface GatewayAdminState {
|
export interface GatewayAdminState {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user