Initial import of AlgaPSA codebase from PSA server
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run

Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
This commit is contained in:
Hermes 2026-06-22 16:12:17 -05:00
commit 284313f908
13303 changed files with 3634536 additions and 0 deletions

1
.crew/audit/prune.jsonl Normal file
View File

@ -0,0 +1 @@
{"action":"prune","keep":10,"kept":[],"removed":[],"auditedAt":"2026-06-10T12:39:57.533Z"}

View File

@ -0,0 +1,222 @@
{"exportedAt":"2026-06-10T12:40:57.494Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:41:57.495Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:42:57.497Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:43:57.497Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:44:57.497Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:45:57.497Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:46:57.497Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:47:57.497Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:48:57.497Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:49:57.497Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:50:57.497Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:51:57.498Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:52:57.498Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:53:57.500Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:54:57.500Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:55:57.501Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:56:57.501Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:57:57.500Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:58:57.501Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T12:59:57.502Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:00:57.501Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:01:57.503Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:02:57.502Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:03:57.503Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:04:57.502Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:05:57.503Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:06:57.503Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:07:57.503Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:08:57.502Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:09:57.503Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:10:57.503Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:11:57.502Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:12:57.504Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:13:57.504Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:14:57.505Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:15:57.505Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:16:57.504Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:17:57.505Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:18:57.505Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:19:57.505Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:20:57.505Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:21:57.505Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:22:57.505Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:23:57.506Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:24:57.506Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:25:57.506Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:26:57.506Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:27:57.507Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:28:57.507Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:29:57.508Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:30:57.508Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:31:57.509Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:32:57.509Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:33:57.510Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:34:57.510Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:35:57.510Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:36:57.511Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:37:57.511Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:38:57.512Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:39:57.513Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:40:57.513Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:41:57.513Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:42:57.513Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:43:57.513Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:44:57.514Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:45:57.514Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:46:57.516Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:47:57.517Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:48:57.518Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:49:57.519Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:50:57.520Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:51:57.520Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:52:57.520Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:53:57.520Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:54:57.520Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:55:57.520Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:56:57.519Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:57:57.520Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:58:57.521Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T13:59:57.521Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:00:57.521Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:01:57.522Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:02:57.522Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:03:57.522Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:04:57.522Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:05:57.522Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:06:57.522Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:07:57.522Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:08:57.522Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:09:57.523Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:10:57.523Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:11:57.524Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:12:57.524Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:13:57.525Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:14:57.526Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:15:57.526Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:16:57.527Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:17:57.527Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:18:57.528Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:19:57.528Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:20:57.529Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:21:57.529Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:22:57.529Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:23:57.529Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:24:57.529Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:25:57.529Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:26:57.529Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:27:57.529Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:28:57.530Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:29:57.529Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:30:57.530Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:31:57.530Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:32:57.530Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:33:57.532Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:34:57.532Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:35:57.532Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:36:57.531Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:37:57.532Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:38:57.532Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:39:57.532Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:40:57.532Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:41:57.533Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:42:57.533Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:43:57.533Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:44:57.534Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:45:57.534Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:46:57.533Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:47:57.534Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:48:57.534Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:49:57.534Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:50:57.533Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:51:57.535Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:52:57.535Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:53:57.535Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:54:57.535Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:55:57.535Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:56:57.535Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:57:57.535Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:58:57.534Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T14:59:57.535Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:00:57.535Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:01:57.534Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:02:57.535Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:03:57.536Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:04:57.537Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:05:57.537Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:06:57.538Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:07:57.538Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:08:57.539Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:09:57.539Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:10:57.539Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:11:57.539Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:12:57.539Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:13:57.540Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:14:57.540Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:15:57.540Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:16:57.541Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:17:57.542Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:18:57.542Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:19:57.542Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:20:57.542Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:21:57.542Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:22:57.542Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:23:57.542Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:24:57.542Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:25:57.542Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:26:57.542Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:27:57.543Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:28:57.544Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:29:57.544Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:30:57.544Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:31:57.544Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:32:57.544Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:33:57.544Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:34:57.544Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:35:57.543Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:36:57.545Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:37:57.546Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:38:57.546Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:39:57.547Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:40:57.547Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:41:57.548Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:42:57.548Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:43:57.548Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:44:57.549Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:45:57.549Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:46:57.549Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:47:57.550Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:48:57.550Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:49:57.551Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:50:57.551Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:51:57.551Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:52:57.552Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:53:57.552Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:54:57.552Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:55:57.551Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:56:57.552Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:57:57.552Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:58:57.552Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T15:59:57.552Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:00:57.553Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:01:57.554Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:02:57.554Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:03:57.555Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:04:57.554Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:05:57.555Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:06:57.555Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:07:57.555Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:08:57.555Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:09:57.555Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:10:57.556Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:11:57.556Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:12:57.556Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:13:57.556Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:14:57.556Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:15:57.556Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:16:57.556Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:17:57.556Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:18:57.556Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:19:57.556Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:20:57.556Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}
{"exportedAt":"2026-06-10T16:21:57.556Z","snapshots":[{"type":"counter","name":"crew.run.count","description":"Total runs by status","values":[]},{"type":"counter","name":"crew.task.count","description":"Total tasks by status","values":[]},{"type":"counter","name":"crew.subagent.count","description":"Total subagent records by status","values":[]},{"type":"counter","name":"crew.mailbox.count","description":"Total mailbox messages by direction","values":[]},{"type":"counter","name":"crew.task.retry_attempt_total","description":"Retry attempts by run and task","values":[]},{"type":"counter","name":"crew.task.deadletter_total","description":"Deadletter triggers by reason","values":[]},{"type":"counter","name":"crew.task.overflow_phase_total","description":"Overflow recovery phase transitions","values":[]},{"type":"counter","name":"crew.task.supervisor_contact_total","description":"Supervisor contact requests by reason","values":[]},{"type":"gauge","name":"crew.heartbeat.staleness_ms","description":"Heartbeat elapsed since last seen, milliseconds","values":[]},{"type":"histogram","name":"crew.run.duration_ms","description":"Run end-to-end duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.duration_ms","description":"Task duration, milliseconds","values":[]},{"type":"histogram","name":"crew.task.retry_count","description":"Retries per task","values":[]},{"type":"histogram","name":"crew.task.tokens_total","description":"Token usage per task","values":[]}]}

82
.dockerignore Normal file
View File

@ -0,0 +1,82 @@
# Version control
.git
.gitignore
# Node.js dependencies
node_modules
**/node_modules
npm-debug.log
# Build outputs
dist
build
**/dist
!shared/dist
!shared/dist/**
!ee/packages/workflows/dist/**
!packages/workflow-streams/dist/**
!packages/event-schemas/dist/**
# Environment variables
.env
.env.local
.env.*.local
.env.development
.env.production
# Docker files
Dockerfile
docker-compose.yml
.dockerignore
# Logs
logs
*.log
# Operating system files
.DS_Store
Thumbs.db
# Editor directories and files
.idea
.vscode
*.swp
*.swo
# Testing
coverage
**/coverage
# Temporary files
tmp
temp
# Volume directories
volumes
# Appliance ISO build artifacts (multi-GB; never part of an app image context)
ee/appliance/ubuntu-iso/output
ee/appliance/ubuntu-iso/work
**/*.iso
**/*.iso.sha256
# Other common directories to ignore
docs
tests
*draft*
#.next
node_modules
temp_wasm_compile
.nx
**/.nx
.next
**/.next
# Allow pre-built Next.js artifacts required by Docker builds
!server/.next
!server/.next/**
server/.next/cache
server/.next/cache/**
server/.next/dev/cache
server/.next/dev/cache/**

44
.env.e2e Normal file
View File

@ -0,0 +1,44 @@
# E2E Testing Environment Configuration
# App Configuration
APP_NAME=sebastian
APP_ENV=test
NODE_ENV=test
VERSION=e2e-test
HOST=localhost
EDITION=community
# Database Configuration
DB_TYPE=postgres
EXPOSE_DB_PORT=5433
# Redis Configuration
EXPOSE_REDIS_PORT=6380
# Server Configuration
EXPOSE_SERVER_PORT=3001
# Email Configuration
EMAIL_ENABLE=true
VERIFY_EMAIL_ENABLED=false
# Logging Configuration
LOG_LEVEL=debug
LOG_IS_FORMAT_JSON=false
LOG_IS_FULL_DETAILS=true
# Auth Configuration
NEXTAUTH_URL=http://localhost:3001
NEXTAUTH_SESSION_EXPIRES=86400
# Crypto Configuration (use test values)
SALT_BYTES=32
ITERATION=10000
KEY_LENGTH=64
ALGORITHM=aes-256-gcm
# Token Configuration
TOKEN_EXPIRES=3600
# Project Configuration
PROJECT_NAME=alga-psa-e2e

372
.env.example Normal file
View File

@ -0,0 +1,372 @@
# Application Settings
APP_VERSION=1.0.0
APP_NAME=sebastian
APP_HOST=0.0.0.0
APP_PORT=3000
APP_EDITION=community # Options: community, enterprise
NEXT_PUBLIC_EDITION=community # Options: community, enterprise
APP_VERIFY_EMAIL=false
APP_ENV=production
NODE_ENV=production
# Container Image Selection (auto-populated by scripts/set-image-tag.sh)
ALGA_IMAGE_TAG=latest
NEXTAUTH_SECRET=dummy
# Login Captcha (optional, Cloudflare Turnstile)
# When both keys are set, sign-in requires a captcha after repeated failed
# attempts. Can also be provided via the secret provider as captcha_site_key /
# captcha_secret_key. Leave unset to rely on rate limiting alone.
# CAPTCHA_SITE_KEY=
# CAPTCHA_SECRET_KEY=
# Redis Configuration
REDIS_HOST=redis
REDIS_PORT=6379
# REDIS_PASSWORD is managed via Docker secrets
# Database Configuration
DB_TYPE=postgres # Required: Must be "postgres"
DB_HOST=postgres
DB_PORT=5432
DB_NAME=server
DB_NAME_SERVER=server
DB_NAME_HOCUSPOCUS=hocuspocus
# Database Users:
# 1. Admin User (postgres):
# - Username: postgres (fixed)
# - Used for: Database administration, setup, migrations
# - Password: Managed via postgres_password secret
# - Has full database access
POSTGRES_USER=postgres
DB_USER_ADMIN=postgres # Required: Admin user for database operations
DB_PASSWORD_ADMIN=/run/secrets/postgres_password # Required: Path to admin password secret
DB_PASSWORD_SUPERUSER=/run/secrets/postgres_password # Required: Path to superuser password secret
DB_PASSWORD_SERVER=/run/secrets/db_password_server # Required: Path to server password secret
# 2. Application User (app_user):
# - Username: app_user (fixed)
# - Used for: Application database access
# - Password: Managed via db_password_server secret
# - Access controlled by Row Level Security (RLS)
DB_USER_SERVER=app_user
# 3. Hocuspocus User:
# - Username: hocuspocus_user
# - Used for: Hocuspocus service database access
# - Password: Managed via db_password_hocuspocus secret
# - Scoped to the dedicated Hocuspocus database
DB_USER_HOCUSPOCUS=hocuspocus_user
# Logging Configuration
LOG_LEVEL=INFO # Required: One of 'SYSTEM' | 'TRACE' | 'DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL'
LOG_IS_FORMAT_JSON=false # Required: Boolean
LOG_IS_FULL_DETAILS=false # Required: Boolean
LOG_ENABLED_FILE_LOGGING=false
LOG_DIR_PATH=/path/to/logs
LOG_ENABLED_EXTERNAL_LOGGING=false
LOG_EXTERNAL_HTTP_HOST=
LOG_EXTERNAL_HTTP_PORT=
# Secret Provider Configuration
# For local dev environments, use env and filesystem only (no vault)
SECRET_READ_CHAIN=env,filesystem
SECRET_WRITE_PROVIDER=filesystem
# Runner Configuration
RUNNER_BACKEND=knative
RUNNER_BASE_URL=http://runner:8080
# Optional override when using the Docker backend locally
RUNNER_DOCKER_HOST=http://localhost:8085
# Accepts absolute URLs or relative paths (e.g., /runner when proxying via Next.js)
RUNNER_PUBLIC_BASE=https://runner.example.com
RUNNER_SERVICE_TOKEN=
LOG_EXTERNAL_HTTP_PATH=
LOG_EXTERNAL_HTTP_LEVEL=
LOG_EXTERNAL_HTTP_TOKEN=
# Hocuspocus Configuration
HOCUSPOCUS_PORT=1234
# Browser-facing WebSocket URL for in-app notifications and collaborative editing.
# Must be NEXT_PUBLIC_* — this is read by client code and baked in at build time.
# In production (non-localhost) the client auto-derives wss://<host>/hocuspocus,
# and your reverse proxy must route /hocuspocus to the hocuspocus container on
# port 1234 with WebSocket upgrade headers (see docs/getting-started/setup_guide.md).
NEXT_PUBLIC_HOCUSPOCUS_URL=ws://localhost:1234
# nm-store Integration (for license management)
NM_STORE_URL=http://localhost:3000 # URL of nm-store service
TEMPORAL_WEBHOOK_SECRET=your-shared-secret-here # Shared secret for Temporal callbacks
ALGA_WEBHOOK_SECRET=your-shared-secret-here # Shared secret for webhook authentication
REQUIRE_HOCUSPOCUS=false # Optional: Set to "true" to require hocuspocus
# Job Runner Configuration
# The job runner type: 'pgboss' (default for CE) or 'temporal' (EE only)
JOB_RUNNER_TYPE=pgboss
# Whether to fall back to PG Boss if Temporal is unavailable (EE only, default: true)
JOB_RUNNER_FALLBACK_TO_PGBOSS=true
# App-wide Search
# Default false for rollout safety. Set true after the search backfill has completed
# so event-bus subscribers begin writing incremental updates to app_search_index.
SEARCH_INDEX_LIVE=false
# Temporal Configuration (EE only - for job runner)
# Address of the Temporal server
TEMPORAL_ADDRESS=temporal-frontend.temporal.svc.cluster.local:7233
# Temporal namespace
TEMPORAL_NAMESPACE=default
# Task queue for generic jobs (separate from workflow-specific queues)
TEMPORAL_JOB_TASK_QUEUE=alga-jobs
# Stripe Integration (for license purchasing)
# Get keys from: Stripe Dashboard → Developers → API keys
STRIPE_SECRET_KEY=sk_test_your_secret_key_here
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
# Master billing tenant (Nine Minds organization)
MASTER_BILLING_TENANT_ID=your-nine-minds-tenant-uuid-here
# Product/Price IDs (create in Stripe Dashboard → Products)
# Pro uses a single per-seat recurring price. Quantity equals licensed users.
STRIPE_PRO_PRICE_ID=price_pro_per_seat_here
# STRIPE_SOLO_BASE_PRICE_ID=price_solo_base_here
# Premium uses multi-item subscriptions (base fee + per-user).
# STRIPE_PREMIUM_BASE_PRICE_ID=price_premium_base_here
# STRIPE_PREMIUM_USER_PRICE_ID=price_premium_per_user_here
# Annual prices
# STRIPE_PRO_ANNUAL_PRICE_ID=price_pro_per_seat_annual_here
# STRIPE_SOLO_BASE_ANNUAL_PRICE_ID=price_solo_base_annual_here
# STRIPE_PREMIUM_BASE_ANNUAL_PRICE_ID=price_premium_base_annual_here
# STRIPE_PREMIUM_USER_ANNUAL_PRICE_ID=price_premium_per_user_annual_here
# Add-on prices
# STRIPE_AI_ADDON_PRICE_ID=price_ai_addon_here
# STRIPE_AI_ADDON_ANNUAL_PRICE_ID=price_ai_addon_annual_here
# STRIPE_TEAMS_ADDON_PRICE_ID=price_teams_addon_here
# STRIPE_TEAMS_ADDON_ANNUAL_PRICE_ID=price_teams_addon_annual_here
# STRIPE_ENTERPRISE_ADDON_PRICE_ID=price_enterprise_addon_here
# STRIPE_ENTERPRISE_ADDON_ANNUAL_PRICE_ID=price_enterprise_addon_annual_here
# Early adopters prices (grandfathered customers migrated from preview)
# STRIPE_EARLY_ADOPTERS_BASE_PRICE_ID=price_early_adopters_base_here
# STRIPE_EARLY_ADOPTERS_USER_PRICE_ID=price_early_adopters_per_user_here
# STRIPE_EARLY_ADOPTERS_BASE_ANNUAL_PRICE_ID=price_early_adopters_base_annual_here
# STRIPE_EARLY_ADOPTERS_USER_ANNUAL_PRICE_ID=price_early_adopters_per_user_annual_here
# AlgaDesk prices (per-user only, no base fee — tenants with product_code='algadesk')
# STRIPE_ALGADESK_USER_PRICE_ID=price_algadesk_per_user_here
# STRIPE_ALGADESK_USER_ANNUAL_PRICE_ID=price_algadesk_per_user_annual_here
# Email Configuration
EMAIL_ENABLE=false # Required: Boolean
EMAIL_FROM=noreply@example.com # Required: Valid email address
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587 # Required: Number greater than 0
EMAIL_USERNAME=noreply@example.com # Required: Valid email address
# EMAIL_PASSWORD is managed via Docker secrets
# Cryptographic Settings
# CRYPTO_KEY is managed via Docker secrets
CRYPTO_SALT_BYTES=16
CRYPTO_ITERATION=100000
CRYPTO_KEY_LENGTH=64
CRYPTO_ALGORITHM=aes-256-gcm
# Authentication Settings
NEXTAUTH_URL=http://localhost:3000 # Required: Valid URL
NEXTAUTH_SESSION_EXPIRES=86400 # Required: Number greater than 0
# OAuth fallback for MSP SSO (CE + EE):
# - Used by NextAuth when tenant-specific provider credentials are not selected/available.
# - Also used when domain-based MSP discovery is unresolved (unknown or ambiguous login domain mapping).
# - CE MSP login can use these values as app-level fallback for Google/Microsoft SSO.
# GOOGLE_OAUTH_CLIENT_ID=
# GOOGLE_OAUTH_CLIENT_SECRET=
# MICROSOFT_OAUTH_CLIENT_ID=
# MICROSOFT_OAUTH_CLIENT_SECRET=
# MICROSOFT_OAUTH_TENANT_ID=common
# MICROSOFT_OAUTH_AUTHORITY=https://login.microsoftonline.com
# Mobile app sign-in (EE only — Cloud and the licensed appliance):
# - Not available on the open-source CE edition: CE builds reject the mobile token
# exchange and report enabled=false from /api/v1/mobile/auth/capabilities.
# - The mobile app signs in against this server via /api/v1/mobile/auth/* and the
# /auth/mobile/handoff web flow; NEXTAUTH_URL must be the server's public URL.
# - Google and/or Microsoft OAuth credentials (above) must be configured for mobile
# sign-in; the capabilities endpoint only advertises configured providers.
# - Comma-separated hostnames allowed for mobile sign-in. Leave empty to allow any
# host (the app connects to whichever server the user configured).
# ALGA_MOBILE_HOST_ALLOWLIST=
# Enterprise AI Chat provider configuration (EE only)
# Defaults to openrouter when unset or invalid.
AI_CHAT_PROVIDER=openrouter # openrouter | vertex
# OpenRouter provider (default)
OPENROUTER_API_KEY=your-openrouter-api-key
OPENROUTER_CHAT_MODEL=minimax/minimax-m2
# Vertex provider (OpenAI-compatible endpoint)
VERTEX_PROJECT_ID=your-gcp-project-id
VERTEX_LOCATION=us-central1
VERTEX_CHAT_MODEL=glm-5-maas
# Optional override for the OpenAI-compatible Vertex endpoint URL.
# VERTEX_OPENAPI_BASE_URL=https://us-central1-aiplatform.googleapis.com/v1/projects/your-gcp-project-id/locations/us-central1/endpoints/openapi
# Optional ADC credentials file path (for on-prem/non-GKE deployments).
# GOOGLE_APPLICATION_CREDENTIALS=/path/to/google-application-credentials.json
# Workflow Configuration
WORKFLOW_DISTRIBUTED_MODE=true # Enable distributed mode with Redis Streams
WORKFLOW_REDIS_STREAM_PREFIX=workflow:events: # Redis stream prefix
WORKFLOW_REDIS_CONSUMER_GROUP=workflow-workers # Consumer group name
WORKFLOW_REDIS_BATCH_SIZE=10 # Number of events to process in a batch
WORKFLOW_REDIS_IDLE_TIMEOUT_MS=60000 # Idle timeout in milliseconds
WORKFLOW_WORKER_REPLICAS=2 # Number of worker containers to run
# Deployment Settings
PROJECT_NAME=sebastian
EXPOSE_DB_PORT=5432
EXPOSE_HOCUSPOCUS_PORT=1234
EXPOSE_REDIS_PORT=6379
EXPOSE_SERVER_PORT=3000
IMAP_WEBHOOK_SECRET=replace-with-a-secure-random-string
EXPOSE_IMAP_TEST_SMTP_PORT=3025
EXPOSE_IMAP_TEST_IMAP_PORT=3143
EXPOSE_IMAP_TEST_IMAPS_PORT=3993
EXPOSE_IMAP_TEST_HTTP_PORT=8080
# Docker Secrets:
# The following sensitive values are managed via Docker secrets:
# 1. Database Passwords:
# - postgres_password: Admin user password
# - db_password_server: Application user password
# - db_password_hocuspocus: Hocuspocus service password
# 2. Redis Password:
# - redis_password
# 3. Email Password:
# - email_password
# 4. Security Keys:
# - crypto_key
# - token_secret_key
# - nextauth_secret
# - alga_auth_key
# - secret_key
# 5. OAuth Credentials:
# - google_oauth_client_id
# - google_oauth_client_secret
# - microsoft_oauth_client_id
# - microsoft_oauth_client_secret
# Google OAuth - Email Integration (existing)
GOOGLE_CLIENT_ID=your-email-app-client-id
GOOGLE_CLIENT_SECRET=your-email-app-client-secret
# Google OAuth - Calendar Integration (NEW - separate app)
GOOGLE_CALENDAR_CLIENT_ID=your-calendar-app-client-id
GOOGLE_CALENDAR_CLIENT_SECRET=your-calendar-app-client-secret
GOOGLE_CALENDAR_PROJECT_ID=your-calendar-project-id
GOOGLE_CALENDAR_REDIRECT_URI=https://yourdomain.com/api/auth/google/calendar/callback
# Microsoft OAuth - Email & Calendar Integration (shared app)
# NOTE: Do not rely on values in this file for production. The server calls `dotenv.config()`
# and the Docker image copies this file into `/app/server/.env`, which can accidentally
# configure placeholder values at runtime. Prefer K8s env vars and/or Vault app secrets.
# MICROSOFT_CLIENT_ID=
# MICROSOFT_CLIENT_SECRET=
# MICROSOFT_TENANT_ID=common
# MICROSOFT_REDIRECT_URI=
# Calendar Webhook Configuration
# Removed: Let the code use NEXTAUTH_URL as the fallback webhook base
# CALENDAR_WEBHOOK_BASE_URL=https://your-ngrok-domain.ngrok-free.app
# Enterprise Edition Gmail Configuration
# These are only used when NEXT_PUBLIC_EDITION=enterprise
# Hosted Gmail provider settings for simplified configuration
EE_GMAIL_CLIENT_ID=your-ee-gmail-client-id
EE_GMAIL_PROJECT_ID=your-ee-gmail-project-id
EE_GMAIL_REDIRECT_URI=https://api.algapsa.com/api/auth/google/callback
# EE_GMAIL_CLIENT_SECRET is managed via filesystem secrets
# Enterprise Edition Microsoft Configuration
# These are only used when NEXT_PUBLIC_EDITION=enterprise
# Hosted Microsoft provider settings for simplified configuration
# EE_MICROSOFT_CLIENT_ID=
# EE_MICROSOFT_TENANT_ID=common
# EE_MICROSOFT_REDIRECT_URI=
# EE_MICROSOFT_CLIENT_SECRET is managed via filesystem secrets/Vault (do not inline here)
# Deployment Identifier for Observability
# Used to identify this deployment in observability tools (Grafana, etc.)
# For hosted: Use environment name (e.g., "production", "staging")
# For on-premise: Use customer/instance identifier (e.g., "customer-abc", "demo-instance")
# DEPLOYMENT_ID=
# Usage Statistics
# Set to false to opt out of anonymous usage statistics
# This data helps us improve the product
ALGA_USAGE_STATS=true
# Analytics User ID Anonymization
# When true (default), user IDs are anonymized for privacy
# When false, actual user IDs are used (prefixed with "user_")
ANALYTICS_ANONYMIZE_USER_IDS=true
# Frontend configuration
# NEXT_PUBLIC_ALGA_USAGE_STATS=true # Set to false to disable
NEXT_PUBLIC_ANALYTICS_ANONYMIZE_USER_IDS=true # Must match ANALYTICS_ANONYMIZE_USER_IDS for frontend
# Instance identification
# If not set, a hash of the hostname will be used
# INSTANCE_ID=
# ============================================================================
# OBSERVABILITY CONFIGURATION (GRAFANA STACK)
# ============================================================================
#
# IMPORTANT: This is for OPERATIONAL OBSERVABILITY only (performance, errors, traces)
# This is completely separate from PostHog usage analytics.
#
# Two separate systems:
# 1. OpenTelemetry → Grafana Alloy → Prometheus/Loki/Tempo (this section)
# - Application performance metrics
# - Error tracking and traces
# - Database query performance
# - HTTP request metrics
# - System resource utilization
#
# 2. PostHog (configured separately above)
# - Product usage analytics
# - User behavior tracking
# - Feature usage statistics
# - Business intelligence data
#
# Deployment behavior:
# - Hosted: Always enabled for operational monitoring
# - On-premise: Opt-in via ALGA_OBSERVABILITY=true environment variable
# ============================================================================
# Enable/Disable Observability
# Set to true to enable local observability (metrics, logs, traces)
# For hosted deployments, this is automatically enabled
# For on-premise deployments, this must be explicitly enabled
ALGA_OBSERVABILITY=true
# OpenTelemetry OTLP Endpoint (Grafana Alloy)
# This is where metrics, logs, and traces are sent
# Grafana Alloy then routes them to Prometheus, Loki, and Tempo
# OTLP_ENDPOINT=
# Grafana Stack Endpoints (optional, for dashboard links and direct access)
# GRAFANA_ENDPOINT=
# PROMETHEUS_ENDPOINT=
# LOKI_ENDPOINT=
# TEMPO_ENDPOINT=
# Logging Configuration for Observability
# LOG_LEVEL is already defined above but affects observability logging
# Additional observability-specific logging settings:
# LOG_INCLUDE_TRACE_CONTEXT=true # Include OpenTelemetry trace context in logs
# PostHog Feature Flag Management (EE only)
POSTHOG_PERSONAL_API_KEY=
POSTHOG_PROJECT_ID=
POSTHOG_API_HOST=https://us.posthog.com

15
.env.localtest Normal file
View File

@ -0,0 +1,15 @@
# Generated by alga-local-wirein
# Requested target path: /home/robert/alga-psa
# Target environment: alga-psa-local-test
# Target path: /home/robert/alga-psa
#
# This file connects local DB-backed tests directly to the target PostgreSQL host port.
DB_HOST=localhost
DB_NAME=server
DB_NAME_SERVER=server
DB_PASSWORD_ADMIN=/run/secrets/postgres_password
DB_PASSWORD_SERVER=/run/secrets/db_password_server
DB_PORT=5472
DB_USER_ADMIN=postgres
DB_USER_SERVER=app_user

21
.env.runner Normal file
View File

@ -0,0 +1,21 @@
RUNNER_REGISTRY_BASE_URL=http://host.docker.internal:3000
REGISTRY_BASE_URL=http://host.docker.internal:3000
STORAGE_API_BASE_URL=http://host.docker.internal:3000
RUNNER_BUNDLE_STORE_BASE=http://host.docker.internal:9000/extensions
BUNDLE_STORE_BASE=http://host.docker.internal:9000/extensions
RUNNER_ALGA_AUTH_KEY=local-runner-key
ALGA_AUTH_KEY=local-runner-key
RUNNER_STORAGE_API_TOKEN=local-runner-key
RUNNER_SERVICE_TOKEN=local-runner-key
RUNNER_DOCKER_PORT=8085
PORT=8080
S3_ACCESS_KEY=minioadmin
S3_SECRET_KEY=minioadmin
S3_REGION=us-east-1
S3_ENDPOINT=http://host.docker.internal:9000
S3_BUCKET=extensions
UI_PROXY_BASE_URL=http://host.docker.internal:3000
UI_PROXY_AUTH_KEY=local-runner-key
RUNNER_PUBLIC_BASE=/runner
EXT_STATIC_STRICT_VALIDATION=false
RUST_LOG=info,ext=info

13
.env.runner.example Normal file
View File

@ -0,0 +1,13 @@
# Example overrides for docker-compose.runner-dev.yml
# Copy to .env.runner and adjust as needed.
RUNNER_REGISTRY_BASE_URL=http://host.docker.internal:3000
RUNNER_BUNDLE_STORE_BASE=http://host.docker.internal:9000/extensions
RUNNER_ALGA_AUTH_KEY=local-runner-key
RUNNER_DOCKER_PORT=8085
# Storage API configuration (recommended)
REGISTRY_BASE_URL=http://host.docker.internal:3000
STORAGE_API_BASE_URL=http://host.docker.internal:3000
RUNNER_SERVICE_TOKEN=local-runner-key
RUNNER_STORAGE_API_TOKEN=local-runner-key

241
.github/README.md vendored Normal file
View File

@ -0,0 +1,241 @@
# Alga PSA: Open-Source MSP Professional Services Automation
Alga PSA is a professional services automation platform built for Managed Service Providers. It brings client records, service tickets, time tracking, contracts, billing, invoicing, documents, assets, reporting, and automation into one MSP-focused system.
It is designed for teams that want more control over their PSA stack: self-hostable Community Edition code, a modern TypeScript/PostgreSQL architecture, and an Enterprise Edition path for commercially licensed modules and larger deployments.
<a href="https://www.nineminds.com/AlgaPSA-features">
<img src="https://www.nineminds.com/imported-media/Overview%20Dashboard.png" alt="Alga PSA overview dashboard" width="900">
</a>
[See the Alga PSA feature tour](https://www.nineminds.com/AlgaPSA-features)
## Why MSPs look at Alga PSA
MSP operations break down when tickets, contracts, time, and invoices live in separate tools. Teams lose billable time, service managers chase updates, and owners have a harder time seeing whether client work is profitable.
Alga PSA is built around the way MSPs operate with clients:
- **Tickets tied to clients, contacts, assets, and service history** so the team has context before work starts.
- **Time and approvals connected to billing** so billable work can move toward invoices with less duplicate entry.
- **Contracts, sales quotes, recurring services, tax, and invoice workflows** for the financial side of service delivery.
- **Client portal and document workflows** so clients have a clearer place to submit requests, view information, and follow progress.
- **Workflow automation** for turning repeatable ticket, billing, notification, and approval steps into managed processes with Event Catalog triggers and scheduled runs.
- **Open-source core with self-hosting support** so MSPs and technical teams can keep control over deployment, data, and code review.
Community Edition is the self-hostable AGPL core. Enterprise Edition covers commercially licensed modules and larger deployment needs. See [Editions and licensing](#editions-and-licensing) for details.
## Features at a glance
### Service desk and client operations
- Support ticketing for client requests, incidents, and follow-up work
- Client, contact, and company management
- Multilingual client portal support for separate MSP and client-facing access
- Email notifications for tickets, invoices, and project updates
- Document management with version control
- Asset management for client equipment, maintenance schedules, and relationships
- Project and task management for longer-running client work
- Scheduling and dispatch views for planned work and technician coordination
### Time, contracts, billing, and invoicing
- Time tracking with approval workflows and utilization reporting
- Automatic interval tracking for ticket work, stored in the browser with IndexedDB
- Conversion of tracked intervals into time entries
- Flexible billing cycles by company, including weekly, bi-weekly, monthly, and quarterly billing
- Billing-period support for proration and unapproved time rollover
- Contract purchase order support with PO numbers and advisory PO limits
- Sales quotes for pricing proposals, optional line items, approvals, client portal acceptance, and conversion to contracts or invoices
- Graphical invoice and quote designer for branded PDF layouts, data-bound fields, line-item tables, preview, and per-document template overrides
- International tax support with composite rates, thresholds, tax holidays, and reverse charge scenarios
### Automation, reporting, and controls
- Workflow Automation with an Event Catalog for ticket, billing, scheduling, email, project, CRM, asset, document, and integration triggers
- Visual workflow designer for event-driven, one-time scheduled, recurring scheduled, and manual runs, with versioning and run history
- Redis-backed event processing for asynchronous work and system events
- Reporting and analytics for operational visibility
- Role-based access control (RBAC) and attribute-based access control (ABAC)
- Multi-portal authentication for MSP users and client portal users
- API, OpenAPI registry material, and extension SDK support for integrations and custom workflows
Feature availability varies by edition, deployment configuration, and enabled feature flags. See the setup and architecture docs for implementation details.
## Product screenshots
These images link directly to screenshots from the [Alga PSA feature tour](https://www.nineminds.com/AlgaPSA-features), [Workflow Automation docs](https://www.nineminds.com/documentation/152-choosing-workflow-triggers), and [Invoice Designer docs](https://www.nineminds.com/documentation/1410-bind-invoice-data-to-your-layout).
| Core workflow | Business operations |
| --- | --- |
| <img src="https://www.nineminds.com/imported-media/Ticketing-1.gif" alt="Alga PSA ticketing screen" width="420"> | <img src="https://www.nineminds.com/imported-media/Billing%20dashboard.png" alt="Alga PSA billing dashboard" width="420"> |
| Ticketing views for client requests, assignment, attachments, and follow-up. | Contracts, billing, and invoice-related workflows in one billing area. |
| <img src="https://www.nineminds.com/imported-media/Screenshot%202026-04-30%20at%2011.33.51%E2%80%AFAM.png" alt="Alga PSA multilingual client portal" width="420"> | <img src="https://www.nineminds.com/imported-media/Screenshot%202026-05-01%20at%201.35.35%20PM.png" alt="Alga PSA time approval screen" width="420"> |
| Multilingual client portal views for client-facing requests and updates. | Time entry views for recording and reviewing work before billing. |
| <img src="https://www.nineminds.com/imported-media/Schedule%20view.png" alt="Alga PSA schedule view" width="420"> | <img src="https://www.nineminds.com/docs-images/invoice-designer-workspace.png" alt="Alga PSA invoice and quote designer workspace" width="420"> |
| Schedule views for dispatch and calendar-based work planning. | Drag-and-drop invoice and quote layout designer for branded PDFs. |
| <img src="https://www.nineminds.com/docs-images/workflow-designer-ticket-triage.png" alt="Alga PSA visual workflow designer" width="420"> | <img src="https://www.nineminds.com/imported-media/Assets%20Asset%20workspace%20overview.png" alt="Alga PSA asset workspace overview" width="420"> |
| Visual workflow designer for ticket triage, notifications, approvals, and other repeatable processes. | Asset views for client equipment and service context. |
## Quick start
For a full installation, use the [Complete Setup Guide](../docs/getting-started/setup_guide.md). It covers release selection, secrets, environment configuration, Docker Compose, initial login credentials, persistence, backups, and production notes.
The current CE prebuilt Docker Compose path is below. Before running these commands, follow the setup guide to create the required `secrets/` directory and `server/.env` file.
The stack boots PostgreSQL, the Next.js application server, and the workflow worker. On first start, the server creates a seeded workspace admin account; tail the logs below to retrieve the credentials.
```bash
git clone https://github.com/nine-minds/alga-psa.git
cd alga-psa
./scripts/set-image-tag.sh
docker compose -f docker-compose.prebuilt.base.yaml -f docker-compose.prebuilt.ce.yaml \
--env-file server/.env --env-file .env.image up -d
```
The prebuilt stack creates named volumes for PostgreSQL data and uploaded files so data survives container restarts and upgrades. See the setup guide for backup and restore procedures.
After the first successful boot, the server logs print a seeded workspace admin account. Tail the logs and update the password before using the system in production.
```bash
docker compose -f docker-compose.prebuilt.base.yaml -f docker-compose.prebuilt.ce.yaml \
--env-file server/.env --env-file .env.image logs -f
```
### Requirements
- Docker Engine 24.0.0 or later
- Docker Compose v2.20.0 or later
- Git
- Node.js `>=20 <25` for source development
For Windows-specific setup, see the [Windows Setup Guide](../docs/getting-started/setup_guide_windows.md).
## Technical architecture
The following details are for teams evaluating the technical stack. For deployment requirements, see [Quick start](#quick-start).
Alga PSA is a TypeScript monorepo with a Next.js application, shared domain packages, worker services, and Docker-based deployment paths.
| Area | Implementation |
| --- | --- |
| Frontend | Next.js application with React, Tailwind, Radix-based components, and shared UI packages |
| Backend | Next.js API routes running on Node.js, shared domain packages, and a dedicated workflow worker service |
| Database | PostgreSQL with row-level security for tenant isolation |
| Event processing | Redis-backed event bus with Zod schema validation for asynchronous system events |
| Workflow execution | Temporal-backed workflow runtime and worker services for event-triggered, scheduled, and manual workflow runs |
| Real-time collaboration | Hocuspocus/Yjs for collaborative document editing |
| Authentication | NextAuth.js with separate MSP and client portal access surfaces |
| Packages | npm workspaces and Nx-managed `@alga-psa/*` packages for billing, clients, tickets, documents, scheduling, reporting, integrations, and shared infrastructure |
| Deployment | Docker Compose for CE/EE stacks, named volumes for PostgreSQL and files, Docker secrets, PgBouncer, and Helm assets for Kubernetes-oriented deployments |
| Extensions and API | Extension SDK, client SDK docs, API docs, and OpenAPI registry material for integrations and custom workflows |
Useful technical docs:
- [Architecture Overview](../docs/architecture/overview.md)
- [Package Build System](../docs/architecture/package-build-system.md)
- [Docker Compose Structure](../docs/getting-started/docker_compose.md)
- [Secrets Management](../docs/security/secrets_management.md)
- [API Overview](../docs/api/api_overview.md)
- [OpenAPI Registry Integration](../docs/openapi/registry-integration.md)
- [Client SDK](../docs/client-sdk/README.md)
- [Inbound Email](../docs/inbound-email/README.md)
- [Testing Standards](../docs/reference/testing-standards.md)
## Documentation
### Setup and configuration
- [Complete Setup Guide](../docs/getting-started/setup_guide.md)
- [Windows Setup Guide](../docs/getting-started/setup_guide_windows.md)
- [Configuration Guide](../docs/getting-started/configuration_guide.md)
- [Development Guide](../docs/getting-started/development_guide.md)
- [Entrypoint Scripts](../docs/getting-started/entrypoint_scripts.md)
### MSP feature areas
- [Billing System](../docs/billing/billing.md)
- [Invoice Templates](../docs/billing/invoice_templates.md)
- [Quoting System](../docs/billing/quoting-system.md)
- [International Tax Support](../docs/billing/tax/international_tax_support.md)
- [Asset Management](../docs/features/asset_management.md)
- [SLA Management](../docs/features/sla.md)
- [Time Entry Guide](../docs/features/time_entry.md)
- [Workflow Automation for MSPs](https://www.nineminds.com/documentation/151-workflow-automation-for-msps)
- [Choosing Workflow Triggers](https://www.nineminds.com/documentation/152-choosing-workflow-triggers)
- [Building Your First MSP Workflow](https://www.nineminds.com/documentation/153-building-your-first-msp-workflow)
- [Publishing and Monitoring Workflows](https://www.nineminds.com/documentation/156-publishing-monitoring-workflows)
### Development and contribution
- [Contributing Guide](../docs/contributing.md)
- [Configuration Standards](../docs/getting-started/configuration_standards.md)
- [Package Build System](../docs/architecture/package-build-system.md)
- [Testing Standards](../docs/reference/testing-standards.md)
## Project structure
```text
alga-psa/
├── server/ # Next.js application server
│ ├── src/app/ # App routes and API routes
│ ├── src/components/ # React components
│ └── src/lib/ # Core application logic
├── packages/ # Shared @alga-psa/* packages
│ ├── billing/ # Billing, invoicing, tax
│ ├── clients/ # Client management
│ ├── tickets/ # Ticketing domain code
│ ├── db/ # Database connection and tenant context
│ ├── event-schemas/ # Event contracts and validation
│ ├── ui/ # Shared UI component library
│ └── ... # Domain and infrastructure packages
├── ee/ # Enterprise Edition code and licensed modules
├── services/ # Background services, including workflow-worker
├── hocuspocus/ # Real-time collaboration server
├── sdk/ # Extension SDK and samples
├── extensions/ # Extension examples and supporting code
├── helm/ # Kubernetes deployment assets
├── redis/ # Redis configuration
├── pgbouncer/ # PostgreSQL connection pooling configuration
├── setup/ # Bootstrap and installation scripts
├── scripts/ # Build, release, and utility scripts
├── tools/ # Developer and automation tooling
└── docs/ # Product, setup, architecture, and developer docs
```
## Development and testing
Install dependencies and run tests from the repository root. Source development requires Node.js `>=20 <25`.
```bash
npm install
npm run test:local
# Run specific tests
npm run test:local -- path/to/test/file.test.ts
```
For development workflow details, package build behavior, and test conventions, see:
- [Development Guide](../docs/getting-started/development_guide.md)
- [Package Build System](../docs/architecture/package-build-system.md)
- [Testing Standards](../docs/reference/testing-standards.md)
## Editions and licensing
Alga PSA uses multiple licenses:
- Documentation (`docs/`): Creative Commons Attribution 4.0 International License (CC BY 4.0)
- Enterprise Edition (`ee/`): See `ee/LICENSE`
- All other content: GNU Affero General Public License Version 3 (AGPL-3.0)
See [LICENSE.md](../LICENSE.md) for details. If your deployment model requires commercial terms or a license outside the AGPL core, visit [algapsa.com](https://algapsa.com) for Enterprise Edition and hosted deployment information.
## Contributing
Contributions are welcome. Start with the [Contributing Guide](../docs/contributing.md) for development setup, coding expectations, pull request guidance, and module conventions.
---
Copyright (c) 2026 Nine Minds LLC

45
.github/docker-compose.yaml vendored Normal file
View File

@ -0,0 +1,45 @@
services:
postgres:
image: ankane/pgvector:latest
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: abcd1234!
POSTGRES_DB: postgres
ports:
- 5433:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
networks:
- test-network
setup:
build:
context: ..
dockerfile: setup/Dockerfile
command: bash -c "npm install pg-boss && node /app/server/setup/create_database.js && PGPASSWORD=${DB_PASSWORD_ADMIN} psql -h postgres -U postgres -d postgres -c 'CREATE DATABASE server;' && PGPASSWORD=${DB_PASSWORD_ADMIN} psql -h postgres -U postgres -d server -c 'CREATE SCHEMA IF NOT EXISTS pgboss;' && npx knex migrate:latest --knexfile /app/knexfile.cjs && npx knex seed:run --knexfile /app/knexfile.cjs && exit"
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_USER_ADMIN: postgres
DB_PASSWORD_ADMIN: abcd1234!
DB_PASSWORD_SUPERUSER: abcd1234!
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_PASSWORD_SERVER: abcd1234!
APP_ENV: development
DB_TYPE: postgres
NODE_OPTIONS: --experimental-vm-modules
KNEXFILE: /app/knexfile.cjs
depends_on:
postgres:
condition: service_healthy
networks:
- test-network
networks:
test-network:
driver: bridge

10
.github/known-cycles.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"cycles": [
"@alga-psa/analytics -> @alga-psa/tenancy -> @alga-psa/workflows",
"@alga-psa/clients -> @alga-psa/workflows -> @alga-psa/projects",
"@alga-psa/notifications -> @alga-psa/workflows",
"@alga-psa/projects -> @alga-psa/workflows",
"@product/settings-extensions -> sebastian-ee -> server",
"sebastian-ee -> server"
]
}

View File

@ -0,0 +1,17 @@
name: Bidi Control Character Guard
on:
pull_request:
push:
branches:
- main
jobs:
bidi-control-guard:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install ripgrep
run: sudo apt-get update && sudo apt-get install -y ripgrep
- name: Check for bidirectional Unicode controls
run: npm run guard:no-bidi-control-chars

45
.github/workflows/circular-deps.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: Circular Dependency Check
on:
pull_request:
push:
branches:
- main
- master
- develop
jobs:
circular-deps:
name: Check for new circular dependencies
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Upgrade npm to version 11
run: npm install -g npm@11
- name: Install dependencies
run: |
if ! npm ci; then
echo "npm ci failed; attempting npm install fallback to regenerate lock data"
npm install --legacy-peer-deps
fi
- name: Export Nx project graph
run: npx nx graph --file=/tmp/project-graph.json
env:
NX_DAEMON: 'false'
- name: Detect circular dependencies
run: node scripts/check-circular-deps.mjs /tmp/project-graph.json --baseline .github/known-cycles.json
- name: Enforce keyboard shortcuts engine boundaries
run: node scripts/guard-keyboard-shortcuts-boundary.mjs --graph /tmp/project-graph.json

View File

@ -0,0 +1,145 @@
name: Citus Migration Smoke
# Production EE runs on Citus, but every other migration check runs on plain
# Postgres — so the Citus branches in the migrations (create_distributed_table,
# reference tables, colocation guards) are otherwise never exercised before
# they reach a real cluster. This job runs the combined CE+EE migration chain
# (the same merge recipe as validate-tenant-management.yaml and
# setup/entrypoint.sh — the only live migration path) against a fresh
# single-node Citus and asserts distribution actually happened.
#
# pgvector note: the official Citus image does not ship pgvector, so the
# vector-dependent pieces of the EE AI migrations skip themselves via their
# pg_available_extensions guard. The vector path is covered by
# validate-tenant-management.yaml, which runs the same combined chain against
# ankane/pgvector. Between the two jobs, both dimensions are exercised without
# maintaining a custom citus+pgvector image.
on:
workflow_dispatch:
pull_request:
paths:
- 'server/migrations/**'
- 'ee/server/migrations/**'
- '.github/workflows/citus-migration-smoke.yml'
push:
branches:
- main
paths:
- 'server/migrations/**'
- 'ee/server/migrations/**'
jobs:
citus-migrate:
name: Combined migrations on single-node Citus
runs-on: ubuntu-latest
timeout-minutes: 45
services:
citus:
image: citusdata/citus:12.1
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: citus_test
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DB_HOST: localhost
DB_PORT: '5432'
DB_USER_ADMIN: postgres
DB_PASSWORD_ADMIN: citus_test
DB_USER_SERVER: app_user
DB_PASSWORD_SERVER: citus_test
DB_NAME_SERVER: server
PGPASSWORD: citus_test
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Upgrade npm to version 11
run: npm install -g npm@11
# No npm install fallback: a failing npm ci means the lockfile is out of
# sync with a package.json — fail loudly (see unit/integration workflows).
- name: Install dependencies
run: npm ci
- name: Wait for Citus to be ready
run: |
until pg_isready -h localhost -p 5432 -U postgres; do
echo "Waiting for citus..."
sleep 2
done
- name: Create database, role, and citus extension
run: |
psql -h localhost -U postgres -c "CREATE DATABASE server;"
psql -h localhost -U postgres -c "CREATE ROLE app_user WITH LOGIN PASSWORD 'citus_test';"
psql -h localhost -U postgres -d server -c "CREATE EXTENSION citus;"
# Keep shard fan-out small: hundreds of distributed tables x default
# 32 shards would create tens of thousands of shard relations.
psql -h localhost -U postgres -d server -c "ALTER DATABASE server SET citus.shard_count = 4;"
- name: Run combined CE+EE migrations
run: |
# Same merge recipe as setup/entrypoint.sh and validate-tenant-management.yaml.
mkdir -p server/combined-migrations
cp server/migrations/*.cjs server/combined-migrations/ 2>/dev/null || true
cp -r server/migrations/utils server/combined-migrations/ 2>/dev/null || true
cp ee/server/migrations/*.cjs server/combined-migrations/ 2>/dev/null || true
cp -r ee/server/migrations/utils server/combined-migrations/ 2>/dev/null || true
cat > server/knexfile-combined.cjs << 'EOF'
module.exports = {
migration: {
client: 'pg',
connection: {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || '5432',
user: process.env.DB_USER_ADMIN || 'postgres',
password: process.env.DB_PASSWORD_ADMIN,
database: process.env.DB_NAME_SERVER || 'server',
},
pool: { min: 2, max: 20 },
migrations: { directory: './combined-migrations', loadExtensions: ['.cjs'] }
}
};
EOF
cd server && npx knex migrate:latest --knexfile knexfile-combined.cjs --env migration
- name: Assert tables were distributed
run: |
DISTRIBUTED=$(psql -h localhost -U postgres -d server -tAc "SELECT count(*) FILTER (WHERE partmethod = 'h') FROM pg_dist_partition;")
REFERENCE=$(psql -h localhost -U postgres -d server -tAc "SELECT count(*) FILTER (WHERE partmethod = 'n' AND repmodel = 't') FROM pg_dist_partition;")
echo "Distributed tables: $DISTRIBUTED, reference tables: $REFERENCE"
psql -h localhost -U postgres -d server -c "SELECT logicalrelid::regclass AS table_name, CASE WHEN partmethod = 'h' THEN 'distributed' WHEN repmodel = 't' THEN 'reference' ELSE 'citus-local' END AS type FROM pg_dist_partition ORDER BY 2, 1;"
# A green run distributes ~160 tables; 100 is a safe floor that still
# catches wholesale silent breakage.
if [ "$DISTRIBUTED" -lt 100 ]; then
echo "::error::Only $DISTRIBUTED tables distributed (expected ~160) — Citus branches of the migrations did not run."
exit 1
fi
# Cornerstone tables that must be distributed for the schema to work
# at all on Citus.
for t in tenants users clients contacts tickets projects documents comments workflow_tasks; do
STATE=$(psql -h localhost -U postgres -d server -tAc "SELECT partmethod FROM pg_dist_partition WHERE logicalrelid = '$t'::regclass;")
if [ "$STATE" != "h" ]; then
echo "::error::Table $t is not distributed (partmethod='$STATE')."
exit 1
fi
done

View File

@ -0,0 +1,899 @@
name: E2E Fresh Install Tests
on:
workflow_dispatch: # Allows manual triggering
pull_request:
branches:
- '**'
# paths filter removed to always trigger
push:
branches:
- main
# paths filter removed to always trigger
jobs:
fresh-install-e2e:
runs-on: ubuntu-latest
timeout-minutes: 60 # Set a timeout for the job
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetches all history for all branches and tags, required for tj-actions/changed-files
#--------------------------------------------------------------
# Detect whether the proposed changes require running the
# (slow) E2E installation tests. Skip if only non-application
# files changed (e.g., Helm charts, docs, scripts).
#--------------------------------------------------------------
- name: Check for relevant file changes
id: changed_files_check
uses: tj-actions/changed-files@v46 # Updated from v42 to fix CVE-2025-30066
with:
# List of paths that, when modified, SHOULD trigger the heavy
# E2E job. Paths NOT listed here (helm/, docs/, scripts/, sdk/)
# will not trigger the tests.
files: |
.github/workflows/e2e-fresh-install-tests.yaml
docker-compose.base.yaml
docker-compose.ce.yaml
docker-compose.prod.yaml
Dockerfile
Dockerfile.build
server/**
setup/**
shared/**
packages/**
services/**
ee/**
e2e-tests/**
package.json
package-lock.json
tsconfig.base.json
# Short-circuit the job if no relevant files changed
- name: Skip job -- no application files changed
if: steps.changed_files_check.outputs.any_changed == 'false'
run: |
echo "No application files were modified skipping E2E Fresh Install Tests."
echo "No further steps will be executed."
# Nothing else to do, the job will report success because the
# previous steps (including this one) have succeeded.
- name: Set up test environment secrets and .env
if: steps.changed_files_check.outputs.any_changed == 'true'
run: |
# Create secrets directory
mkdir -p secrets
# Create sample secrets (placeholders, real values not needed for this test if services are self-contained)
# Using similar placeholders as pr-checks.yaml for consistency
# Use printf instead of echo to avoid trailing newlines
printf "placeholder-password" > secrets/postgres_password
printf "placeholder-password" > secrets/db_password_server
printf "placeholder-password" > secrets/db_password_hocuspocus
printf "placeholder-password" > secrets/redis_password
printf "placeholder-key-32-chars-long-01" > secrets/alga_auth_key
printf "placeholder-key-32-chars-long-02" > secrets/crypto_key
printf "placeholder-key-32-chars-long-03" > secrets/token_secret_key
printf "placeholder-key-32-chars-long-04" > secrets/nextauth_secret
printf "placeholder-password" > secrets/email_password
printf "placeholder-id" > secrets/google_oauth_client_id
printf "placeholder-secret" > secrets/google_oauth_client_secret
# Set permissions
chmod 600 secrets/*
# Copy and configure environment file
cp .env.example .env
# Configure required environment variables for the test
# Set APP_ENV to production for production build testing
cat >> .env << EOL
APP_VERSION=1.0.0-e2e
APP_NAME=alga-e2e-test
APP_ENV=production
APP_HOST=0.0.0.0
APP_PORT=3000
APP_EDITION=community
# Database Configuration (will be overridden by docker-compose services but good to have)
DB_TYPE=postgres
DB_USER_ADMIN=postgres
# Logging Configuration
LOG_LEVEL=INFO
LOG_IS_FORMAT_JSON=false
LOG_IS_FULL_DETAILS=false
# Email Configuration (disabled for tests)
EMAIL_ENABLE=false
# Authentication Configuration
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=placeholder-key-32-chars-long-04
NEXTAUTH_SESSION_EXPIRES=86400
AUTH_TRUST_HOST=true
# Optional Configuration
REQUIRE_HOCUSPOCUS=false
# Secret Provider Configuration (override production defaults)
SECRET_READ_CHAIN=env,filesystem
SECRET_WRITE_PROVIDER=filesystem
EOL
shell: bash
- name: Temporarily rename root docker-compose.yaml to avoid conflict in act
if: env.ACT
run: |
if [ -f docker-compose.yaml ]; then
echo "Temporarily renaming root docker-compose.yaml to docker-compose.yaml.ignored"
sudo mv docker-compose.yaml docker-compose.yaml.ignored
fi
shell: bash
- name: Install Docker Compose v1.29.2 via curl
if: steps.changed_files_check.outputs.any_changed == 'true'
id: install_docker_compose # Add an ID for dependent steps
run: |
COMPOSE_VERSION="v2.36.0"
COMPOSE_URL="https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-$(uname -s)-$(uname -m)"
DEST_PATH="/usr/local/bin/docker-compose" # Standard location
echo "Downloading Docker Compose from ${COMPOSE_URL} to ${DEST_PATH}"
sudo rm -f "${DEST_PATH}" # Remove existing to avoid conflicts
# Use curl with -fS (fail silently on server errors, show client errors) and -L (follow redirects)
sudo curl -fSL "${COMPOSE_URL}" -o "${DEST_PATH}"
# Verify download was successful and file is not empty and is executable
if [ ! -s "${DEST_PATH}" ]; then
echo "Error: Downloaded docker-compose is empty. URL ${COMPOSE_URL} might be incorrect or file not found."
exit 1
fi
if ! file "${DEST_PATH}" | grep -q "executable"; then
echo "Error: Downloaded file at ${DEST_PATH} is not an executable. It might be an HTML error page."
echo "Downloaded content (first 5 lines):"
sudo head -n 5 "${DEST_PATH}"
exit 1
fi
sudo chmod +x "${DEST_PATH}"
echo "Docker Compose version:"
docker-compose --version # Verify installation
shell: bash
- name: Free up disk space
if: steps.install_docker_compose.outcome == 'success'
run: |
echo "Freeing up disk space before build..."
df -h
# Remove large pre-installed toolchains this job never uses. Each is
# guarded with `|| true` so a missing path can't fail the step (the
# shell runs with `set -e`). The previous run started with only ~19G
# free and the four --no-cache image builds exhausted it; this frees
# ~15-20G more on the root volume.
sudo rm -rf /usr/share/dotnet || true
sudo rm -rf /usr/local/lib/android || true
sudo rm -rf /opt/ghc /usr/local/.ghcup || true
sudo rm -rf /opt/hostedtoolcache/CodeQL || true
sudo rm -rf /usr/local/share/boost || true
sudo rm -rf /usr/share/swift || true
# Drop apt caches.
sudo apt-get clean || true
# Reclaim all Docker space (images, build cache, volumes).
docker system prune -a -f --volumes || true
echo "Disk space after cleanup:"
df -h
shell: bash
- name: Build images with no cache
if: steps.install_docker_compose.outcome == 'success'
run: |
echo "Building all required images with --no-cache to ensure fresh build"
# Build all images needed for e2e tests upfront
# Excludes: workflow-worker (EE only, not needed for CE e2e)
# Excludes: hocuspocus (built separately below with repo-root context)
# Note: setup is built here using docker-compose.setup-ubuntu.override.yaml so
# it produces the alga-setup:ubuntu image used by the later "Start setup
# service (Ubuntu build override)" step. That step then starts the
# container without --build to avoid a redundant rebuild.
docker-compose -p alga-e2e-test \
-f docker-compose.base.yaml \
-f docker-compose.ce.yaml \
-f docker-compose.prod.yaml \
-f docker-compose.setup-ubuntu.override.yaml \
build --no-cache \
server setup email-service
shell: bash
- name: Reclaim disk space before hocuspocus build
if: steps.install_docker_compose.outcome == 'success'
run: |
echo "Disk space before reclaim:"
df -h /
# The previous --no-cache build leaves dangling intermediate
# (multi-stage) layers and BuildKit cache behind. Reclaim them so the
# hocuspocus build has headroom -- the earlier run ran out of space
# mid-`apt-get` precisely here, on the last image of the job.
docker image prune -f || true
docker builder prune -af || true
echo "Disk space after reclaim:"
df -h /
shell: bash
- name: Build hocuspocus image with repo-root context
if: steps.install_docker_compose.outcome == 'success'
run: |
# hocuspocus/Dockerfile expects repo-root as build context (it does
# `COPY hocuspocus/package.json ...`), but docker-compose.ce.yaml sets
# `context: ./hocuspocus`, which breaks the build. Build it directly
# here with the correct context and tag it with the name compose v2
# auto-generates (`<project>-<service>:latest`) so subsequent
# `docker-compose up` calls reuse this image instead of rebuilding.
docker build --no-cache \
-f hocuspocus/Dockerfile \
-t alga-e2e-test-hocuspocus:latest \
.
shell: bash
- name: Start foundational services (postgres and redis)
if: steps.install_docker_compose.outcome == 'success'
run: |
echo "Starting foundational services: postgres and redis"
docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml up --build -d postgres redis
shell: bash
- name: Wait for postgres to be ready
if: steps.install_docker_compose.outcome == 'success'
run: |
echo "Waiting for postgres to be ready..."
# Wait for postgres to be healthy
echo "Checking postgres health..."
MAX_ATTEMPTS=30
ATTEMPT_NUM=1
until docker-compose -p alga-e2e-test exec -T postgres pg_isready -U postgres; do
if [ $ATTEMPT_NUM -ge $MAX_ATTEMPTS ]; then
echo "Timeout: Postgres did not become ready within the allocated time."
docker-compose -p alga-e2e-test logs postgres
exit 1
fi
echo "Attempt $ATTEMPT_NUM/$MAX_ATTEMPTS: Postgres not ready. Waiting 5 seconds..."
sleep 5
ATTEMPT_NUM=$((ATTEMPT_NUM+1))
done
echo "Postgres is ready."
shell: bash
- name: Start pgbouncer service
if: steps.install_docker_compose.outcome == 'success'
run: |
echo "Starting pgbouncer service"
docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml up --build -d pgbouncer
shell: bash
- name: Wait for pgbouncer to be ready
if: steps.install_docker_compose.outcome == 'success'
run: |
echo "Waiting for pgbouncer to be ready..."
# Wait for pgbouncer port to be open
echo "Checking pgbouncer port availability..."
MAX_ATTEMPTS=30
ATTEMPT_NUM=1
until docker run --rm --network alga-e2e-test_app-network busybox nc -z pgbouncer 6432; do
if [ $ATTEMPT_NUM -ge $MAX_ATTEMPTS ]; then
echo "Timeout: PgBouncer port did not become available within the allocated time."
docker-compose -p alga-e2e-test logs pgbouncer
exit 1
fi
echo "Attempt $ATTEMPT_NUM/$MAX_ATTEMPTS: PgBouncer port not ready. Waiting 5 seconds..."
sleep 5
ATTEMPT_NUM=$((ATTEMPT_NUM+1))
done
echo "PgBouncer port is ready."
# Additional wait for pgbouncer to fully initialize
echo "Waiting additional 5 seconds for pgbouncer to fully initialize..."
sleep 5
shell: bash
- name: Start setup service (Ubuntu build override)
if: steps.install_docker_compose.outcome == 'success'
run: |
echo "Starting setup service"
# Image (alga-setup:ubuntu) was already built fresh in the earlier
# "Build images with no cache" step using the same override file, so
# we start without --build here to avoid a duplicate ~510 min rebuild
# that previously pushed the job past its 60-minute timeout.
# Set DB_HOST_ADMIN and DB_PORT_ADMIN to connect directly to postgres for admin operations
# while keeping DB_HOST and DB_PORT pointing to pgbouncer for migrations/seeds
DB_HOST_ADMIN=postgres \
DB_PORT_ADMIN=5432 \
docker-compose -p alga-e2e-test \
-f docker-compose.base.yaml \
-f docker-compose.ce.yaml \
-f docker-compose.prod.yaml \
-f docker-compose.setup-ubuntu.override.yaml \
up -d setup
shell: bash
- name: Wait for Setup service to complete (Ubuntu build override)
if: steps.install_docker_compose.outcome == 'success'
id: wait_for_setup
run: |
echo "Waiting for setup service to complete..."
MAX_ATTEMPTS=60 # 10 minutes (60 attempts * 10 seconds)
ATTEMPT_NUM=1
# Get the container ID before the loop, it might be gone after exit
SETUP_CONTAINER_ID=$(docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.setup-ubuntu.override.yaml ps -q setup)
if [ -z "$SETUP_CONTAINER_ID" ]; then
echo "Critical: Could not get initial container ID for setup service. Assuming it failed to start."
docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.setup-ubuntu.override.yaml logs setup
echo "status=failure" >> $GITHUB_OUTPUT
exit 1
fi
echo "Monitoring Setup Container ID: $SETUP_CONTAINER_ID"
while [ $ATTEMPT_NUM -le $MAX_ATTEMPTS ]; do
# Check if the setup container is still running
if ! docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.setup-ubuntu.override.yaml ps setup | grep -q "Up"; then
# Container has exited, check its exit code using the stored ID
echo "Setup container (ID: $SETUP_CONTAINER_ID) is no longer 'Up'. Checking exit code."
# Use the stored SETUP_CONTAINER_ID
if [ -z "$SETUP_CONTAINER_ID" ]; then # Should not happen if initial check passed
echo "Error: Lost setup container ID." # Should be redundant due to initial check
SETUP_EXIT_CODE="1"
else
echo "Inspecting container ID: $SETUP_CONTAINER_ID"
INSPECT_OUTPUT=$(docker inspect -f '{{.State.ExitCode}} {{.State.Error}}' "$SETUP_CONTAINER_ID" 2>/dev/null)
echo "Inspect output: '$INSPECT_OUTPUT'"
SETUP_EXIT_CODE=$(echo "$INSPECT_OUTPUT" | awk '{print $1}')
# If SETUP_EXIT_CODE is empty or not a number, default to 1
if ! [[ "$SETUP_EXIT_CODE" =~ ^[0-9]+$ ]]; then
echo "Failed to parse exit code from inspect output, defaulting to 1."
SETUP_EXIT_CODE="1"
fi
fi
echo "Reported Setup Exit Code: $SETUP_EXIT_CODE"
if [ "$SETUP_EXIT_CODE" -eq 0 ]; then
echo "Setup service completed successfully."
echo "status=success" >> $GITHUB_OUTPUT
exit 0
else
echo "Setup service failed with exit code $SETUP_EXIT_CODE."
docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.setup-ubuntu.override.yaml logs setup
echo "status=failure" >> $GITHUB_OUTPUT
exit 1
fi
fi
# Check logs for completion message as a secondary check (optional, primary is exit code)
if docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.setup-ubuntu.override.yaml logs setup | grep -q "Setup completed!"; then
echo "Setup completion message found in logs. Waiting a bit for container to exit."
sleep 5 # Give it a moment to exit gracefully
# Re-check exit status using the stored ID
if ! docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.setup-ubuntu.override.yaml ps setup | grep -q "Up"; then
# Use the stored SETUP_CONTAINER_ID
INSPECT_OUTPUT_LOG_CHECK=$(docker inspect -f '{{.State.ExitCode}}' "$SETUP_CONTAINER_ID" 2>/dev/null)
SETUP_EXIT_CODE_LOG_CHECK=$(echo "$INSPECT_OUTPUT_LOG_CHECK" | awk '{print $1}')
if ! [[ "$SETUP_EXIT_CODE_LOG_CHECK" =~ ^[0-9]+$ ]]; then
echo "Failed to parse exit code from inspect output (log check), defaulting to 1."
SETUP_EXIT_CODE_LOG_CHECK="1"
fi
if [ "$SETUP_EXIT_CODE_LOG_CHECK" -eq 0 ]; then
echo "Setup service completed successfully after log check (Exit Code: $SETUP_EXIT_CODE_LOG_CHECK)."
echo "status=success" >> $GITHUB_OUTPUT
exit 0 # Successful exit from the script
else
echo "Setup service showed completion log but exited with code $SETUP_EXIT_CODE_LOG_CHECK."
docker-compose -p alga-e2e-test logs setup
echo "status=failure" >> $GITHUB_OUTPUT
exit 1 # Failed exit from the script
fi
fi
fi
echo "Attempt $ATTEMPT_NUM/$MAX_ATTEMPTS: Setup service still running. Waiting 10 seconds..."
sleep 10
ATTEMPT_NUM=$((ATTEMPT_NUM+1))
done
echo "Timeout: Setup service did not complete within the allocated time."
docker-compose -p alga-e2e-test logs
echo "status=failure" >> $GITHUB_OUTPUT
exit 1
shell: bash
- name: Start remaining services after setup completion
if: steps.wait_for_setup.outputs.status == 'success' && steps.install_docker_compose.outcome == 'success'
run: |
echo "Setup completed successfully. Starting remaining services..."
# Start CE services needed for e2e tests (images already built earlier)
# Use --no-recreate to avoid restarting setup which already completed
# Use --no-deps to avoid waiting on setup dependency again
docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml -f docker-compose.imap-test.yaml up -d \
--no-recreate --no-deps \
server email-service imap-test-server hocuspocus
shell: bash
- name: Collect initial container logs
if: steps.wait_for_setup.outputs.status == 'success' && steps.install_docker_compose.outcome == 'success'
run: |
echo "=== CONTAINER STATUS ==="
docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml ps
echo -e "\n=== ALL CONTAINER LOGS ==="
docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml logs --tail=100
echo -e "\n=== SERVER LOGS (detailed) ==="
docker-compose -p alga-e2e-test logs server --tail=200
shell: bash
- name: Wait for Server service to be healthy
if: steps.wait_for_setup.outputs.status == 'success' && steps.install_docker_compose.outcome == 'success'
run: |
echo "Waiting for server service to be healthy..."
MAX_ATTEMPTS=30 # 5 minutes (30 attempts * 10 seconds)
ATTEMPT_NUM=1
# Using APP_PORT from .env, default to 3000 if not found (though it should be there)
APP_PORT_VALUE=$(grep APP_PORT .env | cut -d '=' -f2 | head -n 1 || echo "3000")
HEALTH_CHECK_URL="http://localhost:${APP_PORT_VALUE}/api/health" # Assuming a health endpoint exists
# Fallback if /api/health is not standard, try base URL
# HEALTH_CHECK_URL="http://localhost:${APP_PORT_VALUE}/"
until curl --output /dev/null --silent --head --fail $HEALTH_CHECK_URL; do
if [ $ATTEMPT_NUM -ge $MAX_ATTEMPTS ]; then
echo "Timeout: Server service did not become healthy at $HEALTH_CHECK_URL within the allocated time."
echo "=== FINAL SERVER LOGS ==="
docker-compose -p alga-e2e-test logs server --tail=500
echo "=== ALL CONTAINER STATUS ==="
docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml ps
exit 1
fi
echo "Attempt $ATTEMPT_NUM/$MAX_ATTEMPTS: Server not yet healthy at $HEALTH_CHECK_URL. Waiting 10 seconds..."
# Show server logs every 5 attempts to debug issues
if [ $((ATTEMPT_NUM % 5)) -eq 0 ]; then
echo "=== SERVER LOGS (attempt $ATTEMPT_NUM) ==="
docker-compose -p alga-e2e-test logs server --tail=50
fi
sleep 10
ATTEMPT_NUM=$((ATTEMPT_NUM+1))
done
echo "Server service is healthy at $HEALTH_CHECK_URL."
shell: bash
- name: Trigger Login Page to Generate Credentials
if: steps.wait_for_setup.outputs.status == 'success' && steps.install_docker_compose.outcome == 'success'
run: |
echo "Attempting to trigger login page to generate credentials in server logs..."
APP_PORT_VALUE=$(grep APP_PORT .env | cut -d '=' -f2 | head -n 1 || echo "3000")
LOGIN_PAGE_URL="http://localhost:${APP_PORT_VALUE}/auth/signin"
# Perform a curl request to the login page. We don't need the output, just the action of hitting the page.
# Allow failure as the page might not return 200 immediately or might redirect,
# but the act of requesting it should trigger the log.
curl -s -o /dev/null -w "%{http_code}" "${LOGIN_PAGE_URL}" || echo "Curl to login page finished (ignore exit code here)."
echo "Login page triggered. Waiting a few seconds for logs to propagate..."
sleep 3 # Wait for server to process the request and log credentials
shell: bash
- name: Capture Credentials from Server Logs
if: steps.wait_for_setup.outputs.status == 'success' && steps.install_docker_compose.outcome == 'success'
id: capture_creds
run: |
echo "Attempting to capture credentials from server logs..."
# Wait a few seconds for logs to flush if needed
sleep 5
SERVER_LOGS=$(docker-compose -p alga-e2e-test logs server)
# Extract email (should be glinda@emeraldcity.oz)
USER_EMAIL=$(echo "$SERVER_LOGS" | grep -oP 'User Email is -> \[ \K[^ ]+' || echo "")
# Extract password
USER_PASSWORD=$(echo "$SERVER_LOGS" | grep -oP 'Password is -> \[ \K[^ ]+' || echo "")
if [ -z "$USER_EMAIL" ] || [ -z "$USER_PASSWORD" ]; then
echo "Failed to extract credentials from server logs."
echo "Server Logs:"
echo "$SERVER_LOGS"
exit 1
fi
echo "Successfully extracted credentials."
echo "e2e_user_email=$USER_EMAIL" >> $GITHUB_OUTPUT
# Use a delimiter to safely pass passwords with special characters
EOF_DELIM=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "e2e_user_password<<$EOF_DELIM" >> $GITHUB_OUTPUT
echo "$USER_PASSWORD" >> $GITHUB_OUTPUT
echo "$EOF_DELIM" >> $GITHUB_OUTPUT
# Mask the password in logs
echo "::add-mask::$USER_PASSWORD"
shell: bash
- name: Warm up authentication system
if: steps.wait_for_setup.outputs.status == 'success' && steps.capture_creds.outputs.e2e_user_email != ''
run: |
echo "Warming up authentication system to prime database connections..."
APP_PORT_VALUE=$(grep APP_PORT .env | cut -d '=' -f2 | head -n 1 || echo "3000")
# Make a few requests to warm up the auth system and connection pool
# First, hit the CSRF endpoint to initialize NextAuth
curl -s -o /dev/null -w "CSRF endpoint: %{http_code}\n" \
"http://localhost:${APP_PORT_VALUE}/api/auth/csrf" || true
# Then make a login attempt with invalid credentials to warm up the full auth path
# This primes: database connections, NextAuth session machinery, lazy-loaded modules
CSRF_TOKEN=$(curl -s "http://localhost:${APP_PORT_VALUE}/api/auth/csrf" | grep -oP '"csrfToken":"\K[^"]+' || echo "")
curl -s -o /dev/null -w "Auth warmup: %{http_code}\n" \
-X POST "http://localhost:${APP_PORT_VALUE}/api/auth/callback/credentials" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "email=warmup@test.invalid&password=warmup&csrfToken=${CSRF_TOKEN}" || true
echo "Auth system warmed up. Waiting for connections to stabilize..."
sleep 3
shell: bash
- name: Set up Node.js
if: steps.wait_for_setup.outputs.status == 'success'
uses: actions/setup-node@v3
with:
node-version: '18' # Or your project's Node version
- name: Create E2E test directory and package.json
if: steps.wait_for_setup.outputs.status == 'success'
run: |
mkdir -p e2e-tests/tests
cat << EOF > e2e-tests/package.json
{
"name": "alga-e2e-tests",
"version": "1.0.0",
"description": "E2E tests for Alga PSA",
"main": "index.js",
"scripts": {
"test": "playwright test"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.40.0"
}
}
EOF
shell: bash
- name: Install Playwright npm packages
if: steps.wait_for_setup.outputs.status == 'success'
working-directory: ./e2e-tests
run: npm install
shell: bash
- name: Get Playwright version
if: steps.wait_for_setup.outputs.status == 'success'
id: playwright_version
working-directory: ./e2e-tests
run: |
PLAYWRIGHT_VERSION=$(npx playwright --version | awk '{print $2}')
echo "version=$PLAYWRIGHT_VERSION" >> $GITHUB_OUTPUT
echo "Playwright version: $PLAYWRIGHT_VERSION"
shell: bash
- name: Cache Playwright browsers
if: steps.wait_for_setup.outputs.status == 'success'
id: playwright_cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-browsers-${{ steps.playwright_version.outputs.version }}
- name: Install Playwright browsers
if: steps.wait_for_setup.outputs.status == 'success' && steps.playwright_cache.outputs.cache-hit != 'true'
working-directory: ./e2e-tests
run: npx playwright install
shell: bash
- name: Install Playwright system dependencies
if: steps.wait_for_setup.outputs.status == 'success'
working-directory: ./e2e-tests
run: npx playwright install-deps
shell: bash
- name: Create Playwright config file
if: steps.wait_for_setup.outputs.status == 'success'
run: |
cat << EOF > e2e-tests/playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
timeout: 120000, // 2 minutes global timeout
use: {
baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000', // Default to localhost:3000
trace: 'on-first-retry',
actionTimeout: 60000, // 60 seconds for actions
navigationTimeout: 90000, // 90 seconds for navigation
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});
EOF
shell: bash
- name: Create E2E login test spec
if: steps.wait_for_setup.outputs.status == 'success'
run: |
# Ensure APP_PORT is available for constructing the baseURL if not set via E2E_BASE_URL
APP_PORT_VALUE_FROM_ENV=$(grep '^APP_PORT=' ./.env | cut -d '=' -f2 | head -n 1)
APP_PORT_VALUE=${APP_PORT_VALUE_FROM_ENV:-3000}
BASE_URL="http://localhost:${APP_PORT_VALUE}"
echo "Resolved BASE_URL for Playwright spec: ${BASE_URL}"
cat << EOF > e2e-tests/tests/login.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Login Functionality', () => {
test('should allow a user to log in and redirect to dashboard', async ({ page }) => {
const email = process.env.E2E_USER_EMAIL;
const password = process.env.E2E_USER_PASSWORD;
const baseUrl = process.env.E2E_BASE_URL || '${BASE_URL}'; // Use env var or default
if (!email || !password) {
throw new Error('E2E_USER_EMAIL or E2E_USER_PASSWORD environment variables are not set.');
}
// Navigate to the login page
await page.goto(\`\${baseUrl}/auth/signin\`);
// Fill in the email and password
try {
console.log('Waiting for email field...');
await page.waitForSelector('input[id="msp-email-field"]', { timeout: 15000 });
console.log(\`Filling email: \${email}\`);
await page.fill('input[id="msp-email-field"]', email);
console.log('Waiting for password field...');
await page.waitForSelector('input[id="msp-password-field"]', { timeout: 5000 });
console.log('Filling password...');
await page.fill('input[id="msp-password-field"]', password);
console.log('Credentials filled successfully');
} catch (e) {
console.error('Error filling form fields. Current page HTML:', await page.content());
throw e;
}
// Submit the login form
// Note: Using keyboard Enter instead of button click because React form
// onSubmit handlers are sometimes not triggered by Playwright's click()
try {
console.log('Waiting for sign-in button...');
await page.waitForSelector('button[id="msp-sign-in-button"]', { timeout: 5000 });
console.log('Submitting form via Enter key on password field...');
// Focus the password field and press Enter to submit the form
// This is more reliable than clicking the submit button for React forms
await page.focus('input[id="msp-password-field"]');
// Wait for the credentials POST request while pressing Enter
// NextAuth credentials login POSTs to /api/auth/callback/credentials
const [credentialsResponse] = await Promise.all([
page.waitForResponse(
response => {
const isCredentialsPost = response.url().includes('/api/auth/callback') &&
response.request().method() === 'POST';
if (isCredentialsPost) {
console.log(\`Auth callback response: \${response.status()} \${response.request().method()} \${response.url()}\`);
}
return isCredentialsPost;
},
{ timeout: 30000 }
).catch(e => {
console.log('No auth callback POST detected - trying button click as fallback');
return null;
}),
page.keyboard.press('Enter')
]);
// If Enter didn't work, try clicking the button
if (!credentialsResponse) {
console.log('Enter key did not trigger form submit, trying button click...');
await Promise.all([
page.waitForResponse(
response => {
const isCredentialsPost = response.url().includes('/api/auth/callback') &&
response.request().method() === 'POST';
if (isCredentialsPost) {
console.log(\`Auth callback response (via click): \${response.status()} \${response.request().method()} \${response.url()}\`);
}
return isCredentialsPost;
},
{ timeout: 30000 }
).catch(e => {
console.log('Still no auth callback POST after button click');
return null;
}),
page.click('button[id="msp-sign-in-button"]')
]);
}
console.log('Form submitted, waiting for page response...');
} catch (e) {
console.error('Error submitting login form:', e);
console.error('Current page HTML:', await page.content());
throw e;
}
// Wait for authentication to complete
console.log('Waiting for network to become idle after login...');
await page.waitForLoadState('networkidle', { timeout: 60000 });
// Check current state after networkidle
const currentUrl = page.url();
console.log(\`Current URL after networkidle: \${currentUrl}\`);
// If still on login page, capture diagnostic info
if (currentUrl.includes('/auth/')) {
console.log('Still on auth page - checking for errors...');
// Check for any visible error messages
const pageText = await page.textContent('body');
if (pageText.toLowerCase().includes('error') || pageText.toLowerCase().includes('invalid')) {
console.error('Error text found on page:', pageText.substring(0, 1000));
}
// Check for alert elements
const alertVisible = await page.locator('[role="alert"]').isVisible().catch(() => false);
if (alertVisible) {
const alertText = await page.locator('[role="alert"]').textContent();
console.error('Alert message found:', alertText);
}
// Log the visible form state
const emailValue = await page.inputValue('input[id="msp-email-field"]').catch(() => 'N/A');
const passwordValue = await page.inputValue('input[id="msp-password-field"]').catch(() => 'N/A');
console.log(\`Form state - Email field value: \${emailValue}, Password filled: \${passwordValue ? 'yes' : 'no'}\`);
// Take a screenshot for debugging
await page.screenshot({ path: 'login-debug-screenshot.png', fullPage: true });
console.log('Debug screenshot saved to login-debug-screenshot.png');
}
// Wait for navigation to the dashboard
console.log(\`Waiting for URL: \${baseUrl}/msp/dashboard with 90s timeout.\`);
await page.waitForURL(\`\${baseUrl}/msp/dashboard\`, { timeout: 90000 });
// Assert that the URL is the dashboard URL
expect(page.url()).toBe(\`\${baseUrl}/msp/dashboard\`);
// Optional: Add more assertions, e.g., check for a welcome message or specific element
// await expect(page.locator('h1')).toContainText('Dashboard');
});
});
EOF
working-directory: ./
shell: bash
- name: Run Playwright E2E tests
if: steps.wait_for_setup.outputs.status == 'success' && steps.capture_creds.outputs.e2e_user_email != ''
working-directory: ./e2e-tests
env:
E2E_USER_EMAIL: ${{ steps.capture_creds.outputs.e2e_user_email }}
E2E_USER_PASSWORD: ${{ steps.capture_creds.outputs.e2e_user_password }}
run: npx playwright test
shell: bash
- name: Collect all container logs on failure
if: failure() && steps.install_docker_compose.outcome == 'success'
timeout-minutes: 5
run: |
echo "=== COLLECTING ALL LOGS DUE TO FAILURE ==="
echo "=== CONTAINER STATUS ==="
timeout 60 docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml ps || echo "Could not get container status"
echo -e "\n=== DOCKER SYSTEM INFO ==="
# `docker system df` can hang for minutes against a disk-full/wedged
# daemon (it previously stalled here until the step was SIGTERM'd ->
# exit 143), so cap it with `timeout`.
timeout 60 docker system df || echo "docker system df timed out or failed"
timeout 60 docker images --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}\t{{.CreatedAt}}" || echo "docker images timed out or failed"
echo -e "\n=== ALL CONTAINER LOGS ==="
docker-compose -p alga-e2e-test logs --tail=1000 || echo "Could not get compose logs"
echo -e "\n=== INDIVIDUAL SERVICE LOGS ==="
for service in server setup postgres redis pgbouncer hocuspocus workflow-worker; do
echo "--- $service logs ---"
docker-compose -p alga-e2e-test logs $service --tail=200 2>/dev/null || echo "No logs for $service"
done
echo -e "\n=== DOCKER INSPECT (running containers) ==="
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" | grep alga-e2e-test || echo "No running containers found"
# Try to get logs from any containers that might have the project name
echo -e "\n=== DIRECT DOCKER LOGS ==="
for container in $(docker ps -a --filter "name=alga-e2e-test" --format "{{.Names}}"); do
echo "--- Direct logs for $container ---"
docker logs $container --tail=100 2>/dev/null || echo "Could not get logs for $container"
done
shell: bash
- name: Save container logs as artifacts
if: always() && steps.install_docker_compose.outcome == 'success'
run: |
mkdir -p logs
# Copy any Playwright screenshots to logs directory
cp e2e-tests/*.png logs/ 2>/dev/null || echo "No Playwright screenshots found"
cp e2e-tests/test-results/**/*.png logs/ 2>/dev/null || echo "No test-results screenshots found"
# Save logs for each service
for service in server setup postgres redis pgbouncer hocuspocus workflow-worker; do
echo "Saving logs for $service..."
docker-compose -p alga-e2e-test logs $service --no-color > "logs/${service}.log" 2>/dev/null || echo "No logs for $service" > "logs/${service}.log"
done
# Save combined logs
docker-compose -p alga-e2e-test logs --no-color > "logs/all-services.log" 2>/dev/null || echo "Could not save combined logs"
# Save container status
docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml ps > "logs/container-status.txt" 2>/dev/null || echo "Could not save container status"
# Save environment info
echo "Environment variables:" > logs/environment.txt
env | grep -E "(APP_|NODE_|DB_|REDIS_)" >> logs/environment.txt || echo "Could not save environment"
ls -la logs/
shell: bash
- name: Upload logs as artifacts
if: always() && steps.install_docker_compose.outcome == 'success' && github.actor != 'nektos/act'
uses: actions/upload-artifact@v4
with:
name: container-logs
path: logs/
retention-days: 7
# - name: Upload Playwright Test Report
# if: always() && steps.changed_files_check.outputs.any_changed == 'true' && steps.wait_for_setup.outputs.status == 'success' && !env.ACT # Run even if tests fail, but setup was ok. Skip in ACT due to token issues.
# uses: actions/upload-artifact@v4
# with:
# name: playwright-report
# path: e2e-tests/playwright-report/
# retention-days: 7
- name: Cleanup Docker Compose
if: always() && steps.install_docker_compose.outcome == 'success' # Ensure docker-compose was attempted to be installed
run: |
echo "Cleaning up Docker Compose environment..."
# Check if docker-compose command is available before trying to use it
if command -v docker-compose &> /dev/null; then
docker-compose -p alga-e2e-test -f docker-compose.base.yaml -f docker-compose.ce.yaml -f docker-compose.prod.yaml down -v --remove-orphans || echo "Docker Compose cleanup failed, but continuing."
else
echo "docker-compose command not found, skipping cleanup."
fi
shell: bash
- name: Restore root docker-compose.yaml
if: always() && env.ACT
run: |
if [ -f docker-compose.yaml.ignored ]; then
echo "Restoring root docker-compose.yaml"
sudo mv docker-compose.yaml.ignored docker-compose.yaml
fi
shell: bash

42
.github/workflows/ext-v2-guard.yml vendored Normal file
View File

@ -0,0 +1,42 @@
name: ext-v2 guardrails
on:
pull_request:
push:
branches:
- main
- master
- develop
jobs:
guardrails:
name: Run ext-v2 guard and ESLint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Upgrade npm to version 11
run: npm install -g npm@11
- name: Install dependencies
run: |
if ! npm ci --legacy-peer-deps; then
echo "npm ci failed; attempting npm install fallback to regenerate lock data"
npm install --legacy-peer-deps
fi
- name: Run legacy extension grep guard
run: npm run guard:ext-v2
- name: Run ESLint (errors only)
env:
NODE_OPTIONS: --max-old-space-size=16384
run: npm run lint -- --quiet

141
.github/workflows/integration-tests.yml vendored Normal file
View File

@ -0,0 +1,141 @@
name: Integration Tests
on:
workflow_dispatch:
inputs:
suite:
description: 'Which suite to run'
required: false
default: 'tier1'
type: choice
options:
- tier1
- full
pull_request:
push:
branches:
- main
schedule:
# Nightly full run at 04:30 UTC.
- cron: '30 4 * * *'
jobs:
check-changes:
name: Check for relevant changes
runs-on: ubuntu-latest
if: github.event_name != 'schedule'
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for changes that affect DB-backed tests
id: filter
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "should_run=true" >> $GITHUB_OUTPUT
exit 0
fi
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
else
BASE_SHA="${{ github.event.before }}"
fi
CHANGED_FILES=$(git diff --name-only $BASE_SHA ${{ github.sha }} 2>/dev/null || echo "")
if echo "$CHANGED_FILES" | grep -qE '^(server/|packages/|shared/|ee/server/|\.github/workflows/integration-tests\.yml)'; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "Relevant changes detected - will run integration tests"
else
echo "should_run=false" >> $GITHUB_OUTPUT
echo "No relevant changes - skipping integration tests"
fi
integration-tests:
name: ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }}
needs: check-changes
# `needs` is skipped entirely on schedule; `always()` lets the nightly run proceed.
if: always() && (github.event_name == 'schedule' || needs.check-changes.outputs.should_run == 'true')
runs-on: ubuntu-latest
timeout-minutes: 90
services:
postgres:
image: ankane/pgvector:latest
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: test_password
POSTGRES_DB: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DB_HOST: localhost
DB_PORT: '5432'
DB_USER_ADMIN: postgres
DB_PASSWORD_ADMIN: test_password
DB_USER_SERVER: app_user
DB_PASSWORD_SERVER: test_password
APP_ENV: test
NODE_ENV: test
# Fail loudly instead of describe.skip when the DB is unreachable (see server/test-utils/requireDb.ts).
REQUIRE_DB: '1'
VITEST_SEED: '20260610'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Upgrade npm to version 11
run: npm install -g npm@11
# No npm install fallback: a failing npm ci means the lockfile is out of
# sync with a package.json, and regenerating dependencies on the fly once
# swapped vitest 3→4 and silently broke test-file serialization. Fail
# loudly instead.
- name: Install dependencies
run: npm ci
- name: Wait for Postgres to be ready
run: |
until pg_isready -h localhost -p 5432 -U postgres; do
echo "Waiting for postgres..."
sleep 2
done
# Some tests read credentials from secrets/ files directly (getSecret falls back to env).
- name: Create secrets files
run: |
mkdir -p secrets
echo -n "test_password" > secrets/postgres_password
echo -n "test_password" > secrets/db_password_server
chmod 600 secrets/*
- name: Run Tier-1 integration subset
if: github.event_name != 'schedule' && github.event.inputs.suite != 'full'
working-directory: ./server
run: npm run test:integration:tier1
env:
NODE_OPTIONS: '--max-old-space-size=8192'
- name: Run full integration suite
if: github.event_name == 'schedule' || github.event.inputs.suite == 'full'
working-directory: ./server
run: npm run test:integration:ci
env:
NODE_OPTIONS: '--max-old-space-size=8192'

118
.github/workflows/mobile-checks.yml vendored Normal file
View File

@ -0,0 +1,118 @@
name: Mobile checks
on:
pull_request:
push:
branches:
- main
- master
- develop
jobs:
mobile:
name: Mobile lint + typecheck
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: ee/mobile/package-lock.json
- name: Install dependencies
working-directory: ee/mobile
run: npm ci --include=dev
- name: Lint
working-directory: ee/mobile
run: npm run lint
- name: Typecheck
working-directory: ee/mobile
run: npm run typecheck
mobile-tests:
name: Mobile unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: ee/mobile/package-lock.json
- name: Install dependencies
working-directory: ee/mobile
run: npm ci --include=dev
- name: Test
working-directory: ee/mobile
run: npm run test
env:
NODE_OPTIONS: '--max-old-space-size=4096'
mobile-audit:
name: Mobile dependency audit (report)
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: ee/mobile/package-lock.json
- name: Install dependencies
working-directory: ee/mobile
run: npm ci --include=dev
- name: Generate npm audit report
working-directory: ee/mobile
run: |
npm audit --omit=dev --json > npm-audit.json || true
- name: Upload audit report
uses: actions/upload-artifact@v4
with:
name: mobile-npm-audit
path: ee/mobile/npm-audit.json
mobile-repro:
name: Mobile reproducibility checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: ee/mobile/package-lock.json
- name: Install dependencies (npm ci)
working-directory: ee/mobile
run: npm ci --include=dev
- name: Ensure lockfile is unchanged
run: git diff --exit-code -- ee/mobile/package-lock.json
- name: Verify Expo config resolves
working-directory: ee/mobile
run: npx expo config --type public --json > /tmp/expo-config.json

74
.github/workflows/mobile-distribute.yml vendored Normal file
View File

@ -0,0 +1,74 @@
name: Mobile distribute (TestFlight / Play Internal)
on:
workflow_dispatch:
inputs:
target:
description: "Distribution target"
required: true
type: choice
options:
- testflight
- playInternal
jobs:
distribute:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: ee/mobile/package-lock.json
- name: Install dependencies
working-directory: ee/mobile
run: npm ci --include=dev
- name: Setup Expo / EAS
uses: expo/expo-github-action@v8
with:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}
- name: EAS build (wait)
working-directory: ee/mobile
env:
TARGET: ${{ inputs.target }}
run: |
set -euo pipefail
case "$TARGET" in
testflight)
npx eas-cli build --platform ios --profile testflight --non-interactive --wait
;;
playInternal)
npx eas-cli build --platform android --profile playInternal --non-interactive --wait
;;
*)
echo "Unknown target: $TARGET"
exit 1
;;
esac
- name: EAS submit (latest)
working-directory: ee/mobile
env:
TARGET: ${{ inputs.target }}
run: |
set -euo pipefail
case "$TARGET" in
testflight)
npx eas-cli submit --platform ios --profile testflight --latest --non-interactive
;;
playInternal)
npx eas-cli submit --platform android --profile playInternal --latest --non-interactive
;;
*)
echo "Unknown target: $TARGET"
exit 1
;;
esac

View File

@ -0,0 +1,27 @@
name: Secrets guard (env backups)
on:
pull_request:
push:
branches:
- main
- master
- develop
jobs:
env-backup-guard:
name: Ensure no tracked env backup files
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: "Guard: no tracked env backups"
run: node scripts/guard-no-tracked-env-backups.mjs

View File

@ -0,0 +1,75 @@
name: Temporal Readiness
on:
pull_request:
paths:
- 'ee/temporal-workflows/**'
- 'packages/integrations/**'
- 'scripts/check-temporal-worker-packaging-contract.mjs'
- 'scripts/temporal-worker-dist-import-smoke.mjs'
- 'scripts/temporal-worker-docker-parity.mjs'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/temporal-readiness.yml'
push:
branches:
- main
paths:
- 'ee/temporal-workflows/**'
- 'packages/integrations/**'
- 'scripts/check-temporal-worker-packaging-contract.mjs'
- 'scripts/temporal-worker-dist-import-smoke.mjs'
- 'scripts/temporal-worker-docker-parity.mjs'
- 'package.json'
- 'package-lock.json'
- '.github/workflows/temporal-readiness.yml'
workflow_dispatch:
schedule:
- cron: '0 6 * * *'
jobs:
fast-readiness:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: |
if ! npm ci; then
echo "npm ci failed; attempting npm install fallback to regenerate lock data"
npm install
fi
- name: Temporal readiness fast gate
run: npm run guard:temporal-readiness:fast
docker-parity:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: |
if ! npm ci; then
echo "npm ci failed; attempting npm install fallback to regenerate lock data"
npm install
fi
- name: Docker parity gate
run: npm run guard:temporal-readiness:docker

80
.github/workflows/typecheck.yml vendored Normal file
View File

@ -0,0 +1,80 @@
name: TypeScript Type Check
on:
pull_request:
push:
branches:
- main
- master
- develop
jobs:
typecheck:
name: Nx affected typecheck
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Required for `nx affected --base/--head` on PRs; shallow clones can miss the base SHA.
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Cache Nx computation cache
uses: actions/cache@v4
with:
path: .nx/cache
key: nx-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'nx.json', 'tsconfig.base.json', '**/project.json') }}
restore-keys: |
nx-cache-${{ runner.os }}-
- name: Upgrade npm to version 11
run: npm install -g npm@11
- name: Install dependencies
run: |
if ! npm ci; then
echo "npm ci failed; attempting npm install fallback to regenerate lock data"
npm install --legacy-peer-deps
fi
- name: Determine Nx base/head
shell: bash
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "NX_BASE=${{ github.event.pull_request.base.sha }}" >> $GITHUB_ENV
echo "NX_HEAD=${{ github.event.pull_request.head.sha }}" >> $GITHUB_ENV
else
echo "NX_BASE=${{ github.event.before }}" >> $GITHUB_ENV
echo "NX_HEAD=${{ github.sha }}" >> $GITHUB_ENV
fi
# ubuntu-latest runners have 16 GB RAM. The `server` (and `sebastian-ee`) app
# typechecks pull in the whole type graph and each need ~10-14 GB on their own,
# so they cannot share the runner with any other concurrent tsc. Running the full
# `nx affected` set in parallel let two heavy typechecks overlap and OOM-killed the
# runner ("received a shutdown signal" / "operation was canceled"), even though the
# typecheck itself had no errors. We split into two passes:
# 1. All libraries in parallel (capped heap × parallelism stays under 16 GB).
# 2. The heavy apps serially, each with the whole box to itself.
# Pass 2 re-evaluates `affected` but the libraries are instant Nx cache hits from
# pass 1, so it only does real work for the apps — and only when they are affected.
- name: Nx affected typecheck (libraries, parallel)
run: npx nx affected -t typecheck --base=$NX_BASE --head=$NX_HEAD --output-style=static --parallel=2 --exclude=server,sebastian-ee
env:
NX_DAEMON: 'false'
# 2 concurrent × 6 GB = 12 GB, leaving headroom for the runner.
NODE_OPTIONS: '--max-old-space-size=6144'
- name: Nx affected typecheck (apps, isolated & serial)
run: npx nx affected -t typecheck --base=$NX_BASE --head=$NX_HEAD --output-style=static --parallel=1
env:
NX_DAEMON: 'false'
# Each app runs alone with the whole box; 14 GB leaves headroom for the runner.
NODE_OPTIONS: '--max-old-space-size=14336'

139
.github/workflows/unit-tests.yml vendored Normal file
View File

@ -0,0 +1,139 @@
name: Unit Tests
on:
pull_request:
push:
branches:
- main
- master
- develop
jobs:
skip-budget:
name: Skipped-test budget
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Check skip budget
run: node scripts/check-skip-budget.mjs
unit-tests:
name: Nx affected unit tests
runs-on: ubuntu-latest
# A microtask-spinning test starves vitest's own timeout timer entirely
# (see billing's RenewalAutomationSettings hang), so the job needs its own
# backstop instead of the 6h default.
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
with:
# Required for `nx affected --base/--head` on PRs; shallow clones can miss the base SHA.
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Cache Nx computation cache
uses: actions/cache@v4
with:
path: .nx/cache
key: nx-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'nx.json', 'tsconfig.base.json', '**/project.json') }}
restore-keys: |
nx-cache-${{ runner.os }}-
- name: Upgrade npm to version 11
run: npm install -g npm@11
# No npm install fallback: a failing npm ci means the lockfile is out of
# sync with a package.json, and regenerating dependencies on the fly once
# swapped vitest 3→4 and silently broke test-file serialization. Fail
# loudly instead.
- name: Install dependencies
run: npm ci
- name: Determine Nx base/head
shell: bash
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "NX_BASE=${{ github.event.pull_request.base.sha }}" >> $GITHUB_ENV
echo "NX_HEAD=${{ github.event.pull_request.head.sha }}" >> $GITHUB_ENV
else
echo "NX_BASE=${{ github.event.before }}" >> $GITHUB_ENV
echo "NX_HEAD=${{ github.sha }}" >> $GITHUB_ENV
fi
# Same two-pass split as typecheck.yml: the server app's vitest config inlines
# next/next-auth and needs most of the runner's memory to itself, so libraries
# run in parallel first and the heavy apps run serially afterwards (libraries
# are instant Nx cache hits in pass 2).
# temporal-workflows' suite needs live Postgres/Redis/Temporal (49 of its
# 71 tests fail without them) and its clients reconnect forever, so the
# vitest process never exits and hangs the job. It has no place in a
# unit-test gate until its tests stub their connections.
# Non-blocking until the runner layer is repaired: the @nx/vite:test
# executor is broken under vitest 4 (fails even fully-green suites, and
# reports "Unable to load test config from config file undefined" for
# the ~20 projects with no vitest.config), so a red step here measures
# the executor, not the tests. Past green runs were Nx cache replays.
# Restore blocking once the executor is fixed and a real failure
# baseline exists.
- name: Nx affected tests (libraries, parallel)
continue-on-error: true
run: npx nx affected -t test --base=$NX_BASE --head=$NX_HEAD --output-style=static --parallel=3 --exclude=server,sebastian-ee,temporal-workflows
env:
NX_DAEMON: 'false'
NODE_OPTIONS: '--max-old-space-size=4096'
# Fixed shuffle seed so order-dependent failures are reproducible across reruns.
VITEST_SEED: '20260610'
# sebastian-ee's inferred test target is a bare `vitest` over its full
# 209-file suite (~50 minutes serial — far past this job's 30-minute
# backstop, which it blew via WorkflowAiSchemaSection's render-loop hang
# before that was fixed). Excluded until the suite gets a bounded
# unit-test target that fits a PR gate.
- name: Nx affected tests (apps, isolated & serial)
continue-on-error: true
run: npx nx affected -t test --base=$NX_BASE --head=$NX_HEAD --output-style=static --parallel=1 --exclude=temporal-workflows,sebastian-ee
env:
NX_DAEMON: 'false'
NODE_OPTIONS: '--max-old-space-size=14336'
VITEST_SEED: '20260610'
# Informational coverage report for the server unit suite. Push-to-main only
# (it re-runs the whole suite with instrumentation, too slow for every PR) and
# non-blocking while baselines are established.
coverage-report:
name: Server unit coverage (informational)
if: github.event_name == 'push'
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Upgrade npm to version 11
run: npm install -g npm@11
- name: Install dependencies
run: npm ci
- name: Run server unit tests with coverage
working-directory: ./server
run: npx vitest run src/test/unit src/test/infrastructure --coverage.enabled=true --coverage.reporter=text-summary --coverage.reporter=lcov
env:
NODE_OPTIONS: '--max-old-space-size=14336'
VITEST_SEED: '20260610'
- name: Upload lcov artifact
uses: actions/upload-artifact@v4
with:
name: server-unit-coverage
path: server/coverage/lcov.info
retention-days: 30

View File

@ -0,0 +1,158 @@
name: Validate Tenant Management Schema
on:
workflow_dispatch: # Allows manual triggering
pull_request:
branches:
- '**'
push:
branches:
- main
jobs:
check-changes:
name: Check for relevant changes
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for migration or tenant management changes
id: filter
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "should_run=true" >> $GITHUB_OUTPUT
exit 0
fi
# Get the base ref for comparison
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
else
BASE_SHA="${{ github.event.before }}"
fi
# Check if any relevant files changed
CHANGED_FILES=$(git diff --name-only $BASE_SHA ${{ github.sha }} 2>/dev/null || echo "")
if echo "$CHANGED_FILES" | grep -qE '^(server/migrations/|ee/server/migrations/|ee/temporal-workflows/src/activities/tenant-deletion-activities\.ts|\.github/workflows/validate-tenant-management\.yaml|scripts/validate-tenant-management\.ts)'; then
echo "should_run=true" >> $GITHUB_OUTPUT
echo "Relevant changes detected - will run validation"
else
echo "should_run=false" >> $GITHUB_OUTPUT
echo "No relevant changes - skipping validation"
fi
validate-tenant-management:
name: Validate Tenant Management Schema
needs: check-changes
if: needs.check-changes.outputs.should_run == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
image: ankane/pgvector:latest
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: test_password
POSTGRES_DB: alga_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Wait for Postgres to be ready
run: |
until pg_isready -h localhost -p 5432 -U postgres; do
echo "Waiting for postgres..."
sleep 2
done
echo "Postgres is ready!"
- name: Create secrets directory
run: |
mkdir -p secrets
echo -n "test_password" > secrets/postgres_password
echo -n "test_password" > secrets/db_password_server
chmod 600 secrets/*
- name: Create database and roles
working-directory: ./server
env:
DB_HOST: localhost
DB_PORT: 5432
DB_NAME_SERVER: alga_test
DB_USER_SERVER: app_user
DB_PASSWORD_ADMIN: test_password
DB_PASSWORD_SERVER: test_password
APP_ENV: test
run: node setup/create_database.js
- name: Run combined migrations
env:
DB_HOST: localhost
DB_PORT: 5432
DB_USER_ADMIN: postgres
DB_PASSWORD_ADMIN: test_password
DB_NAME_SERVER: alga_test
DB_TYPE: postgres
run: |
# Combine CE and EE migrations into single directory (same as entrypoint.sh)
mkdir -p server/combined-migrations
cp server/migrations/*.cjs server/combined-migrations/ 2>/dev/null || true
cp -r server/migrations/utils server/combined-migrations/ 2>/dev/null || true
cp ee/server/migrations/*.cjs server/combined-migrations/ 2>/dev/null || true
cp -r ee/server/migrations/utils server/combined-migrations/ 2>/dev/null || true
# Create temporary knexfile for combined migrations
cat > server/knexfile-combined.cjs << 'EOF'
module.exports = {
migration: {
client: 'pg',
connection: {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || '5432',
user: process.env.DB_USER_ADMIN || 'postgres',
password: process.env.DB_PASSWORD_ADMIN,
database: process.env.DB_NAME_SERVER || 'alga_test',
},
pool: { min: 2, max: 20 },
migrations: { directory: './combined-migrations' }
}
};
EOF
# Run migrations
cd server && npx knex migrate:latest --knexfile knexfile-combined.cjs --env migration
# Clean up
rm -rf combined-migrations knexfile-combined.cjs
- name: Validate tenant management schema
env:
DB_HOST: localhost
DB_PORT: 5432
DB_USER: postgres
DB_PASSWORD: test_password
DB_NAME: alga_test
run: npx tsx scripts/validate-tenant-management.ts

View File

@ -0,0 +1,45 @@
name: Validate Translations
on:
pull_request:
paths:
- 'server/public/locales/**'
- 'packages/**'
- 'server/src/**'
- 'ee/server/src/**'
- 'scripts/validate-translations.cjs'
- 'scripts/generate-pseudo-locales.cjs'
- 'scripts/find-missing-i18n-keys.cjs'
jobs:
validate:
name: Translation key consistency
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Validate translation keys
run: node scripts/validate-translations.cjs
find-missing-keys:
name: Missing locale keys
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Scan components for unresolved i18n keys
run: node scripts/find-missing-i18n-keys.cjs

View File

@ -0,0 +1,69 @@
name: EE Workflows Build Guard
on:
pull_request:
paths:
- server/**
- ee/server/**
- packages/workflows/**
- scripts/guard-ee-workflows-next-build.mjs
- scripts/guard-no-legacy-workflows-shims.mjs
- .github/workflows/workflows-ee-build-guard.yml
push:
branches:
- main
paths:
- server/**
- ee/server/**
- packages/workflows/**
- scripts/guard-ee-workflows-next-build.mjs
- scripts/guard-no-legacy-workflows-shims.mjs
- .github/workflows/workflows-ee-build-guard.yml
jobs:
ee-workflows-build-guard:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Upgrade npm to version 11
run: npm install -g npm@11
- name: Install dependencies
run: |
if ! npm ci; then
echo "npm ci failed; attempting npm install fallback to regenerate lock data"
npm install --legacy-peer-deps
fi
- name: Provide build-time env
run: |
cp .env.example server/.env
- name: Build server (EE)
env:
EDITION: ee
NEXT_PUBLIC_EDITION: enterprise
USE_PREBUILT: 'true'
run: |
npx nx build-deps server --output-style=static
# Override the 8 GB cap baked into server/package.json's build script;
# the EE webpack build hits the heap limit on ubuntu-latest at 8 GB.
# 14 GB leaves ~2 GB headroom on the 16 GB runner for the OS and other processes.
cd server
NODE_OPTIONS='--max-old-space-size=14336' NODE_ENV=production npx next build --webpack
- name: "Guard: legacy workflows shims removed"
run: node scripts/guard-no-legacy-workflows-shims.mjs
- name: "Guard: no OSS workflows stub in EE output"
run: node scripts/guard-ee-workflows-next-build.mjs server/.next

231
.gitignore vendored Normal file
View File

@ -0,0 +1,231 @@
# Dependencies
node_modules/
.pnp
.pnp.js
.npm-cache/
.tmp*
# testing
/coverage
test-results/
playwright-test-results/
playwright-report/
screenshots/
*.png
*.jpg
*.jpeg
*.gif
*.webp
*.webm
*.mp4
!ee/mobile/assets/
!ee/mobile/assets/**
ee/server/playwright-storage/
.tmp/
tmp/
tmp-ext/
tmp-ext2/
html/
dist/
test-results/
# Playwright utility scripts (for manual MinIO management)
scripts/minio-test.sh
scripts/start-playwright-minio.sh
scripts/stop-playwright-minio.sh
scripts/dev-install-extension.mjs
# Local development scripts (not committed)
scripts/dev-install-extension.mjs
# Next.js
*.next/
/out/
build/
dist/
# Enterprise Edition - copied files (generated during build)
# /server/src/app/msp/extensions/
# /server/src/lib/extensions/
# /server/src/lib/actions/extension-actions/
# /server/src/app/msp/layout.tsx
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.development
.env.image
# Env backup files (common sources of accidental credential leaks)
**/.env*.bak*
server/.env.local.bak*
ee/server/.env.local.bak*
ee/server/.env
ee/server/.playwright-client-portal-credentials.json
server/.env
server/.env.localtest
host.values.yaml
helm/values-hosted-env.yaml
helm/values-dev-env.yaml
# Logs
logs
logs-test/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
server/logs/
# Nx cache
.nx/
affected.json
deps.json
*-deps.json
*-graph.json
graph.json
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Production
/build
*.map
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Local env files
.env*.local
# misc
.DS_Store
*.pem
.ai/
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.development
.roo/
.mcp.json
CLAUDE.md
.claude/
.codex/
AGENTS.md
.agents/
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
ee/server/src/generated/
# Temporary and test files
temp-values-*.yaml
test-values-*.yaml
*-temp.*
*.temp.*
*.backup
*.db
*_log.txt
fix-*.sh
fix-*.nu
build.log
build_output.txt
typescript-errors-*.md
debug-*.sql
check-*.sql
check_*.sql
update-*.sql
server/.alga-ext-cache/
*draft*.md
*draft*.txt
*draft*.doc*
.vscode
ee/server/playwright-storage/
# EE Playwright test artifacts
ee/server/playwright-report/
ee/server/playwright-test-results/
volumes/
*.code-workspace
# package-lock.json
.aider*
prod.config.ini
secrets
secrets/
!server/src/lib/secrets/
!packages/core/src/lib/secrets/
!shared/workflow/secrets/
!server/src/components/settings/secrets/
.secrets
.clinerules
data/
uploads/
shared/dist
temp_wasm_compile
venv/
.venv
.serena
# OpenCode configuration files
OpenCode.md
opencode.md
.opencode
shared/core/*.d.ts
shared/core/*.js
# Package build artifacts (compiled TypeScript)
packages/*/src/**/*.js
packages/*/src/**/*.d.ts
packages/**/dist/
# Rust build output
/target/
**/target/
# Force CI cache refresh
server/coverage
.nyc_output
coverage-nyc/
server/coverage
packages/product-extension-storage-api/coverage
.nx/cache
.nx/workspace-data
vitest.config.*.timestamp*
# Next.js
.next
out
# Playwright local secret store (never commit)
secrets-playwright/
server/secrets-playwright/
.build-perf/
# build memory harness artifacts
.build-mem/

238
.impeccable/design.json Normal file
View File

@ -0,0 +1,238 @@
{
"schemaVersion": 2,
"generatedAt": "2026-05-16T00:00:00.000Z",
"title": "Design System: Alga PSA",
"extensions": {
"colorMeta": {
"operator-purple": {
"role": "primary",
"displayName": "Operator Purple",
"canonical": "#8a4dea",
"tonalRamp": ["#522e8c", "#6036a4", "#6e3dbb", "#7c45d3", "#8a4dea", "#a673f2", "#caa8f9", "#f6f0fe"]
},
"system-cyan": {
"role": "secondary",
"displayName": "System Cyan",
"canonical": "#40cff9",
"tonalRamp": ["#267c95", "#2d91ae", "#33a6c7", "#3abae0", "#40cff9", "#66dffb", "#b3f3fb", "#ecfcfe"]
},
"attention-amber": {
"role": "tertiary",
"displayName": "Attention Amber",
"canonical": "#ff9c30",
"tonalRamp": ["#995e1c", "#b36d21", "#cc7d26", "#e68c2b", "#ff9c30", "#ffb059", "#ffdb99", "#fff6e6"]
},
"slate-workspace": {
"role": "neutral",
"displayName": "Slate Workspace",
"canonical": "#f8fafc",
"tonalRamp": ["#0f172a", "#1e293b", "#334155", "#64748b", "#94a3b8", "#cbd5e1", "#e2e8f0", "#f8fafc"]
},
"sidebar-ink": {
"role": "neutral",
"displayName": "Sidebar Ink",
"canonical": "#0c111d",
"tonalRamp": ["#0c111d", "#0f172a", "#1e293b", "#334155", "#64748b", "#94a3b8", "#cbd5e1", "#f8fafc"]
}
},
"typographyMeta": {
"display": {
"displayName": "Display",
"purpose": "Page titles and major workspace context only."
},
"headline": {
"displayName": "Headline",
"purpose": "Section titles, drawer titles, and major panel headings."
},
"title": {
"displayName": "Title",
"purpose": "Card titles, modal titles, and local object names."
},
"body": {
"displayName": "Body",
"purpose": "Default UI copy, table cells, helper text, and client-facing explanations."
},
"label": {
"displayName": "Label",
"purpose": "Table headers, badges, compact metadata, and grouping labels."
}
},
"shadows": [
{
"name": "surface-rest",
"value": "none",
"purpose": "Default state for cards, tables, side panels, and content sections."
},
{
"name": "subtle-card",
"value": "0 1px 2px rgb(15 23 42 / 0.06)",
"purpose": "Optional low-risk lift for compact cards on busy workspace surfaces."
},
{
"name": "overlay",
"value": "0 10px 24px rgb(15 23 42 / 0.16)",
"purpose": "Dropdowns, popovers, menus, and temporary surfaces."
},
{
"name": "dialog",
"value": "0 24px 60px rgb(15 23 42 / 0.22)",
"purpose": "Dialogs and high-priority overlays that interrupt the workspace."
}
],
"motion": [
{
"name": "fast-state",
"value": "150ms cubic-bezier(0.25, 1, 0.5, 1)",
"purpose": "Quick hover, focus, and selected-state feedback."
},
{
"name": "normal-state",
"value": "200ms cubic-bezier(0.25, 1, 0.5, 1)",
"purpose": "Menus, disclosure, sidebar mode changes, and non-blocking transitions."
},
{
"name": "slow-overlay",
"value": "300ms cubic-bezier(0.16, 1, 0.3, 1)",
"purpose": "Dialog and drawer entrances when motion is not reduced."
}
],
"breakpoints": [
{ "name": "sm", "value": "640px" },
{ "name": "md", "value": "768px" },
{ "name": "lg", "value": "1024px" },
{ "name": "xl", "value": "1280px" }
]
},
"components": [
{
"name": "Primary Button",
"kind": "button",
"refersTo": "button-primary",
"description": "Primary action for saving, creating, approving, and moving work forward.",
"html": "<button class=\"ds-btn-primary\">Create ticket</button>",
"css": ".ds-btn-primary { display: inline-flex; align-items: center; justify-content: center; height: 40px; padding: 8px 16px; border: 0; border-radius: 6px; background: #8a4dea; color: #f8fafc; font: 600 14px/1.2 -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; cursor: pointer; transition: background-color 150ms cubic-bezier(0.25, 1, 0.5, 1), box-shadow 150ms cubic-bezier(0.25, 1, 0.5, 1); } .ds-btn-primary:hover { background: #7c45d3; } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 2px #f8fafc, 0 0 0 4px #8a4dea; } .ds-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }"
},
{
"name": "Ghost Button",
"kind": "button",
"refersTo": "button-ghost",
"description": "Low-emphasis toolbar, navigation, and utility action.",
"html": "<button class=\"ds-btn-ghost\">Quick Create</button>",
"css": ".ds-btn-ghost { display: inline-flex; align-items: center; justify-content: center; height: 36px; padding: 8px 12px; border: 0; border-radius: 6px; background: transparent; color: #334155; font: 500 14px/1.2 -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; cursor: pointer; transition: background-color 150ms cubic-bezier(0.25, 1, 0.5, 1), color 150ms cubic-bezier(0.25, 1, 0.5, 1); } .ds-btn-ghost:hover { background: #ede2fd; color: #6e3dbb; } .ds-btn-ghost:focus-visible { outline: none; box-shadow: 0 0 0 2px #8a4dea; }"
},
{
"name": "Text Input",
"kind": "input",
"refersTo": "input-default",
"description": "Default text entry control for forms, filters, and object editing.",
"html": "<label class=\"ds-field\"><span class=\"ds-field-label\">Client name</span><input class=\"ds-input\" placeholder=\"Search clients\" /></label>",
"css": ".ds-field { display: grid; gap: 4px; color: #334155; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; } .ds-field-label { font-size: 14px; font-weight: 500; } .ds-input { height: 40px; width: 240px; padding: 8px 12px; border: 1px solid #94a3b8; border-radius: 6px; background: #f8fafc; color: #0f172a; font: 400 14px/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; transition: border-color 150ms cubic-bezier(0.25, 1, 0.5, 1), box-shadow 150ms cubic-bezier(0.25, 1, 0.5, 1); } .ds-input::placeholder { color: #94a3b8; } .ds-input:focus { outline: none; border-color: transparent; box-shadow: 0 0 0 2px #8a4dea; } .ds-input:disabled { opacity: 0.5; cursor: not-allowed; }"
},
{
"name": "Operational Card",
"kind": "card",
"refersTo": "card-default",
"description": "Flat structural container for grouped content, never decoration by itself.",
"html": "<section class=\"ds-card\"><h3 class=\"ds-card-title\">Open tickets</h3><p class=\"ds-card-copy\">12 tickets need triage across 4 clients.</p></section>",
"css": ".ds-card { width: 280px; padding: 24px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; color: #0f172a; box-shadow: none; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; } .ds-card-title { margin: 0 0 8px; font-size: 18px; line-height: 1.25; font-weight: 600; letter-spacing: -0.01em; } .ds-card-copy { margin: 0; color: #64748b; font-size: 14px; line-height: 1.5; } .ds-card:hover { box-shadow: 0 1px 2px rgb(15 23 42 / 0.06); }"
},
{
"name": "Status Badge",
"kind": "chip",
"refersTo": "badge-default",
"description": "Compact status or metadata marker with semantic text and full border.",
"html": "<span class=\"ds-badge ds-badge-success\">Approved</span>",
"css": ".ds-badge { display: inline-flex; align-items: center; border-radius: 9999px; padding: 2px 10px; border: 1px solid #e2e8f0; background: #f1f5f9; color: #334155; font: 600 12px/1.33 -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; } .ds-badge-success { border-color: #bbf7d0; background: #dcfce7; color: #166534; } .ds-badge-warning { border-color: #fde68a; background: #fef3c7; color: #92400e; } .ds-badge-error { border-color: #fecaca; background: #fee2e2; color: #991b1b; }"
},
{
"name": "Data Table",
"kind": "custom",
"refersTo": "data-table",
"description": "Dense operational table for tickets, clients, billing, projects, and assets.",
"html": "<div class=\"ds-table-wrap\"><table class=\"ds-table\"><thead><tr><th>Ticket</th><th>Status</th><th>Owner</th></tr></thead><tbody><tr><td>#1048 VPN outage</td><td><span class=\"ds-mini-pill\">Open</span></td><td>Avery</td></tr><tr><td>#1049 Invoice review</td><td><span class=\"ds-mini-pill\">Waiting</span></td><td>Sam</td></tr></tbody></table></div>",
"css": ".ds-table-wrap { overflow: hidden; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; } .ds-table { width: 100%; border-collapse: collapse; font-size: 14px; color: #334155; } .ds-table th { padding: 12px 24px; text-align: left; color: #334155; font-size: 12px; font-weight: 600; letter-spacing: 0.04em; background: #f8fafc; } .ds-table td { padding: 12px 24px; border-top: 1px solid #f1f5f9; line-height: 1.5; } .ds-table tbody tr:nth-child(odd) { background: #f8fafc; } .ds-table tbody tr:nth-child(even) { background: #f1f5f9; } .ds-table tbody tr:hover { background: #f6f0fe; } .ds-mini-pill { display: inline-flex; border-radius: 9999px; padding: 1px 8px; border: 1px solid #caa8f9; background: #f6f0fe; color: #6e3dbb; font-size: 12px; font-weight: 600; }"
},
{
"name": "Sidebar Item",
"kind": "nav",
"refersTo": "sidebar-item",
"description": "Primary navigation row for the MSP application shell.",
"html": "<a class=\"ds-sidebar-item ds-sidebar-item-active\" href=\"#\"><svg class=\"ds-sidebar-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6h-4v6H5a1 1 0 0 1-1-1v-9.5Z\" fill=\"currentColor\"/></svg><span>Dashboard</span></a>",
"css": ".ds-sidebar-item { display: flex; align-items: center; gap: 8px; min-width: 220px; padding: 8px; margin: 0 8px; border-radius: 6px; color: #f5f5f5; background: transparent; font: 500 14px/1.2 -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; text-decoration: none; transition: background-color 150ms cubic-bezier(0.25, 1, 0.5, 1); } .ds-sidebar-item:hover { background: #1e293b; } .ds-sidebar-item-active { background: rgb(138 77 234 / 0.2); } .ds-sidebar-icon { width: 20px; height: 20px; color: #94a3b8; } .ds-sidebar-item:focus-visible { outline: none; box-shadow: 0 0 0 2px #8a4dea; }"
},
{
"name": "Tabs",
"kind": "custom",
"refersTo": "tabs",
"description": "Compact local navigation for details, comments, time, documents, and settings sections.",
"html": "<div class=\"ds-tabs\"><button class=\"ds-tab ds-tab-active\">Details</button><button class=\"ds-tab\">Comments</button><button class=\"ds-tab\">Time</button></div>",
"css": ".ds-tabs { display: flex; border-bottom: 1px solid #e2e8f0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; } .ds-tab { position: relative; padding: 8px 16px; border: 0; background: transparent; color: #64748b; font-size: 14px; line-height: 1.4; cursor: pointer; transition: color 150ms cubic-bezier(0.25, 1, 0.5, 1); } .ds-tab:hover { color: #334155; } .ds-tab-active { color: #0f172a; font-weight: 600; } .ds-tab-active::after { content: ''; position: absolute; left: 16px; right: 16px; bottom: -1px; height: 2px; border-radius: 9999px; background: #8a4dea; } .ds-tab:focus-visible { outline: none; box-shadow: inset 0 0 0 2px #8a4dea; }"
}
],
"narrative": {
"northStar": "The Modern Workbench",
"overview": "Alga PSA should feel like a modern operations workbench: quiet enough for long service days, dense enough for real MSP throughput, and exact enough that users trust every status, total, and action. The visual system uses familiar product UI patterns, crisp semantic tokens, and compact spacing to keep the task in front of the user.\n\nThe system is product-first. It borrows Notion's directness and Twenty.com's clean business-object clarity, then adapts both for ticket queues, billing approvals, project status, automation, assets, and client-facing transparency. The /msp area can be denser and more operator-focused. The /client area should translate the same model into calmer service visibility.\n\nThis system rejects dated enterprise MSP software, especially the heavy clutter associated with ConnectWise. It also rejects generic AI-generated SaaS UI: purposeless cards, decorative gradients, vague empty states, and elements placed without thought are forbidden.",
"keyCharacteristics": [
"Quiet, dense, and exact.",
"Restrained product color with Operator Purple used for action and selection.",
"Tonal layering first, with lift reserved for interaction.",
"Systematic component vocabulary across MSP and client surfaces.",
"Strong keyboard, focus, and status communication for operational confidence."
],
"rules": [
{
"name": "The Action Rarity Rule",
"body": "Operator Purple is for action, active state, focus, or selection. If it appears only to decorate, remove it.",
"section": "colors"
},
{
"name": "The Semantic Color Rule",
"body": "Success, warning, error, and info states must include iconography, labels, or text. Color alone is never sufficient.",
"section": "colors"
},
{
"name": "The No Decorative Gradient Rule",
"body": "Gradients are prohibited unless they explain a real state or are part of tenant-provided branding in a constrained client portal context.",
"section": "colors"
},
{
"name": "The Native Tool Rule",
"body": "Use one system sans family for product UI. Do not introduce display fonts into buttons, labels, tables, or data-heavy surfaces.",
"section": "typography"
},
{
"name": "The Density Needs Contrast Rule",
"body": "Dense screens need weight, spacing, and muted text contrast. Do not make every label the same size and weight.",
"section": "typography"
},
{
"name": "The Earned Lift Rule",
"body": "A surface earns a shadow only when it floats above other content, appears temporarily, or responds to interaction.",
"section": "elevation"
},
{
"name": "The Flat Workbench Rule",
"body": "Default work areas stay flat. Use borders, spacing, and hierarchy before adding shadow.",
"section": "elevation"
}
],
"dos": [
"Do keep MSP operator screens dense, but use hierarchy so priority, ownership, status, and next action are obvious.",
"Do use Operator Purple for primary action, selected state, focus, and active navigation.",
"Do use Slate Workspace and Slate Panel to separate areas before adding decorative effects.",
"Do make every status readable without color: include text, iconography, or both.",
"Do keep /client surfaces calmer than /msp surfaces while preserving the same component vocabulary.",
"Do use skeleton states for content loading and reserve spinners for short, isolated actions."
],
"donts": [
"Don't make Alga PSA feel like dated enterprise MSP software, especially the heavy clutter associated with ConnectWise.",
"Don't make screens feel AI-generated with decorative gradients, purposeless cards, vague empty states, or elements placed without thought.",
"Don't place elements without a task, state, or orientation purpose. Purpose before presence is mandatory.",
"Don't use side-stripe accent borders on cards, list items, callouts, or alerts. Use a full border, tint, icon, or clearer copy instead.",
"Don't use gradient text.",
"Don't use glassmorphism as a default surface treatment.",
"Don't create low-density dashboards that look polished but fail to support real MSP throughput.",
"Don't use consumer-style gloss for operational, financial, or service-delivery workflows."
]
}
}

6
.npmrc Normal file
View File

@ -0,0 +1,6 @@
# Ensure devDependencies install even if the developer shell has NODE_ENV=production
include=dev
# Peer graph conflict: next-auth@5.0.0-beta.30 wants nodemailer ^7, but
# the project uses nodemailer ^8. Safe to remove once next-auth is updated.
legacy-peer-deps=true

2
.nvmrc Normal file
View File

@ -0,0 +1,2 @@
20

27
.nxignore Normal file
View File

@ -0,0 +1,27 @@
# Directories that are not npm packages but contain Dockerfiles
ee/runner
ee/setup
setup
pgbouncer
redis
docker
# Build artifacts and dependencies
**/node_modules
**/.next
**/dist
**/out
**/build
**/.cache
# Test artifacts
**/coverage
# Other non-project directories
.git
.github
.vscode
tools
cli
docs
scripts

152
ARCHITECTURE_FIX.md Normal file
View File

@ -0,0 +1,152 @@
# Portal Layering Architecture Fix
## Problem
The `ContactPortalTab` component in `@alga-psa/clients` was directly importing portal-related actions from `@alga-psa/client-portal`, creating a horizontal (cross-layer) dependency between two domain layer packages:
```
❌ VIOLATION:
@alga-psa/clients (Layer 3 - Domain)
↓ imports directly from ↓
@alga-psa/client-portal (Layer 3 - Domain)
```
## Solution
Created a new infrastructure layer package `@alga-psa/portal-shared` (Layer 2) that:
1. Defines shared portal type definitions
2. Re-exports portal actions to break direct dependencies
3. Allows both domain packages to safely depend on it without circular dependencies
```
✅ FIXED:
@alga-psa/clients (Layer 3 - Domain)
↓ imports from ↓
@alga-psa/portal-shared (Layer 2 - Infrastructure) ← NEW PACKAGE
↓ re-exports from ↓
@alga-psa/client-portal (Layer 3 - Domain)
```
## Updated Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ LAYER 5: Application │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ server (MSP app) │ EE modules │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────────────────────┘
│ (depends on all layers below)
┌─────────────────────────────────────────────────────────────────────────────┐
│ LAYER 4: Presentation │
│ ┌────────────────────────────────────────────────────────────────────────┐ │
│ │ @alga-psa/ui (UI components, hooks, utilities) │ │
│ │ (No dependencies on domain logic) │ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ LAYER 3: Domain Services │
│ (Each represents a business domain - should NOT depend on each other) │
│ ┌──────────────┬──────────────┬──────────────┬──────────────┬────────────┐ │
│ │ @alga-psa/ │ @alga-psa/ │ @alga-psa/ │ @alga-psa/ │ @alga-psa/│ │
│ │ clients │ tickets │ client-portal│ projects │ billing │ │
│ └──────────────┴──────────────┴──────────────┴──────────────┴────────────┘ │
│ ┌──────────────┬──────────────┬──────────────┬──────────────┬────────────┐ │
│ │ @alga-psa/ │ @alga-psa/ │ @alga-psa/ │ @alga-psa/ │ @alga-psa/│ │
│ │ documents │ tags │notifications │ workflows │scheduling │ │
│ └──────────────┴──────────────┴──────────────┴──────────────┴────────────┘ │
│ │
│ ✅ NO horizontal dependencies between packages (enforced via │
│ infrastructure layer re-exports when cross-cutting logic exists) │
└──────────────────────────┬──────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ LAYER 2: Infrastructure │
│ (Cross-cutting concerns and technical services) │
│ ┌──────────────┬──────────────┬──────────────┬──────────────┬────────────┐ │
│ │ @alga-psa/ │ @alga-psa/ │ @alga-psa/ │ @alga-psa/ │ @alga-psa/ │ │
│ │ auth │ users │ media │ email │ tenancy │ │
│ └──────────────┴──────────────┴──────────────┴──────────────┴────────────┘ │
│ ┌──────────────┬──────────────┬──────────────┬──────────────┬────────────┐ │
│ │ @alga-psa/ │ @alga-psa/ │ @alga-psa/ │ @alga-psa/ │ @alga-psa/ │ │
│ │ validation │ integrations │ reference- │ portal-shared│ core │ │
│ │ │ │ data │ │ │ │
│ └──────────────┴──────────────┴──────────────┴──────────────┴────────────┘ │
│ │
│ NEW: @alga-psa/portal-shared - Re-exports portal functionality to break │
│ domain-level cross-dependencies │
└──────────────────────────┬──────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ LAYER 1: Foundation / Core │
│ (No dependencies on any other @alga-psa/* packages) │
│ ┌──────────────┬──────────────┬──────────────┐ │
│ │ @alga-psa/ │ @alga-psa/ │ @alga-psa/ │ │
│ │ types │ core │ db │ │
│ │ │ │ │ │
│ │ - Interfaces │ - Utilities │ - Migrations │ │
│ │ - Type defs │ - Helpers │ - Schemas │ │
│ │ - Constants │ - Encryption │ - Knex setup │ │
│ └──────────────┴──────────────┴──────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Files Changed
### New Package
- **Created:** `packages/portal-shared/` - New infrastructure layer package
- `package.json` - Package configuration
- `tsconfig.json` - TypeScript configuration
- `README.md` - Package documentation
- `src/types/index.ts` - Shared type definitions
- `src/actions/index.ts` - Actions index
- `src/actions/portalInvitationActions.ts` - Re-exported actions
- `src/index.ts` - Package entry point
### Updated Components
- **Modified:** `packages/clients/src/components/contacts/ContactPortalTab.tsx`
- Updated imports to use `@alga-psa/portal-shared` instead of `@alga-psa/client-portal`
- **Modified:** `packages/clients/src/components/contacts/ContactAvatarUpload.tsx`
- Updated imports to use `@alga-psa/portal-shared` instead of `@alga-psa/client-portal`
## Dependency Flow
### Before (Violation)
```typescript
// packages/clients/src/components/contacts/ContactPortalTab.tsx
import { sendPortalInvitation } from '@alga-psa/client-portal/actions'; // ❌ Direct cross-domain import
```
### After (Fixed)
```typescript
// packages/clients/src/components/contacts/ContactPortalTab.tsx
import { sendPortalInvitation } from '@alga-psa/portal-shared/actions'; // ✅ Via infrastructure layer
// packages/portal-shared/src/actions/portalInvitationActions.ts
export { sendPortalInvitation } from '@alga-psa/client-portal/actions'; // Re-export pattern
```
## Architecture Principles
This solution maintains the following architectural principles:
1. **No Circular Dependencies** - `@alga-psa/clients` does not directly depend on `@alga-psa/client-portal`
2. **Clean Layering** - Infrastructure layer (portal-shared) bridges domain-level concerns
3. **Separation of Concerns** - Each domain package owns its business logic
4. **Dependency Inversion** - Domain packages depend on abstractions (infrastructure re-exports)
5. **Facade Pattern** - Infrastructure layer provides a simplified interface to domain functionality
## Future Improvements
If portal functionality continues to grow, consider:
1. **Moving implementations to portal-shared** - Move actual `PortalInvitationService` and action implementations
2. **Creating domain event bus** - For more complex cross-domain communication patterns
3. **Using dependency injection** - For more sophisticated dependency management
4. **Creating orchestration layer** - Between application layer and domain packages for complex workflows

277
DESIGN.md Normal file
View File

@ -0,0 +1,277 @@
---
name: Alga PSA
description: Quiet, dense, exact product UI for MSP operations and client service visibility.
colors:
operator-purple: "#8a4dea"
operator-purple-hover: "#7c45d3"
operator-purple-soft: "#f6f0fe"
system-cyan: "#40cff9"
system-cyan-soft: "#ecfcfe"
attention-amber: "#ff9c30"
attention-amber-soft: "#fff6e6"
slate-workspace: "#f8fafc"
slate-panel: "#f1f5f9"
slate-border: "#e2e8f0"
slate-border-strong: "#94a3b8"
slate-text: "#0f172a"
slate-text-muted: "#64748b"
sidebar-ink: "#0c111d"
sidebar-hover: "#1e293b"
success: "#22c55e"
warning: "#f59e0b"
error: "#ef4444"
typography:
display:
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, system-ui, sans-serif"
fontSize: "1.5rem"
fontWeight: 600
lineHeight: 1.25
letterSpacing: "-0.01em"
headline:
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, system-ui, sans-serif"
fontSize: "1.25rem"
fontWeight: 600
lineHeight: 1.3
title:
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, system-ui, sans-serif"
fontSize: "1.125rem"
fontWeight: 600
lineHeight: 1.25
body:
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, system-ui, sans-serif"
fontSize: "0.875rem"
fontWeight: 400
lineHeight: 1.5
label:
fontFamily: "-apple-system, BlinkMacSystemFont, Segoe UI, system-ui, sans-serif"
fontSize: "0.75rem"
fontWeight: 600
lineHeight: 1.33
letterSpacing: "0.04em"
rounded:
sm: "4px"
md: "6px"
lg: "8px"
spacing:
xs: "4px"
sm: "8px"
md: "12px"
lg: "16px"
xl: "24px"
xxl: "32px"
components:
button-primary:
backgroundColor: "{colors.operator-purple}"
textColor: "{colors.slate-workspace}"
rounded: "{rounded.md}"
padding: "8px 16px"
height: "40px"
typography: "{typography.body}"
button-primary-hover:
backgroundColor: "{colors.operator-purple-hover}"
textColor: "{colors.slate-workspace}"
rounded: "{rounded.md}"
padding: "8px 16px"
height: "40px"
typography: "{typography.body}"
button-ghost:
backgroundColor: "transparent"
textColor: "{colors.slate-text}"
rounded: "{rounded.md}"
padding: "8px 12px"
height: "36px"
typography: "{typography.body}"
input-default:
backgroundColor: "{colors.slate-workspace}"
textColor: "{colors.slate-text}"
rounded: "{rounded.md}"
padding: "8px 12px"
height: "40px"
typography: "{typography.body}"
card-default:
backgroundColor: "{colors.slate-workspace}"
textColor: "{colors.slate-text}"
rounded: "{rounded.lg}"
padding: "24px"
badge-default:
backgroundColor: "{colors.slate-panel}"
textColor: "{colors.slate-text-muted}"
rounded: "9999px"
padding: "2px 10px"
typography: "{typography.label}"
---
# Design System: Alga PSA
## 1. Overview
**Creative North Star: "The Modern Workbench"**
Alga PSA should feel like a modern operations workbench: quiet enough for long service days, dense enough for real MSP throughput, and exact enough that users trust every status, total, and action. The visual system uses familiar product UI patterns, crisp semantic tokens, and compact spacing to keep the task in front of the user.
The system is product-first. It borrows Notion's directness and Twenty.com's clean business-object clarity, then adapts both for ticket queues, billing approvals, project status, automation, assets, and client-facing transparency. The `/msp` area can be denser and more operator-focused. The `/client` area should translate the same model into calmer service visibility.
This system rejects dated enterprise MSP software, especially the heavy clutter associated with ConnectWise. It also rejects generic AI-generated SaaS UI: purposeless cards, decorative gradients, vague empty states, and elements placed without thought are forbidden.
**Key Characteristics:**
- Quiet, dense, and exact.
- Restrained product color with Operator Purple used for action and selection.
- Tonal layering first, with lift reserved for interaction.
- Systematic component vocabulary across MSP and client surfaces.
- Strong keyboard, focus, and status communication for operational confidence.
## 2. Colors
The palette is a restrained operational system: Slate Workspace carries the work, Operator Purple marks decisions and active state, System Cyan supports secondary system feedback, and Attention Amber highlights caution without turning the product into an alert wall.
### Primary
- **Operator Purple:** The primary action and selection color. Use it for main actions, active navigation, focused controls, selected table states, and high-value action affordances. It must stay rare enough to mean action.
- **Operator Purple Soft:** The quiet tint for hover, selected table rows, low-priority emphasis, and background hints. Use this before reaching for saturated purple.
### Secondary
- **System Cyan:** A secondary system color for non-destructive system feedback, integration context, extension surfaces, and secondary data visualization. It should not compete with Operator Purple for primary action.
### Tertiary
- **Attention Amber:** A warm attention color for warning, time-sensitive, billing-adjacent, and cautionary states. Use it as a semantic signal, not as decoration.
### Neutral
- **Slate Workspace:** The default working surface. It keeps dense screens calm and gives data, controls, and tables room to breathe.
- **Slate Panel:** The subtle secondary layer for table alternation, hover regions, filter surfaces, and low-emphasis containers.
- **Slate Border:** The default divider and container edge. It should be visible enough to organize dense content but never heavy.
- **Slate Text:** The high-confidence text color for labels, headings, data, and primary content.
- **Slate Text Muted:** Secondary explanation, metadata, helper text, timestamps, and de-emphasized table values.
- **Sidebar Ink:** The navigation anchor. It creates a stable operational frame around the lighter workspace.
### Named Rules
**The Action Rarity Rule.** Operator Purple is for action, active state, focus, or selection. If it appears only to decorate, remove it.
**The Semantic Color Rule.** Success, warning, error, and info states must include iconography, labels, or text. Color alone is never sufficient.
**The No Decorative Gradient Rule.** Gradients are prohibited unless they explain a real state or are part of tenant-provided branding in a constrained client portal context.
## 3. Typography
**Display Font:** System sans stack with native platform rendering.
**Body Font:** System sans stack with native platform rendering.
**Label/Mono Font:** System sans by default. Use mono only for identifiers, logs, code, and machine values.
**Character:** Typography is native, compact, and legible. It should feel like a serious work tool, not a marketing page wearing product chrome.
### Hierarchy
- **Display** (600, 1.5rem, 1.25): Page-level titles, major dashboard headings, and primary workspace context.
- **Headline** (600, 1.25rem, 1.3): Section headers, form group headings, drawer titles, and large panel labels.
- **Title** (600, 1.125rem, 1.25): Card titles, table section titles, modal titles, and local object names.
- **Body** (400, 0.875rem, 1.5): Default UI copy, table cells, descriptions, control labels when not compact, and client-facing explanatory text. Prose should stay within 65 to 75 characters per line when it is meant to be read as a paragraph.
- **Label** (600, 0.75rem, 1.33, tracked): Table headers, badge text, section labels, metadata labels, and compact navigation grouping.
### Named Rules
**The Native Tool Rule.** Use one system sans family for product UI. Do not introduce display fonts into buttons, labels, tables, or data-heavy surfaces.
**The Density Needs Contrast Rule.** Dense screens need weight, spacing, and muted text contrast. Do not make every label the same size and weight.
## 4. Elevation
Alga PSA is flat by default, lifted on interaction. Depth is primarily created through tonal layers, borders, table alternation, and selected states. Shadows are reserved for overlays, dropdowns, dialogs, draggable surfaces, and hover moments where the surface has genuinely moved closer to the user.
### Shadow Vocabulary
- **Surface Rest** (`none`): Default cards, table containers, side panels, and content sections.
- **Subtle Card** (`0 1px 2px rgb(15 23 42 / 0.06)`): Optional low-risk lift for compact cards on a busy workspace surface.
- **Overlay** (`0 10px 24px rgb(15 23 42 / 0.16)`): Dropdowns, popovers, menus, and temporary surfaces.
- **Dialog** (`0 24px 60px rgb(15 23 42 / 0.22)`): Dialogs and high-priority overlays that interrupt the workspace.
### Named Rules
**The Earned Lift Rule.** A surface earns a shadow only when it floats above other content, appears temporarily, or responds to interaction.
**The Flat Workbench Rule.** Default work areas stay flat. Use borders, spacing, and hierarchy before adding shadow.
## 5. Components
### Buttons
Buttons are compact, predictable, and stateful.
- **Shape:** Gently curved rectangle (6px radius), matching the current medium radius.
- **Primary:** Operator Purple background with Slate Workspace text, 40px height, 8px vertical padding, 16px horizontal padding, medium-weight 14px text.
- **Hover / Focus:** Hover darkens to the next Operator Purple step. Focus uses a visible 2px ring in Operator Purple with enough offset to separate it from surrounding surfaces.
- **Secondary / Ghost / Tertiary:** Secondary actions should use outline or soft treatments before adding more saturated color. Ghost buttons are for toolbar and navigation actions, never for destructive confirmation.
### Chips
Chips communicate status, filtering, and compact metadata.
- **Style:** Rounded pill shape with 9999px radius, 10 to 12px horizontal padding, 10 to 12px text, and a visible full border for status variants.
- **State:** Selected filter chips use a soft Operator Purple background and clear text. Status chips use semantic color plus text label.
### Cards / Containers
Cards are structural containers, not decoration.
- **Corner Style:** Soft operational corners (8px radius).
- **Background:** Slate Workspace or the current card surface.
- **Shadow Strategy:** Flat at rest. Optional subtle lift only for hoverable or temporary surfaces.
- **Border:** Full 1px border in Slate Border or an equivalent theme token.
- **Internal Padding:** 24px for full cards, 16px for dense panels, 12px for compact table-adjacent surfaces.
### Inputs / Fields
Inputs should feel exact and stable.
- **Style:** 40px default height, 6px radius, full 1px border, Slate Workspace surface, Slate Text content, and muted placeholder text.
- **Focus:** Clear Operator Purple ring or border shift. Never hide focus for mouse users if the component can be reached by keyboard.
- **Error / Disabled:** Error uses semantic red plus inline message. Disabled state lowers opacity and preserves layout.
### Navigation
Navigation is a stable operational frame.
- **Style:** Sidebar Ink background with light text, 16rem expanded width, 4rem collapsed width, compact row spacing, and 8px row radius.
- **Active State:** Operator Purple at low opacity with clear text and icon contrast.
- **Hover State:** Sidebar hover surface only. Do not add additional accent marks.
- **Mobile Treatment:** Collapse structure before shrinking type. Keep targets at comfortable tap sizes.
### Data Tables
Tables are core product surfaces, not generic content blocks.
- **Structure:** Rounded 8px container with full 1px border, compact 12px vertical cell padding, 24px horizontal cell padding, and alternate row tinting.
- **Headers:** 12px medium labels with tracking, muted but readable. Sorting must be visible and keyboard reachable.
- **Rows:** Hover uses a soft Operator Purple or Slate Panel tint. Clickable rows must indicate interactivity consistently.
### Dialogs and Menus
Temporary surfaces must be precise and interrupt only when needed.
- **Menus:** 6px radius, compact padding, full border, overlay shadow, and focusable rows.
- **Dialogs:** Use for blocking decisions, destructive confirmation, and focused edit flows. Prefer inline progressive disclosure when interruption is not required.
## 6. Do's and Don'ts
### Do:
- **Do** keep MSP operator screens dense, but use hierarchy so priority, ownership, status, and next action are obvious.
- **Do** use Operator Purple for primary action, selected state, focus, and active navigation.
- **Do** use Slate Workspace and Slate Panel to separate areas before adding decorative effects.
- **Do** make every status readable without color: include text, iconography, or both.
- **Do** keep `/client` surfaces calmer than `/msp` surfaces while preserving the same component vocabulary.
- **Do** use skeleton states for content loading and reserve spinners for short, isolated actions.
### Don't:
- **Don't** make Alga PSA feel like dated enterprise MSP software, especially the heavy clutter associated with ConnectWise.
- **Don't** make screens feel AI-generated with decorative gradients, purposeless cards, vague empty states, or elements placed without thought.
- **Don't** place elements without a task, state, or orientation purpose. Purpose before presence is mandatory.
- **Don't** use side-stripe accent borders on cards, list items, callouts, or alerts. Use a full border, tint, icon, or clearer copy instead.
- **Don't** use gradient text.
- **Don't** use glassmorphism as a default surface treatment.
- **Don't** create low-density dashboards that look polished but fail to support real MSP throughput.
- **Don't** use consumer-style gloss for operational, financial, or service-delivery workflows.

84
Dockerfile Normal file
View File

@ -0,0 +1,84 @@
# Dockerfile for using pre-built artifacts
# Designed for Argo workflows and CI systems that build separately
# Expects .next, dist, and shared/dist to exist locally
# Pin to the production-known-good Node runtime. node:alpine floated to Node 26.2.0
# and broke bundled Vault provider initialization in the blue deployment.
FROM node:26.1.0-alpine
RUN apk add --no-cache \
bash \
postgresql-client \
redis \
graphicsmagick \
imagemagick \
ghostscript \
curl \
nano \
ffmpeg
WORKDIR /app
# Copy package files for dependency installation
COPY package.json package-lock.json ./
COPY server/package.json ./server/
COPY shared/package.json ./shared/
COPY ee/server/package.json ./ee/server/
COPY ee/packages/workflows/package.json ./ee/packages/workflows/
COPY services/workflow-worker/package.json ./services/workflow-worker/
# Install only production dependencies.
# npm@11 enforces peer dependency resolution more strictly; this image only
# needs runtime deps and should not fail on dev-only peer conflicts.
RUN npm install --omit=dev --legacy-peer-deps
# Copy base files
COPY tsconfig.base.json ./
COPY server/setup /app/server/setup
COPY .env.example /app/.env
COPY .env.example /app/server/.env
# Copy pre-built shared workspace (must exist locally)
COPY ./shared/dist/ ./shared/dist/
COPY ./shared/package.json ./shared/package.json
# Copy pre-built Next.js artifacts (must exist locally)
# server/dist is no longer required here; workflow-worker is built/deployed separately
COPY ./server/.next ./server/.next
# Copy runtime files
COPY ./server/public ./server/public
COPY ./server/next.config.mjs ./server/
COPY ./server/knexfile.cjs ./server/
COPY ./server/tsconfig.json ./server/
COPY ./server/index.ts ./server/
COPY ./server/migrations/ ./server/migrations/
COPY ./server/seeds/ ./server/seeds/
COPY ./server/src/ ./server/src/
COPY ./ee/packages/workflows/ ./ee/packages/workflows/
COPY ./scripts ./scripts
COPY ./shared/workflow/ ./shared/workflow/
# Copy core package.json for version info
COPY packages/core/package.json /app/packages/core/package.json
# Copy entrypoint
COPY server/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
# Create build timestamp for verification
RUN echo "BUILD_TIME=$(date)" > /app/build-info.txt && \
echo "BUILD_EPOCH=$(date +%s)" >> /app/build-info.txt
EXPOSE 3000
# Environment configuration
ENV NODE_ENV=production
# Secret provider configuration
# Default configuration for composite secret system
# Can be overridden in docker-compose or deployment environments
# See docs/DOCKER_SECRET_PROVIDER_CONFIG.md for details
ENV SECRET_READ_CHAIN="env,filesystem"
ENV SECRET_WRITE_PROVIDER="filesystem"
ENTRYPOINT ["/app/entrypoint.sh"]

158
Dockerfile.build Normal file
View File

@ -0,0 +1,158 @@
# Start with a base image and install system dependencies
# Pinned to Node 24 because the monorepo enforces ">=20 <25" in package.json engines.
FROM node:24-alpine AS base
RUN apk add \
graphicsmagick \
imagemagick \
ghostscript \
postgresql-client \
redis \
curl \
nano \
bash
WORKDIR /app
# Stage for installing dependencies (cache-friendly)
FROM base AS deps
# Copy package files for both root and server parts of the project
COPY package.json package-lock.json ./
COPY server/package.json ./server/
COPY shared/package.json ./shared/
COPY ee/packages/workflows/package.json ./ee/packages/workflows/
COPY tsconfig.base.json ./
COPY server/src/invoice-templates/assemblyscript ./server/src/invoice-templates/assemblyscript
# Builder stage for compiling the application
FROM deps AS builder
# Fallback secret values ensure edge-auth guards pass during build; real values come from runtime secrets.
ARG NEXTAUTH_SECRET_BUILD="dev-placeholder-nextauth-secret-32"
ARG NEXT_BUILD_MAX_OLD_SPACE_SIZE="12288"
# Keep every Node-based build step in this stage on the same heap limit. The
# set-image-tag.sh helper passes NEXT_BUILD_MAX_OLD_SPACE_SIZE as a build arg.
ENV NODE_OPTIONS="--max-old-space-size=${NEXT_BUILD_MAX_OLD_SPACE_SIZE}"
# Copy all project files and build the server
WORKDIR /app
COPY . .
# Remove only the EE server tree for CE builds.
# The workflows workspace now lives under ee/packages/workflows and must remain
# present because node_modules/@alga-psa/workflows is a workspace symlink.
RUN rm -rf /app/ee/server
# Copy CE stubs from server/src/empty to ee/server/src for @ee alias resolution
# This ensures both TypeScript and webpack can resolve @ee imports during CE builds
RUN mkdir -p /app/ee/server/src && \
cp -r /app/server/src/empty/. /app/ee/server/src/
# Create a dummy file for the relative import that webpack tries to resolve
RUN mkdir -p /app/ee/server/src/lib/extensions && \
echo "export const initializeExtensions = async () => { console.log('CE build - extensions not available'); };" > /app/ee/server/src/lib/extensions/initialize.js
# Create minimal stubs for packages that use relative paths (not @ee alias)
# packages/product-chat/ee/entry.tsx uses ../../../ee/server/src/services/*
RUN mkdir -p /app/ee/server/src/services && \
printf "export class ChatStreamService {\n static async handleChatStream(req) {\n return new Response(JSON.stringify({ error: 'Chat streaming is only available in Enterprise Edition' }), {\n status: 404,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n static async handleTitleStream(req) {\n return new Response(JSON.stringify({ error: 'Chat streaming is only available in Enterprise Edition' }), {\n status: 404,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n}\n" > /app/ee/server/src/services/chatStreamService.js && \
printf "export class TemporaryApiKeyService {\n static async cleanupExpiredAiKeys() {\n return 0;\n }\n}\n" > /app/ee/server/src/services/temporaryApiKeyService.js && \
printf "export class ChatCompletionsService {\n static async handleRequest(req) {\n return new Response(JSON.stringify({ error: 'Chat completions are only available in Enterprise Edition' }), {\n status: 404,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n static async handleExecute(req) {\n return new Response(JSON.stringify({ error: 'Chat completions are only available in Enterprise Edition' }), {\n status: 404,\n headers: { 'Content-Type': 'application/json' },\n });\n }\n}\n" > /app/ee/server/src/services/chatCompletionsService.js
# Remove EE-specific scripts that shouldn't be in CE build
RUN rm -f /app/server/src/scripts/check-extension.ts
RUN npm install --include=optional
# Build all upstream packages (shared + pre-built) via Nx (handles dependency ordering)
WORKDIR /app
RUN npx nx build-deps server
ENV USE_PREBUILT="true"
# Set edition to community for proper module resolution
ENV EDITION="ce"
ENV NEXT_PUBLIC_EDITION="community"
WORKDIR /app/server
RUN NEXTAUTH_SECRET="${NEXTAUTH_SECRET_BUILD}" \
AUTH_SECRET="${NEXTAUTH_SECRET_BUILD}" \
NODE_ENV=production \
npm exec -- next build --webpack
# Create secrets directory and populate with secure placeholder values
RUN mkdir -p /app/secrets && \
echo "secure-admin-password-placeholder" > /app/secrets/postgres_password && \
echo "secure-app-password-placeholder" > /app/secrets/db_password_server && \
echo "secure-hocuspocus-password-placeholder" > /app/secrets/db_password_hocuspocus && \
echo "secure-redis-password-placeholder" > /app/secrets/redis_password && \
echo "secure-32char-auth-key-placeholder-xxxxx" > /app/secrets/alga_auth_key && \
echo "secure-32char-crypto-key-placeholder-xxxx" > /app/secrets/crypto_key && \
echo "secure-32char-token-key-placeholder-xxxx" > /app/secrets/token_secret_key && \
echo "secure-32char-nextauth-key-placeholder-xx" > /app/secrets/nextauth_secret && \
echo "secure-email-password-placeholder" > /app/secrets/email_password && \
echo "secure-oauth-client-id-placeholder" > /app/secrets/google_oauth_client_id && \
echo "secure-oauth-client-secret-placeholder" > /app/secrets/google_oauth_client_secret && \
echo "secure-gmail-client-id-placeholder" > /app/secrets/GOOGLE_CLIENT_ID && \
echo "secure-gmail-client-secret-placeholder" > /app/secrets/GOOGLE_CLIENT_SECRET && \
echo "secure-ms-client-id-placeholder" > /app/secrets/MICROSOFT_CLIENT_ID && \
echo "secure-ms-client-secret-placeholder" > /app/secrets/MICROSOFT_CLIENT_SECRET && \
chmod 600 /app/secrets/*
# Copy example environment file
COPY .env.example /app/.env
COPY .env.example /app/server/.env
# Final production image with minimal runtime artifacts
FROM node:24-alpine
RUN apk add --no-cache bash \
postgresql-client \
redis \
graphicsmagick \
imagemagick \
ghostscript \
curl \
nano \
bash
WORKDIR /app
COPY tsconfig.base.json ./
COPY server/setup /app/server/setup
COPY .env.example /app/.env
COPY .env.example /app/server/.env
# Copy built application and node_modules from earlier stages -- minimalist approach
COPY --from=builder /app/shared ./shared
COPY --from=builder /app/ee/packages/workflows ./ee/packages/workflows
COPY --from=builder /app/server/.next ./server/.next
COPY --from=builder /app/server/public ./server/public
COPY --from=builder /app/server/next.config.mjs ./server/
COPY --from=builder /app/server/package.json ./server/
COPY --from=builder /app/package.json ./
COPY --from=builder /app/package-lock.json ./
COPY --from=builder /app/server/knexfile.cjs ./server/
COPY --from=builder /app/server/tsconfig.json ./server/
COPY --from=builder /app/server/index.ts ./server/
COPY --from=builder /app/server/migrations/ ./server/migrations/
COPY --from=builder /app/server/seeds/ ./server/seeds/
COPY --from=builder /app/server/src/ ./server/src/
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/server/node_modules ./server/node_modules
RUN npm install -g tsx
# Copy core package.json for version info
COPY packages/core/package.json /app/packages/core/package.json
COPY server/entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
EXPOSE 3000
# Environment configuration
ENV NODE_ENV=production
# Secret provider configuration
# Default configuration for composite secret system
# Can be overridden in docker-compose or deployment environments
ENV SECRET_READ_CHAIN="env,filesystem"
ENV SECRET_WRITE_PROVIDER="filesystem"
ENTRYPOINT ["/app/entrypoint.sh"]

46
Dockerfile.dev Normal file
View File

@ -0,0 +1,46 @@
FROM node:alpine
RUN apk add --no-cache \
bash \
curl \
ghostscript \
graphicsmagick \
imagemagick \
postgresql-client \
redis \
chromium \
nss \
freetype \
harfbuzz \
ca-certificates \
ttf-freefont
# Tell Puppeteer to use system Chromium instead of downloading
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
WORKDIR /app
# Copy manifests first for better layer caching
COPY package.json package-lock.json ./
COPY server/package.json ./server/
COPY shared/package.json ./shared/
COPY services/workflow-worker/package.json ./services/workflow-worker/
COPY ee/server/package.json ./ee/server/
COPY ee/packages/workflows/package.json ./ee/packages/workflows/
COPY packages ./packages
COPY sdk ./sdk
RUN npm config set legacy-peer-deps true && npm install
# Bring in source (server, shared, ee, etc.)
COPY . .
ENV NODE_OPTIONS="--max-old-space-size=4096"
COPY server/dev-entrypoint.sh /app/server-dev-entrypoint.sh
RUN chmod +x /app/server-dev-entrypoint.sh
EXPOSE 3000
ENV NODE_ENV=development
ENTRYPOINT ["/app/server-dev-entrypoint.sh"]

17
Dockerfile.pgvector-clean Normal file
View File

@ -0,0 +1,17 @@
FROM postgres:15
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
git \
build-essential \
postgresql-server-dev-15 \
&& cd /tmp \
&& git clone --branch v0.8.0 --depth 1 https://github.com/pgvector/pgvector.git \
&& cd pgvector \
&& make \
&& make install \
&& cd /tmp \
&& rm -rf pgvector \
&& apt-get purge -y git build-essential postgresql-server-dev-15 \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*

10
Dockerfile.test Normal file
View File

@ -0,0 +1,10 @@
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "test:local"]

21
LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
# License Terms
This project contains components under different licenses:
## Documentation (`docs/` directory)
All content in the `docs/` directory is licensed under the Creative Commons Attribution 4.0 International License (CC BY 4.0). You are free to:
- Share — copy and redistribute the material in any medium or format
- Adapt — remix, transform, and build upon the material for any purpose
Provided you give appropriate attribution to the original authors.
## Enterprise Edition (`ee/` directory)
All content in the `ee/` directory is provided under the terms specified in the `ee/LICENSE` file.
## Dependencies
All third-party dependencies included in this project retain their original licenses. Please refer to each dependency's license file or documentation for specific terms.
## All Other Content
Except as otherwise noted above, all other content in this project is licensed under the GNU Affero General Public License Version 3 (AGPL-3.0). You should have received a copy of the GNU Affero General Public License along with this program. If not, see <https://www.gnu.org/licenses/agpl-3.0.html>.
---
Copyright (c) 2026 Nine Minds LLC

41
Makefile Normal file
View File

@ -0,0 +1,41 @@
install_cli:
./setup/bash/install_cli.sh
validate-secrets:
@./scripts/validate-secrets.sh
# Main docker commands with automatic validation
sebastian-docker-run:
./setup/bash/run-compose.sh ./docker-compose.yaml -d
docker-up-ee:
@./scripts/docker-compose-wrapper.sh -f docker-compose.yaml -f docker-compose.base.yaml -f docker-compose.ee.yaml up -d
docker-up-ce:
@./scripts/docker-compose-wrapper.sh -f docker-compose.yaml -f docker-compose.base.yaml -f docker-compose.ce.yaml up -d
docker-down:
@./scripts/docker-compose-wrapper.sh down
docker-logs:
@./scripts/docker-compose-wrapper.sh logs -f
sebastian-docker-dev:
./setup/bash/run-compose.sh ./docker-compose.yaml --watch
hocuspocus-docker-run:
./setup/bash/run-compose.sh ./hocuspocus/docker-compose.yaml --no-network -d
hocuspocus-dev:
make -C ./hocuspocus run-dev
server-docker-run:
./setup/bash/run-compose.sh ./server/docker-compose.yaml --no-network -d
server-dev:
make -C ./server run-dev
setup-docker-run:
./setup/bash/run-compose.sh ./setup/docker-compose.yaml --no-network -d

48
PRODUCT.md Normal file
View File

@ -0,0 +1,48 @@
# Product
## Register
product
## Users
Alga PSA serves two primary product audiences:
- MSP owners and operators working in the `/msp` area, managing service delivery, client relationships, billing, projects, assets, tickets, automations, and business performance.
- MSP customers working in the `/client` area, checking tickets, projects, billing, assets, documents, appointments, and service progress without needing to understand internal MSP complexity.
Both groups need a product that feels fast, organized, and trustworthy during real operational work. MSP owners need density and control. MSP customers need clarity, confidence, and a guided view into their relationship with the provider.
## Product Purpose
Alga PSA is an open-source Professional Services Automation platform for Managed Service Providers. It exists to help MSPs run daily operations across support, billing, client management, projects, time tracking, assets, documents, reporting, and workflow automation.
Success means users can move from signal to action with minimal friction: resolve tickets, track time, approve billing, understand project status, manage clients, and automate repeated work without feeling buried in legacy enterprise clutter.
## Brand Personality
Efficient, powerful, modern.
The product should feel like a thoughtful operations workspace: structured, capable, and calm under load. It can borrow the purposeful simplicity of Notion and the modern business-object clarity of Twenty.com, while staying optimized for dense service operations rather than note-taking or CRM alone.
The tone should be direct, useful, and confident. The interface should make complex MSP work feel manageable, not simplified past usefulness.
## Anti-references
- Dated enterprise MSP software, especially the heavy, cluttered feeling associated with ConnectWise.
- Generic AI-generated SaaS UI, including decorative gradients, purposeless cards, vague empty states, and components that feel placed without intent.
- Screens where every element competes equally for attention.
- Consumer-style gloss that reduces trust for operational, financial, or service-delivery workflows.
- Low-density dashboards that look polished but fail to support real MSP throughput.
## Design Principles
1. **Purpose before presence.** Every visible element should support a task, explain state, or create trustworthy orientation. If it does not earn its place, remove it.
2. **Modern without novelty.** Use contemporary patterns and clean structure, but avoid invented affordances that slow experienced operators down.
3. **Density with hierarchy.** MSP workflows are information-rich. Preserve useful density while making priority, status, ownership, and next action obvious.
4. **Two audiences, one system.** The MSP workspace can expose depth and control. The client portal should translate the same system into clear, calm status and action.
5. **Operational confidence.** Billing, tickets, projects, and automation should feel dependable, reversible where possible, and explicit about consequences.
## Accessibility & Inclusion
Target WCAG 2.1 AA as the baseline. Support keyboard-first workflows, visible focus states, reduced motion preferences, and color-blind-safe status communication. Do not rely on color alone for priority, ticket status, billing state, or destructive actions. Preserve readable contrast in both light and dark themes.

241
README.md Normal file
View File

@ -0,0 +1,241 @@
# Alga PSA: Open-Source MSP Professional Services Automation
Alga PSA is a professional services automation platform built for Managed Service Providers. It brings client records, service tickets, time tracking, contracts, billing, invoicing, documents, assets, reporting, and automation into one MSP-focused system.
It is designed for teams that want more control over their PSA stack: self-hostable Community Edition code, a modern TypeScript/PostgreSQL architecture, and an Enterprise Edition path for commercially licensed modules and larger deployments.
<a href="https://www.nineminds.com/AlgaPSA-features">
<img src="https://www.nineminds.com/imported-media/Overview%20Dashboard.png" alt="Alga PSA overview dashboard" width="900">
</a>
[See the Alga PSA feature tour](https://www.nineminds.com/AlgaPSA-features)
## Why MSPs look at Alga PSA
MSP operations break down when tickets, contracts, time, and invoices live in separate tools. Teams lose billable time, service managers chase updates, and owners have a harder time seeing whether client work is profitable.
Alga PSA is built around the way MSPs operate with clients:
- **Tickets tied to clients, contacts, assets, and service history** so the team has context before work starts.
- **Time and approvals connected to billing** so billable work can move toward invoices with less duplicate entry.
- **Contracts, sales quotes, recurring services, tax, and invoice workflows** for the financial side of service delivery.
- **Client portal and document workflows** so clients have a clearer place to submit requests, view information, and follow progress.
- **Workflow automation** for turning repeatable ticket, billing, notification, and approval steps into managed processes with Event Catalog triggers and scheduled runs.
- **Open-source core with self-hosting support** so MSPs and technical teams can keep control over deployment, data, and code review.
Community Edition is the self-hostable AGPL core. Enterprise Edition covers commercially licensed modules and larger deployment needs. See [Editions and licensing](#editions-and-licensing) for details.
## Features at a glance
### Service desk and client operations
- Support ticketing for client requests, incidents, and follow-up work
- Client, contact, and company management
- Multilingual client portal support for separate MSP and client-facing access
- Email notifications for tickets, invoices, and project updates
- Document management with version control
- Asset management for client equipment, maintenance schedules, and relationships
- Project and task management for longer-running client work
- Scheduling and dispatch views for planned work and technician coordination
### Time, contracts, billing, and invoicing
- Time tracking with approval workflows and utilization reporting
- Automatic interval tracking for ticket work, stored in the browser with IndexedDB
- Conversion of tracked intervals into time entries
- Flexible billing cycles by company, including weekly, bi-weekly, monthly, and quarterly billing
- Billing-period support for proration and unapproved time rollover
- Contract purchase order support with PO numbers and advisory PO limits
- Sales quotes for pricing proposals, optional line items, approvals, client portal acceptance, and conversion to contracts or invoices
- Graphical invoice and quote designer for branded PDF layouts, data-bound fields, line-item tables, preview, and per-document template overrides
- International tax support with composite rates, thresholds, tax holidays, and reverse charge scenarios
### Automation, reporting, and controls
- Workflow Automation with an Event Catalog for ticket, billing, scheduling, email, project, CRM, asset, document, and integration triggers
- Visual workflow designer for event-driven, one-time scheduled, recurring scheduled, and manual runs, with versioning and run history
- Redis-backed event processing for asynchronous work and system events
- Reporting and analytics for operational visibility
- Role-based access control (RBAC) and attribute-based access control (ABAC)
- Multi-portal authentication for MSP users and client portal users
- API, OpenAPI registry material, and extension SDK support for integrations and custom workflows
Feature availability varies by edition, deployment configuration, and enabled feature flags. See the setup and architecture docs for implementation details.
## Product screenshots
These images link directly to screenshots from the [Alga PSA feature tour](https://www.nineminds.com/AlgaPSA-features), [Workflow Automation docs](https://www.nineminds.com/documentation/152-choosing-workflow-triggers), and [Invoice Designer docs](https://www.nineminds.com/documentation/1410-bind-invoice-data-to-your-layout).
| Core workflow | Business operations |
| --- | --- |
| <img src="https://www.nineminds.com/imported-media/Ticketing-1.gif" alt="Alga PSA ticketing screen" width="420"> | <img src="https://www.nineminds.com/imported-media/Billing%20dashboard.png" alt="Alga PSA billing dashboard" width="420"> |
| Ticketing views for client requests, assignment, attachments, and follow-up. | Contracts, billing, and invoice-related workflows in one billing area. |
| <img src="https://www.nineminds.com/imported-media/Screenshot%202026-04-30%20at%2011.33.51%E2%80%AFAM.png" alt="Alga PSA multilingual client portal" width="420"> | <img src="https://www.nineminds.com/imported-media/Screenshot%202026-05-01%20at%201.35.35%20PM.png" alt="Alga PSA time approval screen" width="420"> |
| Multilingual client portal views for client-facing requests and updates. | Time entry views for recording and reviewing work before billing. |
| <img src="https://www.nineminds.com/imported-media/Schedule%20view.png" alt="Alga PSA schedule view" width="420"> | <img src="https://www.nineminds.com/docs-images/invoice-designer-workspace.png" alt="Alga PSA invoice and quote designer workspace" width="420"> |
| Schedule views for dispatch and calendar-based work planning. | Drag-and-drop invoice and quote layout designer for branded PDFs. |
| <img src="https://www.nineminds.com/docs-images/workflow-designer-ticket-triage.png" alt="Alga PSA visual workflow designer" width="420"> | <img src="https://www.nineminds.com/imported-media/Assets%20Asset%20workspace%20overview.png" alt="Alga PSA asset workspace overview" width="420"> |
| Visual workflow designer for ticket triage, notifications, approvals, and other repeatable processes. | Asset views for client equipment and service context. |
## Quick start
For a full installation, use the [Complete Setup Guide](docs/getting-started/setup_guide.md). It covers release selection, secrets, environment configuration, Docker Compose, initial login credentials, persistence, backups, and production notes.
The current CE prebuilt Docker Compose path is below. Before running these commands, follow the setup guide to create the required `secrets/` directory and `server/.env` file.
The stack boots PostgreSQL, the Next.js application server, and the workflow worker. On first start, the server creates a seeded workspace admin account; tail the logs below to retrieve the credentials.
```bash
git clone https://github.com/nine-minds/alga-psa.git
cd alga-psa
./scripts/set-image-tag.sh
docker compose -f docker-compose.prebuilt.base.yaml -f docker-compose.prebuilt.ce.yaml \
--env-file server/.env --env-file .env.image up -d
```
The prebuilt stack creates named volumes for PostgreSQL data and uploaded files so data survives container restarts and upgrades. See the setup guide for backup and restore procedures.
After the first successful boot, the server logs print a seeded workspace admin account. Tail the logs and update the password before using the system in production.
```bash
docker compose -f docker-compose.prebuilt.base.yaml -f docker-compose.prebuilt.ce.yaml \
--env-file server/.env --env-file .env.image logs -f
```
### Requirements
- Docker Engine 24.0.0 or later
- Docker Compose v2.20.0 or later
- Git
- Node.js `>=20 <25` for source development
For Windows-specific setup, see the [Windows Setup Guide](docs/getting-started/setup_guide_windows.md).
## Technical architecture
The following details are for teams evaluating the technical stack. For deployment requirements, see [Quick start](#quick-start).
Alga PSA is a TypeScript monorepo with a Next.js application, shared domain packages, worker services, and Docker-based deployment paths.
| Area | Implementation |
| --- | --- |
| Frontend | Next.js application with React, Tailwind, Radix-based components, and shared UI packages |
| Backend | Next.js API routes running on Node.js, shared domain packages, and a dedicated workflow worker service |
| Database | PostgreSQL with row-level security for tenant isolation |
| Event processing | Redis-backed event bus with Zod schema validation for asynchronous system events |
| Workflow execution | Temporal-backed workflow runtime and worker services for event-triggered, scheduled, and manual workflow runs |
| Real-time collaboration | Hocuspocus/Yjs for collaborative document editing |
| Authentication | NextAuth.js with separate MSP and client portal access surfaces |
| Packages | npm workspaces and Nx-managed `@alga-psa/*` packages for billing, clients, tickets, documents, scheduling, reporting, integrations, and shared infrastructure |
| Deployment | Docker Compose for CE/EE stacks, named volumes for PostgreSQL and files, Docker secrets, PgBouncer, and Helm assets for Kubernetes-oriented deployments |
| Extensions and API | Extension SDK, client SDK docs, API docs, and OpenAPI registry material for integrations and custom workflows |
Useful technical docs:
- [Architecture Overview](docs/architecture/overview.md)
- [Package Build System](docs/architecture/package-build-system.md)
- [Docker Compose Structure](docs/getting-started/docker_compose.md)
- [Secrets Management](docs/security/secrets_management.md)
- [API Overview](docs/api/api_overview.md)
- [OpenAPI Registry Integration](docs/openapi/registry-integration.md)
- [Client SDK](docs/client-sdk/README.md)
- [Inbound Email](docs/inbound-email/README.md)
- [Testing Standards](docs/reference/testing-standards.md)
## Documentation
### Setup and configuration
- [Complete Setup Guide](docs/getting-started/setup_guide.md)
- [Windows Setup Guide](docs/getting-started/setup_guide_windows.md)
- [Configuration Guide](docs/getting-started/configuration_guide.md)
- [Development Guide](docs/getting-started/development_guide.md)
- [Entrypoint Scripts](docs/getting-started/entrypoint_scripts.md)
### MSP feature areas
- [Billing System](docs/billing/billing.md)
- [Invoice Templates](docs/billing/invoice_templates.md)
- [Quoting System](docs/billing/quoting-system.md)
- [International Tax Support](docs/billing/tax/international_tax_support.md)
- [Asset Management](docs/features/asset_management.md)
- [SLA Management](docs/features/sla.md)
- [Time Entry Guide](docs/features/time_entry.md)
- [Workflow Automation for MSPs](https://www.nineminds.com/documentation/151-workflow-automation-for-msps)
- [Choosing Workflow Triggers](https://www.nineminds.com/documentation/152-choosing-workflow-triggers)
- [Building Your First MSP Workflow](https://www.nineminds.com/documentation/153-building-your-first-msp-workflow)
- [Publishing and Monitoring Workflows](https://www.nineminds.com/documentation/156-publishing-monitoring-workflows)
### Development and contribution
- [Contributing Guide](docs/contributing.md)
- [Configuration Standards](docs/getting-started/configuration_standards.md)
- [Package Build System](docs/architecture/package-build-system.md)
- [Testing Standards](docs/reference/testing-standards.md)
## Project structure
```text
alga-psa/
├── server/ # Next.js application server
│ ├── src/app/ # App routes and API routes
│ ├── src/components/ # React components
│ └── src/lib/ # Core application logic
├── packages/ # Shared @alga-psa/* packages
│ ├── billing/ # Billing, invoicing, tax
│ ├── clients/ # Client management
│ ├── tickets/ # Ticketing domain code
│ ├── db/ # Database connection and tenant context
│ ├── event-schemas/ # Event contracts and validation
│ ├── ui/ # Shared UI component library
│ └── ... # Domain and infrastructure packages
├── ee/ # Enterprise Edition code and licensed modules
├── services/ # Background services, including workflow-worker
├── hocuspocus/ # Real-time collaboration server
├── sdk/ # Extension SDK and samples
├── extensions/ # Extension examples and supporting code
├── helm/ # Kubernetes deployment assets
├── redis/ # Redis configuration
├── pgbouncer/ # PostgreSQL connection pooling configuration
├── setup/ # Bootstrap and installation scripts
├── scripts/ # Build, release, and utility scripts
├── tools/ # Developer and automation tooling
└── docs/ # Product, setup, architecture, and developer docs
```
## Development and testing
Install dependencies and run tests from the repository root. Source development requires Node.js `>=20 <25`.
```bash
npm install
npm run test:local
# Run specific tests
npm run test:local -- path/to/test/file.test.ts
```
For development workflow details, package build behavior, and test conventions, see:
- [Development Guide](docs/getting-started/development_guide.md)
- [Package Build System](docs/architecture/package-build-system.md)
- [Testing Standards](docs/reference/testing-standards.md)
## Editions and licensing
Alga PSA uses multiple licenses:
- Documentation (`docs/`): Creative Commons Attribution 4.0 International License (CC BY 4.0)
- Enterprise Edition (`ee/`): See `ee/LICENSE`
- All other content: GNU Affero General Public License Version 3 (AGPL-3.0)
See [LICENSE.md](LICENSE.md) for details. If your deployment model requires commercial terms or a license outside the AGPL core, visit [algapsa.com](https://algapsa.com) for Enterprise Edition and hosted deployment information.
## Contributing
Contributions are welcome. Start with the [Contributing Guide](docs/contributing.md) for development setup, coding expectations, pull request guidance, and module conventions.
---
Copyright (c) 2026 Nine Minds LLC

347
admin-theme-generator.py Normal file
View File

@ -0,0 +1,347 @@
#!/usr/bin/env python3
"""
Generate admin panel theme CSS overrides for AlgaPSA.
Reads /opt/alga-psa/admin-theme.json and outputs CSS to /var/www/alga-admin-theme.css.
The CSS overrides --color-primary-* and --color-secondary-* variables used throughout
the AlgaPSA admin dashboard.
Usage:
python3 /opt/alga-psa/admin-theme-generator.py
Config format (/opt/alga-psa/admin-theme.json):
{
"primaryColor": "#0066CC",
"secondaryColor": "#00AA88"
}
Leave either field empty or omit it to keep the default AlgaPSA color for that channel.
"""
import json
import math
import os
import sys
CONFIG_PATH = "/opt/alga-psa/admin-theme.json"
OUTPUT_PATH = "/var/www/alga-admin-theme.css"
def hex_to_rgb(hex_color):
"""Convert hex color to (r, g, b) tuple."""
hex_color = hex_color.lstrip("#")
if len(hex_color) != 6:
return None
try:
return (
int(hex_color[0:2], 16),
int(hex_color[2:4], 16),
int(hex_color[4:6], 16),
)
except ValueError:
return None
def generate_shades(rgb):
"""Generate 10 shades (50-900) matching AlgaPSA's generateColorShades algorithm."""
r, g, b = rgb
shades = {}
# Lighter shades (50-400) — interpolate toward white
shades[50] = (
min(255, round(r + (255 - r) * 0.95)),
min(255, round(g + (255 - g) * 0.95)),
min(255, round(b + (255 - b) * 0.95)),
)
shades[100] = (
min(255, round(r + (255 - r) * 0.90)),
min(255, round(g + (255 - g) * 0.90)),
min(255, round(b + (255 - b) * 0.90)),
)
shades[200] = (
min(255, round(r + (255 - r) * 0.75)),
min(255, round(g + (255 - g) * 0.75)),
min(255, round(b + (255 - b) * 0.75)),
)
shades[300] = (
min(255, round(r + (255 - r) * 0.60)),
min(255, round(g + (255 - g) * 0.60)),
min(255, round(b + (255 - b) * 0.60)),
)
shades[400] = (
min(255, round(r + (255 - r) * 0.30)),
min(255, round(g + (255 - g) * 0.30)),
min(255, round(b + (255 - b) * 0.30)),
)
# Base color (500)
shades[500] = (r, g, b)
# Darker shades (600-900) — multiply toward black
shades[600] = (
max(0, round(r * 0.85)),
max(0, round(g * 0.85)),
max(0, round(b * 0.85)),
)
shades[700] = (
max(0, round(r * 0.70)),
max(0, round(g * 0.70)),
max(0, round(b * 0.70)),
)
shades[800] = (
max(0, round(r * 0.50)),
max(0, round(g * 0.50)),
max(0, round(b * 0.50)),
)
shades[900] = (
max(0, round(r * 0.30)),
max(0, round(g * 0.30)),
max(0, round(b * 0.30)),
)
return shades
def invert_shades(shades):
"""Invert shade mapping for dark mode (50<->900, etc.), matching AlgaPSA logic."""
return {
50: shades[900],
100: shades[800],
200: shades[700],
300: shades[600],
400: shades[400],
500: shades[500],
600: shades[300],
700: shades[200],
800: shades[100],
900: shades[50],
}
def rgb_str(rgb_tuple):
"""Format RGB tuple as 'R G B' string for CSS var."""
return f"{rgb_tuple[0]} {rgb_tuple[1]} {rgb_tuple[2]}"
def palette_vars(name, shades):
"""Generate CSS custom property lines for a color palette."""
lines = []
for i in [50, 100, 200, 300, 400, 500, 600, 700, 800, 900]:
lines.append(f" --color-{name}-{i}: {rgb_str(shades[i])} !important;")
return "\n".join(lines)
def generate_overrides(name):
"""Generate the Tailwind/utility class overrides for a color channel."""
if name == "primary":
return f"""
/* Switch/toggle */
button[role="switch"][data-state="checked"] {{
background-color: rgb(var(--color-primary-500)) !important;
}}
/* Button variant classes using CSS variables */
.bg-\\[rgb\\(var\\(--color-primary-500\\)\\)\\] {{ background-color: rgb(var(--color-primary-500)) !important; }}
.bg-\\[rgb\\(var\\(--color-primary-600\\)\\)\\] {{ background-color: rgb(var(--color-primary-600)) !important; }}
.hover\\:bg-\\[rgb\\(var\\(--color-primary-600\\)\\)\\]:hover {{ background-color: rgb(var(--color-primary-600)) !important; }}
.bg-\\[rgb\\(var\\(--color-primary-100\\)\\)\\] {{ background-color: rgb(var(--color-primary-100)) !important; }}
.bg-\\[rgb\\(var\\(--color-primary-200\\)\\)\\] {{ background-color: rgb(var(--color-primary-200)) !important; }}
.hover\\:bg-\\[rgb\\(var\\(--color-primary-200\\)\\)\\]:hover {{ background-color: rgb(var(--color-primary-200)) !important; }}
.bg-\\[rgb\\(var\\(--color-primary-50\\)\\)\\] {{ background-color: rgb(var(--color-primary-50)) !important; }}
.hover\\:bg-\\[rgb\\(var\\(--color-primary-50\\)\\)\\]:hover {{ background-color: rgb(var(--color-primary-50)) !important; }}
.text-\\[rgb\\(var\\(--color-primary-500\\)\\)\\] {{ color: rgb(var(--color-primary-500)) !important; }}
.text-\\[rgb\\(var\\(--color-primary-700\\)\\)\\] {{ color: rgb(var(--color-primary-700)) !important; }}
.hover\\:text-\\[rgb\\(var\\(--color-primary-700\\)\\)\\]:hover {{ color: rgb(var(--color-primary-700)) !important; }}
.border-\\[rgb\\(var\\(--color-primary-500\\)\\)\\] {{ border-color: rgb(var(--color-primary-500)) !important; }}
/* Nav link hover text */
a[class*="hover\\:text-\\[rgb\\(var\\(--color-primary"]:hover {{
color: rgb(var(--color-primary-500)) !important;
}}
/* Purple/indigo Tailwind classes mapped to primary */
.bg-purple-600, .bg-purple-500, .bg-indigo-600, .bg-indigo-500 {{
background-color: rgb(var(--color-primary-500)) !important;
}}
.bg-purple-100, .bg-indigo-100 {{
background-color: rgb(var(--color-primary-100)) !important;
}}
.text-purple-600, .text-purple-500, .text-indigo-600, .text-indigo-500 {{
color: rgb(var(--color-primary-500)) !important;
}}
.border-purple-600, .border-purple-500, .border-indigo-600, .border-indigo-500 {{
border-color: rgb(var(--color-primary-500)) !important;
}}
.hover\\:bg-purple-700:hover, .hover\\:bg-purple-600:hover,
.hover\\:bg-indigo-700:hover, .hover\\:bg-indigo-600:hover {{
background-color: rgb(var(--color-primary-600)) !important;
}}
.hover\\:text-purple-700:hover, .hover\\:text-purple-600:hover,
.hover\\:text-indigo-700:hover, .hover\\:text-indigo-600:hover {{
color: rgb(var(--color-primary-600)) !important;
}}
.focus\\:ring-purple-500:focus, .focus\\:ring-indigo-500:focus {{
--tw-ring-color: rgb(var(--color-primary-500)) !important;
}}
.focus\\:border-purple-500:focus, .focus\\:border-indigo-500:focus {{
border-color: rgb(var(--color-primary-500)) !important;
}}
/* Focus ring default */
*:focus-visible {{
--tw-ring-color: rgb(var(--color-primary-500)) !important;
}}
/* Inputs */
input:focus, textarea:focus, select:focus {{
border-color: rgb(var(--color-primary-500)) !important;
--tw-ring-color: rgb(var(--color-primary-500)) !important;
}}
input[type="checkbox"]:checked, input[type="radio"]:checked {{
background-color: rgb(var(--color-primary-500)) !important;
border-color: rgb(var(--color-primary-500)) !important;
}}
input[type="checkbox"]:focus, input[type="radio"]:focus {{
--tw-ring-color: rgb(var(--color-primary-500)) !important;
border-color: rgb(var(--color-primary-500)) !important;
}}
/* Border-primary helper */
.border-primary {{ border-color: rgb(var(--color-primary-500)) !important; }}
"""
else: # secondary
return f"""
/* Secondary color classes */
.bg-\\[rgb\\(var\\(--color-secondary-500\\)\\)\\] {{ background-color: rgb(var(--color-secondary-500)) !important; }}
.bg-\\[rgb\\(var\\(--color-secondary-600\\)\\)\\] {{ background-color: rgb(var(--color-secondary-600)) !important; }}
.hover\\:bg-\\[rgb\\(var\\(--color-secondary-600\\)\\)\\]:hover {{ background-color: rgb(var(--color-secondary-600)) !important; }}
.bg-\\[rgb\\(var\\(--color-secondary-50\\)\\)\\] {{ background-color: rgb(var(--color-secondary-50)) !important; }}
.bg-\\[rgb\\(var\\(--color-secondary-100\\)\\)\\] {{ background-color: rgb(var(--color-secondary-100)) !important; }}
.bg-\\[rgb\\(var\\(--color-secondary-200\\)\\)\\] {{ background-color: rgb(var(--color-secondary-200)) !important; }}
.bg-\\[rgb\\(var\\(--color-secondary-300\\)\\)\\] {{ background-color: rgb(var(--color-secondary-300)) !important; }}
.bg-\\[rgb\\(var\\(--color-secondary-400\\)\\)\\] {{ background-color: rgb(var(--color-secondary-400)) !important; }}
.text-\\[rgb\\(var\\(--color-secondary-500\\)\\)\\] {{ color: rgb(var(--color-secondary-500)) !important; }}
.text-\\[rgb\\(var\\(--color-secondary-700\\)\\)\\] {{ color: rgb(var(--color-secondary-700)) !important; }}
.border-\\[rgb\\(var\\(--color-secondary-400\\)\\)\\] {{ border-color: rgb(var(--color-secondary-400)) !important; }}
/* Accent classes mapped to secondary */
.bg-\\[rgb\\(var\\(--color-accent-500\\)\\)\\] {{ background-color: rgb(var(--color-secondary-500)) !important; }}
.bg-\\[rgb\\(var\\(--color-accent-50\\)\\)\\] {{ background-color: rgb(var(--color-secondary-50)) !important; }}
.text-\\[rgb\\(var\\(--color-accent-500\\)\\)\\] {{ color: rgb(var(--color-secondary-500)) !important; }}
.text-\\[rgb\\(var\\(--color-accent-700\\)\\)\\] {{ color: rgb(var(--color-secondary-700)) !important; }}
.border-\\[rgb\\(var\\(--color-accent-500\\)\\)\\] {{ border-color: rgb(var(--color-secondary-500)) !important; }}
/* Blue Tailwind classes mapped to secondary */
.text-blue-600, .text-blue-500 {{ color: rgb(var(--color-secondary-600)) !important; }}
.text-blue-700 {{ color: rgb(var(--color-secondary-700)) !important; }}
.text-blue-800 {{ color: rgb(var(--color-secondary-800)) !important; }}
.hover\\:text-blue-600:hover, .hover\\:text-blue-500:hover {{ color: rgb(var(--color-secondary-600)) !important; }}
.hover\\:text-blue-700:hover {{ color: rgb(var(--color-secondary-700)) !important; }}
.hover\\:text-blue-800:hover {{ color: rgb(var(--color-secondary-800)) !important; }}
.border-blue-600, .border-blue-500 {{ border-color: rgb(var(--color-secondary-600)) !important; }}
.bg-blue-600, .bg-blue-500 {{ background-color: rgb(var(--color-secondary-600)) !important; }}
.bg-blue-50 {{ background-color: rgb(var(--color-secondary-50)) !important; }}
.bg-blue-100 {{ background-color: rgb(var(--color-secondary-100)) !important; }}
.bg-blue-200 {{ background-color: rgb(var(--color-secondary-200)) !important; }}
/* Tab active state */
[data-state="active"] {{
color: rgb(var(--color-secondary-600)) !important;
border-color: rgb(var(--color-secondary-600)) !important;
}}
.data-\\[state\\=active\\]\\:text-blue-600[data-state="active"] {{ color: rgb(var(--color-secondary-600)) !important; }}
.data-\\[state\\=active\\]\\:border-blue-600[data-state="active"] {{ border-color: rgb(var(--color-secondary-600)) !important; }}
"""
def main():
# Load config
if not os.path.exists(CONFIG_PATH):
print(f"No config found at {CONFIG_PATH}, generating empty CSS.")
css = "/* No admin theme configured */\n"
with open(OUTPUT_PATH, "w") as f:
f.write(css)
return
with open(CONFIG_PATH) as f:
config = json.load(f)
primary_hex = config.get("primaryColor", "").strip()
secondary_hex = config.get("secondaryColor", "").strip()
has_primary = bool(primary_hex)
has_secondary = bool(secondary_hex)
if not has_primary and not has_secondary:
print("No colors configured, generating empty CSS.")
css = "/* No admin theme colors configured */\n"
with open(OUTPUT_PATH, "w") as f:
f.write(css)
return
# Generate shades
primary_shades = None
secondary_shades = None
if has_primary:
rgb = hex_to_rgb(primary_hex)
if not rgb:
print(f"Invalid primary color: {primary_hex}", file=sys.stderr)
sys.exit(1)
primary_shades = generate_shades(rgb)
if has_secondary:
rgb = hex_to_rgb(secondary_hex)
if not rgb:
print(f"Invalid secondary color: {secondary_hex}", file=sys.stderr)
sys.exit(1)
secondary_shades = generate_shades(rgb)
# Build light mode body
root_parts = []
if primary_shades:
root_parts.append(palette_vars("primary", primary_shades))
if secondary_shades:
root_parts.append(palette_vars("secondary", secondary_shades))
root_body = "\n".join(root_parts)
# Build dark mode body (inverted shades)
dark_parts = []
if primary_shades:
dark_parts.append(palette_vars("primary", invert_shades(primary_shades)))
if secondary_shades:
dark_parts.append(palette_vars("secondary", invert_shades(secondary_shades)))
dark_body = "\n".join(dark_parts)
# Build overrides
overrides = ""
if primary_shades:
overrides += generate_overrides("primary")
if secondary_shades:
overrides += generate_overrides("secondary")
css = f"""/* Admin Panel Theme Override — generated by admin-theme-generator.py */
/* Do not edit manually. Edit {CONFIG_PATH} and re-run the generator. */
:root {{
{root_body}
}}
html.dark {{
{dark_body}
}}
{overrides}
"""
with open(OUTPUT_PATH, "w") as f:
f.write(css)
print(f"Generated {len(css)} bytes of CSS to {OUTPUT_PATH}")
if has_primary:
print(f" Primary: {primary_hex}")
if has_secondary:
print(f" Secondary: {secondary_hex}")
if __name__ == "__main__":
main()

1
admin-theme.json Normal file
View File

@ -0,0 +1 @@
{"primaryColor": "", "secondaryColor": ""}

3
app.json Normal file
View File

@ -0,0 +1,3 @@
{
"expo": {}
}

105
cli/CONFIG.md Normal file
View File

@ -0,0 +1,105 @@
# Alga CLI Configuration
The Alga CLI now supports a configuration file to store user preferences and defaults, eliminating the need to repeatedly specify common options.
## Configuration File Location
The configuration file is stored at:
- Linux/macOS: `~/.config/alga-cli/config.toml`
- Or respects `$XDG_CONFIG_HOME` if set: `$XDG_CONFIG_HOME/alga-cli/config.toml`
## Quick Start
Initialize your configuration:
```bash
nu cli/main.nu config init
```
This will prompt you for:
- Your git author name
- Your git author email
- Default edition preference (ce/ee)
## Configuration Commands
### Initialize Configuration
```bash
nu cli/main.nu config init [--force]
```
Creates a new configuration file with prompts. Use `--force` to overwrite existing config.
### Show Configuration
```bash
nu cli/main.nu config show
```
Displays the current configuration and file location.
### Get Configuration Value
```bash
nu cli/main.nu config get <key>
```
Examples:
```bash
nu cli/main.nu config get dev_env.author.name
nu cli/main.nu config get dev_env.author.email
nu cli/main.nu config get dev_env.default_edition
```
### Set Configuration Value
```bash
nu cli/main.nu config set <key> <value>
```
Examples:
```bash
nu cli/main.nu config set dev_env.author.name "John Doe"
nu cli/main.nu config set dev_env.author.email "john@example.com"
nu cli/main.nu config set dev_env.default_edition "ee"
```
## Configuration Structure
The configuration file uses TOML format:
```toml
version = "1.0"
[dev_env]
default_edition = "ee"
[dev_env.author]
name = "John Doe"
email = "john@example.com"
```
## Using Configuration with dev-env-create
When creating a development environment, the CLI will use your configured author information by default:
```bash
# Uses author info from config
nu cli/main.nu dev-env-create my-feature
# Override config with command-line options
nu cli/main.nu dev-env-create my-feature --author-name "Jane Doe" --author-email "jane@example.com"
```
If author information is loaded from config, you'll see a message:
```
Using git author from config: John Doe <john@example.com>
```
## Priority Order
The CLI uses the following priority for git author information:
1. Command-line parameters (`--author-name`, `--author-email`)
2. Configuration file values
3. Default values ("Dev Environment" <dev@alga.local>)
## Future Configuration Options
The configuration system is designed to be extensible. Future options may include:
- Default Kubernetes namespace patterns
- Preferred external port ranges
- Custom repository URLs
- Build and deployment preferences
- AI automation settings

831
cli/build.nu Normal file
View File

@ -0,0 +1,831 @@
# Build Docker image for specified edition
export def build-image [
edition: string, # Edition to build (ce or ee)
--tag: string = "" # Docker tag to use (defaults to unique tag)
--push # Push to registry after building
--use-latest # Use 'latest' tag instead of unique tag
] {
if not ($edition in ["ce", "ee"]) {
error make { msg: $"($env.ALGA_COLOR_RED)Edition must be 'ce' or 'ee'($env.ALGA_COLOR_RESET)" }
}
print $"($env.ALGA_COLOR_CYAN)Building ($edition | str upcase) Docker image...($env.ALGA_COLOR_RESET)"
let project_root = find-project-root
cd $project_root
# Determine image name and build context
let image_name = $"harbor.nineminds.com/nineminds/alga-psa-($edition)"
# Generate unique tag if not provided and not using latest
let sha_tag = if ($tag | str length) > 0 {
$tag
} else {
# Generate unique tag using git commit SHA only (consistent across calls)
let git_sha = (git rev-parse --short HEAD | complete)
if $git_sha.exit_code == 0 {
let sha = ($git_sha.stdout | str trim)
$sha
} else {
# Fallback if git is not available - use timestamp
let timestamp = (date now | format date '%Y%m%d-%H%M%S')
$"build-($timestamp)"
}
}
# Build list of tags to apply
let tags_to_apply = if $use_latest {
# When --use-latest is specified, tag with both SHA and latest
[$sha_tag, "latest"]
} else {
# Otherwise just use the single tag
[$sha_tag]
}
# Build tag arguments for docker build
let tag_args = ($tags_to_apply | each { |t| ["-t", $"($image_name):($t)"] } | flatten)
# Build the image
print $"($env.ALGA_COLOR_YELLOW)Building with tags: ($tags_to_apply | str join ', ')($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_CYAN)Build output will be streamed to terminal...($env.ALGA_COLOR_RESET)"
if $edition == "ee" {
# EE build includes everything
docker build --platform linux/amd64 -f server/Dockerfile ...$tag_args .
} else {
# CE build excludes EE directory
docker build --platform linux/amd64 -f server/Dockerfile ...$tag_args --build-arg EXCLUDE_EE=true .
}
# Check if build succeeded by checking if image exists
let image_check = do {
docker image inspect $"($image_name):($sha_tag)" | complete
}
if $image_check.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)Build failed - image not created($env.ALGA_COLOR_RESET)"
error make { msg: "Docker build failed" }
}
print $"($env.ALGA_COLOR_GREEN)Successfully built with tags: ($tags_to_apply | str join ', ')($env.ALGA_COLOR_RESET)"
if $push {
# Push all tags
for tag in $tags_to_apply {
let full_tag = $"($image_name):($tag)"
print $"($env.ALGA_COLOR_YELLOW)Pushing: ($full_tag)($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_CYAN)Push output will be streamed to terminal...($env.ALGA_COLOR_RESET)"
# Push the image - stream output directly
docker push $full_tag
# Check if push succeeded
let push_check = do {
docker manifest inspect $full_tag | complete
}
if $push_check.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)Push may have failed for ($full_tag) - unable to verify image in registry($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_YELLOW)Note: This could also mean the registry doesn't support manifest inspection($env.ALGA_COLOR_RESET)"
} else {
print $"($env.ALGA_COLOR_GREEN)Successfully pushed: ($full_tag)($env.ALGA_COLOR_RESET)"
}
}
}
}
# Build Docker images for both CE and EE editions
export def build-all-images [
--tag: string = "latest" # Docker tag to use
--push # Push to registry after building
] {
print $"($env.ALGA_COLOR_CYAN)Building all edition Docker images...($env.ALGA_COLOR_RESET)"
# Build CE edition
if $push {
build-image "ce" --tag $tag --push
} else {
build-image "ce" --tag $tag
}
# Build EE edition
if $push {
build-image "ee" --tag $tag --push
} else {
build-image "ee" --tag $tag
}
print $"($env.ALGA_COLOR_GREEN)All builds completed successfully!($env.ALGA_COLOR_RESET)"
}
# Build code-server Docker image
export def build-code-server [
--tag: string = "" # Docker tag to use (defaults to SHA)
--push # Push to registry after building
--use-latest # Tag with both SHA and 'latest'
] {
print $"($env.ALGA_COLOR_CYAN)Building code-server Docker image...($env.ALGA_COLOR_RESET)"
let project_root = find-project-root
cd $project_root
# Determine image name
let registry = "harbor.nineminds.com"
let namespace = "nineminds"
let image_name = "alga-code-server"
let base_image = $"($registry)/($namespace)/($image_name)"
# Generate SHA tag
let sha_tag = if ($tag | str length) > 0 {
$tag
} else {
# Generate unique tag using git commit SHA only (consistent across calls)
let git_sha = (git rev-parse --short HEAD | complete)
if $git_sha.exit_code == 0 {
let sha = ($git_sha.stdout | str trim)
$sha
} else {
# Fallback if git is not available - use timestamp
let timestamp = (date now | format date '%Y%m%d-%H%M%S')
$"build-($timestamp)"
}
}
# Build list of tags to apply
let tags_to_apply = if $use_latest {
# When --use-latest is specified, tag with both SHA and latest
[$sha_tag, "latest"]
} else {
# Otherwise just use the single tag
[$sha_tag]
}
# Build tag arguments for docker build
let tag_args = ($tags_to_apply | each { |t| ["-t", $"($base_image):($t)"] } | flatten)
print $"($env.ALGA_COLOR_YELLOW)Building with tags: ($tags_to_apply | str join ', ')($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_CYAN)Build output will be streamed to terminal...($env.ALGA_COLOR_RESET)"
# Build the image - stream output directly
docker build --platform linux/amd64 -f docker/dev-env/Dockerfile.code-server ...$tag_args .
# Check if build succeeded by checking if image exists
let image_check = do {
docker image inspect $"($base_image):($sha_tag)" | complete
}
if $image_check.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)Build failed - image not created($env.ALGA_COLOR_RESET)"
error make { msg: "Docker build failed" }
}
print $"($env.ALGA_COLOR_GREEN)Successfully built with tags: ($tags_to_apply | str join ', ')($env.ALGA_COLOR_RESET)"
if $push {
# Push all tags
for tag in $tags_to_apply {
let full_image = $"($base_image):($tag)"
print $"($env.ALGA_COLOR_YELLOW)Pushing: ($full_image)($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_CYAN)Push output will be streamed to terminal...($env.ALGA_COLOR_RESET)"
# Push the image - stream output directly
docker push $full_image
# Check if push succeeded by trying to pull the image info from registry
let push_check = do {
docker manifest inspect $full_image | complete
}
if $push_check.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)Push may have failed for ($full_image) - unable to verify image in registry($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_YELLOW)Note: This could also mean the registry doesn't support manifest inspection($env.ALGA_COLOR_RESET)"
} else {
print $"($env.ALGA_COLOR_GREEN)Successfully pushed: ($full_image)($env.ALGA_COLOR_RESET)"
}
}
}
}
# Build AI API Docker image
export def build-ai-api [
--tag: string = "" # Docker tag to use (defaults to SHA)
--push # Push to registry after building
--use-latest # Tag with both SHA and 'latest'
] {
print $"($env.ALGA_COLOR_CYAN)Building AI API Docker image...($env.ALGA_COLOR_RESET)"
let project_root = find-project-root
cd $project_root
# Determine image name
let registry = "harbor.nineminds.com"
let namespace = "nineminds"
let image_name = "alga-ai-api"
let base_image = $"($registry)/($namespace)/($image_name)"
# Generate SHA tag
let sha_tag = if ($tag | str length) > 0 {
$tag
} else {
# Generate unique tag using git commit SHA only (consistent across calls)
let git_sha = (git rev-parse --short HEAD | complete)
if $git_sha.exit_code == 0 {
let sha = ($git_sha.stdout | str trim)
$sha
} else {
# Fallback if git is not available - use timestamp
let timestamp = (date now | format date '%Y%m%d-%H%M%S')
$"build-($timestamp)"
}
}
# Build list of tags to apply
let tags_to_apply = if $use_latest {
# When --use-latest is specified, tag with both SHA and latest
[$sha_tag, "latest"]
} else {
# Otherwise just use the single tag
[$sha_tag]
}
# Build tag arguments for docker build
let tag_args = ($tags_to_apply | each { |t| ["-t", $"($base_image):($t)"] } | flatten)
print $"($env.ALGA_COLOR_YELLOW)Building with tags: ($tags_to_apply | str join ', ')($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_CYAN)Build output will be streamed to terminal...($env.ALGA_COLOR_RESET)"
# Build the image from the ai-automation directory
cd ($project_root | path join "tools" "ai-automation")
docker build --platform linux/amd64 -f Dockerfile ...$tag_args .
# Check if build succeeded by checking if image exists
let image_check = do {
docker image inspect $"($base_image):($sha_tag)" | complete
}
if $image_check.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)Build failed - image not created($env.ALGA_COLOR_RESET)"
error make { msg: "Docker build failed" }
}
print $"($env.ALGA_COLOR_GREEN)Successfully built with tags: ($tags_to_apply | str join ', ')($env.ALGA_COLOR_RESET)"
if $push {
# Push all tags
for tag in $tags_to_apply {
let full_image = $"($base_image):($tag)"
print $"($env.ALGA_COLOR_YELLOW)Pushing: ($full_image)($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_CYAN)Push output will be streamed to terminal...($env.ALGA_COLOR_RESET)"
# Push the image - stream output directly
docker push $full_image
# Check if push succeeded by trying to pull the image info from registry
let push_check = do {
docker manifest inspect $full_image | complete
}
if $push_check.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)Push may have failed for ($full_image) - unable to verify image in registry($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_YELLOW)Note: This could also mean the registry doesn't support manifest inspection($env.ALGA_COLOR_RESET)"
} else {
print $"($env.ALGA_COLOR_GREEN)Successfully pushed: ($full_image)($env.ALGA_COLOR_RESET)"
}
}
}
}
# Build AI Web Docker image
export def build-ai-web [
--tag: string = "" # Docker tag to use (defaults to SHA)
--push # Push to registry after building
--use-latest # Tag with both SHA and 'latest'
--local # Build locally instead of in Kubernetes
--cpu: string = "4" # CPU cores to allocate for Kubernetes builds
--memory: string = "4Gi" # Memory to allocate for Kubernetes builds
] {
# If --local flag is NOT set, use Kubernetes build (default)
if not $local {
if $push and $use_latest {
build-ai-web-k8s --tag $tag --push --use-latest --cpu $cpu --memory $memory
} else if $push {
build-ai-web-k8s --tag $tag --push --cpu $cpu --memory $memory
} else if $use_latest {
build-ai-web-k8s --tag $tag --use-latest --cpu $cpu --memory $memory
} else {
build-ai-web-k8s --tag $tag --cpu $cpu --memory $memory
}
return
}
# Local build logic
print $"($env.ALGA_COLOR_CYAN)Building AI Web Docker image locally...($env.ALGA_COLOR_RESET)"
let project_root = find-project-root
cd $project_root
# Determine image name
let registry = "harbor.nineminds.com"
let namespace = "nineminds"
let image_name = "alga-ai-web"
let base_image = $"($registry)/($namespace)/($image_name)"
# Generate SHA tag
let sha_tag = if ($tag | str length) > 0 {
$tag
} else {
# Generate unique tag using git commit SHA only (consistent across calls)
let git_sha = (git rev-parse --short HEAD | complete)
if $git_sha.exit_code == 0 {
let sha = ($git_sha.stdout | str trim)
$sha
} else {
# Fallback if git is not available - use timestamp
let timestamp = (date now | format date '%Y%m%d-%H%M%S')
$"build-($timestamp)"
}
}
# Build list of tags to apply
let tags_to_apply = if $use_latest {
# When --use-latest is specified, tag with both SHA and latest
[$sha_tag, "latest"]
} else {
# Otherwise just use the single tag
[$sha_tag]
}
# Build tag arguments for docker build
let tag_args = ($tags_to_apply | each { |t| ["-t", $"($base_image):($t)"] } | flatten)
print $"($env.ALGA_COLOR_YELLOW)Building with tags: ($tags_to_apply | str join ', ')($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_CYAN)Build output will be streamed to terminal...($env.ALGA_COLOR_RESET)"
# Build the image from the ai-automation/web directory
cd ($project_root | path join "tools" "ai-automation" "web")
docker build --platform linux/amd64 -f Dockerfile ...$tag_args .
# Check if build succeeded by checking if image exists
let image_check = do {
docker image inspect $"($base_image):($sha_tag)" | complete
}
if $image_check.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)Build failed - image not created($env.ALGA_COLOR_RESET)"
error make { msg: "Docker build failed" }
}
print $"($env.ALGA_COLOR_GREEN)Successfully built with tags: ($tags_to_apply | str join ', ')($env.ALGA_COLOR_RESET)"
if $push {
# Push all tags
for tag in $tags_to_apply {
let full_image = $"($base_image):($tag)"
print $"($env.ALGA_COLOR_YELLOW)Pushing: ($full_image)($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_CYAN)Push output will be streamed to terminal...($env.ALGA_COLOR_RESET)"
# Push the image - stream output directly
docker push $full_image
# Check if push succeeded by trying to pull the image info from registry
let push_check = do {
docker manifest inspect $full_image | complete
}
if $push_check.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)Push may have failed for ($full_image) - unable to verify image in registry($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_YELLOW)Note: This could also mean the registry doesn't support manifest inspection($env.ALGA_COLOR_RESET)"
} else {
print $"($env.ALGA_COLOR_GREEN)Successfully pushed: ($full_image)($env.ALGA_COLOR_RESET)"
}
}
}
}
# Build AI Web Docker image using Kubernetes job
export def build-ai-web-k8s [
--tag: string = "" # Docker tag to use (defaults to SHA)
--push # Push to registry after building
--use-latest # Tag with both SHA and 'latest'
--namespace: string = "default" # Kubernetes namespace to run the job in
--cpu: string = "4" # CPU cores to allocate
--memory: string = "4Gi" # Memory to allocate
] {
print $"($env.ALGA_COLOR_CYAN)Building AI Web Docker image using Kubernetes job...($env.ALGA_COLOR_RESET)"
let project_root = find-project-root
cd $project_root
# Determine image name
let registry = "harbor.nineminds.com"
let namespace_img = "nineminds"
let image_name = "alga-ai-web"
let base_image = $"($registry)/($namespace_img)/($image_name)"
# Generate SHA tag
let sha_tag = if ($tag | str length) > 0 {
$tag
} else {
# Generate unique tag using git commit SHA only (consistent across calls)
let git_sha = (git rev-parse --short HEAD | complete)
if $git_sha.exit_code == 0 {
let sha = ($git_sha.stdout | str trim)
$sha
} else {
# Fallback if git is not available - use timestamp
let timestamp = (date now | format date '%Y%m%d-%H%M%S')
$"build-($timestamp)"
}
}
# Get current git branch/ref
let git_ref = (git rev-parse HEAD | complete)
let current_ref = if $git_ref.exit_code == 0 {
($git_ref.stdout | str trim)
} else {
"main"
}
# Build list of tags to apply
let tags_to_apply = if $use_latest {
# When --use-latest is specified, tag with both SHA and latest
[$sha_tag, "latest"]
} else {
# Otherwise just use the single tag
[$sha_tag]
}
# Generate unique job name
let timestamp = (date now | format date '%Y%m%d-%H%M%S')
let job_name = $"ai-web-build-($timestamp)"
print $"($env.ALGA_COLOR_YELLOW)Building with tags: ($tags_to_apply | str join ', ')($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_YELLOW)Using Kubernetes job: ($job_name)($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_CYAN)Using existing harbor-credentials secret for registry authentication($env.ALGA_COLOR_RESET)"
# Create values file for the Helm job
let values_content = {
buildJob: {
name: $job_name,
namespace: $namespace,
type: "ai-web",
timeout: 1800,
ttl: 300,
gitRepo: "https://github.com/nine-minds/alga-psa.git",
gitRef: $current_ref,
buildPath: "tools/ai-automation/web",
dockerfile: "Dockerfile",
context: ".",
registry: $registry,
push: $push,
tags: ($tags_to_apply | each { |t| $"($base_image):($t)" }),
resources: {
cpu: $cpu,
memory: $memory,
cpuLimit: $cpu,
memoryLimit: $memory
}
}
}
print $"($env.ALGA_COLOR_CYAN)Creating build job in Kubernetes...($env.ALGA_COLOR_RESET)"
# Ensure harbor-credentials exists in the namespace
let secret_check = do {
kubectl get secret harbor-credentials -n $namespace | complete
}
if $secret_check.exit_code != 0 {
print $"($env.ALGA_COLOR_YELLOW)Copying harbor-credentials to namespace ($namespace)...($env.ALGA_COLOR_RESET)"
let copy_result = do {
kubectl get secret harbor-credentials -n nineminds -o yaml | sed $"s/namespace: nineminds/namespace: ($namespace)/" | kubectl apply -f - | complete
}
if $copy_result.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)Failed to copy harbor-credentials to namespace($env.ALGA_COLOR_RESET)"
error make { msg: "Harbor credentials not available in target namespace" }
}
}
# Build docker tags arguments
let docker_tags = ($tags_to_apply | each { |t| $"-t ($base_image):($t)" } | str join ' ')
# Build push commands if needed
let push_commands = if $push {
let push_cmds = ($tags_to_apply | each { |t| $"docker push ($base_image):($t)" } | str join "\n")
$"echo 'Pushing Docker images...'\n($push_cmds)"
} else {
""
}
# Create the shell script content
let build_script = '#!/bin/sh
set -e
echo "Starting build process..."
# Wait for Docker daemon to be ready
timeout=60
until docker info >/dev/null 2>&1; do
if [ $timeout -le 0 ]; then
echo "Docker daemon did not start in time"
exit 1
fi
echo "Waiting for Docker daemon..."
timeout=$((timeout - 5))
sleep 5
done
echo "Docker daemon is ready"
# Configure Docker to use the registry from harbor-credentials secret
echo "Configuring Docker registry authentication..."
mkdir -p /root/.docker
cp /harbor-creds/.dockerconfigjson /root/.docker/config.json
echo "Docker registry authentication configured"
# Clone the repository
echo "Cloning repository..."
git clone https://github.com/nine-minds/alga-psa.git /workspace
cd /workspace
# Checkout the specified branch/commit
echo "Checking out ' + $current_ref + '..."
git checkout ' + $current_ref + '
# Navigate to the build directory
cd tools/ai-automation/web
# Build the Docker image
echo "Building Docker image..."
docker build --platform linux/amd64 -f Dockerfile ' + $docker_tags + ' .
# Push the images if requested
' + $push_commands + '
echo "Build completed successfully!"
# Signal the docker daemon to shut down
echo "Signaling Docker daemon to shut down..."
touch /tmp/build-complete'
# Create job manifest
let job_manifest = {
apiVersion: "batch/v1",
kind: "Job",
metadata: {
name: $job_name,
namespace: $namespace,
labels: {
app: "alga-build-job",
"build-type": "ai-web"
}
},
spec: {
activeDeadlineSeconds: 1800,
ttlSecondsAfterFinished: 300,
template: {
metadata: {
labels: {
app: "alga-build-job",
"build-type": "ai-web"
}
},
spec: {
restartPolicy: "Never",
containers: [{
name: "build",
image: "docker:24-dind",
command: ["/bin/sh"],
args: ["-c", $build_script],
env: [{
name: "DOCKER_HOST",
value: "tcp://localhost:2375"
}],
resources: {
requests: {
memory: $memory,
cpu: $cpu
},
limits: {
memory: $memory,
cpu: $cpu
}
},
volumeMounts: [{
name: "workspace",
mountPath: "/workspace"
}, {
name: "harbor-creds",
mountPath: "/harbor-creds",
readOnly: true
}, {
name: "shared",
mountPath: "/tmp"
}]
}, {
name: "docker-daemon",
image: "docker:24-dind",
command: ["/bin/sh"],
args: ["-c", "dockerd-entrypoint.sh & while [ ! -f /tmp/build-complete ]; do sleep 5; done; echo 'Build complete signal received, shutting down...'; sleep 10"],
securityContext: {
privileged: true
},
env: [{
name: "DOCKER_TLS_CERTDIR",
value: ""
}],
resources: {
requests: {
memory: "1Gi",
cpu: "1"
},
limits: {
memory: "2Gi",
cpu: "2"
}
},
volumeMounts: [{
name: "docker-storage",
mountPath: "/var/lib/docker"
}, {
name: "shared",
mountPath: "/tmp"
}]
}],
volumes: [{
name: "workspace",
emptyDir: {}
}, {
name: "docker-storage",
emptyDir: {}
}, {
name: "shared",
emptyDir: {}
}, {
name: "harbor-creds",
secret: {
secretName: "harbor-credentials",
items: [{
key: ".dockerconfigjson",
path: ".dockerconfigjson"
}]
}
}]
}
}
}
}
# Write job manifest to file
let job_file = $"/tmp/build-job-($timestamp).yaml"
$job_manifest | to yaml | save -f $job_file
# Create the job
let helm_result = do {
kubectl apply -f $job_file -n $namespace | complete
}
if $helm_result.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)Failed to create build job($env.ALGA_COLOR_RESET)"
rm -f $job_file
error make { msg: "Failed to create Kubernetes job" }
}
print $"($env.ALGA_COLOR_GREEN)Build job created successfully($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_CYAN)Monitoring job progress...($env.ALGA_COLOR_RESET)"
# Monitor the job
let start_time = (date now | format date '%s' | into int)
let timeout_seconds = 1800 # 30 minutes
loop {
# Check job status
let job_status = do {
kubectl get job $job_name -n $namespace -o json | complete
}
if $job_status.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)Failed to get job status($env.ALGA_COLOR_RESET)"
break
}
let status = ($job_status.stdout | from json)
# Check if job completed
if ("succeeded" in $status.status) and ($status.status.succeeded? | default 0) > 0 {
print $"($env.ALGA_COLOR_GREEN)Build completed successfully!($env.ALGA_COLOR_RESET)"
break
}
# Check if job failed
if ("failed" in $status.status) and ($status.status.failed? | default 0) > 0 {
print $"($env.ALGA_COLOR_RED)Build failed!($env.ALGA_COLOR_RESET)"
# Get pod logs
let pods = do {
kubectl get pods -n $namespace -l job-name=$job_name -o json | complete
}
if $pods.exit_code == 0 {
let pod_list = ($pods.stdout | from json)
if ($pod_list.items | length) > 0 {
let pod_name = $pod_list.items.0.metadata.name
print $"($env.ALGA_COLOR_YELLOW)Fetching logs from pod: ($pod_name)($env.ALGA_COLOR_RESET)"
kubectl logs $pod_name -n $namespace -c build --tail=100
}
}
# Clean up job
kubectl delete job $job_name -n $namespace --ignore-not-found | complete
rm -f $job_file
error make { msg: "Build job failed" }
}
# Check timeout
let current_time = (date now | format date '%s' | into int)
let elapsed = ($current_time - $start_time)
if $elapsed > $timeout_seconds {
print $"($env.ALGA_COLOR_RED)Build timed out after ($elapsed) seconds($env.ALGA_COLOR_RESET)"
kubectl delete job $job_name -n $namespace --ignore-not-found | complete
rm -f $job_file
error make { msg: "Build job timed out" }
}
# Get current pod status
let pods = do {
kubectl get pods -n $namespace -l job-name=$job_name --no-headers | complete
}
if $pods.exit_code == 0 and ($pods.stdout | str trim | str length) > 0 {
print -n $"\r($env.ALGA_COLOR_CYAN)Job status: ($pods.stdout | str trim | split column -c '\\s+' | get column2.0)/Running - Elapsed: ($elapsed)s($env.ALGA_COLOR_RESET)"
}
sleep 5sec
}
# Stream logs from the completed job
print $"\n($env.ALGA_COLOR_CYAN)Build logs:($env.ALGA_COLOR_RESET)"
let pods = do {
kubectl get pods -n $namespace -l job-name=$job_name -o json | complete
}
if $pods.exit_code == 0 {
let pod_list = ($pods.stdout | from json)
if ($pod_list.items | length) > 0 {
let pod_name = $pod_list.items.0.metadata.name
kubectl logs $pod_name -n $namespace -c build
}
}
# Clean up
print $"($env.ALGA_COLOR_CYAN)Cleaning up...($env.ALGA_COLOR_RESET)"
kubectl delete job $job_name -n $namespace --ignore-not-found | complete
rm -f $job_file
print $"($env.ALGA_COLOR_GREEN)Build process completed!($env.ALGA_COLOR_RESET)"
if $push {
for tag in $tags_to_apply {
print $"($env.ALGA_COLOR_GREEN)Image pushed: ($base_image):($tag)($env.ALGA_COLOR_RESET)"
}
}
}
# Build all AI Docker images (API and Web)
export def build-ai-all [
--tag: string = "" # Docker tag to use (defaults to SHA)
--push # Push to registry after building
--use-latest # Tag with both SHA and 'latest'
] {
print $"($env.ALGA_COLOR_CYAN)Building all AI Docker images...($env.ALGA_COLOR_RESET)"
# Build AI API
if $push and $use_latest {
build-ai-api --tag $tag --push --use-latest
} else if $push {
build-ai-api --tag $tag --push
} else if $use_latest {
build-ai-api --tag $tag --use-latest
} else {
build-ai-api --tag $tag
}
# Build AI Web
if $push and $use_latest {
build-ai-web --tag $tag --push --use-latest
} else if $push {
build-ai-web --tag $tag --push
} else if $use_latest {
build-ai-web --tag $tag --use-latest
} else {
build-ai-web --tag $tag
}
print $"($env.ALGA_COLOR_GREEN)All AI images built successfully!($env.ALGA_COLOR_RESET)"
}

738
cli/cleanup-tenant.nu Normal file
View File

@ -0,0 +1,738 @@
#!/usr/bin/env nu
# Tenant Cleanup Tool for Alga PSA
#
# Usage:
# ./cleanup-tenant.nu list # List all tenants
# ./cleanup-tenant.nu inspect <tenant-id> # Inspect tenant data
# ./cleanup-tenant.nu cleanup <tenant-id> # Cleanup tenant (dry-run)
# ./cleanup-tenant.nu cleanup <tenant-id> --execute # Actually delete data
#
# Options:
# --environment <local|production> Environment to use (default: local)
# --execute Actually delete data (without this, it's a dry run)
# --preserve-tenant Keep the tenant record itself
# --force Skip confirmation prompts
# Read secret from file
def read-secret [filename: string] {
let secret_path = $"($env.PWD)/secrets/($filename)"
if ($secret_path | path exists) {
open $secret_path | str trim
} else {
null
}
}
# Get database configuration
def get-db-config [env_name: string = "local"] {
if $env_name == "local" {
{
host: ($env.DB_HOST? | default "localhost")
port: ($env.DB_PORT? | default 5432)
database: ($env.DB_NAME? | default "server")
user: ($env.DB_USER? | default "postgres")
password: ($env.DB_PASSWORD? | default "postpass123")
}
} else {
let prod_password = (read-secret "db_password_prod") | default $env.PROD_DB_PASSWORD?
let prod_host = (read-secret "db_host_prod") | default ($env.PROD_DB_HOST? | default "localhost")
let prod_port = (read-secret "db_port_prod") | default ($env.PROD_DB_PORT? | default "5433")
let prod_database = (read-secret "db_name_prod") | default ($env.PROD_DB_NAME? | default "server")
let prod_user = (read-secret "db_user_prod") | default ($env.PROD_DB_USER? | default "postgres")
if $prod_password == null {
print "Production database password not found!"
print "Please either:"
print "1. Create secrets/db_password_prod file with the password"
print "2. Or set PROD_DB_PASSWORD environment variable"
exit 1
}
{
host: $prod_host
port: ($prod_port | into int)
database: $prod_database
user: $prod_user
password: $prod_password
}
}
}
# Build PostgreSQL connection string
def build-connection-string [config: record] {
$"postgresql://($config.user):($config.password)@($config.host):($config.port)/($config.database)"
}
# Execute SQL query
def execute-sql [
query: string
--env-name: string = "local"
] {
let config = get-db-config $env_name
let conn_str = build-connection-string $config
psql $conn_str -t -A -c $query | lines | where {|line| $line != "" }
}
# Execute SQL query and return as table
def query-sql [
query: string
--env-name: string = "local"
] {
let config = get-db-config $env_name
let conn_str = build-connection-string $config
psql $conn_str --csv -c $query | from csv
}
# List all tenants
def "main list" [
--environment: string = "local" # Environment to use
] {
print $"Environment: ($environment)"
print "\nFetching tenants...\n"
let query = "
SELECT
t.tenant,
t.client_name,
t.created_at,
COUNT(DISTINCT u.user_id) as user_count,
COUNT(DISTINCT tk.ticket_id) as ticket_count,
MAX(tk.entered_at) as last_ticket_date
FROM tenants t
LEFT JOIN users u ON t.tenant = u.tenant
LEFT JOIN tickets tk ON t.tenant = tk.tenant
GROUP BY t.tenant, t.client_name, t.created_at
ORDER BY t.created_at DESC
"
let tenants = query-sql $query --env-name $environment
print $"Found ($tenants | length) tenants:\n"
# Format and display the table with quick total counts
let enriched_tenants = $tenants | each { |row|
let user_count = if ($row.user_count? | is-empty) { 0 } else { $row.user_count | into int }
let ticket_count = if ($row.ticket_count? | is-empty) { 0 } else { $row.ticket_count | into int }
# Quick count of key tables for est_total_records (for performance)
let count_query = "
SELECT
(SELECT COUNT(*) FROM users WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM clients WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM contacts WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM tickets WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM projects WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM documents WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM time_entries WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM invoices WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM comments WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM roles WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM user_roles WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM role_permissions WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM workflow_executions WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM workflow_events WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM task_checklist_items WHERE tenant = '" + $row.tenant + "') +
(SELECT COUNT(*) FROM project_tasks WHERE tenant = '" + $row.tenant + "')
as est_total_records
"
let total_result = query-sql $count_query --env-name $environment | first
let est_total_records = if ($total_result.est_total_records? | is-empty) { 0 } else { $total_result.est_total_records | into int }
{
tenant: $row.tenant
client: $row.client_name
created: ($row.created_at | str substring 0..10)
users: $user_count
tickets: $ticket_count
last_ticket: (if ($row.last_ticket_date? | is-empty) { "never" } else { $row.last_ticket_date | str substring 0..10 })
est_total_records: $est_total_records
}
}
$enriched_tenants | table
}
# Inspect tenant data
def "main inspect" [
tenant_id: string # Tenant ID to inspect
--environment: string = "local" # Environment to use
] {
print $"\nInspecting tenant: ($tenant_id)\n"
# Get tenant info
let tenant_query = "SELECT * FROM tenants WHERE tenant = '" + $tenant_id + "'"
let tenant = query-sql $tenant_query --env-name $environment | first
if ($tenant | is-empty) {
print "Tenant not found!"
exit 1
}
print $"Client: ($tenant.client_name)"
print $"Created: ($tenant.created_at)"
print "\nData breakdown:\n"
# Get all tables with tenant column
let tables_query = "
SELECT
c.table_name,
c.column_name as tenant_column
FROM information_schema.columns c
JOIN information_schema.tables t ON c.table_name = t.table_name
AND c.table_schema = t.table_schema
WHERE c.column_name IN ('tenant', 'tenant_id')
AND c.table_schema = 'public'
AND t.table_type = 'BASE TABLE'
ORDER BY c.table_name
"
let tables = query-sql $tables_query --env-name $environment
# Count records in each table
let counts = $tables | par-each { |table|
let count_query = "SELECT COUNT(*) as count FROM " + $table.table_name + " WHERE " + $table.tenant_column + " = '" + $tenant_id + "'"
try {
let result = query-sql $count_query --env-name $environment | first
let count = if ($result.count? | is-empty) { 0 } else { $result.count | into int }
if $count > 0 {
{ table: $table.table_name, records: $count }
} else {
null
}
} catch {
null
}
} | where {|item| $item != null } | sort-by records --reverse
# Display results
$counts | table
print $"\nTables with data: ($counts | length)"
print $"Estimated total records: ($counts | get records | math sum)"
}
# Cleanup tenant
def "main cleanup" [
tenant_id: string # Tenant ID to cleanup
--environment: string = "local" # Environment to use
--execute # Actually delete (default is dry-run)
--preserve-tenant # Keep the tenant record itself
--force # Skip confirmation prompts
] {
let is_dry_run = not $execute
print ("=" | fill -w 60)
let mode = if $is_dry_run { "DRY RUN" } else { "*** ACTUAL DELETION ***" }
print $"TENANT CLEANUP - ($mode)"
print ("=" | fill -w 60)
# Get tenant info
let tenant_query = "SELECT * FROM tenants WHERE tenant = '" + $tenant_id + "'"
let tenant = query-sql $tenant_query --env-name $environment | first
if ($tenant | is-empty) {
print "Tenant not found!"
exit 1
}
print $"Tenant ID: ($tenant_id)"
print $"Client: ($tenant.client_name)"
print $"Created: ($tenant.created_at)"
print $"Preserve tenant record: ($preserve_tenant)"
print $"Environment: ($environment)"
# Check if this looks like production data
let activity_query = (
"SELECT " +
"(SELECT COUNT(*) FROM users WHERE tenant = '" + $tenant_id + "') as user_count, " +
"(SELECT COUNT(*) FROM invoices WHERE tenant = '" + $tenant_id + "' AND created_at > NOW() - INTERVAL '30 days') as recent_invoices, " +
"(SELECT COUNT(*) FROM tickets WHERE tenant = '" + $tenant_id + "' AND entered_at > NOW() - INTERVAL '7 days') as recent_tickets"
)
let activity = query-sql $activity_query --env-name $environment | first
let user_count = $activity.user_count | into int
let recent_invoices = $activity.recent_invoices | into int
let recent_tickets = $activity.recent_tickets | into int
let is_production = ($user_count > 20) or ($recent_invoices > 0) or ($recent_tickets > 5)
if $is_production {
print "\n*** WARNING: This appears to be an ACTIVE/PRODUCTION tenant! ***"
print $"Users: ($user_count)"
print $"Recent invoices \(30d\): ($recent_invoices)"
print $"Recent tickets \(7d\): ($recent_tickets)"
if (not $force) and (not $is_dry_run) {
let answer = input "\nAre you SURE you want to delete this production tenant? (yes/no): "
if $answer != "yes" {
print "Cleanup cancelled."
exit 0
}
}
}
if (not $is_dry_run) and (not $force) {
let answer = input "\nThis will PERMANENTLY delete data. Continue? (yes/no): "
if $answer != "yes" {
print "Cleanup cancelled."
exit 0
}
}
print "\nStarting cleanup...\n"
# Tables to delete from (in dependency order - most dependent first)
# The order is critical due to foreign key constraints
let tables = [
# === LEVEL 0: Sessions (CRITICAL - must be deleted before users/tenants) ===
"sessions"
# === LEVEL 1: Leaf tables with no dependencies ===
# Global search index (no FKs, denormalized projection)
"app_search_index"
# Workflow details
"workflow_action_results" "workflow_event_attachments" "workflow_snapshots"
"workflow_action_dependencies" "workflow_sync_points" "workflow_timers"
"workflow_task_history" "workflow_form_schemas"
# Workflow runtime V2 (child tables first, then parent) — keep in sync
# with TENANT_TABLES_DELETION_ORDER in tenant-deletion-activities.ts
"workflow_run_steps" "workflow_run_waits" "workflow_run_snapshots"
"workflow_action_invocations" "workflow_definition_versions"
"workflow_run_logs" "workflow_runtime_events" "workflow_step_usage_periods"
"workflow_runs" "tenant_workflow_schedule" "workflow_definitions"
# Task/project details
"task_checklist_items" "project_task_dependencies" "task_resources"
"project_ticket_links" "project_task_comments"
# Project template details
"project_template_checklist_items" "project_template_dependencies"
"project_template_task_resources" "project_template_status_mappings"
"project_template_tasks" "project_template_phases" "project_templates"
# Quote details
"quote_activities" "quote_items" "quote_document_template_assignments"
"quote_document_templates" "standard_quote_document_templates" "quotes"
# Invoice details
"invoice_charges" "invoice_annotations" "invoice_time_entries" "invoice_usage_records"
"invoice_charge_details" "invoice_charge_fixed_details" "invoice_items"
"invoice_payment_links" "invoice_payments" "invoice_template_assignments"
# Time tracking
"time_sheet_comments" "time_entries" "time_sheets"
# Document details
"document_block_content" "document_versions" "document_content"
"document_folders" "document_system_entries"
# Messages and comments
# comment_threads is the parent of comments.thread_id / project_task_comments.thread_id
# (CASCADE) and email_sending_logs.comment_thread_id (SET NULL); delete AFTER comments
# and project_task_comments (which is removed at the project_task_comments line above).
"comments" "comment_threads"
"gmail_processed_history" "email_processed_messages"
"email_reply_tokens" "email_sending_logs" "email_rate_limits"
# User related details
"user_activity_group_items" "user_activity_groups"
"user_notification_preferences" "user_internal_notification_preferences" "user_preferences"
"role_permissions" "user_roles" "user_auth_accounts"
# Apple IAP billing
"apple_iap_subscriptions" "apple_iap_notifications"
# Schedule and team
"schedule_entry_assignees" "schedule_conflicts" "team_members"
"availability_exceptions" "availability_settings"
# Calendar
"calendar_event_mappings" "calendar_provider_health"
"google_calendar_provider_config" "microsoft_calendar_provider_config" "calendar_providers"
# Tags and resources
"tag_mappings" "ticket_resources"
# Logs and notifications
"job_details" "jobs" "audit_logs" "notification_logs" "internal_notifications"
# Import/export
"import_job_items" "import_jobs" "import_sources"
"accounting_export_errors" "accounting_export_lines" "accounting_export_batches"
# Asset details
"asset_maintenance_notifications" "asset_maintenance_history" "asset_service_history"
"asset_ticket_associations" "asset_document_associations" "asset_relationships"
"asset_history" "asset_associations" "asset_software"
"workstation_assets" "server_assets" "network_device_assets" "mobile_device_assets" "printer_assets"
# Asset type registry (RESTRICT FK to tenants; delete before tenants)
"asset_type_registry"
# Software catalog
"software_catalog"
# RMM
"rmm_alert_rules" "rmm_alerts" "rmm_organization_mappings" "rmm_integrations"
# Hudu integration (one connection row per tenant)
"hudu_integrations"
# Survey
"survey_responses" "survey_invitations" "survey_triggers" "survey_templates"
# Appointment
"appointment_requests"
# SLA leaf tables (must be before tickets, statuses, priorities, boards)
"sla_notifications_sent" "sla_audit_log"
"sla_notification_thresholds" "sla_policy_targets"
"status_sla_pause_config"
"business_hours_entries" "holidays"
"escalation_managers"
"sla_settings"
# === LEVEL 2: Tables that depend on level 3+ ===
# Payment/Stripe
"stripe_webhook_events" "stripe_subscriptions" "stripe_prices" "stripe_products"
"stripe_customers" "stripe_accounts"
"payment_webhook_events" "payment_provider_configs" "client_payment_customers"
# Billing details
"credit_allocations" "credit_reconciliation_reports" "credit_tracking"
"usage_tracking" "bucket_usage" "transactions"
"client_contracts" "contract_line_service_rate_tiers" "contract_line_service_bucket_config"
"contract_line_service_hourly_config" "contract_line_service_hourly_configs" "contract_line_service_usage_config"
"contract_line_service_fixed_config" "contract_line_service_configuration"
"contract_line_service_defaults" "contract_pricing_schedules"
"service_rate_tiers" "service_prices" "contract_line_discounts" "discounts"
"client_billing_cycles" "client_billing_settings"
"contract_line_services" "contract_lines" "contracts"
# Contract line presets
"contract_line_preset_fixed_config" "contract_line_preset_services" "contract_line_presets"
# Contract templates (must be deleted before contracts)
"contract_template_compare_view" "contract_template_line_defaults"
"contract_template_line_fixed_config" "contract_template_line_service_bucket_config"
"contract_template_line_service_configuration" "contract_template_line_service_hourly_config"
"contract_template_line_service_usage_config" "contract_template_line_services"
"contract_template_line_terms" "contract_template_lines"
"contract_template_pricing_schedules" "contract_template_services" "contract_templates"
# Client details (must come before clients)
"client_tax_rates" "client_tax_settings" "client_inbound_email_domains"
"client_name_aliases"
"tenant_companies"
# Project/task entities
"project_tasks" "project_phases" "project_status_mappings"
# Workflow entities
"workflow_tasks" "workflow_executions" "workflow_events" "workflow_event_processing"
"workflow_registration_versions" "workflow_triggers" "workflow_form_definitions"
"workflow_task_definitions"
# === LEVEL 3: Mid-level entities ===
# Document folder templates: items and init rows reference document_folder_templates
# (CASCADE / SET NULL), so we delete the children first for a clean trail.
"document_folder_template_items" "document_entity_folder_init" "document_folder_templates"
# Document associations must come before documents
"document_associations"
# Assets must come after asset details
"asset_maintenance_schedules" "assets"
# Contract Lines
"contract_lines" "payment_methods"
# Interactions must come BEFORE tickets (tickets reference interactions in some cases)
"interactions" "interaction_types"
# Schedule entries
"schedule_entries"
# Service catalog
"service_catalog" "service_types" "service_categories"
# Settings that might be referenced
"approval_thresholds"
# Conditional display rules must come BEFORE invoice_templates
"conditional_display_rules"
# === LEVEL 4: Core business entities ===
# Invoice templates (after conditional_display_rules)
"invoice_templates"
# Invoices (after invoice_templates)
"invoices"
# Projects
"projects"
# External files and documents
"external_files" "documents" "document_types"
# Workflow templates
"workflow_registrations" "workflow_templates" "workflow_template_categories"
# === Ticket close rules (2026-06-10) ===
# All seven FK to tickets / boards / statuses, so they must be deleted
# BEFORE those. Internal order follows the FKs among them:
# ticket_auto_close_state → board_auto_close_rules, and
# checklist_template_items / _apply_rules → checklist_templates.
"ticket_auto_close_state" "board_auto_close_rules"
"ticket_checklist_items"
"checklist_template_apply_rules" "checklist_template_items" "checklist_templates"
"board_close_rules"
# === LEVEL 5: Tickets and related ===
# Tickets MUST be deleted BEFORE categories, statuses, etc that it references
# AND BEFORE client_locations that tickets reference via location_id
"tickets"
# === LEVEL 6: Client locations (referenced by tickets.location_id) ===
# Must be deleted AFTER tickets
"client_locations"
# === LEVEL 6: Lookup tables referenced by tickets ===
# These can only be deleted AFTER tickets
"categories"
"standard_statuses" "statuses"
"priorities" "severities" "urgencies" "impacts"
# === LEVEL 7: Boards (referenced by categories) ===
# Boards must be deleted AFTER categories (renamed from channels)
"boards"
# === LEVEL 8: Breaking circular dependencies ===
# There's a complex circular dependency:
# - users.contact_id → contacts (with ON DELETE SET NULL that fails on NOT NULL constraint)
# - contacts.client_id → clients
# - clients.account_manager → users
# Tax configuration (no dependencies on core entities)
"tax_components" "tax_rates" "tax_regions"
# Permissions and roles (must be deleted before users)
"permissions" "roles" "teams"
# The correct order to avoid constraint violations:
# 1. Delete clients first (after NULLing account_manager)
# 2. Delete contacts second (after NULLing client_id, before users that reference them)
# 3. Delete users last (they have NOT NULL contact_id that references contacts)
"clients" # Delete clients FIRST (after NULLing account_manager references)
"contacts" # Delete contacts SECOND (after clients, before users that have NOT NULL contact_id)
"users" # Delete users LAST (they have NOT NULL contact_id → contacts)
# SLA policies (referenced by clients.sla_policy_id and boards.sla_policy_id - must come after both)
"sla_policies"
# Business hours (referenced by sla_policies.business_hours_schedule_id - must come after sla_policies)
"business_hours_schedules"
# === LEVEL 7: Configuration and settings ===
# API and auth
"mobile_auth_otts" "mobile_refresh_tokens"
"api_keys" "portal_invitations" "password_reset_tokens"
"portal_domain_session_otts" "portal_domains"
# Policies and resources
"policies" "resources"
# Email configuration
"google_email_provider_config" "microsoft_email_provider_config"
"email_provider_health" "email_provider_configs" "email_providers"
"email_templates" "email_domains" "tenant_email_settings"
# Storage configuration
"storage_records" "storage_schemas" "storage_usage"
"storage_configurations" "storage_providers"
"ext_storage_records" "ext_storage_schemas" "ext_storage_usage"
# Templates and layouts
"tenant_email_templates" "template_sections"
"approval_levels" # After approval_thresholds
# Custom fields and attributes
"attribute_definitions" "custom_fields"
"layout_blocks" "tag_definitions" "custom_task_types"
# Time period settings (tenant_time_period_settings must come BEFORE time_period_types)
"tenant_time_period_settings"
"time_periods" "time_period_types" "time_period_settings"
# External entity mappings and tax
"external_entity_mappings" "external_tax_imports"
# Tenant notification settings
"tenant_internal_notification_category_settings" "tenant_internal_notification_subtype_settings"
"tenant_notification_category_settings" "tenant_notification_subtype_settings"
# Other tenant settings
"tenant_telemetry_settings"
"tenant_external_entity_mappings" "telemetry_consent_log"
"default_billing_settings" "notification_settings"
# inbound_email_rules references inbound_ticket_defaults (fallback destination)
"inbound_email_rules"
"inbound_ticket_defaults" "user_type_rates" "next_number"
"event_catalog" "provider_events"
# Tenant settings last
"tenant_settings"
]
let config = get-db-config $environment
let conn_str = build-connection-string $config
mut total_deleted = 0
mut tables_affected = 0
# First, break circular dependencies by NULLing foreign keys
if not $is_dry_run {
print "Breaking circular dependencies..."
# The circular dependency chain:
# clients.account_manager_id → users.user_id
# users.contact_id → contacts.contact_id (NOT NULL constraint!)
# contacts.client_id → clients.client_id
# Step 1: NULL out account_manager_id in clients to break clients → users dependency
try {
let null_query = "UPDATE clients SET account_manager_id = NULL WHERE tenant = '" + $tenant_id + "'"
execute-sql $null_query --env-name $environment
print " Cleared account_manager_id references in clients"
} catch {
# Ignore if column doesn't exist or already NULL
}
# Step 2: NULL out client_id in contacts to break contacts → clients dependency
try {
let null_query = "UPDATE contacts SET client_id = NULL WHERE tenant = '" + $tenant_id + "'"
execute-sql $null_query --env-name $environment
print " Cleared client_id references in contacts"
} catch {
# Ignore if column doesn't exist or already NULL
}
# Step 3: NULL out client_id in inbound_ticket_defaults to break the
# inbound_ticket_defaults → clients dependency. inbound_ticket_defaults
# is deleted late in the order, so this lets clients be deleted first.
try {
let null_query = "UPDATE inbound_ticket_defaults SET client_id = NULL WHERE tenant = '" + $tenant_id + "'"
execute-sql $null_query --env-name $environment
print " Cleared client_id references in inbound_ticket_defaults"
} catch {
# Ignore if column doesn't exist or already NULL
}
# Note: We cannot NULL users.contact_id because it has a NOT NULL constraint
# Instead, we'll delete in the order: clients → contacts → users
# This way contacts are deleted before users tries to reference them
}
# Delete from each table
for table in $tables {
# Check if table has tenant column
let check_query = (
"SELECT column_name " +
"FROM information_schema.columns " +
"WHERE table_name = '" + $table + "' " +
"AND column_name IN ('tenant', 'tenant_id') " +
"AND table_schema = 'public' " +
"LIMIT 1"
)
let column_result = execute-sql $check_query --env-name $environment
if ($column_result | length) > 0 {
let column_name = $column_result | first
# Count records
let count_query = "SELECT COUNT(*) FROM " + $table + " WHERE " + $column_name + " = '" + $tenant_id + "'"
let count_result = execute-sql $count_query --env-name $environment | first
let count = if ($count_result | is-empty) { 0 } else { $count_result | into int }
if $count > 0 {
if $is_dry_run {
print $" Would delete ($count) records from ($table)"
} else {
let delete_query = "DELETE FROM " + $table + " WHERE " + $column_name + " = '" + $tenant_id + "'"
execute-sql $delete_query --env-name $environment
print $" Deleted ($count) records from ($table)"
}
$total_deleted = $total_deleted + $count
$tables_affected = $tables_affected + 1
}
}
}
# Handle tenant record
if not $preserve_tenant {
if $is_dry_run {
print " Would delete tenant record from tenants table"
} else {
let delete_tenant_query = "DELETE FROM tenants WHERE tenant = '" + $tenant_id + "'"
execute-sql $delete_tenant_query --env-name $environment
print " Deleted tenant record from tenants table"
}
$total_deleted = $total_deleted + 1
$tables_affected = $tables_affected + 1
} else {
print " Preserving tenant record in tenants table (as requested)"
}
print ""
print ("=" | fill -w 60)
print "CLEANUP SUMMARY"
print ("=" | fill -w 60)
let mode_text = if $is_dry_run { "DRY RUN" } else { "ACTUAL DELETION" }
print $"Mode: ($mode_text)"
print $"Tenant: ($tenant_id) \(($tenant.client_name)\)"
print $"Tables affected: ($tables_affected)"
let action_text = if $is_dry_run { "to delete" } else { "deleted" }
print $"Estimated total records ($action_text): ($total_deleted)"
if $is_dry_run {
print "\n*** This was a DRY RUN - no data was actually deleted ***"
print "*** Add --execute flag to actually delete data ***"
} else {
print "\n*** Data has been PERMANENTLY DELETED ***"
}
}
# Show help
def main [] {
print "Tenant Cleanup Tool for Alga PSA\n"
print "Commands:"
print " list List all tenants"
print " inspect <tenant-id> Inspect tenant data"
print " cleanup <tenant-id> Cleanup tenant (dry-run by default)"
print ""
print "Options:"
print " --environment <local|production> Environment to use (default: local)"
print " --execute Actually delete data (for cleanup command)"
print " --preserve-tenant Keep the tenant record itself"
print " --force Skip confirmation prompts"
print ""
print "Examples:"
print " nu cli/cleanup-tenant.nu list"
print " nu cli/cleanup-tenant.nu list --environment production"
print " nu cli/cleanup-tenant.nu inspect 12345678-1234-1234-1234-123456789012"
print " nu cli/cleanup-tenant.nu cleanup 12345678-1234-1234-1234-123456789012"
print " nu cli/cleanup-tenant.nu cleanup 12345678-1234-1234-1234-123456789012 --execute"
print ""
print "Workflow:"
print " 1. List tenants to identify test ones: nu cli/cleanup-tenant.nu list --environment production"
print " 2. Inspect a tenant: nu cli/cleanup-tenant.nu inspect <id> --environment production"
print " 3. Dry run first: nu cli/cleanup-tenant.nu cleanup <id> --environment production"
print " 4. Execute if safe: nu cli/cleanup-tenant.nu cleanup <id> --environment production --execute"
}

9
cli/colors.nu Normal file
View File

@ -0,0 +1,9 @@
# --- Color Constants ---
export-env {
$env.ALGA_COLOR_RESET = (ansi reset)
$env.ALGA_COLOR_GREEN = (ansi green)
$env.ALGA_COLOR_YELLOW = (ansi yellow)
$env.ALGA_COLOR_RED = (ansi red)
$env.ALGA_COLOR_CYAN = (ansi cyan)
}

217
cli/config.nu Normal file
View File

@ -0,0 +1,217 @@
#!/usr/bin/env nu
# Alga CLI Configuration Module
# Manages user-specific configuration for the Alga development CLI
use "utils.nu" *
# Get the configuration file path
export def get-config-path [] {
let config_dir = if ($env.XDG_CONFIG_HOME? | is-empty) {
$nu.home-path | path join ".config"
} else {
$env.XDG_CONFIG_HOME
}
let alga_config_dir = $config_dir | path join "alga-cli"
let config_file = $alga_config_dir | path join "config.toml"
{ dir: $alga_config_dir, file: $config_file }
}
# Load configuration from file
export def load-config [] {
let paths = get-config-path
# Return default config if file doesn't exist
if not ($paths.file | path exists) {
return {
version: "1.0"
dev_env: {
author: {
name: ""
email: ""
}
default_edition: "ee"
}
}
}
# Load and parse TOML config file
try {
open $paths.file | from toml
} catch {
print $"($env.ALGA_COLOR_YELLOW)Warning: Could not parse config file at ($paths.file)($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_YELLOW)Using default configuration($env.ALGA_COLOR_RESET)"
# Return default config on error
{
version: "1.0"
dev_env: {
author: {
name: ""
email: ""
}
default_edition: "ee"
}
}
}
}
# Save configuration to file
export def save-config [config: record] {
let paths = get-config-path
# Create config directory if it doesn't exist
if not ($paths.dir | path exists) {
mkdir $paths.dir
}
# Save config as TOML
$config | to toml | save -f $paths.file
print $"($env.ALGA_COLOR_GREEN)Configuration saved to ($paths.file)($env.ALGA_COLOR_RESET)"
}
# Get a specific configuration value
export def get-config-value [key: string] {
let config = load-config
# Navigate through nested keys (e.g., "dev_env.author.name")
let keys = $key | split row "."
mut value = $config
for k in $keys {
if ($value | describe) == "record" and ($k in $value) {
$value = $value | get $k
} else {
return null
}
}
$value
}
# Set a specific configuration value
export def set-config-value [key: string, value: any] {
mut config = load-config
# Navigate through nested keys
let keys = $key | split row "."
if ($keys | length) == 1 {
# Simple key
$config = $config | upsert $key $value
} else if ($keys | length) == 2 {
# One level nested
let parent = $keys.0
let child = $keys.1
# Ensure parent exists as a record
if not ($parent in $config) {
$config = $config | upsert $parent {}
}
let parent_value = $config | get $parent | upsert $child $value
$config = $config | upsert $parent $parent_value
} else if ($keys | length) == 3 {
# Two levels nested (e.g., dev_env.author.name)
let level1 = $keys.0
let level2 = $keys.1
let level3 = $keys.2
# Ensure nested structure exists
if not ($level1 in $config) {
$config = $config | upsert $level1 {}
}
let level1_value = $config | get $level1
if not ($level2 in $level1_value) {
let updated_level1 = $level1_value | upsert $level2 {}
$config = $config | upsert $level1 $updated_level1
}
let level2_value = $config | get $level1 | get $level2 | upsert $level3 $value
let updated_level1 = $config | get $level1 | upsert $level2 $level2_value
$config = $config | upsert $level1 $updated_level1
}
save-config $config
}
# Initialize configuration with prompts
export def init-config [--force] {
let paths = get-config-path
if ($paths.file | path exists) and not $force {
print $"($env.ALGA_COLOR_YELLOW)Configuration file already exists at ($paths.file)($env.ALGA_COLOR_RESET)"
let overwrite = (input "Do you want to overwrite it? (y/N): ")
if $overwrite != "y" {
print "Configuration initialization cancelled."
return
}
}
print $"($env.ALGA_COLOR_CYAN)Alga CLI Configuration Setup($env.ALGA_COLOR_RESET)"
print "═══════════════════════════════════════════"
# Get user information
let author_name = (input "Git author name (e.g., John Doe): ")
let author_email = (input "Git author email (e.g., john@example.com): ")
# Get default edition preference
print ""
print "Default edition for dev environments:"
print " ce - Community Edition"
print " ee - Enterprise Edition"
let default_edition = (input "Default edition (ce/ee) [ee]: ")
let edition = if ($default_edition | str trim | is-empty) { "ee" } else { $default_edition }
# Validate edition
if not ($edition in ["ce", "ee"]) {
print $"($env.ALGA_COLOR_RED)Invalid edition. Using 'ee' as default.($env.ALGA_COLOR_RESET)"
let edition = "ee"
}
# Create configuration
let config = {
version: "1.0"
dev_env: {
author: {
name: $author_name
email: $author_email
}
default_edition: $edition
}
}
# Save configuration
save-config $config
print ""
print $"($env.ALGA_COLOR_GREEN)Configuration initialized successfully!($env.ALGA_COLOR_RESET)"
print ""
print "Your configuration:"
print $" Author Name: ($author_name)"
print $" Author Email: ($author_email)"
print $" Default Edition: ($edition)"
}
# Display current configuration
export def show-config [] {
let paths = get-config-path
let config = load-config
print $"($env.ALGA_COLOR_CYAN)Alga CLI Configuration($env.ALGA_COLOR_RESET)"
print $"Location: ($paths.file)"
print "═══════════════════════════════════════════"
if ($paths.file | path exists) {
print ""
print "Current configuration:"
print ($config | to yaml)
} else {
print ""
print $"($env.ALGA_COLOR_YELLOW)No configuration file found.($env.ALGA_COLOR_RESET)"
print "Run 'nu main.nu config init' to create one."
}
}

1238
cli/dev-env.nu Normal file

File diff suppressed because it is too large Load Diff

1097
cli/hosted-env.nu Normal file

File diff suppressed because it is too large Load Diff

812
cli/main.nu Executable file
View File

@ -0,0 +1,812 @@
#!/usr/bin/env nu
# Alga Development CLI
# Source the other modules
source-env "colors.nu"
use "utils.nu" *
use "migrate.nu" *
use "workflows.nu" *
use "dev-env.nu" *
use "hosted-env.nu" *
use "build.nu" *
use "config.nu" *
use "tenant.nu" *
use "portal-domain.nu" *
# Main CLI entry point function
def --wrapped main [
...args: string # All arguments and flags as strings
] {
let command = ($args | get 0? | default null)
# Handle help flags
if $command in ["--help", "-h", "help"] {
print $"($env.ALGA_COLOR_CYAN)Alga Dev CLI($env.ALGA_COLOR_RESET)"
print "Usage:"
print " nu main.nu migrate <action> [--ee]"
print " Action: up, latest, down, status"
print " --ee: Run combined CE + EE migrations (latest, down, status)"
print " Example: nu main.nu migrate latest"
print " Example: nu main.nu migrate latest --ee"
print " Example: nu main.nu migrate status --ee"
print " Example: nu main.nu migrate down --ee"
print ""
print " nu main.nu -- dev-up [--detached] [--edition ce|ee] # Start development environment"
print " nu main.nu dev-down # Stop development environment"
print ""
print " nu main.nu dev-env-create <branch> [--edition ce|ee] [--use-latest] [--checkout] [--from-tag <tag>] [--author-name <name>] [--author-email <email>]"
print " Create on-demand development environment for branch"
print " --use-latest: Use 'latest' tag instead of unique tag (avoids cache issues by default)"
print " --checkout: Checkout the branch locally (default: true)"
print " --from-tag: Deploy from existing image tag instead of building (mutually exclusive with --use-latest)"
print " --author-name: Git author name for commits (e.g., \"John Doe\")"
print " --author-email: Git author email for commits (e.g., \"john@example.com\")"
print " Example: nu main.nu dev-env-create my-feature --edition ee"
print " Example: nu main.nu dev-env-create my-feature --from-tag v1.2.3"
print " Example: nu main.nu dev-env-create my-feature --author-name \"John Doe\" --author-email \"john@example.com\""
print " nu main.nu dev-env-list # List active development environments"
print " nu main.nu dev-env-connect <branch>"
print " Connect to development environment with port forwarding"
print " nu main.nu dev-env-destroy <branch> [--force]"
print " Destroy development environment"
print " nu main.nu dev-env-force-cleanup <branch>"
print " Force cleanup stuck development environment"
print " nu main.nu dev-env-status [<branch>]"
print " Get environment status and URLs"
print ""
print " nu main.nu hosted-env-create <branch> [--environment hosted|sebastian] # Create hosted code-server env"
print " nu main.nu hosted-env-list [--environment hosted|sebastian] # List hosted environments"
print " nu main.nu hosted-env-connect <branch> --canary <header> [--environment hosted|sebastian] # Port-forward code-server"
print " nu main.nu hosted-env-destroy <branch> [--force] [--environment hosted|sebastian]"
print " nu main.nu hosted-env-status <branch> [--environment hosted|sebastian] # Show k8s objects"
print ""
print "Note: Use '--' before dev-up when using flags to prevent Nu from parsing them:"
print " nu main.nu -- dev-up --edition ee --detached"
print ""
print " nu main.nu update-workflow <base_workflow_name> # Update latest version definition"
print " Example: nu main.nu update-workflow invoice-sync"
print ""
print " nu main.nu register-workflow <base_workflow_name> # Add new version (creates registration if needed)"
print " Example: nu main.nu register-workflow customer-sync"
print ""
print " nu main.nu build-image <edition> [--tag <tag>] [--push] [--use-latest]"
print " Build Docker image for specified edition (ce|ee)"
print " --use-latest: Tag with both SHA and 'latest' (pushes both tags if --push is used)"
print " Example: nu main.nu build-image ce --tag v1.0.0 --push"
print " Example: nu main.nu build-image ee --use-latest --push"
print " nu main.nu build-all-images [--tag <tag>] [--push]"
print " Build Docker images for both CE and EE editions"
print " Example: nu main.nu build-all-images --tag latest --push"
print " nu main.nu build-code-server [--tag <tag>] [--push] [--use-latest]"
print " Build code-server Docker image"
print " --use-latest: Tag with both SHA and 'latest' (pushes both tags if --push is used)"
print " Example: nu main.nu build-code-server --push"
print " Example: nu main.nu build-code-server --tag v1.0.0 --push"
print " nu main.nu build-ai-api [--tag <tag>] [--push] [--use-latest]"
print " Build AI API Docker image"
print " Example: nu main.nu build-ai-api --push"
print " Example: nu main.nu build-ai-api --use-latest --push"
print " nu main.nu build-ai-web [--tag <tag>] [--push] [--use-latest] [--local] [--cpu <cores>] [--memory <size>]"
print " Build AI Web Docker image (in Kubernetes by default)"
print " --local: Build locally instead of in Kubernetes"
print " --cpu: CPU cores to allocate for Kubernetes builds (default: 4)"
print " --memory: Memory to allocate for Kubernetes builds (default: 4Gi)"
print " Example: nu main.nu build-ai-web --push"
print " Example: nu main.nu build-ai-web --push --cpu 8 --memory 8Gi"
print " Example: nu main.nu build-ai-web --local --push # for local build"
print " nu main.nu build-ai-web-k8s [--tag <tag>] [--push] [--use-latest] [--namespace <ns>] [--cpu <cores>] [--memory <size>]"
print " Build AI Web Docker image using Kubernetes job (faster, uses server resources)"
print " --namespace: Kubernetes namespace (default: default)"
print " --cpu: CPU cores to allocate (default: 4)"
print " --memory: Memory to allocate (default: 4Gi)"
print " Example: nu main.nu build-ai-web-k8s --push"
print " Example: nu main.nu build-ai-web-k8s --tag v1.0.0 --push --cpu 8 --memory 8Gi"
print " nu main.nu build-ai-all [--tag <tag>] [--push] [--use-latest]"
print " Build all AI Docker images (API and Web)"
print " Example: nu main.nu build-ai-all --push"
print " Example: nu main.nu build-ai-all --use-latest --push"
print ""
print " nu main.nu config init [--force] # Initialize CLI configuration"
print " Set up default author information and preferences"
print " Example: nu main.nu config init"
print " nu main.nu config show # Display current configuration"
print " nu main.nu config get <key> # Get a specific config value"
print " Example: nu main.nu config get dev_env.author.name"
print " nu main.nu config set <key> <value> # Set a specific config value"
print " Example: nu main.nu config set dev_env.author.email \"john@example.com\""
print ""
print " nu main.nu create-tenant <name> <email> [options] # Create a new tenant"
print " --first-name: Admin user's first name (default: Admin)"
print " --last-name: Admin user's last name (default: User)"
print " --client-name: Client name (defaults to tenant name)"
print " --password: Admin password (generated if not provided)"
print " --seed-onboarding: Run onboarding seeds (default: true)"
print " --skip-onboarding: Set onboarding_skipped flag in tenant_settings"
print " Example: nu main.nu create-tenant \"Test Client\" \"admin@test.com\""
print " Example: nu main.nu create-tenant \"Test Client\" \"admin@test.com\" --skip-onboarding"
print " nu main.nu list-tenants # List all tenants"
print ""
print " nu main.nu cleanup-tenant <action> [args] # Clean up test tenant data"
print " Actions:"
print " list [--environment local|production] # List all tenants"
print " inspect <id> [--environment local|production] # Inspect tenant data"
print " cleanup <id> [--environment local|production] [--execute] [--preserve-tenant] [--force]"
print ""
print "Alternatively, source the script ('source main.nu') and run commands directly:"
print " run-migrate <action> [--ee]"
print " dev-up [--detached] [--edition ce|ee]"
print " dev-down"
print " update-workflow <workflow_name>"
print " register-workflow <workflow_name>"
print " dev-env-create <branch> [options]"
print " dev-env-list, dev-env-connect, dev-env-destroy, dev-env-status"
return
}
# Basic usage check
if $command == null {
print $"($env.ALGA_COLOR_CYAN)Alga Dev CLI($env.ALGA_COLOR_RESET)"
print "Usage:"
print " nu main.nu migrate <action> [--ee]"
print " Action: up, latest, down, status"
print " --ee: Run combined CE + EE migrations (latest, down, status)"
print " Example: nu main.nu migrate latest"
print " Example: nu main.nu migrate latest --ee"
print " Example: nu main.nu migrate status --ee"
print " Example: nu main.nu migrate down --ee"
print ""
print " nu main.nu -- dev-up [--detached] [--edition ce|ee] # Start development environment"
print " nu main.nu dev-down # Stop development environment"
print ""
print " nu main.nu dev-env-create <branch> [--edition ce|ee] [--use-latest] [--checkout] [--from-tag <tag>] [--author-name <name>] [--author-email <email>]"
print " Create on-demand development environment for branch"
print " --use-latest: Use 'latest' tag instead of unique tag (avoids cache issues by default)"
print " --checkout: Checkout the branch locally (default: true)"
print " --from-tag: Deploy from existing image tag instead of building (mutually exclusive with --use-latest)"
print " --author-name: Git author name for commits (e.g., \"John Doe\")"
print " --author-email: Git author email for commits (e.g., \"john@example.com\")"
print " Example: nu main.nu dev-env-create my-feature --edition ee"
print " Example: nu main.nu dev-env-create my-feature --from-tag v1.2.3"
print " Example: nu main.nu dev-env-create my-feature --author-name \"John Doe\" --author-email \"john@example.com\""
print " nu main.nu dev-env-list # List active development environments"
print " nu main.nu dev-env-connect <branch>"
print " Connect to development environment with port forwarding"
print " nu main.nu dev-env-destroy <branch> [--force]"
print " Destroy development environment"
print " nu main.nu dev-env-force-cleanup <branch>"
print " Force cleanup stuck development environment"
print " nu main.nu dev-env-status [<branch>]"
print " Get environment status and URLs"
print ""
print " nu main.nu hosted-env-create <branch> [--environment hosted|sebastian] # Create hosted code-server env"
print " nu main.nu hosted-env-list [--environment hosted|sebastian] # List hosted environments"
print " nu main.nu hosted-env-connect <branch> --canary <header> [--environment hosted|sebastian] # Port-forward code-server"
print " nu main.nu hosted-env-destroy <branch> [--force] [--environment hosted|sebastian]"
print " nu main.nu hosted-env-status <branch> [--environment hosted|sebastian] # Show k8s objects"
print ""
print "Note: Use '--' before dev-up when using flags to prevent Nu from parsing them:"
print " nu main.nu -- dev-up --edition ee --detached"
print ""
print " nu main.nu update-workflow <base_workflow_name> # Update latest version definition"
print " Example: nu main.nu update-workflow invoice-sync"
print ""
print " nu main.nu register-workflow <base_workflow_name> # Add new version (creates registration if needed)"
print " Example: nu main.nu register-workflow customer-sync"
print ""
print " nu main.nu build-image <edition> [--tag <tag>] [--push] [--use-latest]"
print " Build Docker image for specified edition (ce|ee)"
print " --use-latest: Tag with both SHA and 'latest' (pushes both tags if --push is used)"
print " Example: nu main.nu build-image ce --tag v1.0.0 --push"
print " Example: nu main.nu build-image ee --use-latest --push"
print " nu main.nu build-all-images [--tag <tag>] [--push]"
print " Build Docker images for both CE and EE editions"
print " Example: nu main.nu build-all-images --tag latest --push"
print " nu main.nu build-code-server [--tag <tag>] [--push] [--use-latest]"
print " Build code-server Docker image"
print " --use-latest: Tag with both SHA and 'latest' (pushes both tags if --push is used)"
print " Example: nu main.nu build-code-server --push"
print " Example: nu main.nu build-code-server --tag v1.0.0 --push"
print " nu main.nu build-ai-api [--tag <tag>] [--push] [--use-latest]"
print " Build AI API Docker image"
print " Example: nu main.nu build-ai-api --push"
print " Example: nu main.nu build-ai-api --use-latest --push"
print " nu main.nu build-ai-web [--tag <tag>] [--push] [--use-latest] [--local] [--cpu <cores>] [--memory <size>]"
print " Build AI Web Docker image (in Kubernetes by default)"
print " --local: Build locally instead of in Kubernetes"
print " --cpu: CPU cores to allocate for Kubernetes builds (default: 4)"
print " --memory: Memory to allocate for Kubernetes builds (default: 4Gi)"
print " Example: nu main.nu build-ai-web --push"
print " Example: nu main.nu build-ai-web --push --cpu 8 --memory 8Gi"
print " Example: nu main.nu build-ai-web --local --push # for local build"
print " nu main.nu build-ai-web-k8s [--tag <tag>] [--push] [--use-latest] [--namespace <ns>] [--cpu <cores>] [--memory <size>]"
print " Build AI Web Docker image using Kubernetes job (faster, uses server resources)"
print " --namespace: Kubernetes namespace (default: default)"
print " --cpu: CPU cores to allocate (default: 4)"
print " --memory: Memory to allocate (default: 4Gi)"
print " Example: nu main.nu build-ai-web-k8s --push"
print " Example: nu main.nu build-ai-web-k8s --tag v1.0.0 --push --cpu 8 --memory 8Gi"
print " nu main.nu build-ai-all [--tag <tag>] [--push] [--use-latest]"
print " Build all AI Docker images (API and Web)"
print " Example: nu main.nu build-ai-all --push"
print " Example: nu main.nu build-ai-all --use-latest --push"
print ""
print " nu main.nu config init [--force] # Initialize CLI configuration"
print " Set up default author information and preferences"
print " Example: nu main.nu config init"
print " nu main.nu config show # Display current configuration"
print " nu main.nu config get <key> # Get a specific config value"
print " Example: nu main.nu config get dev_env.author.name"
print " nu main.nu config set <key> <value> # Set a specific config value"
print " Example: nu main.nu config set dev_env.author.email \"john@example.com\""
print "\nAlternatively, source the script ('source main.nu') and run commands directly:"
print " run-migrate <action> [--ee]"
print " dev-up [--detached] [--edition ce|ee]"
print " dev-down"
print " update-workflow <workflow_name>"
print " register-workflow <workflow_name>"
print " dev-env-create <branch> [options]"
print " dev-env-list, dev-env-connect, dev-env-destroy, dev-env-status"
print " init-config, show-config, get-config-value, set-config-value"
return # Exit if arguments are missing
}
# Route command
match $command {
"migrate" => {
let command_args = ($args | skip 1)
let ee_flag = ($command_args | any { |arg| $arg == "--ee" })
let action_candidates = ($command_args | where { |arg| $arg != "--ee" })
let action = ($action_candidates | get 0? | default null)
if $action == null {
error make { msg: $"($env.ALGA_COLOR_RED)migrate command requires an action: up, latest, down, status($env.ALGA_COLOR_RESET)" }
}
if ($action_candidates | length) > 1 {
error make { msg: $"($env.ALGA_COLOR_RED)Multiple migrate actions provided. Supply one action plus optional --ee flag($env.ALGA_COLOR_RESET)" }
}
if not ($action in ["up", "latest", "down", "status"]) {
error make { msg: $"($env.ALGA_COLOR_RED)Invalid migrate action '($action)'. Must be one of: up, latest, down, status($env.ALGA_COLOR_RESET)" }
}
if $ee_flag and not ($action in ["latest", "down", "status"]) {
error make { msg: $"($env.ALGA_COLOR_RED)--ee is only supported with 'latest', 'down', or 'status' actions($env.ALGA_COLOR_RESET)" }
}
if $ee_flag {
run-migrate $action --ee
} else {
run-migrate $action
}
}
"dev-up" => {
# Parse flags from args (skip the command itself)
let command_args = ($args | skip 1)
let detached = ($command_args | any { |arg| $arg == "--detached" or $arg == "-d" })
let edition_idx = ($command_args | enumerate | where {|item| $item.item == "--edition" or $item.item == "-e"} | get 0?.index | default null)
let edition = if $edition_idx != null {
($command_args | get ($edition_idx + 1) | default "ce")
} else {
"ce"
}
if $detached {
dev-up --detached --edition $edition
} else {
dev-up --edition $edition
}
}
"dev-down" => {
dev-down
}
"dev-env-create" => {
let branch = ($args | get 1? | default null)
if $branch == null {
error make { msg: $"($env.ALGA_COLOR_RED)dev-env-create command requires a branch name($env.ALGA_COLOR_RESET)" }
}
# Parse flags
let command_args = ($args | skip 2)
let edition_idx = ($command_args | enumerate | where {|item| $item.item == "--edition"} | get 0?.index | default null)
let edition = if $edition_idx != null {
($command_args | get ($edition_idx + 1) | default "ee")
} else {
"ee"
}
let from_tag_idx = ($command_args | enumerate | where {|item| $item.item == "--from-tag"} | get 0?.index | default null)
let from_tag = if $from_tag_idx != null {
($command_args | get ($from_tag_idx + 1) | default "latest")
} else {
"latest"
}
let author_name_idx = ($command_args | enumerate | where {|item| $item.item == "--author-name"} | get 0?.index | default null)
let author_name = if $author_name_idx != null {
($command_args | get ($author_name_idx + 1) | default "")
} else {
""
}
let author_email_idx = ($command_args | enumerate | where {|item| $item.item == "--author-email"} | get 0?.index | default null)
let author_email = if $author_email_idx != null {
($command_args | get ($author_email_idx + 1) | default "")
} else {
""
}
let use_latest = ($command_args | any { |arg| $arg == "--use-latest" })
let checkout = not ($command_args | any { |arg| $arg == "--no-checkout" })
# Call the dev-env-create command
dev-env-create $branch --edition $edition --use-latest=$use_latest --checkout=$checkout --from-tag $from_tag --author-name $author_name --author-email $author_email
}
"portal-domain" => {
let subcommand = ($args | get 1? | default null)
if $subcommand == null {
error make { msg: $"($env.ALGA_COLOR_RED)portal-domain command requires a subcommand (e.g., sessions)($env.ALGA_COLOR_RESET)" }
}
match $subcommand {
"sessions" => {
let action = ($args | get 2? | default null)
if $action == null {
error make { msg: $"($env.ALGA_COLOR_RED)portal-domain sessions requires an action (e.g., prune)($env.ALGA_COLOR_RESET)" }
}
match $action {
"prune" => {
let command_args = ($args | skip 3)
portal-domain-sessions-prune ...$command_args
}
_ => {
error make { msg: $"($env.ALGA_COLOR_RED)Unsupported portal-domain sessions action: ($action)($env.ALGA_COLOR_RESET)" }
}
}
}
_ => {
error make { msg: $"($env.ALGA_COLOR_RED)Unsupported portal-domain subcommand: ($subcommand)($env.ALGA_COLOR_RESET)" }
}
}
}
"dev-env-list" => {
dev-env-list
}
"dev-env-connect" => {
let branch = ($args | get 1? | default null)
if $branch == null {
error make { msg: $"($env.ALGA_COLOR_RED)dev-env-connect command requires a branch name($env.ALGA_COLOR_RESET)" }
}
# Call the dev-env-connect command (port forwarding is now always enabled)
dev-env-connect $branch
}
"dev-env-destroy" => {
let branch = ($args | get 1? | default null)
if $branch == null {
error make { msg: $"($env.ALGA_COLOR_RED)dev-env-destroy command requires a branch name($env.ALGA_COLOR_RESET)" }
}
# Parse flags
let command_args = ($args | skip 2)
let force = ($command_args | any { |arg| $arg == "--force" })
# Call the dev-env-destroy command
if $force {
dev-env-destroy $branch --force
} else {
dev-env-destroy $branch
}
}
"dev-env-status" => {
let branch = ($args | get 1? | default null)
if $branch != null {
dev-env-status $branch
} else {
dev-env-status
}
}
"dev-env-force-cleanup" => {
let branch = ($args | get 1? | default null)
if $branch == null {
error make { msg: $"($env.ALGA_COLOR_RED)dev-env-force-cleanup command requires a branch name($env.ALGA_COLOR_RESET)" }
}
# Call the dev-env-force-cleanup command
dev-env-force-cleanup $branch
}
"update-workflow" => {
let workflow_name = ($args | get 1? | default null)
if $workflow_name == null {
error make { msg: $"($env.ALGA_COLOR_RED)update-workflow command requires a workflow name($env.ALGA_COLOR_RESET)" }
}
# Call the update-workflow command
update-workflow $workflow_name
}
"register-workflow" => {
let workflow_name = ($args | get 1? | default null)
if $workflow_name == null {
error make { msg: $"($env.ALGA_COLOR_RED)register-workflow command requires a workflow name($env.ALGA_COLOR_RESET)" }
}
# Call the register-workflow command
register-workflow $workflow_name
}
"build-image" => {
let edition = ($args | get 1? | default null)
if $edition == null {
error make { msg: $"($env.ALGA_COLOR_RED)build-image command requires an edition (ce|ee)($env.ALGA_COLOR_RESET)" }
}
# Parse flags
let command_args = ($args | skip 2)
let tag_idx = ($command_args | enumerate | where {|item| $item.item == "--tag"} | get 0?.index | default null)
let tag = if $tag_idx != null { ($command_args | get ($tag_idx + 1) | default "") } else { "" }
let push = ($command_args | any { |arg| $arg == "--push" })
let use_latest = ($command_args | any { |arg| $arg == "--use-latest" })
# Call the build-image command
if $push and $use_latest {
build-image $edition --tag $tag --push --use-latest
} else if $push {
build-image $edition --tag $tag --push
} else if $use_latest {
build-image $edition --tag $tag --use-latest
} else {
build-image $edition --tag $tag
}
}
"build-all-images" => {
# Parse flags
let command_args = ($args | skip 1)
let tag_idx = ($command_args | enumerate | where {|item| $item.item == "--tag"} | get 0?.index | default null)
let tag = if $tag_idx != null { ($command_args | get ($tag_idx + 1) | default "latest") } else { "latest" }
let push = ($command_args | any { |arg| $arg == "--push" })
# Call the build-all-images command
if $push {
build-all-images --tag $tag --push
} else {
build-all-images --tag $tag
}
}
"build-code-server" => {
# Parse flags
let command_args = ($args | skip 1)
let tag_idx = ($command_args | enumerate | where {|item| $item.item == "--tag"} | get 0?.index | default null)
let tag = if $tag_idx != null { ($command_args | get ($tag_idx + 1) | default "") } else { "" }
let push = ($command_args | any { |arg| $arg == "--push" })
let use_latest = ($command_args | any { |arg| $arg == "--use-latest" })
# Call the build-code-server command
if $push and $use_latest {
build-code-server --tag $tag --push --use-latest
} else if $push {
build-code-server --tag $tag --push
} else if $use_latest {
build-code-server --tag $tag --use-latest
} else {
build-code-server --tag $tag
}
}
"build-ai-api" => {
# Parse flags
let command_args = ($args | skip 1)
let tag_idx = ($command_args | enumerate | where {|item| $item.item == "--tag"} | get 0?.index | default null)
let tag = if $tag_idx != null { ($command_args | get ($tag_idx + 1) | default "") } else { "" }
let push = ($command_args | any { |arg| $arg == "--push" })
let use_latest = ($command_args | any { |arg| $arg == "--use-latest" })
# Call the build-ai-api command
if $push and $use_latest {
build-ai-api --tag $tag --push --use-latest
} else if $push {
build-ai-api --tag $tag --push
} else if $use_latest {
build-ai-api --tag $tag --use-latest
} else {
build-ai-api --tag $tag
}
}
"build-ai-web" => {
# Parse flags
let command_args = ($args | skip 1)
let tag_idx = ($command_args | enumerate | where {|item| $item.item == "--tag"} | get 0?.index | default null)
let tag = if $tag_idx != null { ($command_args | get ($tag_idx + 1) | default "") } else { "" }
let push = ($command_args | any { |arg| $arg == "--push" })
let use_latest = ($command_args | any { |arg| $arg == "--use-latest" })
let local = ($command_args | any { |arg| $arg == "--local" })
let cpu_idx = ($command_args | enumerate | where {|item| $item.item == "--cpu"} | get 0?.index | default null)
let cpu = if $cpu_idx != null { ($command_args | get ($cpu_idx + 1) | default "4") } else { "4" }
let memory_idx = ($command_args | enumerate | where {|item| $item.item == "--memory"} | get 0?.index | default null)
let memory = if $memory_idx != null { ($command_args | get ($memory_idx + 1) | default "4Gi") } else { "4Gi" }
# Call the build-ai-web command
if $local {
if $push and $use_latest {
build-ai-web --tag $tag --push --use-latest --local
} else if $push {
build-ai-web --tag $tag --push --local
} else if $use_latest {
build-ai-web --tag $tag --use-latest --local
} else {
build-ai-web --tag $tag --local
}
} else if $push and $use_latest {
build-ai-web --tag $tag --push --use-latest --cpu $cpu --memory $memory
} else if $push {
build-ai-web --tag $tag --push --cpu $cpu --memory $memory
} else if $use_latest {
build-ai-web --tag $tag --use-latest --cpu $cpu --memory $memory
} else {
build-ai-web --tag $tag --cpu $cpu --memory $memory
}
}
"build-ai-web-k8s" => {
# Parse flags
let command_args = ($args | skip 1)
let tag_idx = ($command_args | enumerate | where {|item| $item.item == "--tag"} | get 0?.index | default null)
let tag = if $tag_idx != null { ($command_args | get ($tag_idx + 1) | default "") } else { "" }
let push = ($command_args | any { |arg| $arg == "--push" })
let use_latest = ($command_args | any { |arg| $arg == "--use-latest" })
let namespace_idx = ($command_args | enumerate | where {|item| $item.item == "--namespace"} | get 0?.index | default null)
let namespace = if $namespace_idx != null { ($command_args | get ($namespace_idx + 1) | default "default") } else { "default" }
let cpu_idx = ($command_args | enumerate | where {|item| $item.item == "--cpu"} | get 0?.index | default null)
let cpu = if $cpu_idx != null { ($command_args | get ($cpu_idx + 1) | default "4") } else { "4" }
let memory_idx = ($command_args | enumerate | where {|item| $item.item == "--memory"} | get 0?.index | default null)
let memory = if $memory_idx != null { ($command_args | get ($memory_idx + 1) | default "4Gi") } else { "4Gi" }
# Call the build-ai-web-k8s command
if $push and $use_latest {
build-ai-web-k8s --tag $tag --push --use-latest --namespace $namespace --cpu $cpu --memory $memory
} else if $push {
build-ai-web-k8s --tag $tag --push --namespace $namespace --cpu $cpu --memory $memory
} else if $use_latest {
build-ai-web-k8s --tag $tag --use-latest --namespace $namespace --cpu $cpu --memory $memory
} else {
build-ai-web-k8s --tag $tag --namespace $namespace --cpu $cpu --memory $memory
}
}
"build-ai-all" => {
# Parse flags
let command_args = ($args | skip 1)
let tag_idx = ($command_args | enumerate | where {|item| $item.item == "--tag"} | get 0?.index | default null)
let tag = if $tag_idx != null { ($command_args | get ($tag_idx + 1) | default "") } else { "" }
let push = ($command_args | any { |arg| $arg == "--push" })
let use_latest = ($command_args | any { |arg| $arg == "--use-latest" })
# Call the build-ai-all command
if $push and $use_latest {
build-ai-all --tag $tag --push --use-latest
} else if $push {
build-ai-all --tag $tag --push
} else if $use_latest {
build-ai-all --tag $tag --use-latest
} else {
build-ai-all --tag $tag
}
}
"config" => {
let subcommand = ($args | get 1? | default null)
if $subcommand == null {
print $"($env.ALGA_COLOR_RED)config command requires a subcommand: init, show, get, set($env.ALGA_COLOR_RESET)"
return
}
match $subcommand {
"init" => {
let force = ($args | skip 2 | any { |arg| $arg == "--force" })
if $force {
init-config --force
} else {
init-config
}
}
"show" => {
show-config
}
"get" => {
let key = ($args | get 2? | default null)
if $key == null {
error make { msg: $"($env.ALGA_COLOR_RED)config get requires a key argument($env.ALGA_COLOR_RESET)" }
}
let value = get-config-value $key
if $value == null {
print $"($env.ALGA_COLOR_YELLOW)Config key '($key)' not found($env.ALGA_COLOR_RESET)"
} else {
print $value
}
}
"set" => {
let key = ($args | get 2? | default null)
let value = ($args | get 3? | default null)
if $key == null or $value == null {
error make { msg: $"($env.ALGA_COLOR_RED)config set requires key and value arguments($env.ALGA_COLOR_RESET)" }
}
set-config-value $key $value
}
_ => {
error make { msg: $"($env.ALGA_COLOR_RED)Unknown config subcommand: '($subcommand)'. Must be 'init', 'show', 'get', or 'set'.($env.ALGA_COLOR_RESET)" }
}
}
}
"create-tenant" => {
let tenant_name = ($args | get 1? | default null)
let admin_email = ($args | get 2? | default null)
if $tenant_name == null or $admin_email == null {
error make { msg: $"($env.ALGA_COLOR_RED)create-tenant requires tenant name and admin email arguments($env.ALGA_COLOR_RESET)" }
}
# Parse optional flags
let first_name = (parse-flag $args "--first-name" | default "Admin")
let last_name = (parse-flag $args "--last-name" | default "User")
let client_name = (parse-flag $args "--client-name" | default "")
let password = (parse-flag $args "--password" | default "")
let seed_onboarding = not (check-flag $args "--no-seed-onboarding")
let skip_onboarding = (check-flag $args "--skip-onboarding")
create-tenant $tenant_name $admin_email --first-name $first_name --last-name $last_name --client-name $client_name --password $password --seed-onboarding $seed_onboarding --skip-onboarding $skip_onboarding
}
"list-tenants" => {
list-tenants
}
"cleanup-tenant" => {
let action = ($args | get 1? | default null)
if $action == null {
# Show help for cleanup-tenant
nu cli/cleanup-tenant.nu
return
}
# Build the command arguments
let remaining_args = ($args | skip 2 | str join " ")
# Execute the cleanup-tenant script with the action and remaining arguments
if $remaining_args == "" {
nu cli/cleanup-tenant.nu $action
} else {
nu cli/cleanup-tenant.nu $action ...(($args | skip 2))
}
}
# Hosted environment commands
"hosted-env-create" => {
let env_flag = (parse-flag $args "--environment")
let env_short = (parse-flag $args "-e")
let environment = if $env_flag != null { $env_flag } else { $env_short }
let branch_state = (($args | skip 1) | reduce -f { branch: null skip_env: false } { |arg, acc|
if $acc.skip_env {
{ branch: $acc.branch, skip_env: false }
} else if $arg == "--environment" or $arg == "-e" {
{ branch: $acc.branch, skip_env: true }
} else {
if $acc.branch == null {
{ branch: $arg, skip_env: false }
} else {
$acc
}
}
})
let branch = ($branch_state | get branch)
if $branch == null {
error make { msg: $"($env.ALGA_COLOR_RED)hosted-env-create requires branch argument($env.ALGA_COLOR_RESET)" }
}
if $environment == null {
hosted-env-create $branch
} else {
hosted-env-create $branch --environment $environment
}
}
"hosted-env-list" => {
let env_flag = (parse-flag $args "--environment")
let env_short = (parse-flag $args "-e")
let environment = if $env_flag != null { $env_flag } else { $env_short }
if $environment == null {
hosted-env-list
} else {
hosted-env-list --environment $environment
}
}
"hosted-env-connect" => {
let env_flag = (parse-flag $args "--environment")
let env_short = (parse-flag $args "-e")
let environment = if $env_flag != null { $env_flag } else { $env_short }
let canary_flag = (parse-flag $args "--canary")
let canary_short = (parse-flag $args "-c")
let canary = if $canary_flag != null { $canary_flag } else { $canary_short }
let branch_state = (($args | skip 1) | reduce -f { branch: null skip_next: false } { |arg, acc|
if $acc.skip_next {
{ branch: $acc.branch, skip_next: false }
} else if $arg == "--environment" or $arg == "-e" or $arg == "--canary" or $arg == "-c" {
{ branch: $acc.branch, skip_next: true }
} else if ($arg | str starts-with "-") {
$acc
} else {
if $acc.branch == null {
{ branch: $arg, skip_next: false }
} else {
$acc
}
}
})
let branch = ($branch_state | get branch)
if $branch == null {
error make { msg: $"($env.ALGA_COLOR_RED)hosted-env-connect requires branch argument($env.ALGA_COLOR_RESET)" }
}
if $canary == null {
error make { msg: $"($env.ALGA_COLOR_RED)hosted-env-connect requires '--canary <header_value>'.($env.ALGA_COLOR_RESET)" }
}
if $environment == null {
hosted-env-connect $branch --canary $canary
} else {
hosted-env-connect $branch --environment $environment --canary $canary
}
}
"hosted-env-destroy" => {
let env_flag = (parse-flag $args "--environment")
let env_short = (parse-flag $args "-e")
let environment = if $env_flag != null { $env_flag } else { $env_short }
let branch_state = (($args | skip 1) | reduce -f { branch: null skip_env: false } { |arg, acc|
if $acc.skip_env {
{ branch: $acc.branch, skip_env: false }
} else if $arg == "--environment" or $arg == "-e" {
{ branch: $acc.branch, skip_env: true }
} else if $arg == "--force" {
$acc
} else {
if $acc.branch == null {
{ branch: $arg, skip_env: false }
} else {
$acc
}
}
})
let branch = ($branch_state | get branch)
if $branch == null {
error make { msg: $"($env.ALGA_COLOR_RED)hosted-env-destroy requires branch argument($env.ALGA_COLOR_RESET)" }
}
let force = (check-flag $args "--force")
if $environment == null {
hosted-env-destroy $branch --force $force
} else {
hosted-env-destroy $branch --force $force --environment $environment
}
}
"hosted-env-status" => {
let env_flag = (parse-flag $args "--environment")
let env_short = (parse-flag $args "-e")
let environment = if $env_flag != null { $env_flag } else { $env_short }
let branch_state = (($args | skip 1) | reduce -f { branch: null skip_env: false } { |arg, acc|
if $acc.skip_env {
{ branch: $acc.branch, skip_env: false }
} else if $arg == "--environment" or $arg == "-e" {
{ branch: $acc.branch, skip_env: true }
} else {
if $acc.branch == null {
{ branch: $arg, skip_env: false }
} else {
$acc
}
}
})
let branch = ($branch_state | get branch)
if $branch == null {
error make { msg: $"($env.ALGA_COLOR_RED)hosted-env-status requires branch argument($env.ALGA_COLOR_RESET)" }
}
if $environment == null {
hosted-env-status $branch
} else {
hosted-env-status $branch --environment $environment
}
}
_ => {
error make { msg: $"($env.ALGA_COLOR_RED)Unknown command: '($command)'. Must be 'migrate', 'dev-up', 'dev-down', 'dev-env-*', 'dev-env-force-cleanup', 'update-workflow', 'register-workflow', 'build-image', 'build-all-images', 'build-code-server', 'build-ai-api', 'build-ai-web', 'build-ai-web-k8s', 'build-ai-all', 'config', 'create-tenant', 'list-tenants', or 'cleanup-tenant'.($env.ALGA_COLOR_RESET)" }
}
}
}

167
cli/migrate.nu Normal file
View File

@ -0,0 +1,167 @@
# Manage database migrations
def --env load-migration-env [project_root: string] {
let server_dir = ($project_root | path join "server")
let env_files = [
($server_dir | path join ".env")
($server_dir | path join ".env.local")
]
mut loaded_files = []
for env_file in $env_files {
if ($env_file | path exists) {
let env_vars = (open $env_file
| lines
| each { |line| $line | str trim }
| where { |line| not ($line | str starts-with '#') and ($line | str length) > 0 and ($line | str contains '=') }
| split column "=" -n 2
| rename key value
| update key { |it| $it.key | str trim }
| update value { |it|
if ($it.value | is-empty) {
""
} else {
let raw_value = ($it.value | str trim)
let is_quoted = (($raw_value | str starts-with '"') or ($raw_value | str starts-with "'"))
let normalized = ($raw_value | str trim -c '"' | str trim -c "'")
if $is_quoted {
$normalized
} else {
$normalized | str replace -r '\s+#.*$' ''
}
}
}
| reduce -f {} { |item, acc| $acc | upsert $item.key $item.value })
if (($env_vars | columns | length) > 0) {
load-env $env_vars
}
$loaded_files = ($loaded_files | append ($env_file | path basename))
}
}
if (($loaded_files | length) > 0) {
print $"($env.ALGA_COLOR_CYAN)Loaded migration env from ($loaded_files | str join ', '); server/.env.local overrides server/.env.($env.ALGA_COLOR_RESET)"
} else {
print $"($env.ALGA_COLOR_YELLOW)No server env files found (expected server/.env and optionally server/.env.local). Using current shell env only.($env.ALGA_COLOR_RESET)"
}
}
export def run-migrate [
action: string # The migration action to perform: up, latest, down, or status
--ee # Run combined CE + EE migrations (latest, down, status)
] {
let project_root = find-project-root
load-migration-env $project_root
if $ee {
if not ($action in ["latest", "down", "status"]) {
error make { msg: $"($env.ALGA_COLOR_RED)--ee is only supported with 'latest', 'down', or 'status' actions($env.ALGA_COLOR_RESET)" }
}
# Map action to npm script name
let npm_script = match $action {
"latest" => "migrate:ee",
"down" => "migrate:ee:down",
"status" => "migrate:ee:status"
}
print $"($env.ALGA_COLOR_CYAN)Running CE + EE migrations ($action)...($env.ALGA_COLOR_RESET)"
let result = do {
cd $project_root
npm -w server run $npm_script | complete
}
if $result.exit_code == 0 {
if not ($result.stdout | is-empty) {
print $result.stdout
}
print $"($env.ALGA_COLOR_GREEN)CE + EE migrations ($action) completed successfully.($env.ALGA_COLOR_RESET)"
return
}
if not ($result.stderr | is-empty) {
print $"($env.ALGA_COLOR_RED)($result.stderr)($env.ALGA_COLOR_RESET)"
}
error make { msg: $"($env.ALGA_COLOR_RED)CE + EE migrations ($action) failed($env.ALGA_COLOR_RESET)", code: $result.exit_code }
}
match $action {
"up" => {
print $"($env.ALGA_COLOR_CYAN)Running next pending database migration...($env.ALGA_COLOR_RESET)"
# Change to the server directory and run the knex command
# Capture stdout and stderr
let result = do {
cd ($project_root | path join "server")
npx knex migrate:up --knexfile knexfile.cjs --env migration | complete # Use migrate:up
}
# Print output or error
if $result.exit_code == 0 {
print $result.stdout # Keep knex output as is
print $"($env.ALGA_COLOR_GREEN)Migration 'up' completed successfully.($env.ALGA_COLOR_RESET)"
} else {
print $"($env.ALGA_COLOR_RED)($result.stderr)($env.ALGA_COLOR_RESET)"
error make { msg: $"($env.ALGA_COLOR_RED)Migration 'up' failed($env.ALGA_COLOR_RESET)", code: $result.exit_code }
}
}
"latest" => { # Add separate case for 'latest'
print $"($env.ALGA_COLOR_CYAN)Running all pending database migrations...($env.ALGA_COLOR_RESET)"
# Change to the server directory and run the knex command
# Capture stdout and stderr
let result = do {
cd ($project_root | path join "server")
npx knex migrate:latest --knexfile knexfile.cjs --env migration | complete # Use migrate:latest
}
# Print output or error
if $result.exit_code == 0 {
print $result.stdout # Keep knex output as is
print $"($env.ALGA_COLOR_GREEN)Migrations 'latest' completed successfully.($env.ALGA_COLOR_RESET)"
} else {
print $"($env.ALGA_COLOR_RED)($result.stderr)($env.ALGA_COLOR_RESET)"
error make { msg: $"($env.ALGA_COLOR_RED)Migration 'latest' failed($env.ALGA_COLOR_RESET)", code: $result.exit_code }
}
}
"down" => {
print $"($env.ALGA_COLOR_CYAN)Reverting last database migration...($env.ALGA_COLOR_RESET)"
# Change to the server directory and run the knex command
# Capture stdout and stderr
let result = do {
cd ($project_root | path join "server")
npx knex migrate:down --knexfile knexfile.cjs --env migration | complete
}
# Print output or error
if $result.exit_code == 0 {
print $result.stdout # Keep knex output as is
print $"($env.ALGA_COLOR_GREEN)Migration reverted successfully.($env.ALGA_COLOR_RESET)"
} else {
print $"($env.ALGA_COLOR_RED)($result.stderr)($env.ALGA_COLOR_RESET)"
error make { msg: $"($env.ALGA_COLOR_RED)Migration revert failed($env.ALGA_COLOR_RESET)", code: $result.exit_code }
}
}
"status" => {
print $"($env.ALGA_COLOR_CYAN)Checking migration status...($env.ALGA_COLOR_RESET)"
# Change to the server directory and run the knex command
# Capture stdout and stderr
let result = do {
cd ($project_root | path join "server")
npx knex migrate:status --knexfile knexfile.cjs --env migration | complete
}
# Print output or error
if $result.exit_code == 0 {
print $result.stdout # Keep knex output as is
print $"($env.ALGA_COLOR_GREEN)Migration status checked successfully.($env.ALGA_COLOR_RESET)"
} else {
print $"($env.ALGA_COLOR_RED)($result.stderr)($env.ALGA_COLOR_RESET)"
error make { msg: $"($env.ALGA_COLOR_RED)Checking migration status failed($env.ALGA_COLOR_RESET)", code: $result.exit_code }
}
}
_ => {
# This case should technically not be reachable due to the type annotation
# but it's good practice to include it.
error make { msg: $"($env.ALGA_COLOR_RED)Unknown migration action: ($action)($env.ALGA_COLOR_RESET)" }
}
}
}

49
cli/portal-domain.nu Normal file
View File

@ -0,0 +1,49 @@
#!/usr/bin/env nu
use "utils.nu" [find-project-root parse-flag check-flag]
export def portal-domain-sessions-prune [
...args: string
] {
let tenant = (parse-flag $args "--tenant")
let minutes_value = (parse-flag $args "--minutes")
let older_than = if $minutes_value == null {
(parse-flag $args "--older-than-minutes")
} else {
$minutes_value
}
let dry_run = (check-flag $args "--dry-run")
let minutes = if $older_than == null {
"10"
} else {
$older_than
}
let project_root = (find-project-root)
mut args_list = [
"tsx"
"scripts/portal-domain-sessions-prune.ts"
"--older-than-minutes"
$minutes
]
if $tenant != null {
$args_list = $args_list ++ ["--tenant" $tenant]
}
if $dry_run {
$args_list = $args_list ++ ["--dry-run"]
}
let result = (
cd $"($project_root)/server";
^npx ...$args_list | complete
)
if $result.exit_code != 0 {
error make { msg: $"($env.ALGA_COLOR_RED)Failed to prune portal domain OTT sessions: ($result.stderr)($env.ALGA_COLOR_RESET)" }
}
print $result.stdout
}

214
cli/tenant.nu Normal file
View File

@ -0,0 +1,214 @@
#!/usr/bin/env nu
# Tenant management commands for Alga PSA
# Create a new tenant
export def create-tenant [
tenant_name: string # Name of the tenant client
admin_email: string # Email for the admin user
--first-name: string = "Admin" # Admin user's first name
--last-name: string = "User" # Admin user's last name
--client-name: string = "" # Client name (defaults to tenant name)
--password: string = "" # Admin password (generated if not provided)
--seed-onboarding = true # Run onboarding seeds after creation
--skip-onboarding = false # Set onboarding_skipped flag to true in tenant_settings
] {
print $"($env.ALGA_COLOR_CYAN)Creating new tenant: ($tenant_name)($env.ALGA_COLOR_RESET)"
# Set client name to tenant name if not provided
let client_name = if $client_name == "" { $tenant_name } else { $client_name }
# Get the project root
let project_root = (find-project-root)
# Run the tenant creation
print $"($env.ALGA_COLOR_YELLOW)→ Creating tenant and admin user...($env.ALGA_COLOR_RESET)"
# Use the shared tenant creation module
let result = (
cd $"($project_root)/server"; npm run --silent create-tenant -- --tenant $"($tenant_name)" --email $"($admin_email)" --firstName $"($first_name)" --lastName $"($last_name)" --clientName $"($client_name)" --password $"($password)"
| complete
)
if $result.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)✗ Failed to create tenant($env.ALGA_COLOR_RESET)"
print $"Error: ($result.stderr)"
return
}
# Extract tenant ID and password from output
let output = $result.stdout
let tenant_id_line = ($output | lines | where { |line| $line | str contains "Tenant ID:" } | first)
# Remove brackets from tenant ID
let tenant_id = ($tenant_id_line | split column ":" | get column2 | first | str trim | str replace "[" "" | str replace "]" "")
let temp_password = if $password == "" {
let password_line = ($output | lines | where { |line| $line | str contains "Temporary Password:" } | first)
($password_line | split column ":" | get column2 | first | str trim)
} else {
$password
}
print $"($env.ALGA_COLOR_GREEN)✓ Tenant created successfully!($env.ALGA_COLOR_RESET)"
print $" Tenant ID: ($tenant_id)"
print $" Admin Email: ($admin_email)"
if $password == "" {
print $" Temporary Password: ($temp_password)"
}
# Create tenant_settings record
print $"\n($env.ALGA_COLOR_YELLOW)→ Creating tenant settings...($env.ALGA_COLOR_RESET)"
print $" Debug: Tenant ID = ($tenant_id)"
let settings_sql = if $skip_onboarding {
"INSERT INTO tenant_settings (tenant, onboarding_skipped, onboarding_completed, created_at, updated_at) VALUES (?, true, false, NOW(), NOW()) ON CONFLICT (tenant) DO UPDATE SET onboarding_skipped = true, updated_at = NOW()"
} else {
"INSERT INTO tenant_settings (tenant, onboarding_skipped, onboarding_completed, created_at, updated_at) VALUES (?, false, false, NOW(), NOW()) ON CONFLICT (tenant) DO NOTHING"
}
let settings_result = (
cd $"($project_root)/server"; node scripts/run-sql.cjs migration $settings_sql $tenant_id
| complete
)
if $settings_result.exit_code == 0 {
if $skip_onboarding {
print $"($env.ALGA_COLOR_GREEN)✓ Tenant settings created with onboarding skipped($env.ALGA_COLOR_RESET)"
} else {
print $"($env.ALGA_COLOR_GREEN)✓ Tenant settings created($env.ALGA_COLOR_RESET)"
}
} else {
print $"($env.ALGA_COLOR_YELLOW)⚠ Could not create tenant settings($env.ALGA_COLOR_RESET)"
# Try to debug the error
print $"Debug: ($settings_result.stderr)"
}
# Run onboarding seeds if requested
if $seed_onboarding {
print $"\n($env.ALGA_COLOR_YELLOW)→ Running onboarding seeds...($env.ALGA_COLOR_RESET)"
seed-tenant-onboarding $tenant_id
}
print $"\n($env.ALGA_COLOR_GREEN)✓ Tenant setup complete!($env.ALGA_COLOR_RESET)"
print $" Login URL: http://localhost:3000"
print $" Email: ($admin_email)"
if $password == "" {
print $" Password: ($temp_password)"
}
}
# Seed a tenant with onboarding data
export def seed-tenant-onboarding [
tenant_id: string # The tenant ID to seed
] {
print $"($env.ALGA_COLOR_CYAN)Seeding onboarding data for tenant: ($tenant_id)($env.ALGA_COLOR_RESET)"
# Get the project root
let project_root = (find-project-root)
# Run all seeds from the onboarding directory
print $"($env.ALGA_COLOR_YELLOW)→ Running all onboarding seeds...($env.ALGA_COLOR_RESET)"
# Create a temporary knexfile that points to the onboarding directory
let temp_knexfile_content = "
const baseConfig = require('./knexfile.cjs');
module.exports = {
...baseConfig,
migration: {
...baseConfig.migration,
seeds: {
directory: './seeds/dev/onboarding',
loadExtensions: ['.cjs', '.js']
}
}
};"
# Write the temporary knexfile
let temp_knexfile_path = $"($project_root)/server/knexfile.onboarding.cjs"
$temp_knexfile_content | save -f $temp_knexfile_path
# Run the seeds
let result = (
cd $"($project_root)/server"; with-env {TENANT_ID: $tenant_id} { npx knex seed:run --knexfile knexfile.onboarding.cjs --env migration }
| complete
)
# Clean up the temporary file
rm -f $temp_knexfile_path
if $result.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)✗ Failed to run onboarding seeds($env.ALGA_COLOR_RESET)"
print $"Error: ($result.stderr)"
return
}
print $"($env.ALGA_COLOR_GREEN)✓ All onboarding seeds completed($env.ALGA_COLOR_RESET)"
print $"\n($env.ALGA_COLOR_GREEN)✓ Onboarding seeds completed!($env.ALGA_COLOR_RESET)"
}
# List all tenants
export def list-tenants [] {
print $"($env.ALGA_COLOR_CYAN)Listing all tenants($env.ALGA_COLOR_RESET)\n"
# Get the project root
let project_root = (find-project-root)
# Query the database for tenants
let sql = "SELECT t.tenant, t.client_name, t.email, t.created_at, ts.onboarding_completed, ts.onboarding_skipped FROM tenants t LEFT JOIN tenant_settings ts ON t.tenant = ts.tenant ORDER BY t.created_at DESC"
let result = (
cd $"($project_root)/server"; node scripts/run-sql.cjs migration $sql 2>/dev/null
| complete
)
if $result.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)✗ Failed to list tenants($env.ALGA_COLOR_RESET)"
print $"Error: ($result.stderr)"
return
}
# Parse and display results
let tenants = ($result.stdout | from json)
if ($tenants | length) == 0 {
print "No tenants found."
return
}
# Display as a table
$tenants | table
}
# Delete a tenant (use with caution!)
export def delete-tenant [
tenant_id: string # The tenant ID to delete
--force = false # Skip confirmation
] {
if not $force {
print $"($env.ALGA_COLOR_YELLOW)⚠️ WARNING: This will permanently delete the tenant and all associated data!($env.ALGA_COLOR_RESET)"
let confirm = (input $"Are you sure you want to delete tenant ($tenant_id)? (yes/no): ")
if $confirm != "yes" {
print "Deletion cancelled."
return
}
}
print $"($env.ALGA_COLOR_RED)Deleting tenant: ($tenant_id)($env.ALGA_COLOR_RESET)"
# Get the project root
let project_root = (find-project-root)
# Use the rollback function from the tenant creation module
let result = (
cd $"($project_root)/server"; npm run --silent rollback-tenant -- --tenantId $"($tenant_id)"
| complete
)
if $result.exit_code != 0 {
print $"($env.ALGA_COLOR_RED)✗ Failed to delete tenant($env.ALGA_COLOR_RESET)"
print $"Error: ($result.stderr)"
return
}
print $"($env.ALGA_COLOR_GREEN)✓ Tenant deleted successfully($env.ALGA_COLOR_RESET)"
}

80
cli/utils.nu Normal file
View File

@ -0,0 +1,80 @@
# Find the project root directory by looking for key files
export def find-project-root [] {
let current_dir = pwd
mut search_dir = $current_dir
# Look for characteristic files that indicate project root
let root_indicators = ["package.json", "docker-compose.yaml", "README.md", "cli"]
# Search up the directory tree
loop {
# Check if all indicators exist in current directory
let current_search_dir = $search_dir # Copy to avoid capture issue
let has_indicators = ($root_indicators | all { |indicator|
($current_search_dir | path join $indicator | path exists)
})
if $has_indicators {
return $search_dir
}
# Move up one directory
let parent = ($search_dir | path dirname)
# If we've reached the filesystem root, stop
if $parent == $search_dir {
error make { msg: $"($env.ALGA_COLOR_RED)Could not find project root. Make sure you're running from within the alga-psa project directory.($env.ALGA_COLOR_RESET)" }
}
$search_dir = $parent
}
}
# Load Database Environment Variables from server/.env
export def load-db-env [] {
let project_root = find-project-root
let env_path = ($project_root | path join "server" ".env")
if not ($env_path | path exists) {
error make { msg: $"($env.ALGA_COLOR_RED)Database environment file not found: ($env_path)($env.ALGA_COLOR_RESET)" }
}
# Read, filter comments/empty lines, parse key=value
open $env_path
| lines
| each { |line| $line | str trim } # Trim whitespace
| where { |line| not ($line | str starts-with '#') and ($line | str length) > 0 } # Filter comments/empty
| split column "=" -n 2 # Split into max 2 columns based on the first '='
| rename key value # Rename columns for clarity
| update key {|it| $it.key | str trim } # Trim whitespace from key
| update value {|it| if ($it.value | is-empty) { "" } else { $it.value | str trim | str trim -c '"' | str trim -c "'" } } # Trim whitespace/quotes from value, handle empty
| reduce -f {} { |item, acc| $acc | upsert $item.key $item.value } # Fold into a record
# Select the CORRECT keys provided by the user
| select DB_HOST DB_PORT DB_USER_ADMIN DB_NAME_SERVER DB_PASSWORD_ADMIN
}
# Parse a flag value from arguments list
export def parse-flag [
args: list<string> # The arguments list
flag: string # The flag to look for
] {
let matches = ($args | enumerate | where { |it| $it.item == $flag })
if ($matches | length) > 0 {
let index = ($matches | first | get index)
if ($index + 1) < ($args | length) {
$args | get ($index + 1)
} else {
null
}
} else {
null
}
}
# Check if a flag exists in arguments list
export def check-flag [
args: list<string> # The arguments list
flag: string # The flag to check for
] {
$flag in $args
}

181
cli/workflows.nu Normal file
View File

@ -0,0 +1,181 @@
# Update System Workflow Registration
# Reads a workflow definition file and updates the latest version in the database.
export def update-workflow [
workflow_name: string # The BASE name of the workflow (e.g., 'invoice-sync', 'qboCustomerSyncWorkflow'), without path or .ts extension
] {
let project_root = find-project-root
print $"($env.ALGA_COLOR_CYAN)Updating system workflow registration for '($workflow_name)'...($env.ALGA_COLOR_RESET)"
# Construct file path (assuming .ts extension)
let workflow_file = ($project_root | path join "server" "src" "lib" "workflows" $"($workflow_name).ts")
# Check if file exists
if not ($workflow_file | path exists) {
error make { msg: $"($env.ALGA_COLOR_RED)Workflow file not found: ($workflow_file)($env.ALGA_COLOR_RESET)" }
}
# Read file content
let file_content = open $workflow_file
# Define the SQL query using psql variables
# Updates the 'definition' of the most recently created version of the named system workflow
let sql_update = "
UPDATE system_workflow_registration_versions
SET code = :'content' -- Store content as text
WHERE version_id = (
SELECT sv.version_id
FROM system_workflow_registration_versions sv
JOIN system_workflow_registrations sw ON sv.registration_id = sw.registration_id
WHERE sw.name = :'workflow_name'
ORDER BY sv.created_at DESC
LIMIT 1
);
"
# Load Database Environment Variables using the helper function
let db_env = load-db-env
print $"($env.ALGA_COLOR_CYAN)Executing database update...($env.ALGA_COLOR_RESET)"
# Execute psql command using explicit connection params from loaded env
let result = do {
cd ($project_root | path join "server")
with-env { PGPASSWORD: $db_env.DB_PASSWORD_ADMIN } {
$sql_update | psql -h $db_env.DB_HOST -p $db_env.DB_PORT -U $db_env.DB_USER_ADMIN -d $db_env.DB_NAME_SERVER -v ON_ERROR_STOP=1 -v $"content=($file_content)" -v $"workflow_name=($workflow_name)" -f - | complete
}
}
# Check result and print feedback
if $result.exit_code == 0 {
# Check if any rows were updated (psql might return 'UPDATE 0' or 'UPDATE 1')
if ($result.stdout | str contains "UPDATE 1") {
print $"($env.ALGA_COLOR_GREEN)System workflow '($workflow_name)' updated successfully.($env.ALGA_COLOR_RESET)"
} else if ($result.stdout | str contains "UPDATE 0") {
print $"($env.ALGA_COLOR_YELLOW)Warning: No matching system workflow named '($workflow_name)' found or no update needed.($env.ALGA_COLOR_RESET)"
} else {
print $result.stdout # Print other potential output
print $"($env.ALGA_COLOR_YELLOW)System workflow '($workflow_name)' update command executed, but result unclear.($env.ALGA_COLOR_RESET)"
}
} else {
print $"($env.ALGA_COLOR_RED)($result.stderr)($env.ALGA_COLOR_RESET)"
error make { msg: $"($env.ALGA_COLOR_RED)System workflow update failed($env.ALGA_COLOR_RESET)", code: $result.exit_code }
}
}
# Register or Add New Version for a System Workflow
# Creates the registration if it doesn't exist, then adds a new version
# based on the file content, marking it as the current version.
export def register-workflow [
workflow_name: string # The BASE name of the workflow (e.g., 'invoice-sync', 'qboCustomerSyncWorkflow'), without path or .ts extension
] {
let project_root = find-project-root
print $"($env.ALGA_COLOR_CYAN)Registering/Versioning system workflow '($workflow_name)'...($env.ALGA_COLOR_RESET)"
# Construct file path
let workflow_file = ($project_root | path join "server" "src" "lib" "workflows" $"($workflow_name).ts")
if not ($workflow_file | path exists) {
error make { msg: $"($env.ALGA_COLOR_RED)Workflow file not found: ($workflow_file)($env.ALGA_COLOR_RESET)" }
}
let file_content = open $workflow_file
# Load Database Environment Variables using the helper function
let db_env = load-db-env
# --- Check if latest version already matches file content ---
print $"($env.ALGA_COLOR_CYAN)Checking current version in database...($env.ALGA_COLOR_RESET)"
let sql_check = "
SELECT sv.code
FROM system_workflow_registration_versions sv
JOIN system_workflow_registrations sw ON sv.registration_id = sw.registration_id
WHERE sw.name = :'workflow_name'
ORDER BY sv.created_at DESC
LIMIT 1;
"
let check_result = do {
cd ($project_root | path join "server")
with-env { PGPASSWORD: $db_env.DB_PASSWORD_ADMIN } {
$sql_check | psql -h $db_env.DB_HOST -p $db_env.DB_PORT -U $db_env.DB_USER_ADMIN -d $db_env.DB_NAME_SERVER -v $"workflow_name=($workflow_name)" -t -A -f - | complete
}
}
if $check_result.exit_code == 0 {
let current_definition = ($check_result.stdout | str trim) # Trim potential trailing newline
if $current_definition == $file_content {
print $"($env.ALGA_COLOR_GREEN)Workflow '($workflow_name)' is already up-to-date with the current file content. No changes made.($env.ALGA_COLOR_RESET)"
return
} else {
print $"($env.ALGA_COLOR_CYAN)Current version differs or does not exist. Proceeding with registration/versioning...($env.ALGA_COLOR_RESET)"
}
} else {
# If the check fails (e.g., workflow not registered yet), proceed with registration
print $"($env.ALGA_COLOR_YELLOW)Warning: Could not retrieve current workflow definition (Exit Code: ($check_result.exit_code)). Proceeding with registration/versioning...($env.ALGA_COLOR_RESET)"
print $"($env.ALGA_COLOR_RED)($check_result.stderr)($env.ALGA_COLOR_RESET)"
}
# --- End Check ---
# Generate a version string (using timestamp for simplicity in dev)
let new_version_string = (date now | format date '%Y%m%d%H%M%S%f')
# Define the transactional SQL query
# Uses CTEs for clarity and ensures atomicity with BEGIN/COMMIT
let sql_transaction = "
BEGIN;
-- Step 1: Ensure registration exists and get its ID into a temporary table
CREATE TEMP TABLE _tmp_reg_id (registration_id UUID) ON COMMIT DROP;
WITH upsert_reg AS (
INSERT INTO system_workflow_registrations (name, version, status)
VALUES (:'workflow_name', :'new_version_string', 'draft')
ON CONFLICT (name) DO UPDATE SET updated_at = now()
RETURNING registration_id
)
INSERT INTO _tmp_reg_id (registration_id)
SELECT registration_id FROM upsert_reg
UNION ALL
SELECT registration_id FROM system_workflow_registrations
WHERE name = :'workflow_name' AND NOT EXISTS (SELECT 1 FROM upsert_reg LIMIT 1) -- Ensure this only runs if upsert_reg was empty (conflict occurred)
LIMIT 1;
-- Step 2: Unset existing 'is_current' flag for this registration
UPDATE system_workflow_registration_versions
SET is_current = false
WHERE registration_id = (SELECT registration_id FROM _tmp_reg_id) AND is_current = true;
-- Step 3: Insert the new version, marking it as current
INSERT INTO system_workflow_registration_versions (registration_id, version, is_current, code)
SELECT registration_id, :'new_version_string', true, :'content' -- Store content as text
FROM _tmp_reg_id;
-- RETURNING version_id; -- Not strictly needed for the rest of this transaction block
-- Step 4: Update the main registration's version string and status
UPDATE system_workflow_registrations
SET
version = :'new_version_string',
status = 'active', -- Set to active once a version is added
updated_at = now()
WHERE registration_id = (SELECT registration_id FROM _tmp_reg_id);
COMMIT;
"
print $"($env.ALGA_COLOR_CYAN)Executing database transaction...($env.ALGA_COLOR_RESET)"
# Execute psql command using explicit connection params from loaded env
let result = do {
cd ($project_root | path join "server")
with-env { PGPASSWORD: $db_env.DB_PASSWORD_ADMIN } {
$sql_transaction | psql -h $db_env.DB_HOST -p $db_env.DB_PORT -U $db_env.DB_USER_ADMIN -d $db_env.DB_NAME_SERVER -v ON_ERROR_STOP=1 -v $"workflow_name=($workflow_name)" -v $"new_version_string=($new_version_string)" -v $"content=($file_content)" -f - | complete
}
}
# Check result
if $result.exit_code == 0 {
let version_info = $"Version: ($new_version_string)"
print $"($env.ALGA_COLOR_GREEN)System workflow '($workflow_name)' registered/versioned successfully (($version_info)).($env.ALGA_COLOR_RESET)"
} else {
print $"($env.ALGA_COLOR_RED)($result.stderr)($env.ALGA_COLOR_RESET)"
# Note: psql might not output specific errors easily here if ON_ERROR_STOP is used
error make { msg: $"($env.ALGA_COLOR_RED)System workflow registration/versioning failed. Transaction rolled back.($env.ALGA_COLOR_RESET)", code: $result.exit_code }
}
}

145
context.md Normal file
View File

@ -0,0 +1,145 @@
# Code Context: Non-RBAC (ABAC-candidate) Constraints Across Product Areas
## Files Retrieved
1. `server/src/interfaces/authorization.interface.ts` (full) — ABAC scaffold: `IPolicy`, `ICondition` (userAttribute/operator/resourceAttribute) already defined but not wired into enforcement
2. `packages/auth/src/lib/policy/PolicyEngine.ts` (full) — ABAC policy engine: evaluates conditions (==, !=, contains) comparing user attributes vs resource attributes
3. `packages/auth/src/lib/attributes/EntityAttributes.ts` (full) — User attributes (user_id, team_id, roles, isAdmin) and Ticket attributes (creator_id, assignee_id, team_id, status, isOverdue)
4. `packages/auth/src/lib/attributes/AttributeSystem.ts` (full) — Attribute base classes: DBFieldAttribute, ComputedAttribute, StaticAttribute
5. `packages/auth/src/actions/policyActions.ts` (full) — CRUD for policies, getUserAttributes, getTicketAttributes, evaluateAccess — the ABAC wiring surface
6. `packages/auth/src/lib/withAuth.ts` (full) — Session auth wrapper; injects user + tenant context
7. `server/src/lib/auth/rbac.ts` (full) — Core RBAC: `hasPermission()` checks role→permission with msp/client flag gating
8. `server/src/middleware.ts` (full) — Edge middleware: API key gate, user_type routing (internal→/msp, client→/client-portal)
9. `packages/tickets/src/lib/clientPortalVisibility.ts` (full) — **Board-level visibility groups**: `getClientContactVisibilityContext()` + `applyVisibilityBoardFilter()` — a concrete ABAC pattern
10. `packages/client-portal/src/actions/client-portal-actions/client-tickets.ts` (lines 1220) — **Client→own client_id + visibility group board filter** on ticket queries
11. `packages/client-portal/src/actions/client-portal-actions/visibilityGroupActions.ts` (lines ~130165) — **is_client_admin** attribute gate for visibility group management
12. `packages/client-portal/src/lib/clientAuth.ts` (full) — `getAuthenticatedClientId()`: user→contact→client_id ownership chain
13. `packages/client-portal/src/actions/client-portal-actions/client-documents.ts` (lines 180) — Client document access gated by resolved client_id
14. `server/src/app/api/documents/view/[fileId]/route.ts` (lines 100400) — **Rich attribute-based document access**: checks user_type, ownership (own avatar, own contact), client association match, project_task→client ownership, contract→client ownership, ticket→contact/client ownership, is_client_visible flag, tenant-logo public access, same-tenant team avatar access
15. `packages/scheduling/src/actions/timeEntryDelegationAuth.ts` (full) — **`assertCanActOnBehalf()`**: self / manager-of-subject (team membership + manager_id) / reports-to-chain (teams-v2 flag) / tenant-wide (read_all) — classic ABAC delegation
16. `packages/scheduling/src/actions/timeSheetActions.ts` (lines 110200) — **Timesheet approval scoping**: non-read_all users see only team members where they are manager_id; reports-to subordinates via teams-v2 flag
17. `packages/billing/src/actions/quoteActions.ts` (lines 720850) — **Quote approval workflow**: status gates (draft→pending_approval→approved), separate `requireQuoteApprovePermission()`
18. `packages/billing/src/actions/recurringApprovalBlockers.ts` (lines 160) — **Billing blocked by time approval status**: invoice generation checks `time_entries.approval_status` != 'APPROVED'
19. `packages/projects/src/actions/projectTaskCommentActions.ts` (lines 145175) — **Comment edit: own comment OR internal user** — attribute check on user_id match + user_type
20. `packages/tags/src/lib/permissions.ts` (full) — Duplicated RBAC with msp/client flag — candidate for ABAC consolidation
21. `server/src/lib/extensions/gateway/auth.ts` (full) — Extension proxy resolves user_type + client_id for runner forwarding; `assertAccess()` is a TODO stub
22. `packages/client-portal/src/actions/client-portal-actions/client-billing-metrics.ts` (lines 180) — Billing metrics scoped to user's client_id via contact chain
23. `server/src/lib/api/controllers/ApiBaseController.ts` (lines 1130) — API key auth + `checkPermission()` — pure RBAC, no ABAC
24. `server/src/app/api/v1/tickets/[id]/route.ts` area + `ApiTicketController.ts` (full) — Ticket API: pure RBAC (ticket:read/update/delete), no board/client/visibility filtering in API layer
---
## Key Code
### 1. Existing ABAC Scaffold (unwired)
**`packages/auth/src/lib/policy/PolicyEngine.ts`**
```ts
evaluateAccess(user: IUserWithRoles, resource: any, action: string): boolean {
for (const policy of this.policies) {
if (policy.resource === resource.constructor.name && policy.action === action) {
if (this.evaluateConditions(user, resource, policy.conditions)) return true;
}
}
return false;
}
```
**`packages/auth/src/lib/attributes/EntityAttributes.ts`** — Only User and Ticket entity attributes defined. Missing: Client, Project, Document, Invoice, TimeEntry, Contract, Schedule, Integration entities.
### 2. Client Portal Visibility Groups (ABAC in practice)
**`packages/tickets/src/lib/clientPortalVisibility.ts`**
```ts
export interface ContactVisibilityContext {
contactId: string;
clientId: string;
visibilityGroupId: string | null;
visibleBoardIds: string[] | null; // null = unrestricted
}
export function applyVisibilityBoardFilter(query, visibleBoardIds, boardColumn = 't.board_id') {
if (visibleBoardIds === null) return query; // unrestricted
if (visibleBoardIds.length === 0) { query.whereRaw('1 = 0'); return query; }
query.whereIn(boardColumn, visibleBoardIds);
return query;
}
```
### 3. Time Entry Delegation Auth (manager-chain ABAC)
**`packages/scheduling/src/actions/timeEntryDelegationAuth.ts`**
```ts
export async function assertCanActOnBehalf(actor, tenant, subjectUserId, db): Promise<DelegationScope> {
if (actor.user_id === subjectUserId) return 'self';
const canApprove = await hasPermission(actor, 'timesheet', 'approve', db);
if (!canApprove) throw new Error('Permission denied');
const canReadAll = await hasPermission(actor, 'timesheet', 'read_all', db);
if (canReadAll) return 'tenant-wide';
if (await isManagerOfSubject(db, tenant, actor.user_id, subjectUserId)) return 'manager';
if (reportsToEnabled && await User.isInReportsToChain(db, actor.user_id, subjectUserId)) return 'manager';
throw new Error('Permission denied');
}
```
### 4. Document View Access (multi-attribute check)
**`server/src/app/api/documents/view/[fileId]/route.ts`** (lines 120330)
Checks in order:
- `isTenantLogo` → public
- `user.user_type === 'internal'` → full access
- `associatedUserId === user.user_id` → own avatar
- `associatedContactId === user.contact_id` → own contact avatar
- `userClientId === associatedClientId` + `is_client_visible` → client doc
- `associatedUserId && same tenant` → same-tenant avatar
- team association + same tenant
- `project_task → project.client_id === userClientId` + `is_client_visible`
- `contract → billing_plans.company_id === userClientId` + `is_client_visible`
- `ticket → contact_name_id match OR client_id match` + `is_client_visible`
---
## Architecture
### Current Access Control Layers
1. **Edge Middleware** (`server/src/middleware.ts`): API key presence check, user_type routing (internal vs client)
2. **RBAC** (`packages/auth/src/lib/rbac.ts` + `server/src/lib/auth/rbac.ts`): `hasPermission(user, resource, action)` — role-based with msp/client flag gating
3. **ABAC Scaffold** (`packages/auth/src/lib/policy/PolicyEngine.ts`): PolicyEngine + EntityAttributes exist but `evaluateAccess()` is not called anywhere in production code
4. **Inline Attribute Checks** (scattered): user_type checks, ownership checks, client_id resolution, board visibility filtering, manager-chain checks
### Data Flow
```
Request → Edge Middleware (user_type routing) → Route Handler
→ withAuth() (session → user + tenant context)
→ hasPermission() (RBAC check)
→ Inline attribute checks (non-RBAC constraints)
```
### Key Observation
ABAC constraints are **ad-hoc and scattered** — each product area implements its own attribute resolution and filtering inline rather than going through the PolicyEngine. The PolicyEngine exists but is dormant.
---
## Product Area ABAC Constraint Summary
| Area | Constraint Type | Where | Pattern |
|------|----------------|-------|---------|
| **Tickets** | Board visibility groups | `packages/tickets/src/lib/clientPortalVisibility.ts` | contact→visibility group→board_ids filter |
| **Tickets** | Client ownership | `packages/client-portal/.../client-tickets.ts:resolveVisibleTicket` | client_id match on ticket |
| **Tickets** | API layer: no ABAC | `server/src/lib/api/controllers/ApiTicketController.ts` | Pure RBAC only |
| **Billing/Invoices** | Client scoping | `packages/client-portal/.../client-billing-metrics.ts` | user→contact→client_id filter |
| **Billing/Quotes** | Approval status gate | `packages/billing/src/actions/quoteActions.ts:800` | status must be 'pending_approval' |
| **Billing/Recurring** | Time approval blocker | `packages/billing/src/actions/recurringApprovalBlockers.ts` | approval_status != 'APPROVED' blocks invoicing |
| **Projects/Documents** | Comment ownership | `packages/projects/.../projectTaskCommentActions.ts:152` | own comment OR internal user_type |
| **Documents** | Multi-entity association | `server/src/app/api/documents/view/[fileId]/route.ts` | client/contact/project/contract/ticket ownership chain |
| **Documents** | is_client_visible flag | Same file | Client users need doc.is_client_visible=true |
| **Contacts/Clients** | Client admin gate | `packages/client-portal/.../visibilityGroupActions.ts:140` | is_client_admin attribute check |
| **Scheduling/Time** | Manager chain delegation | `packages/scheduling/.../timeEntryDelegationAuth.ts` | self / manager / reports-to / tenant-wide |
| **Scheduling/Time** | Team manager scope | `packages/scheduling/.../timeSheetActions.ts:163` | team_members + manager_id join |
| **Workflows** | Permission hierarchy | `ee/packages/workflows/.../workflow-schedule-v2-actions.ts:133` | read fallback to view/manage/admin |
| **Integrations** | TODO stub | `server/src/lib/extensions/gateway/auth.ts:assertAccess()` | `// TODO: implement RBAC and per-tenant endpoint checks` |
---
## Start Here
Open **`packages/auth/src/lib/policy/PolicyEngine.ts`** — this is the existing ABAC engine. It has the attribute comparison logic but:
1. It's not called anywhere in production request paths
2. EntityAttributes only cover User and Ticket (need Client, Project, Document, Invoice, TimeEntry, Contract, etc.)
3. The `evaluateAccess()` method matches on `resource.constructor.name` which is fragile
The first task is deciding whether to extend this engine or replace it, then mapping the inline constraints listed above into the chosen ABAC model.

19
devbox.json Normal file
View File

@ -0,0 +1,19 @@
{
"$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.14.2/.schema/devbox.schema.json",
"packages": [
"nodejs_22@latest",
"gh@2.72.0",
"nodejs_21@latest",
"nodejs@latest"
],
"shell": {
"init_hook": [
"echo 'Welcome to devbox!' > /dev/null"
],
"scripts": {
"test": [
"echo \"Error: no test specified\" && exit 1"
]
}
}
}

284
devbox.lock Normal file
View File

@ -0,0 +1,284 @@
{
"lockfile_version": "1",
"packages": {
"gh@2.72.0": {
"last_modified": "2025-05-16T20:19:48Z",
"resolved": "github:NixOS/nixpkgs/12a55407652e04dcf2309436eb06fef0d3713ef3#gh",
"source": "devbox-search",
"version": "2.72.0",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/w5cf663k03fnvnhr2vvjski9mxd3x1py-gh-2.72.0",
"default": true
}
],
"store_path": "/nix/store/w5cf663k03fnvnhr2vvjski9mxd3x1py-gh-2.72.0"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/y215k0r7njz85c0dkl3ymyaxinwybmwx-gh-2.72.0",
"default": true
}
],
"store_path": "/nix/store/y215k0r7njz85c0dkl3ymyaxinwybmwx-gh-2.72.0"
},
"x86_64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/f07aqxx5diq5g5v49n28yxfkkl438i2x-gh-2.72.0",
"default": true
}
],
"store_path": "/nix/store/f07aqxx5diq5g5v49n28yxfkkl438i2x-gh-2.72.0"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/8mg9yiwkvrf5bnr6fisamzdnv95l4pmv-gh-2.72.0",
"default": true
}
],
"store_path": "/nix/store/8mg9yiwkvrf5bnr6fisamzdnv95l4pmv-gh-2.72.0"
}
}
},
"github:NixOS/nixpkgs/nixpkgs-unstable": {
"last_modified": "2025-07-01T15:05:04Z",
"resolved": "github:NixOS/nixpkgs/d31a91c9b3bee464d054633d5f8b84e17a637862?lastModified=1751382304&narHash=sha256-p%2BUruOjULI5lV16FkBqkzqgFasLqfx0bihLBeFHiZAs%3D"
},
"nodejs@latest": {
"last_modified": "2025-08-10T04:38:50Z",
"plugin_version": "0.0.2",
"resolved": "github:NixOS/nixpkgs/641d909c4a7538f1539da9240dedb1755c907e40#nodejs_24",
"source": "devbox-search",
"version": "24.5.0",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/b1j05q96hwagn787p2jlgqcjg2nf5x49-nodejs-24.5.0",
"default": true
},
{
"name": "dev",
"path": "/nix/store/j6ayg4xpqy9xdxgrhpqylzq8v7v07c6r-nodejs-24.5.0-dev"
},
{
"name": "libv8",
"path": "/nix/store/3ys6v5s5gvd9snwnl4saynl6av7mz3vy-nodejs-24.5.0-libv8"
}
],
"store_path": "/nix/store/b1j05q96hwagn787p2jlgqcjg2nf5x49-nodejs-24.5.0"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/1kn0vh4gf3a22arldrw694apq3fhgp15-nodejs-24.5.0",
"default": true
},
{
"name": "dev",
"path": "/nix/store/i3lqaj3j6znhnzh8ayka6q85r81ppxnw-nodejs-24.5.0-dev"
},
{
"name": "libv8",
"path": "/nix/store/jjw6xgmg6qynp336g9igqnzlfbhzxr2i-nodejs-24.5.0-libv8"
}
],
"store_path": "/nix/store/1kn0vh4gf3a22arldrw694apq3fhgp15-nodejs-24.5.0"
},
"x86_64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/sbcg21wp4bdzyh2542v77sp535kvfbfq-nodejs-24.5.0",
"default": true
},
{
"name": "libv8",
"path": "/nix/store/75b7iix0pbmxmfnmv90l3q0ll1gc75az-nodejs-24.5.0-libv8"
},
{
"name": "dev",
"path": "/nix/store/fg7pi9s6m0spci1pfqbny0kxmk832i3r-nodejs-24.5.0-dev"
}
],
"store_path": "/nix/store/sbcg21wp4bdzyh2542v77sp535kvfbfq-nodejs-24.5.0"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/357id3rjy9417k4dkvxxmpgd9bxrwc7l-nodejs-24.5.0",
"default": true
},
{
"name": "dev",
"path": "/nix/store/0drh8jjq84sji6889l2k3ysmvy7sc9sg-nodejs-24.5.0-dev"
},
{
"name": "libv8",
"path": "/nix/store/kdlv4q7sgap0z43cylklhxz1g1q7751b-nodejs-24.5.0-libv8"
}
],
"store_path": "/nix/store/357id3rjy9417k4dkvxxmpgd9bxrwc7l-nodejs-24.5.0"
}
}
},
"nodejs_21@latest": {
"last_modified": "2024-04-19T21:36:04Z",
"plugin_version": "0.0.2",
"resolved": "github:NixOS/nixpkgs/92d295f588631b0db2da509f381b4fb1e74173c5#nodejs_21",
"source": "devbox-search",
"version": "21.7.3",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/hxr5ziwxn72wxcrhxasdy542h6z0r9hg-nodejs-21.7.3",
"default": true
},
{
"name": "libv8",
"path": "/nix/store/y6xj0m2n7mlzgx5mc62m0lb4h02dzklr-nodejs-21.7.3-libv8"
}
],
"store_path": "/nix/store/hxr5ziwxn72wxcrhxasdy542h6z0r9hg-nodejs-21.7.3"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/xfddvy0bfzc78yipmyx81irjqfpkm1d0-nodejs-21.7.3",
"default": true
},
{
"name": "libv8",
"path": "/nix/store/vjr64cmj37yglbzjz6hrwqa4z6hw2mz2-nodejs-21.7.3-libv8"
}
],
"store_path": "/nix/store/xfddvy0bfzc78yipmyx81irjqfpkm1d0-nodejs-21.7.3"
},
"x86_64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/pnrd7gl28dmy86pprrpifq7qny3sx1xp-nodejs-21.7.3",
"default": true
},
{
"name": "libv8",
"path": "/nix/store/wnzqqc8ik64a6zn4k4bbcgzvi8dp8vnb-nodejs-21.7.3-libv8"
}
],
"store_path": "/nix/store/pnrd7gl28dmy86pprrpifq7qny3sx1xp-nodejs-21.7.3"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/dfs59hwhc4mb4hdk77nc5dd39k2v2qs2-nodejs-21.7.3",
"default": true
},
{
"name": "libv8",
"path": "/nix/store/iq38acw88hl92vpwamfkzgk3pk3dsr0x-nodejs-21.7.3-libv8"
}
],
"store_path": "/nix/store/dfs59hwhc4mb4hdk77nc5dd39k2v2qs2-nodejs-21.7.3"
}
}
},
"nodejs_22@latest": {
"last_modified": "2025-06-06T01:46:53Z",
"plugin_version": "0.0.2",
"resolved": "github:NixOS/nixpkgs/6ad174a6dc07c7742fc64005265addf87ad08615#nodejs_22",
"source": "devbox-search",
"version": "22.14.0",
"systems": {
"aarch64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/g5bv4gi6p7ryhs2hbaryxs6ivsxa6lqc-nodejs-22.14.0",
"default": true
},
{
"name": "dev",
"path": "/nix/store/96vpirdcns8zxdwzwl7n804012lwrf1n-nodejs-22.14.0-dev"
},
{
"name": "libv8",
"path": "/nix/store/xph2837xqjnra80mqlk64xp7vb3kkqxs-nodejs-22.14.0-libv8"
}
],
"store_path": "/nix/store/g5bv4gi6p7ryhs2hbaryxs6ivsxa6lqc-nodejs-22.14.0"
},
"aarch64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/cwb781xbp70f72njqh8vhvbsf1xq87i5-nodejs-22.14.0",
"default": true
},
{
"name": "dev",
"path": "/nix/store/0w5mql46am2qai707a42q983l44l1h9z-nodejs-22.14.0-dev"
},
{
"name": "libv8",
"path": "/nix/store/vy56lahz381ayvw3hym3wj4r4ggv7gbk-nodejs-22.14.0-libv8"
}
],
"store_path": "/nix/store/cwb781xbp70f72njqh8vhvbsf1xq87i5-nodejs-22.14.0"
},
"x86_64-darwin": {
"outputs": [
{
"name": "out",
"path": "/nix/store/086zjv14ipka2vbpgwil3xyvi5ml4vh3-nodejs-22.14.0",
"default": true
},
{
"name": "dev",
"path": "/nix/store/24n01nfvy908pwzwpgsl0k4227187i7i-nodejs-22.14.0-dev"
},
{
"name": "libv8",
"path": "/nix/store/jiwk0in1wk85crn9vp03gg937gbh1s0w-nodejs-22.14.0-libv8"
}
],
"store_path": "/nix/store/086zjv14ipka2vbpgwil3xyvi5ml4vh3-nodejs-22.14.0"
},
"x86_64-linux": {
"outputs": [
{
"name": "out",
"path": "/nix/store/c8jxsih8yy2rnncdmx2hyraizf689nvp-nodejs-22.14.0",
"default": true
},
{
"name": "libv8",
"path": "/nix/store/s8gnrgh9hgnbkrc1wrn21d6zvkbvm9vi-nodejs-22.14.0-libv8"
},
{
"name": "dev",
"path": "/nix/store/xw8c1c0inxq3xl1d7axz19vq8c05kjk5-nodejs-22.14.0-dev"
}
],
"store_path": "/nix/store/c8jxsih8yy2rnncdmx2hyraizf689nvp-nodejs-22.14.0"
}
}
}
}
}

152
docker-compose.base.yaml Normal file
View File

@ -0,0 +1,152 @@
x-environment: &shared-environment
# ---- APP -------
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
VERIFY_EMAIL_ENABLED: ${VERIFY_EMAIL_ENABLED:-false}
# ---- REDIS ----
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
# ---- PGBOUNCER ----
PGBOUNCER_HOST: ${PGBOUNCER_HOST:-pgbouncer}
PGBOUNCER_PORT: ${PGBOUNCER_PORT:-6432}
EXPOSE_PGBOUNCER_PORT: ${EXPOSE_PGBOUNCER_PORT:-6432}
# ---- DATABASE ----
DB_TYPE: ${DB_TYPE}
DB_HOST: postgres
DB_PORT: ${DB_PORT:-5432}
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
POSTGRES_USER: postgres
# ---- LOGGING ----
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
LOG_ENABLED_FILE_LOGGING: ${LOG_ENABLED_FILE_LOGGING}
LOG_DIR_PATH: ${LOG_DIR_PATH}
LOG_ENABLED_EXTERNAL_LOGGING: ${LOG_ENABLED_EXTERNAL_LOGGING}
LOG_EXTERNAL_HTTP_HOST: ${LOG_EXTERNAL_HTTP_HOST}
LOG_EXTERNAL_HTTP_PORT: ${LOG_EXTERNAL_HTTP_PORT}
LOG_EXTERNAL_HTTP_PATH: ${LOG_EXTERNAL_HTTP_PATH}
LOG_EXTERNAL_HTTP_LEVEL: ${LOG_EXTERNAL_HTTP_LEVEL}
LOG_EXTERNAL_HTTP_TOKEN: ${LOG_EXTERNAL_HTTP_TOKEN}
# ---- HOCUSPOCUS ----
HOCUSPOCUS_PORT: ${HOCUSPOCUS_PORT}
HOCUSPOCUS_URL: ${HOCUSPOCUS_URL}
# ---- EMAIL ----
EMAIL_ENABLE: ${EMAIL_ENABLE:-false}
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_USERNAME: ${EMAIL_USERNAME:-noreply@example.com}
# ---- CRYPTO ----
CRYPTO_SALT_BYTES: ${SALT_BYTES}
CRYPTO_ITERATION: ${ITERATION}
CRYPTO_KEY_LENGTH: ${KEY_LENGTH}
CRYPTO_ALGORITHM: ${ALGORITHM}
# ---- TOKEN ----
TOKEN_EXPIRES: ${TOKEN_EXPIRES}
# ---- AI / LLM ----
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
OPENROUTER_API: ${OPENROUTER_API:-}
# ---- AUTH ----
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
# Required by edge-auth (read from env, not Docker secret file)
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_SESSION_EXPIRES: ${NEXTAUTH_SESSION_EXPIRES:-86400}
AUTH_TRUST_HOST: ${AUTH_TRUST_HOST:-false}
# ---- DEPLOY INFO ----
PROJECT_NAME: ${PROJECT_NAME}
EXPOSE_DB_PORT: ${EXPOSE_DB_PORT:-5432}
EXPOSE_HOCUSPOCUS_PORT: ${EXPOSE_HOCUSPOCUS_PORT:-1234}
EXPOSE_REDIS_PORT: ${EXPOSE_REDIS_PORT:-6379}
EXPOSE_SERVER_PORT: ${EXPOSE_SERVER_PORT:-3000}
secrets:
db_password_server:
file: ./secrets/db_password_server
db_password_hocuspocus:
file: ./secrets/db_password_hocuspocus
postgres_password:
file: ./secrets/postgres_password
redis_password:
file: ./secrets/redis_password
email_password:
file: ./secrets/email_password
crypto_key:
file: ./secrets/crypto_key
token_secret_key:
file: ./secrets/token_secret_key
nextauth_secret:
file: ./secrets/nextauth_secret
google_oauth_client_id:
file: ./secrets/google_oauth_client_id
google_oauth_client_secret:
file: ./secrets/google_oauth_client_secret
alga_auth_key:
file: ./secrets/alga_auth_key
ninjaone_client_id:
file: ./secrets/ninjaone_client_id
ninjaone_client_secret:
file: ./secrets/ninjaone_client_secret
services:
postgres: # Renamed from postgres-base
image: pgvector-clean:0.8.0-pg15
#container_name: ${APP_NAME:-sebastian}_postgres
command: postgres -c password_encryption=md5
environment:
<<: *shared-environment
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
POSTGRES_INITDB_ARGS: "--auth-host=md5 --auth-local=md5"
secrets:
- postgres_password
ports:
- "${EXPOSE_DB_PORT:-5432}:5432"
networks:
- app-network
redis: # Renamed from redis-base
build:
context: .
dockerfile: redis/Dockerfile
#container_name: ${APP_NAME:-sebastian}_redis
environment:
<<: *shared-environment
ports:
- '${EXPOSE_REDIS_PORT:-6379}:6379'
secrets:
- redis_password
networks:
- app-network
pgbouncer:
extends:
file: ./pgbouncer/docker-compose.yaml
service: pgbouncer
environment:
<<: *shared-environment
secrets:
- postgres_password
- db_password_server
networks:
- app-network
depends_on:
postgres: # Corrected to match the renamed service name
condition: service_started
networks:
app-network:
driver: bridge

357
docker-compose.ce.yaml Normal file
View File

@ -0,0 +1,357 @@
services:
server:
extends:
file: ./server/docker-compose.yaml
service: server
container_name: ${APP_NAME:-sebastian}_server_ce
# Build the CE server image from the current worktree (no GHCR dependency).
# This image is also reused by the `setup` one-shot container.
image: ${APP_NAME:-sebastian}_server_ce_local
build:
context: .
dockerfile: Dockerfile.build
args:
NEXT_BUILD_MAX_OLD_SPACE_SIZE: ${NEXT_BUILD_MAX_OLD_SPACE_SIZE:-12288}
environment:
EDITION: community
DB_NAME: server
PGBOSS_DATABASE: server
DB_NAME_SERVER: server
DB_NAME_HOCUSPOCUS: ${DB_NAME_HOCUSPOCUS:-hocuspocus}
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
VERIFY_EMAIL_ENABLED: ${VERIFY_EMAIL_ENABLED:-false}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
REQUIRE_HOCUSPOCUS: ${REQUIRE_HOCUSPOCUS:-false}
COLLAB_PERSIST_API_KEY: ${COLLAB_PERSIST_API_KEY:-alga-collab-persist-dev}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
EMAIL_ENABLE: ${EMAIL_ENABLE:-false}
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_USERNAME: ${EMAIL_USERNAME:-noreply@example.com}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SESSION_EXPIRES: ${NEXTAUTH_SESSION_EXPIRES:-86400}
# Secret provider configuration (CE edition)
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
# Workflow configuration
WORKFLOW_DISTRIBUTED_MODE: "true"
WORKFLOW_REDIS_STREAM_PREFIX: "workflow:events:"
WORKFLOW_REDIS_CONSUMER_GROUP: "workflow-workers"
IMAP_WEBHOOK_SECRET: ${IMAP_WEBHOOK_SECRET:-}
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
entrypoint: ["/bin/sh", "-c", "export DATABASE_URL=postgresql://app_user:$$(cat /run/secrets/db_password_server)@${PGBOUNCER_HOST:-pgbouncer}:${PGBOUNCER_PORT:-6432}/server && /app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- db_password_hocuspocus
- redis_password
- email_password
- crypto_key
- token_secret_key
- nextauth_secret
- google_oauth_client_id
- google_oauth_client_secret
- alga_auth_key
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
hocuspocus:
condition: service_started
setup:
condition: service_completed_successfully
setup:
# One-shot DB bootstrap (create DB/users, run migrations/seeds).
# Use the locally-built CE server image so this works without GHCR access.
image: ${APP_NAME:-sebastian}_server_ce_local
build:
context: .
dockerfile: Dockerfile.build
args:
NEXT_BUILD_MAX_OLD_SPACE_SIZE: ${NEXT_BUILD_MAX_OLD_SPACE_SIZE:-12288}
environment:
EDITION: community
NODE_OPTIONS: --experimental-vm-modules
DB_NAME_SERVER: server
DB_NAME_HOCUSPOCUS: ${DB_NAME_HOCUSPOCUS:-hocuspocus}
DB_USER_SERVER: app_user
DB_USER_HOCUSPOCUS: ${DB_USER_HOCUSPOCUS:-hocuspocus_user}
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
PGBOSS_DATABASE: server
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${DB_HOST_ADMIN:-postgres}
DB_PORT: ${DB_PORT_ADMIN:-5432}
# Ensure admin operations talk directly to Postgres even if app traffic points to PgBouncer
DB_HOST_ADMIN: ${DB_HOST_ADMIN:-postgres}
DB_PORT_ADMIN: ${DB_PORT_ADMIN:-5432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
EMAIL_ENABLE: ${EMAIL_ENABLE:-false}
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_USERNAME: ${EMAIL_USERNAME:-noreply@example.com}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SESSION_EXPIRES: ${NEXTAUTH_SESSION_EXPIRES:-86400}
# Secret provider configuration for setup (CE edition)
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
volumes:
- type: bind
source: ./setup/config.ini
target: /app/setup/config.ini
read_only: true
- type: bind
source: ./setup/entrypoint.sh
target: /app/setup/entrypoint.sh
read_only: true
- type: bind
source: ./secrets/postgres_password
target: /run/secrets/postgres_password
read_only: true
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
secrets:
- postgres_password
- db_password_server
- db_password_hocuspocus
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer: # Added dependency on pgbouncer
condition: service_started
entrypoint: ["/app/setup/entrypoint.sh"]
workflow-worker:
# Remove extends to use a custom build
# extends:
# file: ./server/docker-compose.yaml
# service: server
# Remove container_name to allow multiple instances
# container_name: ${APP_NAME:-sebastian}_workflow_worker_ce
build:
context: .
dockerfile: services/workflow-worker/Dockerfile
environment:
EDITION: community
DB_NAME: server
PGBOSS_DATABASE: server
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
# Secret provider configuration for workflow-worker (CE edition)
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
# Workflow-specific configuration
# Run v2 runtime by default (legacy can be re-enabled via WORKFLOW_WORKER_MODE=all|legacy)
WORKFLOW_WORKER_MODE: ${WORKFLOW_WORKER_MODE:-v2}
WORKFLOW_DISTRIBUTED_MODE: "true"
WORKFLOW_REDIS_STREAM_PREFIX: "workflow:events:"
WORKFLOW_REDIS_CONSUMER_GROUP: "workflow-workers"
WORKFLOW_REDIS_BATCH_SIZE: "10"
WORKFLOW_REDIS_IDLE_TIMEOUT_MS: "60000"
ports:
# Expose a random port for health checks/monitoring
- "3000"
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./services/workflow-worker/entrypoint.sh
target: /app/entrypoint.sh
read_only: true
entrypoint: ["/app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- redis_password
- crypto_key
- token_secret_key
- nextauth_secret
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
server:
condition: service_started
# Enable scaling of worker instances
deploy:
replicas: ${WORKFLOW_WORKER_REPLICAS:-1}
email-service:
build:
context: .
dockerfile: services/email-service/Dockerfile
environment:
EDITION: community
DB_NAME: server
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
IMAP_PROVIDER_REFRESH_MS: ${IMAP_PROVIDER_REFRESH_MS:-60000}
IMAP_POLL_INTERVAL_MS: ${IMAP_POLL_INTERVAL_MS:-30000}
IMAP_LEASE_TTL_MS: ${IMAP_LEASE_TTL_MS:-120000}
IMAP_MAX_CONNECTIONS_PER_TENANT: ${IMAP_MAX_CONNECTIONS_PER_TENANT:-5}
IMAP_MAX_ATTACHMENT_BYTES: ${IMAP_MAX_ATTACHMENT_BYTES:-0}
IMAP_FETCH_DELAY_MS: ${IMAP_FETCH_DELAY_MS:-0}
IMAP_EVENT_CHANNEL_BY_TENANT: ${IMAP_EVENT_CHANNEL_BY_TENANT:-false}
IMAP_OAUTH_AUTH_MECHANISM: ${IMAP_OAUTH_AUTH_MECHANISM:-XOAUTH2}
IMAP_WEBHOOK_URL: ${IMAP_WEBHOOK_URL:-http://server:3000/api/email/webhooks/imap}
IMAP_WEBHOOK_TIMEOUT_MS: ${IMAP_WEBHOOK_TIMEOUT_MS:-10000}
IMAP_WEBHOOK_MAX_ATTEMPTS: ${IMAP_WEBHOOK_MAX_ATTEMPTS:-3}
IMAP_WEBHOOK_SECRET: ${IMAP_WEBHOOK_SECRET:-}
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./services/email-service/entrypoint.sh
target: /app/entrypoint.sh
read_only: true
entrypoint: ["/app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- redis_password
- crypto_key
- token_secret_key
- nextauth_secret
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
server:
condition: service_started
hocuspocus:
extends:
file: ./hocuspocus/docker-compose.yaml
service: hocuspocus
container_name: ${APP_NAME:-sebastian}_hocuspocus_ce
build:
context: .
dockerfile: hocuspocus/Dockerfile
environment:
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
REDIS_HOST: redis
REDIS_PORT: 6379
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: postgres
DB_PORT: 5432
DB_NAME_HOCUSPOCUS: ${DB_NAME_HOCUSPOCUS:-hocuspocus}
DB_USER_HOCUSPOCUS: ${DB_USER_HOCUSPOCUS:-hocuspocus_user}
AI_DOCUMENT_API_URL: ${AI_DOCUMENT_API_URL:-http://host.docker.internal:3000/api/v1/ai/document-assist}
AI_DOCUMENT_API_KEY: ${AI_DOCUMENT_API_KEY:-}
COLLAB_PERSIST_API_URL: ${COLLAB_PERSIST_API_URL:-http://host.docker.internal:3000/api/internal/collab/persist}
COLLAB_PERSIST_API_KEY: ${COLLAB_PERSIST_API_KEY:-alga-collab-persist-dev}
secrets:
- postgres_password
- db_password_hocuspocus
- redis_password
networks:
- app-network
depends_on:
redis:
condition: service_started
postgres:
extends:
file: docker-compose.base.yaml
service: postgres # Updated from postgres-base
environment:
POSTGRES_DB: server
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: postgres
DB_PORT: ${DB_PORT:-5432}
secrets:
- postgres_password
redis:
extends:
file: docker-compose.base.yaml
service: redis # Updated from redis-base
networks:
app-network:
driver: bridge

View File

@ -0,0 +1,223 @@
# E2E Testing Configuration for Local Development
# This configuration excludes the setup container since tests handle their own DB setup
# Run from the alga-psa root directory
services:
# Test-specific PostgreSQL instance
postgres-test:
image: ankane/pgvector:latest
container_name: ${APP_NAME:-sebastian}_postgres_test
environment:
POSTGRES_DB: server_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets:
- postgres_password
ports:
- "5433:5432"
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d server_test"]
interval: 10s
timeout: 5s
retries: 5
# Test-specific Redis instance
redis-test:
build:
context: .
dockerfile: redis/Dockerfile
container_name: ${APP_NAME:-sebastian}_redis_test
environment:
REDIS_PASSWORD_FILE: /run/secrets/redis_password
secrets:
- redis_password
ports:
- "6380:6379"
networks:
- app-network
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 3s
retries: 5
# Test-specific PgBouncer instance
pgbouncer-test:
build:
context: ./pgbouncer
dockerfile: Dockerfile
container_name: ${APP_NAME:-sebastian}_pgbouncer_test
environment:
POSTGRES_USER: postgres
# Override database configuration for test
DATABASES_HOST: postgres-test
DATABASES_PORT: 5432
DATABASES_DBNAME: server_test
secrets:
- postgres_password
- db_password_server
ports:
- "6434:6432" # Different port from main pgbouncer
networks:
- app-network
depends_on:
postgres-test:
condition: service_healthy
# Workflow Worker Service - Dev mode with volume mounts
workflow-worker-test:
image: node:20.9-alpine
container_name: ${APP_NAME:-sebastian}_workflow_worker_test
working_dir: /app
command: sh -c "apk add --no-cache curl postgresql-client && cd /app/services/workflow-worker && npm run dev"
environment:
# Database configuration
DB_TYPE: postgres
DB_HOST: postgres-test
DB_PORT: 5432
DB_NAME_SERVER: server_test
DB_USER_SERVER: app_user
POSTGRES_HOST: postgres-test # Also set POSTGRES_HOST for entrypoint script
# Redis configuration
REDIS_HOST: redis-test
REDIS_PORT: 6379
# Workflow worker configuration
WORKER_COUNT: 2
POLL_INTERVAL_MS: 300000 # 5 minutes in milliseconds
BATCH_SIZE: 5
MAX_RETRIES: 3
CONCURRENCY_LIMIT: 3
HEALTH_CHECK_INTERVAL_MS: 300000 # 5 minutes
METRICS_REPORTING_INTERVAL_MS: 300000 # 5 minutes
# Logging
LOG_LEVEL: debug
LOG_IS_FORMAT_JSON: false
LOG_IS_FULL_DETAILS: true
# App info
APP_NAME: ${APP_NAME:-sebastian}
APP_ENV: test
NODE_ENV: test
VERSION: e2e-test
# Security
CRYPTO_SALT_BYTES: 32
CRYPTO_ITERATION: 10000
CRYPTO_KEY_LENGTH: 64
CRYPTO_ALGORITHM: aes-256-gcm
TOKEN_EXPIRES: 3600
networks:
- app-network
volumes:
# Mount the entire project for development
- type: bind
source: .
target: /app
read_only: false
# Mount secrets
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./secrets/redis_password
target: /run/secrets/redis_password
read_only: true
- type: bind
source: ./secrets/crypto_key
target: /run/secrets/crypto_key
read_only: true
- type: bind
source: ./secrets/token_secret_key
target: /run/secrets/token_secret_key
read_only: true
secrets:
- db_password_server
- redis_password
- crypto_key
- token_secret_key
depends_on:
postgres-test:
condition: service_healthy
redis-test:
condition: service_healthy
# No dependency on setup-test since tests handle their own setup
ports:
- "4001:3001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# MailHog for email testing
mailhog:
image: mailhog/mailhog:latest
container_name: ${APP_NAME:-sebastian}_mailhog_test
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
networks:
- app-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8025"]
interval: 30s
timeout: 10s
retries: 3
# WireMock for webhook mocking
webhook-mock:
image: wiremock/wiremock:latest
container_name: ${APP_NAME:-sebastian}_webhook_mock_test
ports:
- "8080:8080"
volumes:
- ./test-config/wiremock:/home/wiremock
networks:
- app-network
command: ["--global-response-templating", "--verbose"]
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/__admin/health"]
interval: 30s
timeout: 10s
retries: 3
# WireMock for OAuth mocking
oauth-mock:
image: wiremock/wiremock:3.9.2
container_name: ${APP_NAME:-sebastian}_oauth_mock_test
ports:
- "8081:8080"
volumes:
- ./test-config/wiremock-oauth:/home/wiremock
networks:
- app-network
command: ["--global-response-templating", "--verbose"]
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/__admin/health"]
interval: 5s
timeout: 10s
retries: 5
secrets:
postgres_password:
file: ./secrets/postgres_password
db_password_server:
file: ./secrets/db_password_server
redis_password:
file: ./secrets/redis_password
crypto_key:
file: ./secrets/crypto_key
token_secret_key:
file: ./secrets/token_secret_key
networks:
app-network:
name: ${APP_NAME:-sebastian}_app-network-test
driver: bridge

View File

@ -0,0 +1,87 @@
# Simplified E2E Testing Configuration
# This uses the existing infrastructure with test-specific additions
# Run from the alga-psa root directory
services:
# Test-specific PostgreSQL instance
postgres-test:
image: ankane/pgvector:latest
container_name: ${APP_NAME:-sebastian}_postgres_test
environment:
POSTGRES_DB: server_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets:
- postgres_password
ports:
- "5433:5432"
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d server_test"]
interval: 10s
timeout: 5s
retries: 5
# Test-specific Redis instance
redis-test:
build:
context: .
dockerfile: redis/Dockerfile
container_name: ${APP_NAME:-sebastian}_redis_test
environment:
REDIS_PASSWORD_FILE: /run/secrets/redis_password
secrets:
- redis_password
ports:
- "6380:6379"
networks:
- app-network
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 3s
retries: 5
# MailHog for email testing
mailhog:
image: mailhog/mailhog:latest
container_name: ${APP_NAME:-sebastian}_mailhog_test
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
networks:
- app-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8025"]
interval: 30s
timeout: 10s
retries: 3
# WireMock for webhook mocking
webhook-mock:
image: wiremock/wiremock:latest
container_name: ${APP_NAME:-sebastian}_webhook_mock_test
ports:
- "8080:8080"
volumes:
- ./test-config/wiremock:/home/wiremock
networks:
- app-network
command: ["--global-response-templating", "--verbose"]
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/__admin/health"]
interval: 30s
timeout: 10s
retries: 3
secrets:
postgres_password:
file: ./secrets/postgres_password
redis_password:
file: ./secrets/redis_password
networks:
app-network:
name: ${APP_NAME:-sebastian}_app-network-test
driver: bridge

View File

@ -0,0 +1,255 @@
# E2E Testing Configuration with Workflow Worker
# This extends the basic infrastructure with workflow worker service
# Run from the alga-psa root directory
services:
# Test-specific PostgreSQL instance
postgres-test:
image: ankane/pgvector:latest
container_name: ${APP_NAME:-sebastian}_postgres_test
environment:
POSTGRES_DB: server
POSTGRES_USER: postgres
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets:
- postgres_password
ports:
- "5433:5432"
networks:
- app-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d server"]
interval: 10s
timeout: 5s
retries: 5
# Test-specific Redis instance
redis-test:
build:
context: .
dockerfile: redis/Dockerfile
container_name: ${APP_NAME:-sebastian}_redis_test
environment:
REDIS_PASSWORD_FILE: /run/secrets/redis_password
secrets:
- redis_password
ports:
- "6380:6379"
networks:
- app-network
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 3s
retries: 5
# Test setup service (database migrations and seeding)
setup-test:
build:
context: .
dockerfile: setup/Dockerfile
args:
SERVER_IMAGE_REPO: ${ALGA_SETUP_IMAGE_REPO:-ghcr.io/nine-minds/alga-psa-ce}
ALGA_IMAGE_TAG: ${ALGA_IMAGE_TAG:-latest}
networks:
- app-network
environment:
# Database configuration
DB_TYPE: postgres
DB_HOST: postgres-test
DB_PORT: 5432
DB_NAME_SERVER: server
DB_USER_ADMIN: postgres
DB_USER_SERVER: app_user
POSTGRES_USER: postgres
DB_PASSWORD_ADMIN_FILE: /run/secrets/postgres_password
# Logging
LOG_LEVEL: debug
NODE_OPTIONS: --experimental-vm-modules
# App info
APP_NAME: ${APP_NAME:-sebastian}
APP_ENV: test
VERSION: e2e-test
volumes:
- type: bind
source: ./secrets/postgres_password
target: /run/secrets/postgres_password
read_only: true
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
secrets:
- postgres_password
- db_password_server
entrypoint: ["/opt/setup/entrypoint.sh"]
depends_on:
postgres-test:
condition: service_healthy
# Workflow Worker Service
workflow-worker-test:
build:
context: .
dockerfile: services/workflow-worker/Dockerfile
container_name: ${APP_NAME:-sebastian}_workflow_worker_test
environment:
# Database configuration
DB_TYPE: postgres
DB_HOST: postgres-test
DB_PORT: 5432
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
POSTGRES_HOST: postgres-test # Also set POSTGRES_HOST for entrypoint script
# Redis configuration
REDIS_HOST: redis-test
REDIS_PORT: 6379
# Workflow worker configuration
WORKER_COUNT: 2
POLL_INTERVAL_MS: 300000 # 5 minutes in milliseconds
BATCH_SIZE: 5
MAX_RETRIES: 3
CONCURRENCY_LIMIT: 3
HEALTH_CHECK_INTERVAL_MS: 300000 # 5 minutes
METRICS_REPORTING_INTERVAL_MS: 300000 # 5 minutes
# Logging
LOG_LEVEL: debug
LOG_IS_FORMAT_JSON: false
LOG_IS_FULL_DETAILS: true
# App info
APP_NAME: ${APP_NAME:-sebastian}
APP_ENV: test
NODE_ENV: development
VERSION: e2e-test
# Development mode - use tsx watch for hot reload
DEV_MODE: "true"
# Security
CRYPTO_SALT_BYTES: 32
CRYPTO_ITERATION: 10000
CRYPTO_KEY_LENGTH: 64
CRYPTO_ALGORITHM: aes-256-gcm
TOKEN_EXPIRES: 3600
networks:
- app-network
volumes:
# Mount source code for live development
- type: bind
source: ./services/workflow-worker/src
target: /app/services/workflow-worker/src
read_only: false
# For development, we keep the built shared library but mount shared src for tsx to see changes
- type: bind
source: ./shared/core
target: /app/shared/core
read_only: false
- type: bind
source: ./shared/db
target: /app/shared/db
read_only: false
- type: bind
source: ./shared/workflow
target: /app/shared/workflow
read_only: false
- type: bind
source: ./shared/types
target: /app/shared/types
read_only: false
# Secrets
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
secrets:
- db_password_server
- redis_password
- crypto_key
- token_secret_key
depends_on:
postgres-test:
condition: service_healthy
redis-test:
condition: service_healthy
setup-test:
condition: service_completed_successfully
ports:
- "4001:4000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
# MailHog for email testing
mailhog:
image: mailhog/mailhog:latest
container_name: ${APP_NAME:-sebastian}_mailhog_test
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
networks:
- app-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8025"]
interval: 30s
timeout: 10s
retries: 3
# WireMock for webhook mocking
webhook-mock:
image: wiremock/wiremock:latest
container_name: ${APP_NAME:-sebastian}_webhook_mock_test
ports:
- "8080:8080"
volumes:
- ./test-config/wiremock:/home/wiremock
networks:
- app-network
command: ["--global-response-templating", "--verbose"]
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/__admin/health"]
interval: 30s
timeout: 10s
retries: 3
# WireMock for OAuth mocking
oauth-mock:
image: wiremock/wiremock:3.9.2
container_name: ${APP_NAME:-sebastian}_oauth_mock_test
ports:
- "8081:8080"
volumes:
- ./test-config/wiremock-oauth:/home/wiremock
networks:
- app-network
command: ["--global-response-templating", "--verbose"]
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/__admin/health"]
interval: 5s
timeout: 10s
retries: 5
secrets:
postgres_password:
file: ./secrets/postgres_password
db_password_server:
file: ./secrets/db_password_server
redis_password:
file: ./secrets/redis_password
crypto_key:
file: ./secrets/crypto_key
token_secret_key:
file: ./secrets/token_secret_key
networks:
app-network:
name: ${APP_NAME:-sebastian}_app-network-test
driver: bridge

345
docker-compose.e2e.yaml Normal file
View File

@ -0,0 +1,345 @@
# Test-specific environment variables
x-test-environment: &test-environment
# ---- APP -------
VERSION: ${VERSION}
APP_NAME: ${APP_NAME:-sebastian}
APP_ENV: test
NODE_ENV: test
HOST: ${HOST}
VERIFY_EMAIL_ENABLED: ${VERIFY_EMAIL_ENABLED:-false}
EDITION: ${EDITION:-community}
# ---- REDIS ----
REDIS_HOST: redis-test
REDIS_PORT: 6379
# ---- DATABASE ----
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: postgres-test
DB_PORT: 5432
DB_NAME: server_test
DB_NAME_HOCUSPOCUS: ${DB_NAME_HOCUSPOCUS:-server_test}
DB_USER_HOCUSPOCUS: ${DB_USER_HOCUSPOCUS:-app_user}
DB_NAME_SERVER: server_test
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
POSTGRES_USER: postgres
# ---- LOGGING ----
LOG_LEVEL: debug
LOG_IS_FORMAT_JSON: false
LOG_IS_FULL_DETAILS: true
LOG_ENABLED_FILE_LOGGING: ""
LOG_DIR_PATH: ""
LOG_ENABLED_EXTERNAL_LOGGING: ""
LOG_EXTERNAL_HTTP_HOST: ""
LOG_EXTERNAL_HTTP_PORT: ""
LOG_EXTERNAL_HTTP_PATH: ""
LOG_EXTERNAL_HTTP_LEVEL: ""
LOG_EXTERNAL_HTTP_TOKEN: ""
# ---- HOCUSPOCUS ----
HOCUSPOCUS_PORT: ""
HOCUSPOCUS_URL: ""
REQUIRE_HOCUSPOCUS: false
# ---- EMAIL ----
EMAIL_ENABLE: true
EMAIL_FROM: test@example.com
EMAIL_HOST: mailhog
EMAIL_PORT: 1025
EMAIL_USERNAME: test@example.com
# ---- CRYPTO ----
CRYPTO_SALT_BYTES: ${SALT_BYTES}
CRYPTO_ITERATION: ${ITERATION}
CRYPTO_KEY_LENGTH: ${KEY_LENGTH}
CRYPTO_ALGORITHM: ${ALGORITHM}
# ---- TOKEN ----
TOKEN_EXPIRES: ${TOKEN_EXPIRES}
# ---- AUTH ----
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SESSION_EXPIRES: ${NEXTAUTH_SESSION_EXPIRES:-86400}
# ---- DEPLOY INFO ----
PROJECT_NAME: ${PROJECT_NAME:-alga-psa-e2e}
EXPOSE_DB_PORT: 5433
EXPOSE_HOCUSPOCUS_PORT: 1234
EXPOSE_REDIS_PORT: 6380
EXPOSE_SERVER_PORT: 3001
# ---- TEST-SPECIFIC ----
WEBHOOK_BASE_URL: http://webhook-mock:8080
# Use same secrets as main compose
secrets:
db_password_server:
file: ./secrets/db_password_server
db_password_hocuspocus:
file: ./secrets/db_password_hocuspocus
postgres_password:
file: ./secrets/postgres_password
redis_password:
file: ./secrets/redis_password
email_password:
file: ./secrets/email_password
crypto_key:
file: ./secrets/crypto_key
token_secret_key:
file: ./secrets/token_secret_key
nextauth_secret:
file: ./secrets/nextauth_secret
google_oauth_client_id:
file: ./secrets/google_oauth_client_id
google_oauth_client_secret:
file: ./secrets/google_oauth_client_secret
alga_auth_key:
file: ./secrets/alga_auth_key
services:
# Test-specific PostgreSQL instance
postgres-test:
image: ankane/pgvector:latest
container_name: ${APP_NAME:-sebastian}_postgres_test
networks:
- app-network
environment:
<<: *test-environment
POSTGRES_DB: server_test
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets:
- postgres_password
ports:
- "5433:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d server_test"]
interval: 10s
timeout: 5s
retries: 5
# Test-specific Redis instance
redis-test:
build:
context: .
dockerfile: redis/Dockerfile
container_name: ${APP_NAME:-sebastian}_redis_test
networks:
- app-network
environment:
<<: *test-environment
secrets:
- redis_password
ports:
- "6380:6379"
healthcheck:
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
interval: 10s
timeout: 3s
retries: 5
# Test-specific pgbouncer
pgbouncer-test:
extends:
file: ./pgbouncer/docker-compose.yaml
service: pgbouncer
container_name: ${APP_NAME:-sebastian}_pgbouncer_test
environment:
<<: *test-environment
DB_HOST: postgres-test
POSTGRES_HOST: postgres-test
PGBOUNCER_HOST: pgbouncer-test
PGBOUNCER_PORT: 6432
secrets:
- postgres_password
- db_password_server
networks:
- app-network
depends_on:
postgres-test:
condition: service_healthy
ports:
- "6433:6432"
# Test setup service
setup-test:
build:
context: .
dockerfile: setup/Dockerfile
args:
SERVER_IMAGE_REPO: ${ALGA_SETUP_IMAGE_REPO:-ghcr.io/nine-minds/alga-psa-ce}
ALGA_IMAGE_TAG: ${ALGA_IMAGE_TAG:-latest}
networks:
- app-network
environment:
<<: *test-environment
DB_HOST: postgres-test
DB_PORT: 5432
NODE_OPTIONS: --experimental-vm-modules
volumes:
- type: bind
source: ./secrets/postgres_password
target: /run/secrets/postgres_password
read_only: true
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
secrets:
- postgres_password
- db_password_server
entrypoint: ["/opt/setup/entrypoint.sh"]
depends_on:
postgres-test:
condition: service_healthy
# Main server for testing
server-test:
extends:
file: ./server/docker-compose.yaml
service: server
container_name: ${APP_NAME:-sebastian}_server_test
networks:
- app-network
environment:
<<: *test-environment
DB_HOST: pgbouncer-test
DB_PORT: 6432
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
entrypoint: ["/bin/sh", "-c", "export DATABASE_URL=postgresql://app_user:$$(cat /run/secrets/db_password_server)@pgbouncer-test:6432/server_test && /app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- db_password_hocuspocus
- redis_password
- email_password
- crypto_key
- token_secret_key
- nextauth_secret
- google_oauth_client_id
- google_oauth_client_secret
- alga_auth_key
depends_on:
postgres-test:
condition: service_healthy
pgbouncer-test:
condition: service_started
redis-test:
condition: service_healthy
setup-test:
condition: service_completed_successfully
ports:
- "3001:3000"
# Workflow Worker Service
workflow-worker:
build:
context: .
dockerfile: services/workflow-worker/Dockerfile
container_name: ${APP_NAME:-sebastian}_workflow_worker_test
environment:
<<: *test-environment
DB_HOST: pgbouncer-test
DB_PORT: 6432
WORKER_COUNT: 1
POLL_INTERVAL_MS: 1000
BATCH_SIZE: 3
MAX_RETRIES: 3
CONCURRENCY_LIMIT: 1
HEALTH_CHECK_INTERVAL_MS: 10000
METRICS_REPORTING_INTERVAL_MS: 30000
# Database pool settings for E2E - increased for workflow processing
DB_POOL_MIN: 2
DB_POOL_MAX: 15
DB_POOL_ACQUIRE_TIMEOUT: 15000
DB_POOL_CREATE_TIMEOUT: 15000
# Enable development mode for live code reloading
DEV_MODE: true
networks:
- app-network
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
# Mount local workflow-worker code for live development
- type: bind
source: ./services/workflow-worker/src
target: /app/services/workflow-worker/src
read_only: false
# Mount shared code for live development
- type: bind
source: ./shared
target: /app/shared-dev
read_only: false
# Mount compiled shared dist for workflow imports
- type: bind
source: ./shared/dist
target: /app/shared/dist
read_only: false
secrets:
- postgres_password
- db_password_server
- redis_password
- crypto_key
- token_secret_key
depends_on:
postgres-test:
condition: service_healthy
redis-test:
condition: service_healthy
pgbouncer-test:
condition: service_started
setup-test:
condition: service_completed_successfully
ports:
- "4001:4000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4000/api/health/worker"]
interval: 30s
timeout: 10s
retries: 3
# MailHog for email testing
mailhog:
image: mailhog/mailhog:latest
container_name: ${APP_NAME:-sebastian}_mailhog_test
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
networks:
- app-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8025"]
interval: 30s
timeout: 10s
retries: 3
# WireMock for webhook mocking
webhook-mock:
image: wiremock/wiremock:latest
container_name: ${APP_NAME:-sebastian}_webhook_mock_test
ports:
- "8080:8080"
volumes:
- ./test-config/wiremock:/home/wiremock
networks:
- app-network
command: ["--global-response-templating", "--verbose"]
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/__admin/health"]
interval: 30s
timeout: 10s
retries: 3
networks:
app-network:
name: ${APP_NAME:-sebastian}_app-network-test
driver: bridge

531
docker-compose.ee.yaml Normal file
View File

@ -0,0 +1,531 @@
version: '3.8'
services:
server:
extends:
file: ./server/docker-compose.yaml
service: server
container_name: ${APP_NAME:-sebastian}_server_ee
build:
context: .
dockerfile: Dockerfile.dev
args:
INCLUDE_EE: "true"
environment:
EDITION: enterprise
DB_NAME: server
PGBOSS_DATABASE: server
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
APP_EDITION: ${APP_EDITION:-enterprise}
NEXT_PUBLIC_EDITION: ${NEXT_PUBLIC_EDITION:-enterprise}
HOST: ${HOST}
VERIFY_EMAIL_ENABLED: ${VERIFY_EMAIL_ENABLED:-false}
REDIS_HOST: ${REDIS_HOST_DOCKER:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST_DOCKER:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
REQUIRE_HOCUSPOCUS: ${REQUIRE_HOCUSPOCUS:-false}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
EMAIL_ENABLE: ${EMAIL_ENABLE:-false}
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_USERNAME: ${EMAIL_USERNAME:-noreply@example.com}
EMAIL_PROVIDER_TYPE: ${EMAIL_PROVIDER_TYPE:-smtp}
RESEND_API_KEY: ${RESEND_API_KEY:-}
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
OPENROUTER_API: ${OPENROUTER_API:-}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_SESSION_EXPIRES: ${NEXTAUTH_SESSION_EXPIRES:-86400}
COLLAB_PERSIST_API_KEY: ${COLLAB_PERSIST_API_KEY:-alga-collab-persist-dev}
TEMPORAL_ADDRESS: ${TEMPORAL_ADDRESS:-temporal-dev:7233}
TEMPORAL_NAMESPACE: ${TEMPORAL_NAMESPACE:-default}
TEMPORAL_JOB_TASK_QUEUE: ${TEMPORAL_JOB_TASK_QUEUE:-alga-jobs}
# Secret provider configuration (EE edition - for local dev, vault is disabled by default)
# For production with vault, set SECRET_READ_CHAIN=env,filesystem,vault in your environment
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
# Workflow configuration
WORKFLOW_DISTRIBUTED_MODE: "true"
WORKFLOW_REDIS_STREAM_PREFIX: "workflow:events:"
WORKFLOW_REDIS_CONSUMER_GROUP: "workflow-workers"
IMAP_WEBHOOK_SECRET: ${IMAP_WEBHOOK_SECRET:-}
# NinjaOne Integration Configuration
# These can be set via environment variables OR via secrets files (mounted at /run/secrets/)
# The secret provider will check filesystem secrets first (if SECRET_READ_CHAIN includes 'filesystem')
NINJAONE_CLIENT_ID: ${NINJAONE_CLIENT_ID:-}
NINJAONE_CLIENT_SECRET: ${NINJAONE_CLIENT_SECRET:-}
NINJAONE_REDIRECT_URI: ${NINJAONE_REDIRECT_URI:-}
# Extension runner (Docker backend) + bundle storage (MinIO dev)
RUNNER_BACKEND: ${RUNNER_BACKEND:-docker}
RUNNER_BASE_URL: ${RUNNER_BASE_URL:-http://host.docker.internal:8085}
RUNNER_DOCKER_HOST: ${RUNNER_DOCKER_HOST:-http://host.docker.internal:8085}
RUNNER_PUBLIC_BASE: ${RUNNER_PUBLIC_BASE:-/runner}
RUNNER_SERVICE_TOKEN: ${RUNNER_SERVICE_TOKEN:-local-runner-key}
RUNNER_STORAGE_API_TOKEN: ${RUNNER_STORAGE_API_TOKEN:-local-runner-key}
RUNNER_CONFIG_API_TOKEN: ${RUNNER_CONFIG_API_TOKEN:-local-runner-key}
STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT:-http://host.docker.internal:9000}
STORAGE_S3_REGION: ${STORAGE_S3_REGION:-us-east-1}
STORAGE_S3_ACCESS_KEY: ${STORAGE_S3_ACCESS_KEY:-minioadmin}
STORAGE_S3_SECRET_KEY: ${STORAGE_S3_SECRET_KEY:-minioadmin}
STORAGE_S3_BUCKET: ${STORAGE_S3_BUCKET:-extensions}
STORAGE_S3_BUNDLE_BUCKET: ${STORAGE_S3_BUNDLE_BUCKET:-extensions}
STORAGE_S3_FORCE_PATH_STYLE: ${STORAGE_S3_FORCE_PATH_STYLE:-true}
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
# Mount ngrok volume to access ngrok URL for OAuth redirects and webhooks
- ngrok-data:/app/ngrok:ro
secrets:
- postgres_password
- db_password_server
- db_password_hocuspocus
- redis_password
- email_password
- crypto_key
- token_secret_key
- nextauth_secret
- google_oauth_client_id
- google_oauth_client_secret
- alga_auth_key
- ninjaone_client_id
- ninjaone_client_secret
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
hocuspocus:
condition: service_started
setup:
condition: service_completed_successfully
setup:
build:
context: .
dockerfile: ee/setup/Dockerfile.dev
container_name: ${APP_NAME:-sebastian}_setup_ee
environment:
EDITION: enterprise
NODE_OPTIONS: --experimental-vm-modules
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
PGBOSS_DATABASE: server
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST_DOCKER:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
EMAIL_ENABLE: ${EMAIL_ENABLE:-false}
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_USERNAME: ${EMAIL_USERNAME:-noreply@example.com}
EMAIL_PROVIDER_TYPE: ${EMAIL_PROVIDER_TYPE:-smtp}
RESEND_API_KEY: ${RESEND_API_KEY:-}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SESSION_EXPIRES: ${NEXTAUTH_SESSION_EXPIRES:-86400}
# Secret provider configuration for setup (EE edition - for local dev, vault is disabled by default)
# For production with vault, set SECRET_READ_CHAIN=env,filesystem,vault in your environment
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
volumes:
- type: bind
source: ./setup/config.ini
target: /opt/setup/config.ini
read_only: true
- type: bind
source: ./ee/setup/entrypoint.sh
target: /opt/setup/ee-entrypoint.sh
read_only: true
- type: bind
source: ./secrets/postgres_password
target: /run/secrets/postgres_password
read_only: true
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
# Mount migrations/seeds from the current worktree so setup stays in sync with branch changes.
# These are merged by /opt/setup/ee-entrypoint.sh into /app/server/migrations and /app/server/seeds.
- type: bind
source: ./server/migrations
target: /app/server/migrations-ce
read_only: true
- type: bind
source: ./ee/server/migrations
target: /app/server/migrations-ee
read_only: true
- type: bind
source: ./server/seeds
target: /app/server/seeds-ce
read_only: true
- type: bind
source: ./ee/server/seeds
target: /app/server/seeds-ee
read_only: true
secrets:
- postgres_password
- db_password_server
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
entrypoint: ["/opt/setup/ee-entrypoint.sh"]
workflow-worker:
build:
context: .
dockerfile: services/workflow-worker/Dockerfile
environment:
EDITION: enterprise
DB_NAME: server
PGBOSS_DATABASE: server
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
REDIS_HOST: ${REDIS_HOST_DOCKER:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST_DOCKER:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
# Secret provider configuration for workflow-worker (EE edition - for local dev, vault is disabled by default)
# For production with vault, set SECRET_READ_CHAIN=env,filesystem,vault in your environment
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
# Workflow-specific configuration
# Run v2 runtime by default (legacy can be re-enabled via WORKFLOW_WORKER_MODE=all|legacy)
WORKFLOW_WORKER_MODE: ${WORKFLOW_WORKER_MODE:-v2}
WORKFLOW_DISTRIBUTED_MODE: "true"
WORKFLOW_REDIS_STREAM_PREFIX: "workflow:events:"
WORKFLOW_REDIS_CONSUMER_GROUP: "workflow-workers"
WORKFLOW_REDIS_BATCH_SIZE: "10"
WORKFLOW_REDIS_IDLE_TIMEOUT_MS: "60000"
TEMPORAL_ADDRESS: ${TEMPORAL_ADDRESS:-temporal-dev:7233}
TEMPORAL_NAMESPACE: ${TEMPORAL_NAMESPACE:-default}
TEMPORAL_JOB_TASK_QUEUE: ${TEMPORAL_JOB_TASK_QUEUE:-alga-jobs}
APPLICATION_URL: ${APPLICATION_URL:-http://localhost:3000}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-local-nextauth-secret}
ALGA_AUTH_KEY: ${ALGA_AUTH_KEY:-local-alga-auth-key}
# WorkerServer listens on PORT (defaults to 4000)
PORT: "4000"
ports:
# Expose a random port for health checks/monitoring
- "4000"
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./services/workflow-worker/entrypoint.sh
target: /app/entrypoint.sh
read_only: true
entrypoint: ["/app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- redis_password
- crypto_key
- token_secret_key
- nextauth_secret
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
server:
condition: service_started
temporal-dev:
condition: service_started
# Enable scaling of worker instances
deploy:
replicas: ${WORKFLOW_WORKER_REPLICAS:-1}
email-service:
build:
context: .
dockerfile: services/email-service/Dockerfile
environment:
EDITION: enterprise
DB_NAME: server
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
IMAP_PROVIDER_REFRESH_MS: ${IMAP_PROVIDER_REFRESH_MS:-60000}
IMAP_POLL_INTERVAL_MS: ${IMAP_POLL_INTERVAL_MS:-30000}
IMAP_LEASE_TTL_MS: ${IMAP_LEASE_TTL_MS:-120000}
IMAP_MAX_CONNECTIONS_PER_TENANT: ${IMAP_MAX_CONNECTIONS_PER_TENANT:-5}
IMAP_MAX_ATTACHMENT_BYTES: ${IMAP_MAX_ATTACHMENT_BYTES:-0}
IMAP_FETCH_DELAY_MS: ${IMAP_FETCH_DELAY_MS:-0}
IMAP_EVENT_CHANNEL_BY_TENANT: ${IMAP_EVENT_CHANNEL_BY_TENANT:-false}
IMAP_OAUTH_AUTH_MECHANISM: ${IMAP_OAUTH_AUTH_MECHANISM:-XOAUTH2}
IMAP_TLS_REJECT_UNAUTHORIZED: ${IMAP_TLS_REJECT_UNAUTHORIZED:-true}
IMAP_WEBHOOK_URL: ${IMAP_WEBHOOK_URL:-http://server:3000/api/email/webhooks/imap}
IMAP_WEBHOOK_TIMEOUT_MS: ${IMAP_WEBHOOK_TIMEOUT_MS:-10000}
IMAP_WEBHOOK_MAX_ATTEMPTS: ${IMAP_WEBHOOK_MAX_ATTEMPTS:-3}
IMAP_WEBHOOK_SECRET: ${IMAP_WEBHOOK_SECRET:-}
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./secrets/tenants
target: /run/secrets/tenants
- type: bind
source: ./services/email-service/entrypoint.sh
target: /app/entrypoint.sh
read_only: true
entrypoint: ["/app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- redis_password
- crypto_key
- token_secret_key
- nextauth_secret
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
server:
condition: service_started
hocuspocus:
extends:
file: ./hocuspocus/docker-compose.yaml
service: hocuspocus
container_name: ${APP_NAME:-sebastian}_hocuspocus_ee
build:
context: .
dockerfile: hocuspocus/Dockerfile
environment:
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
REDIS_HOST: ${REDIS_HOST_DOCKER:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${DB_HOST_DOCKER:-postgres}
DB_PORT: ${DB_PORT:-5432}
AI_DOCUMENT_API_URL: ${AI_DOCUMENT_API_URL:-http://server:3000/api/v1/ai/document-assist}
AI_DOCUMENT_API_KEY: ${AI_DOCUMENT_API_KEY:-}
COLLAB_PERSIST_API_URL: ${COLLAB_PERSIST_API_URL:-http://server:3000/api/internal/collab/persist}
COLLAB_PERSIST_API_KEY: ${COLLAB_PERSIST_API_KEY:-alga-collab-persist-dev}
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_hocuspocus
read_only: true
secrets:
- postgres_password
- db_password_server
- redis_password
networks:
- app-network
depends_on:
redis:
condition: service_started
postgres:
extends:
file: docker-compose.base.yaml
service: postgres
environment:
POSTGRES_DB: server
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: postgres
DB_PORT: ${DB_PORT:-5432}
secrets:
- postgres_password
redis:
extends:
file: docker-compose.base.yaml
service: redis
pgbouncer:
extends:
file: docker-compose.base.yaml
service: pgbouncer
temporal-dev:
image: temporalio/auto-setup:1.24.2
ports:
- "${EXPOSE_TEMPORAL_PORT:-7233}:7233"
environment:
- DB=postgres12
- DB_PORT=5432
- POSTGRES_SEEDS=postgres
- POSTGRES_USER=postgres
entrypoint: ["/bin/sh"]
command:
- -c
- |
export POSTGRES_PWD=$(cat /run/secrets/postgres_password)
exec /etc/temporal/entrypoint.sh autosetup
secrets:
- postgres_password
depends_on:
postgres:
condition: service_started
networks:
- app-network
temporal-worker:
image: temporal-worker-dev
build:
context: .
dockerfile: ee/temporal-workflows/Dockerfile
target: production
working_dir: /app/ee/temporal-workflows
entrypoint: ["docker-entrypoint.sh"]
command: ["node", "dist/ee/temporal-workflows/src/worker.js"]
environment:
- LOG_LEVEL=INFO
- SECRET_READ_CHAIN=filesystem,env
- APPLICATION_URL=${APPLICATION_URL:-http://localhost:3000}
- NEXTAUTH_URL=${NEXTAUTH_URL:-http://localhost:3000}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-local-nextauth-secret}
- ALGA_AUTH_KEY=${ALGA_AUTH_KEY:-local-alga-auth-key}
- DB_HOST=${DB_HOST:-pgbouncer}
- DB_PORT=${DB_PORT:-6432}
- DB_NAME_SERVER=${DB_NAME_SERVER:-server}
- DB_USER_SERVER=${DB_USER_SERVER:-app_user}
- DB_USER_ADMIN=${DB_USER_ADMIN:-postgres}
- TEMPORAL_ADDRESS=${TEMPORAL_ADDRESS:-temporal-dev:7233}
- TEMPORAL_NAMESPACE=${TEMPORAL_NAMESPACE:-default}
- TEMPORAL_TASK_QUEUE=${TEMPORAL_JOB_TASK_QUEUE:-alga-jobs}
# Authored runtime queue ownership belongs to workflow-worker.
- TEMPORAL_TASK_QUEUES=tenant-workflows,portal-domain-workflows,email-domain-workflows,alga-jobs
- PORTAL_DOMAIN_BASE_VIRTUAL_SERVICE=${PORTAL_DOMAIN_BASE_VIRTUAL_SERVICE:-msp/alga-psa-vs}
- REDIS_HOST=${REDIS_HOST:-redis}
- REDIS_PORT=${REDIS_PORT:-6379}
# Smoke-only: defaults to unset so the worker uses the real GDAP endpoints. Override in
# the compose invocation or shell env to test self-tenant mode without CSP/GDAP.
- ENTRA_DIRECT_SMOKE_SELF_TENANT_MODE=${ENTRA_DIRECT_SMOKE_SELF_TENANT_MODE:-}
# Smoke-only: partitions the self-tenant /users response into N synthetic managed
# tenants so cross-client bleed (Flow 2) can be exercised without CSP/GDAP. Format:
# comma-separated `id|primary_domain|display_name` entries.
- ENTRA_DIRECT_SMOKE_SYNTHETIC_TENANTS=${ENTRA_DIRECT_SMOKE_SYNTHETIC_TENANTS:-}
# Smoke-only: forces accountEnabled=false for listed emails/UPNs so the
# offboard → deactivate path (Flow 5) can be exercised without disabling
# real users in Entra. Format: comma-separated email/UPN list.
- ENTRA_DIRECT_SMOKE_DISABLED_USER_EMAILS=${ENTRA_DIRECT_SMOKE_DISABLED_USER_EMAILS:-}
# Smoke-only: injects fake users into the /users response pinned to a
# specific synthetic tenant bucket so Flow 7 (ambiguous match) can be
# exercised without adding real users to Entra. Format: comma-separated
# `objectId|upn|displayName|bucketIndex` entries.
- ENTRA_DIRECT_SMOKE_EXTRA_USERS=${ENTRA_DIRECT_SMOKE_EXTRA_USERS:-}
volumes:
- type: bind
source: ./secrets/tenants
target: /run/secrets/tenants
read_only: true
- type: bind
source: ./secrets/postgres_password
target: /run/secrets/DB_PASSWORD_ADMIN
read_only: true
secrets:
- db_password_server
- postgres_password
- alga_auth_key
- nextauth_secret
- redis_password
- ninjaone_client_id
- ninjaone_client_secret
depends_on:
temporal-dev:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
networks:
- app-network
temporal-ui:
image: temporalio/ui:latest
environment:
- TEMPORAL_ADDRESS=temporal-dev:7233
ports:
- "${EXPOSE_TEMPORAL_UI_PORT:-8088}:8080"
depends_on:
temporal-dev:
condition: service_started
networks:
- app-network
volumes:
ngrok-data:
external: true
name: ${COMPOSE_PROJECT_NAME:-alga}_ngrok_data
networks:
app-network:
driver: bridge

View File

@ -0,0 +1,58 @@
services:
minio:
image: minio/minio:latest
profiles:
- test
environment:
MINIO_ROOT_USER: ${IMAP_STORAGE_S3_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${IMAP_STORAGE_S3_SECRET_KEY:-minioadmin}
command: server /data --console-address ":9001"
ports:
- "${EXPOSE_IMAP_MINIO_API_PORT:-3900}:9000"
- "${EXPOSE_IMAP_MINIO_CONSOLE_PORT:-3901}:9001"
volumes:
- minio_imap_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
minio-init:
image: minio/mc:latest
profiles:
- test
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set minio http://minio:9000 ${IMAP_STORAGE_S3_ACCESS_KEY:-minioadmin} ${IMAP_STORAGE_S3_SECRET_KEY:-minioadmin};
mc mb minio/${IMAP_STORAGE_S3_BUCKET:-inbound-email-artifacts} --ignore-existing;
mc anonymous set private minio/${IMAP_STORAGE_S3_BUCKET:-inbound-email-artifacts};
"
restart: "no"
networks:
- app-network
imap-test-server:
image: greenmail/standalone:latest
profiles:
- test
environment:
GREENMAIL_OPTS: >-
-Dgreenmail.setup.test.all
-Dgreenmail.hostname=0.0.0.0
-Dgreenmail.users=imap_user:imap_pass@localhost
ports:
- "${EXPOSE_IMAP_TEST_SMTP_PORT:-3025}:3025"
- "${EXPOSE_IMAP_TEST_IMAP_PORT:-3143}:3143"
- "${EXPOSE_IMAP_TEST_IMAPS_PORT:-3993}:3993"
- "${EXPOSE_IMAP_TEST_HTTP_PORT:-8080}:8080"
networks:
- app-network
volumes:
minio_imap_data:

158
docker-compose.imap.ce.yaml Normal file
View File

@ -0,0 +1,158 @@
services:
# Keep this override aligned with hocuspocus/docker-compose.yaml: the Dockerfile
# expects repository-root context so it can `COPY hocuspocus/...`.
hocuspocus:
build:
context: .
dockerfile: hocuspocus/Dockerfile
workflow-worker:
build:
context: .
dockerfile: services/workflow-worker/Dockerfile
environment:
EDITION: ${APP_EDITION:-community}
DB_NAME: server
PGBOSS_DATABASE: server
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
# Secret provider configuration for workflow-worker (CE edition)
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
# Workflow-specific configuration
# Run v2 runtime by default (legacy can be re-enabled via WORKFLOW_WORKER_MODE=all|legacy)
WORKFLOW_WORKER_MODE: ${WORKFLOW_WORKER_MODE:-v2}
WORKFLOW_DISTRIBUTED_MODE: "true"
WORKFLOW_REDIS_STREAM_PREFIX: "workflow:events:"
WORKFLOW_REDIS_CONSUMER_GROUP: "workflow-workers"
WORKFLOW_REDIS_BATCH_SIZE: "10"
WORKFLOW_REDIS_IDLE_TIMEOUT_MS: "60000"
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./services/workflow-worker/entrypoint.sh
target: /app/entrypoint.sh
read_only: true
entrypoint: ["/app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- redis_password
- crypto_key
- token_secret_key
- nextauth_secret
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
server:
condition: service_started
deploy:
replicas: ${WORKFLOW_WORKER_REPLICAS:-1}
email-service:
build:
context: .
dockerfile: services/email-service/Dockerfile
environment:
EDITION: ${APP_EDITION:-community}
DB_NAME: server
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
IMAP_PROVIDER_REFRESH_MS: ${IMAP_PROVIDER_REFRESH_MS:-60000}
IMAP_POLL_INTERVAL_MS: ${IMAP_POLL_INTERVAL_MS:-30000}
IMAP_LEASE_TTL_MS: ${IMAP_LEASE_TTL_MS:-120000}
IMAP_MAX_CONNECTIONS_PER_TENANT: ${IMAP_MAX_CONNECTIONS_PER_TENANT:-5}
IMAP_MAX_ATTACHMENT_BYTES: ${IMAP_MAX_ATTACHMENT_BYTES:-0}
IMAP_FETCH_DELAY_MS: ${IMAP_FETCH_DELAY_MS:-0}
IMAP_EVENT_CHANNEL_BY_TENANT: ${IMAP_EVENT_CHANNEL_BY_TENANT:-false}
IMAP_OAUTH_AUTH_MECHANISM: ${IMAP_OAUTH_AUTH_MECHANISM:-XOAUTH2}
IMAP_TLS_REJECT_UNAUTHORIZED: ${IMAP_TLS_REJECT_UNAUTHORIZED:-true}
IMAP_WEBHOOK_URL: ${IMAP_WEBHOOK_URL:-http://server:3000/api/email/webhooks/imap}
IMAP_WEBHOOK_TIMEOUT_MS: ${IMAP_WEBHOOK_TIMEOUT_MS:-10000}
IMAP_WEBHOOK_MAX_ATTEMPTS: ${IMAP_WEBHOOK_MAX_ATTEMPTS:-3}
IMAP_WEBHOOK_SECRET: ${IMAP_WEBHOOK_SECRET:-}
STORAGE_DEFAULT_PROVIDER: ${IMAP_STORAGE_DEFAULT_PROVIDER:-s3}
STORAGE_S3_ENDPOINT: ${IMAP_STORAGE_S3_ENDPOINT:-http://minio:9000}
STORAGE_S3_REGION: ${IMAP_STORAGE_S3_REGION:-us-east-1}
STORAGE_S3_BUCKET: ${IMAP_STORAGE_S3_BUCKET:-inbound-email-artifacts}
STORAGE_S3_ACCESS_KEY: ${IMAP_STORAGE_S3_ACCESS_KEY:-minioadmin}
STORAGE_S3_SECRET_KEY: ${IMAP_STORAGE_S3_SECRET_KEY:-minioadmin}
STORAGE_S3_FORCE_PATH_STYLE: ${IMAP_STORAGE_S3_FORCE_PATH_STYLE:-true}
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./secrets/tenants
target: /run/secrets/tenants
- type: bind
source: ./services/email-service/entrypoint.sh
target: /app/entrypoint.sh
read_only: true
entrypoint: ["/app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- redis_password
- crypto_key
- token_secret_key
- nextauth_secret
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
server:
condition: service_started
minio:
condition: service_healthy
minio-init:
condition: service_completed_successfully
networks:
app-network:
driver: bridge

View File

@ -0,0 +1,49 @@
# Docker Compose configuration for shared MinIO instance for local development
# This provides S3-compatible storage for extension bundles across multiple Alga environments
#
# Usage: docker compose -f docker-compose.minio-dev.yaml up -d
# Console: http://localhost:9001 (minioadmin / minioadmin)
# API: http://localhost:9000
services:
# MinIO - S3-compatible object storage for development
minio:
image: minio/minio:latest
container_name: alga_minio_dev
ports:
- "9000:9000" # MinIO API
- "9001:9001" # MinIO Console UI
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
MINIO_DOMAIN: localhost
command: server /data --console-address ":9001"
volumes:
- minio_dev_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# MinIO Client - for bucket initialization
minio-init:
image: minio/mc:latest
container_name: alga_minio_init
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set minio http://minio:9000 minioadmin minioadmin;
mc mb minio/extensions --ignore-existing;
echo 'Extension bundles bucket created';
mc anonymous set download minio/extensions;
echo 'MinIO initialized successfully for local development';
exit 0;
"
volumes:
minio_dev_data:
driver: local

112
docker-compose.ngrok.yaml Normal file
View File

@ -0,0 +1,112 @@
# Modular ngrok tunnel for webhook testing
# Works with any Alga PSA worktree without modification
#
# Usage: Add this file to your compose chain:
# docker compose -f docker-compose.yaml -f docker-compose.base.yaml -f docker-compose.ee.yaml -f docker-compose.ngrok.yaml up -d
#
# The ngrok dashboard is available at http://localhost:${NGROK_DASHBOARD_PORT:-4040}
# The ngrok URL is written to /app/ngrok/url inside the server container
#
# To check the current ngrok URL:
# docker compose logs ngrok-sync | grep "Public URL"
# - OR -
# curl -s http://localhost:${NGROK_DASHBOARD_PORT:-4040}/api/tunnels | jq -r '.tunnels[0].public_url'
#
# Prerequisites:
# 1. Create ./secrets/ngrok_authtoken with your ngrok auth token
# Get one at: https://dashboard.ngrok.com/get-started/your-authtoken
volumes:
ngrok-data:
name: ${COMPOSE_PROJECT_NAME:-alga}_ngrok_data
services:
# Init container to fix volume permissions (runs as root, exits immediately)
ngrok-init:
image: alpine:latest
volumes:
- ngrok-data:/ngrok:rw
command: sh -c "rm -f /ngrok/url /ngrok/updated_at 2>/dev/null; chmod 777 /ngrok && echo '[ngrok-init] Volume ready'"
restart: "no"
ngrok:
image: ngrok/ngrok:latest
restart: unless-stopped
networks:
- app-network
ports:
- "${NGROK_DASHBOARD_PORT:-4040}:4040"
volumes:
- ./secrets/ngrok_authtoken:/run/secrets/ngrok_authtoken:ro
- ngrok-data:/ngrok:rw
entrypoint: /bin/sh
command:
- -c
- |
export NGROK_AUTHTOKEN=$$(cat /run/secrets/ngrok_authtoken)
while true; do
echo "[ngrok] Starting tunnel to server:3000..."
# Start ngrok and capture public URL
ngrok http http://server:3000 --log=stdout 2>&1 | while read line; do
echo "$$line"
# Extract and save public URL when it appears in logs
if echo "$$line" | grep -q "started tunnel"; then
PUBLIC_URL=$$(echo "$$line" | grep -oP 'url=\K[^ ]+')
if [ -n "$$PUBLIC_URL" ]; then
echo "$$PUBLIC_URL" > /ngrok/url
echo "$$(date -Iseconds)" > /ngrok/updated_at
echo "[ngrok] Saved URL: $$PUBLIC_URL"
fi
fi
done && break
echo "[ngrok] Exited, retrying in 10 seconds..."
sleep 10
done
depends_on:
ngrok-init:
condition: service_completed_successfully
server:
condition: service_started
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:4040/api/tunnels"]
interval: 5s
timeout: 3s
retries: 3
start_period: 10s
ngrok-sync:
image: alpine:latest
restart: unless-stopped
networks:
- app-network
volumes:
- ngrok-data:/ngrok:ro
depends_on:
ngrok:
condition: service_healthy
entrypoint: /bin/sh
command:
- -c
- |
echo "[ngrok-sync] Monitoring ngrok URL file..."
LAST_URL=""
while true; do
if [ -f /ngrok/url ]; then
PUBLIC_URL=$$(cat /ngrok/url 2>/dev/null || echo "")
if [ -n "$$PUBLIC_URL" ] && [ "$$PUBLIC_URL" != "$$LAST_URL" ]; then
echo "[ngrok-sync] ✓ Public URL: $$PUBLIC_URL"
LAST_URL="$$PUBLIC_URL"
fi
else
echo "[ngrok-sync] Waiting for ngrok to create /ngrok/url..."
fi
sleep 5
done
# Note: The server service should mount ngrok-data:/app/ngrok:ro
# This can be added to your docker-compose.yaml server service definition as:
# volumes:
# - ngrok-data:/app/ngrok:ro

View File

@ -0,0 +1,30 @@
services:
server:
environment:
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
# Ensure file changes on macOS/Windows propagate reliably for Next.js dev/HMR
WATCHPACK_POLLING: ${WATCHPACK_POLLING:-true}
CHOKIDAR_USEPOLLING: ${CHOKIDAR_USEPOLLING:-true}
volumes:
# Bind-mount the worktree so Next.js dev server sees edits immediately (HMR)
- type: bind
source: .
target: /app
consistency: delegated
# Keep container-installed node_modules (avoid shadowing by the bind mount)
- /app/node_modules
- /app/server/node_modules
- /app/shared/node_modules
- /app/packages/node_modules
- /app/services/workflow-worker/node_modules
- /app/ee/server/node_modules
workflow-worker:
environment:
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
# Run the v2 runtime by default in this worktree.
WORKFLOW_WORKER_MODE: ${WORKFLOW_WORKER_MODE:-v2}
WORKFLOW_RUNTIME_V2_EVENT_CONSUMER_GROUP: ${WORKFLOW_RUNTIME_V2_EVENT_CONSUMER_GROUP:-workflow-runtime-v2}
# Visibility for local debugging (safe logs: IDs/counts, not full payloads)
LOG_LEVEL: ${WORKFLOW_WORKER_LOG_LEVEL:-debug}
WORKFLOW_WORKER_VERBOSE: ${WORKFLOW_WORKER_VERBOSE:-true}

View File

@ -0,0 +1,30 @@
# Local deps for Playwright runs (Postgres + Redis) with fixed ports.
# This avoids relying on image-specific *_FILE env support.
services:
postgres-playwright:
image: ankane/pgvector:latest
container_name: extsched_playwright_postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postpass123
POSTGRES_DB: postgres
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s
timeout: 5s
retries: 10
redis-playwright:
image: redis:7-alpine
container_name: extsched_playwright_redis
command: ["redis-server", "--appendonly", "no"]
ports:
- "6380:6379"
healthcheck:
test: ["CMD", "redis-cli", "--raw", "PING"]
interval: 5s
timeout: 3s
retries: 10

View File

@ -0,0 +1,130 @@
services:
postgres-playwright:
image: ankane/pgvector:latest
environment:
POSTGRES_USER: ${PLAYWRIGHT_DB_ADMIN_USER:-postgres}
POSTGRES_PASSWORD: ${PLAYWRIGHT_DB_ADMIN_PASSWORD:-postpass123}
POSTGRES_DB: postgres
ports:
- "${PLAYWRIGHT_DB_PORT:-5439}:5432"
volumes:
- playwright-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PLAYWRIGHT_DB_ADMIN_USER:-postgres} -d postgres"]
interval: 5s
timeout: 3s
retries: 20
redis-playwright:
image: redis:7-alpine
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:-sebastian123}"]
ports:
- "${REDIS_PORT:-16379}:6379"
volumes:
- playwright-redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-sebastian123}", "ping"]
interval: 5s
timeout: 3s
retries: 20
temporal-playwright:
image: temporalio/auto-setup:1.24.2
environment:
DB: postgres12
DB_PORT: "5432"
POSTGRES_SEEDS: postgres-playwright
POSTGRES_USER: ${PLAYWRIGHT_DB_ADMIN_USER:-postgres}
POSTGRES_PWD: ${PLAYWRIGHT_DB_ADMIN_PASSWORD:-postpass123}
ports:
- "${PLAYWRIGHT_TEMPORAL_PORT:-17233}:7233"
depends_on:
postgres-playwright:
condition: service_healthy
workflow-worker-playwright:
build:
context: .
dockerfile: services/workflow-worker/Dockerfile
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
EDITION: enterprise
APP_ENV: test
NODE_ENV: production
LOG_LEVEL: debug
WORKFLOW_WORKER_VERBOSE: "true"
WORKFLOW_WORKER_MODE: v2
WORKFLOW_RUNTIME_V2_EVENT_CONSUMER_GROUP: workflow-runtime-v2
TEMPORAL_ADDRESS: temporal-playwright:7233
TEMPORAL_NAMESPACE: default
# DB (internal docker network)
DB_TYPE: postgres
DB_HOST: postgres-playwright
DB_PORT: 5432
DB_NAME_SERVER: ${PLAYWRIGHT_DB_NAME:-alga_contract_wizard_test}
DB_USER_ADMIN: ${PLAYWRIGHT_DB_ADMIN_USER:-postgres}
DB_PASSWORD_ADMIN: ${PLAYWRIGHT_DB_ADMIN_PASSWORD:-postpass123}
DB_USER_SERVER: ${PLAYWRIGHT_DB_APP_USER:-app_user}
DB_PASSWORD_SERVER: ${PLAYWRIGHT_DB_APP_PASSWORD:-postpass123}
# Redis (internal docker network)
REDIS_HOST: redis-playwright
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:-sebastian123}
# App/runtime
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:-test-nextauth-secret}
SECRET_READ_CHAIN: env
SECRET_WRITE_PROVIDER: filesystem
# Workflow runtime v2 streams
WORKFLOW_DISTRIBUTED_MODE: "true"
WORKFLOW_REDIS_STREAM_PREFIX: "workflow:events:"
WORKFLOW_REDIS_CONSUMER_GROUP: "workflow-workers"
WORKFLOW_REDIS_BATCH_SIZE: "10"
WORKFLOW_REDIS_IDLE_TIMEOUT_MS: "60000"
depends_on:
postgres-playwright:
condition: service_healthy
redis-playwright:
condition: service_healthy
temporal-playwright:
condition: service_started
ports:
- "4000"
hocuspocus-playwright:
build:
context: .
dockerfile: hocuspocus/Dockerfile
environment:
NODE_ENV: development
REDIS_HOST: redis-playwright
REDIS_PORT: 6379
REDIS_PASSWORD: ${REDIS_PASSWORD:-sebastian123}
DB_HOST: postgres-playwright
DB_PORT: 5432
DB_NAME_HOCUSPOCUS: ${PLAYWRIGHT_DB_NAME:-alga_contract_wizard_test}
DB_USER_HOCUSPOCUS: ${PLAYWRIGHT_DB_ADMIN_USER:-postgres}
DB_PASSWORD_HOCUSPOCUS: ${PLAYWRIGHT_DB_ADMIN_PASSWORD:-postpass123}
HOCUSPOCUS_JWT_SECRET: ${HOCUSPOCUS_JWT_SECRET:-dev-hocuspocus-jwt-secret}
EXPOSE_HOCUSPOCUS_PORT: ${PLAYWRIGHT_HOCUSPOCUS_PORT:-1234}
PORT: 1234
depends_on:
postgres-playwright:
condition: service_healthy
redis-playwright:
condition: service_healthy
ports:
- "${PLAYWRIGHT_HOCUSPOCUS_PORT:-1234}:1234"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:1234/health"]
interval: 5s
timeout: 5s
retries: 20
volumes:
playwright-postgres-data:
playwright-redis-data:

View File

@ -0,0 +1,24 @@
services:
minio-test:
image: minio/minio:latest
container_name: alga-psa-minio-test
command: server /data --console-address ":9003"
ports:
- "9002:9000" # API port (different from your Payload MinIO on 9000)
- "9003:9003" # Console port
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
volumes:
- minio-test-data:/data
networks:
- playwright-test
networks:
playwright-test:
name: alga-psa-playwright-test
driver: bridge
volumes:
minio-test-data:
name: alga-psa-minio-test-data

View File

@ -0,0 +1,136 @@
version: '3.8'
x-environment: &shared-environment
# ---- APP -------
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-production}
NODE_ENV: ${NODE_ENV:-${APP_ENV:-production}}
HOST: ${HOST}
VERIFY_EMAIL_ENABLED: ${VERIFY_EMAIL_ENABLED:-false}
# ---- REDIS ----
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
# ---- DATABASE ----
DB_TYPE: ${DB_TYPE}
DB_HOST: ${DB_HOST:-postgres}
DB_PORT: ${DB_PORT:-5432}
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
POSTGRES_USER: postgres
# ---- LOGGING ----
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
LOG_ENABLED_FILE_LOGGING: ${LOG_ENABLED_FILE_LOGGING}
LOG_DIR_PATH: ${LOG_DIR_PATH}
LOG_ENABLED_EXTERNAL_LOGGING: ${LOG_ENABLED_EXTERNAL_LOGGING}
LOG_EXTERNAL_HTTP_HOST: ${LOG_EXTERNAL_HTTP_HOST}
LOG_EXTERNAL_HTTP_PORT: ${LOG_EXTERNAL_HTTP_PORT}
LOG_EXTERNAL_HTTP_PATH: ${LOG_EXTERNAL_HTTP_PATH}
LOG_EXTERNAL_HTTP_LEVEL: ${LOG_EXTERNAL_HTTP_LEVEL}
LOG_EXTERNAL_HTTP_TOKEN: ${LOG_EXTERNAL_HTTP_TOKEN}
# ---- HOCUSPOCUS ----
HOCUSPOCUS_PORT: ${HOCUSPOCUS_PORT}
HOCUSPOCUS_URL: ${HOCUSPOCUS_URL}
# ---- EMAIL ----
EMAIL_ENABLE: ${EMAIL_ENABLE:-false}
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_USERNAME: ${EMAIL_USERNAME:-noreply@example.com}
# ---- CRYPTO ----
CRYPTO_SALT_BYTES: ${SALT_BYTES}
CRYPTO_ITERATION: ${ITERATION}
CRYPTO_KEY_LENGTH: ${KEY_LENGTH}
CRYPTO_ALGORITHM: ${ALGORITHM}
# ---- TOKEN ----
TOKEN_EXPIRES: ${TOKEN_EXPIRES}
# ---- AUTH ----
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SESSION_EXPIRES: ${NEXTAUTH_SESSION_EXPIRES:-86400}
# ---- DEPLOY INFO ----
PROJECT_NAME: ${PROJECT_NAME}
EXPOSE_DB_PORT: ${EXPOSE_DB_PORT:-5432}
EXPOSE_HOCUSPOCUS_PORT: ${EXPOSE_HOCUSPOCUS_PORT:-1234}
EXPOSE_REDIS_PORT: ${EXPOSE_REDIS_PORT:-6379}
EXPOSE_SERVER_PORT: ${EXPOSE_SERVER_PORT:-3000}
secrets:
db_password_server:
file: ./secrets/db_password_server
db_password_hocuspocus:
file: ./secrets/db_password_hocuspocus
postgres_password:
file: ./secrets/postgres_password
redis_password:
file: ./secrets/redis_password
email_password:
file: ./secrets/email_password
crypto_key:
file: ./secrets/crypto_key
token_secret_key:
file: ./secrets/token_secret_key
nextauth_secret:
file: ./secrets/nextauth_secret
google_oauth_client_id:
file: ./secrets/google_oauth_client_id
google_oauth_client_secret:
file: ./secrets/google_oauth_client_secret
alga_auth_key:
file: ./secrets/alga_auth_key
services:
postgres:
image: ankane/pgvector:latest
environment:
<<: *shared-environment
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
POSTGRES_HOST_AUTH_METHOD: md5
secrets:
- postgres_password
ports:
- "${EXPOSE_DB_PORT:-5432}:5432"
networks:
- app-network
redis:
build:
context: .
dockerfile: redis/Dockerfile
environment:
<<: *shared-environment
ports:
- '${EXPOSE_REDIS_PORT:-6379}:6379'
secrets:
- redis_password
networks:
- app-network
pgbouncer:
extends:
file: ./pgbouncer/docker-compose.yaml
service: pgbouncer
environment:
<<: *shared-environment
secrets:
- postgres_password
- db_password_server
networks:
- app-network
depends_on:
postgres:
condition: service_started
networks:
app-network:
driver: bridge

View File

@ -0,0 +1,378 @@
version: '3.8'
services:
server:
extends:
file: ./server/docker-compose.prebuilt.yaml
service: server
image: "ghcr.io/nine-minds/alga-psa-ce:${ALGA_IMAGE_TAG:-latest}"
platform: linux/amd64
environment:
EDITION: community
DB_NAME: server
PGBOSS_DATABASE: server
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
# Prebuilt stacks should run the server in production mode (use the baked `.next` output).
APP_ENV: production
NODE_ENV: production
# The prebuilt server image listens on port 3000 inside the container.
APP_PORT: "3000"
PORT: "3000"
HOST: ${HOST}
VERIFY_EMAIL_ENABLED: ${VERIFY_EMAIL_ENABLED:-false}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
DB_NAME_HOCUSPOCUS: ${DB_NAME_HOCUSPOCUS:-hocuspocus}
DB_USER_HOCUSPOCUS: ${DB_USER_HOCUSPOCUS:-hocuspocus_user}
HOCUSPOCUS_JWT_SECRET: ${HOCUSPOCUS_JWT_SECRET}
DB_PASSWORD_HOCUSPOCUS: ${DB_PASSWORD_HOCUSPOCUS}
REQUIRE_HOCUSPOCUS: ${REQUIRE_HOCUSPOCUS:-false}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
EMAIL_ENABLE: ${EMAIL_ENABLE:-false}
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_USERNAME: ${EMAIL_USERNAME:-noreply@example.com}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SESSION_EXPIRES: ${NEXTAUTH_SESSION_EXPIRES:-86400}
IMAP_WEBHOOK_SECRET: ${IMAP_WEBHOOK_SECRET:-}
volumes:
# Persist user-uploaded documents and generated files
- files_data:/data/files
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./secrets/tenants
target: /run/secrets/tenants
- type: bind
source: ./server/migrations
target: /app/server/migrations
read_only: true
- type: bind
source: ./server/seeds
target: /app/server/seeds
read_only: true
- type: bind
source: ./scripts
target: /app/scripts
read_only: true
entrypoint: ["/bin/sh", "-c", "export DATABASE_URL=postgresql://app_user:$$(cat /run/secrets/db_password_server)@${PGBOUNCER_HOST:-pgbouncer}:${PGBOUNCER_PORT:-6432}/server && /app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- db_password_hocuspocus
- redis_password
- email_password
- crypto_key
- token_secret_key
- nextauth_secret
- google_oauth_client_id
- google_oauth_client_secret
- alga_auth_key
networks:
- app-network
depends_on:
setup:
condition: service_completed_successfully
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
hocuspocus:
condition: service_started
required: false
setup:
image: "ghcr.io/nine-minds/alga-psa-ce:${ALGA_IMAGE_TAG:-latest}"
platform: linux/amd64
restart: "no"
environment:
EDITION: community
NODE_OPTIONS: --experimental-vm-modules
DB_NAME_SERVER: server
DB_NAME_HOCUSPOCUS: ${DB_NAME_HOCUSPOCUS:-hocuspocus}
DB_USER_SERVER: app_user
DB_USER_HOCUSPOCUS: ${DB_USER_HOCUSPOCUS:-hocuspocus_user}
HOCUSPOCUS_JWT_SECRET: ${HOCUSPOCUS_JWT_SECRET}
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
PGBOSS_DATABASE: server
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: production
NODE_ENV: production
HOST: ${HOST}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${DB_HOST:-postgres}
DB_PORT: ${DB_PORT:-5432}
# Prefer direct Postgres for admin operations even if DB_HOST points to PgBouncer
DB_HOST_ADMIN: ${DB_HOST_ADMIN:-postgres}
DB_PORT_ADMIN: ${DB_PORT_ADMIN:-5432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
EMAIL_ENABLE: ${EMAIL_ENABLE:-false}
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_USERNAME: ${EMAIL_USERNAME:-noreply@example.com}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SESSION_EXPIRES: ${NEXTAUTH_SESSION_EXPIRES:-86400}
volumes:
- type: bind
source: ./setup/config.ini
target: /app/setup/config.ini
read_only: true
- type: bind
source: ./setup/entrypoint.sh
target: /app/setup/entrypoint.sh
read_only: true
- type: bind
source: ./secrets/postgres_password
target: /run/secrets/postgres_password
read_only: true
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./server/migrations
target: /app/server/migrations
read_only: true
- type: bind
source: ./server/seeds
target: /app/server/seeds
read_only: true
- type: bind
source: ./scripts
target: /app/scripts
read_only: true
secrets:
- postgres_password
- db_password_server
- db_password_hocuspocus
networks:
- app-network
depends_on:
postgres:
condition: service_started
entrypoint: ["/app/setup/entrypoint.sh"]
workflow-worker:
# NOTE: The published CE server image does not currently ship the workflow-worker runtime bundle.
# For local prebuilt-stack testing, build the worker image from source.
build:
context: .
dockerfile: services/workflow-worker/Dockerfile
platform: linux/amd64
environment:
EDITION: community
DB_NAME: server
PGBOSS_DATABASE: server
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: production
NODE_ENV: production
HOST: ${HOST}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
# Secret provider configuration for workflow-worker (CE edition)
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
# Workflow-specific configuration
# Run v2 runtime by default (legacy can be re-enabled via WORKFLOW_WORKER_MODE=all|legacy)
WORKFLOW_WORKER_MODE: ${WORKFLOW_WORKER_MODE:-v2}
WORKFLOW_DISTRIBUTED_MODE: "true"
WORKFLOW_REDIS_STREAM_PREFIX: "workflow:events:"
WORKFLOW_REDIS_CONSUMER_GROUP: "workflow-workers"
WORKFLOW_REDIS_BATCH_SIZE: "10"
WORKFLOW_REDIS_IDLE_TIMEOUT_MS: "60000"
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./services/workflow-worker/entrypoint.sh
target: /app/entrypoint.sh
read_only: true
entrypoint: ["/app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- redis_password
- crypto_key
- token_secret_key
- nextauth_secret
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
server:
condition: service_started
deploy:
replicas: ${WORKFLOW_WORKER_REPLICAS:-1}
email-service:
# NOTE: The published CE server image does not currently ship the email-service runtime bundle.
# For local prebuilt-stack testing, build the IMAP service image from source.
build:
context: .
dockerfile: services/email-service/Dockerfile
platform: linux/amd64
environment:
EDITION: community
DB_NAME: server
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: production
NODE_ENV: production
HOST: ${HOST}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
# Secret provider configuration for email-service (CE edition)
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
# IMAP service configuration
IMAP_PROVIDER_REFRESH_MS: ${IMAP_PROVIDER_REFRESH_MS:-60000}
IMAP_POLL_INTERVAL_MS: ${IMAP_POLL_INTERVAL_MS:-30000}
IMAP_LEASE_TTL_MS: ${IMAP_LEASE_TTL_MS:-120000}
IMAP_MAX_CONNECTIONS_PER_TENANT: ${IMAP_MAX_CONNECTIONS_PER_TENANT:-5}
IMAP_MAX_ATTACHMENT_BYTES: ${IMAP_MAX_ATTACHMENT_BYTES:-0}
IMAP_FETCH_DELAY_MS: ${IMAP_FETCH_DELAY_MS:-0}
IMAP_EVENT_CHANNEL_BY_TENANT: ${IMAP_EVENT_CHANNEL_BY_TENANT:-false}
IMAP_OAUTH_AUTH_MECHANISM: ${IMAP_OAUTH_AUTH_MECHANISM:-XOAUTH2}
IMAP_WEBHOOK_SECRET: ${IMAP_WEBHOOK_SECRET:-}
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./secrets/tenants
target: /run/secrets/tenants
- type: bind
source: ./services/email-service/entrypoint.sh
target: /app/entrypoint.sh
read_only: true
entrypoint: ["/app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- redis_password
- crypto_key
- token_secret_key
- nextauth_secret
- google_oauth_client_id
- google_oauth_client_secret
- alga_auth_key
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
server:
condition: service_started
hocuspocus:
extends:
file: ./hocuspocus/docker-compose.yaml
service: hocuspocus
build:
context: .
dockerfile: hocuspocus/Dockerfile
environment:
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${DB_HOST_ADMIN:-postgres}
DB_PORT: ${DB_PORT_ADMIN:-5432}
DB_NAME_HOCUSPOCUS: ${DB_NAME_HOCUSPOCUS:-hocuspocus}
DB_USER_HOCUSPOCUS: ${DB_USER_HOCUSPOCUS:-hocuspocus_user}
HOCUSPOCUS_JWT_SECRET: ${HOCUSPOCUS_JWT_SECRET}
secrets:
- db_password_hocuspocus
- redis_password
- postgres_password
networks:
- app-network
depends_on:
redis:
condition: service_started
postgres:
extends:
file: docker-compose.base.yaml
service: postgres
volumes:
# Persist Postgres data across restarts/upgrades
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: server
POSTGRES_HOST_AUTH_METHOD: trust
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
DB_TYPE: postgres
DB_HOST: ${DB_HOST:-postgres}
DB_PORT: ${DB_PORT:-5432}
secrets:
- postgres_password
redis:
extends:
file: docker-compose.base.yaml
service: redis
networks:
app-network:
driver: bridge
volumes:
# Named volumes for production-like persistence
postgres_data:
files_data:

View File

@ -0,0 +1,366 @@
version: '3.8'
services:
server:
extends:
file: ./server/docker-compose.prebuilt.yaml
service: server
container_name: ${APP_NAME:-sebastian}_server_ee
image: "ghcr.io/nine-minds/alga-psa-ee:${ALGA_IMAGE_TAG:-latest}"
platform: linux/amd64
environment:
EDITION: enterprise
DB_NAME: server
PGBOSS_DATABASE: server
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
# Prebuilt stacks should run the server in production mode (use the baked `.next` output).
APP_ENV: production
NODE_ENV: production
# The prebuilt server image listens on port 3000 inside the container.
APP_PORT: "3000"
PORT: "3000"
HOST: ${HOST}
VERIFY_EMAIL_ENABLED: ${VERIFY_EMAIL_ENABLED:-false}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
REQUIRE_HOCUSPOCUS: ${REQUIRE_HOCUSPOCUS:-false}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
EMAIL_ENABLE: ${EMAIL_ENABLE:-false}
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_USERNAME: ${EMAIL_USERNAME:-noreply@example.com}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SESSION_EXPIRES: ${NEXTAUTH_SESSION_EXPIRES:-86400}
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
# Workflow configuration
WORKFLOW_DISTRIBUTED_MODE: "true"
WORKFLOW_REDIS_STREAM_PREFIX: "workflow:events:"
WORKFLOW_REDIS_CONSUMER_GROUP: "workflow-workers"
IMAP_WEBHOOK_SECRET: ${IMAP_WEBHOOK_SECRET:-}
volumes:
# Persist user-uploaded documents and generated files
- files_data:/data/files
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
entrypoint: ["/bin/sh", "-c", "export DATABASE_URL=postgresql://app_user:$$(cat /run/secrets/db_password_server)@${PGBOUNCER_HOST:-pgbouncer}:${PGBOUNCER_PORT:-6432}/server && /app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- db_password_hocuspocus
- redis_password
- email_password
- crypto_key
- token_secret_key
- nextauth_secret
- google_oauth_client_id
- google_oauth_client_secret
- alga_auth_key
networks:
- app-network
depends_on:
setup:
condition: service_completed_successfully
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
hocuspocus:
condition: service_started
required: false
setup:
image: "ghcr.io/nine-minds/alga-psa-ee:${ALGA_IMAGE_TAG:-latest}"
platform: linux/amd64
container_name: ${APP_NAME:-sebastian}_setup_ee
restart: "no"
environment:
EDITION: enterprise
NODE_OPTIONS: --experimental-vm-modules
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
PGBOSS_DATABASE: server
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: production
NODE_ENV: production
HOST: ${HOST}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${DB_HOST:-postgres}
DB_PORT: ${DB_PORT:-5432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
EMAIL_ENABLE: ${EMAIL_ENABLE:-false}
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_USERNAME: ${EMAIL_USERNAME:-noreply@example.com}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SESSION_EXPIRES: ${NEXTAUTH_SESSION_EXPIRES:-86400}
# Secret provider configuration for setup (EE edition)
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem,vault}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
volumes:
- type: bind
source: ./setup/config.ini
target: /app/setup/config.ini
read_only: true
- type: bind
source: ./setup/entrypoint.sh
target: /app/setup/entrypoint.sh
read_only: true
- type: bind
source: ./secrets/postgres_password
target: /run/secrets/postgres_password
read_only: true
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
# Mount EE migrations and knexfile
- type: bind
source: ./ee/server/migrations
target: /app/ee/server/migrations
read_only: true
- type: bind
source: ./ee/server/knexfile.cjs
target: /app/ee/server/knexfile.cjs
read_only: true
secrets:
- postgres_password
- db_password_server
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
entrypoint: ["/app/setup/entrypoint.sh"]
workflow-worker:
# NOTE: The published EE server image does not currently ship the workflow-worker runtime bundle.
# For local prebuilt-stack testing, build the worker image from source.
build:
context: .
dockerfile: services/workflow-worker/Dockerfile
platform: linux/amd64
environment:
EDITION: enterprise
DB_NAME: server
PGBOSS_DATABASE: server
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: production
NODE_ENV: production
HOST: ${HOST}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
# Secret provider configuration for workflow-worker (EE edition)
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem,vault}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
# Workflow-specific configuration
# Run v2 runtime by default (legacy can be re-enabled via WORKFLOW_WORKER_MODE=all|legacy)
WORKFLOW_WORKER_MODE: ${WORKFLOW_WORKER_MODE:-v2}
WORKFLOW_DISTRIBUTED_MODE: "true"
WORKFLOW_REDIS_STREAM_PREFIX: "workflow:events:"
WORKFLOW_REDIS_CONSUMER_GROUP: "workflow-workers"
WORKFLOW_REDIS_BATCH_SIZE: "10"
WORKFLOW_REDIS_IDLE_TIMEOUT_MS: "60000"
ports:
# Expose a random port for health checks/monitoring
- "3000"
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./services/workflow-worker/entrypoint.sh
target: /app/entrypoint.sh
read_only: true
entrypoint: ["/app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- redis_password
- crypto_key
- token_secret_key
- nextauth_secret
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
server:
condition: service_started
# Enable scaling of worker instances
deploy:
replicas: ${WORKFLOW_WORKER_REPLICAS:-1}
email-service:
# NOTE: The published EE server image does not currently ship the email-service runtime bundle.
# For local prebuilt-stack testing, build the IMAP service image from source.
build:
context: .
dockerfile: services/email-service/Dockerfile
platform: linux/amd64
environment:
EDITION: enterprise
DB_NAME: server
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: production
NODE_ENV: production
HOST: ${HOST}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
# Secret provider configuration for email-service (EE edition)
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem,vault}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
# IMAP service configuration
IMAP_PROVIDER_REFRESH_MS: ${IMAP_PROVIDER_REFRESH_MS:-60000}
IMAP_POLL_INTERVAL_MS: ${IMAP_POLL_INTERVAL_MS:-30000}
IMAP_LEASE_TTL_MS: ${IMAP_LEASE_TTL_MS:-120000}
IMAP_MAX_CONNECTIONS_PER_TENANT: ${IMAP_MAX_CONNECTIONS_PER_TENANT:-5}
IMAP_MAX_ATTACHMENT_BYTES: ${IMAP_MAX_ATTACHMENT_BYTES:-0}
IMAP_FETCH_DELAY_MS: ${IMAP_FETCH_DELAY_MS:-0}
IMAP_EVENT_CHANNEL_BY_TENANT: ${IMAP_EVENT_CHANNEL_BY_TENANT:-false}
IMAP_OAUTH_AUTH_MECHANISM: ${IMAP_OAUTH_AUTH_MECHANISM:-XOAUTH2}
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./secrets/tenants
target: /run/secrets/tenants
- type: bind
source: ./services/email-service/entrypoint.sh
target: /app/entrypoint.sh
read_only: true
entrypoint: ["/app/entrypoint.sh"]
secrets:
- postgres_password
- db_password_server
- redis_password
- crypto_key
- token_secret_key
- nextauth_secret
- google_oauth_client_id
- google_oauth_client_secret
- alga_auth_key
networks:
- app-network
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
server:
condition: service_started
hocuspocus:
extends:
file: ./hocuspocus/docker-compose.yaml
service: hocuspocus
container_name: ${APP_NAME:-sebastian}_hocuspocus_ee
build:
context: .
dockerfile: hocuspocus/Dockerfile
environment:
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
secrets:
- db_password_hocuspocus
- redis_password
networks:
- app-network
depends_on:
redis:
condition: service_started
postgres:
extends:
file: docker-compose.base.yaml
service: postgres
volumes:
# Persist Postgres data across restarts/upgrades
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: server
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
DB_TYPE: postgres
DB_HOST: ${DB_HOST:-postgres}
DB_PORT: ${DB_PORT:-5432}
secrets:
- postgres_password
redis:
extends:
file: docker-compose.base.yaml
service: redis
pgbouncer:
extends:
file: docker-compose.base.yaml
service: pgbouncer
networks:
app-network:
driver: bridge
volumes:
# Named volumes for production-like persistence
postgres_data:
files_data:

50
docker-compose.prod.yaml Normal file
View File

@ -0,0 +1,50 @@
services:
server:
environment:
NODE_ENV: production
APP_ENV: production
# Production secret provider configuration
# Override defaults for production vault integration
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem,vault}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
IMAP_WEBHOOK_SECRET: ${IMAP_WEBHOOK_SECRET:-}
build:
context: .
dockerfile: Dockerfile.build
args:
NEXT_BUILD_MAX_OLD_SPACE_SIZE: ${NEXT_BUILD_MAX_OLD_SPACE_SIZE:-12288}
setup:
environment:
NODE_ENV: production
APP_ENV: production
# Production secret provider configuration for setup
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem,vault}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
hocuspocus:
environment:
NODE_ENV: production
APP_ENV: production
# Production secret provider configuration for hocuspocus
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem,vault}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
email-service:
build:
context: .
dockerfile: services/email-service/Dockerfile
environment:
NODE_ENV: production
APP_ENV: production
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem,vault}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
IMAP_WEBHOOK_SECRET: ${IMAP_WEBHOOK_SECRET:-}
IMAP_PROVIDER_REFRESH_MS: ${IMAP_PROVIDER_REFRESH_MS:-60000}
IMAP_POLL_INTERVAL_MS: ${IMAP_POLL_INTERVAL_MS:-30000}
IMAP_LEASE_TTL_MS: ${IMAP_LEASE_TTL_MS:-120000}
IMAP_MAX_CONNECTIONS_PER_TENANT: ${IMAP_MAX_CONNECTIONS_PER_TENANT:-5}
IMAP_MAX_ATTACHMENT_BYTES: ${IMAP_MAX_ATTACHMENT_BYTES:-0}
IMAP_FETCH_DELAY_MS: ${IMAP_FETCH_DELAY_MS:-0}
IMAP_EVENT_CHANNEL_BY_TENANT: ${IMAP_EVENT_CHANNEL_BY_TENANT:-false}
IMAP_OAUTH_AUTH_MECHANISM: ${IMAP_OAUTH_AUTH_MECHANISM:-XOAUTH2}

View File

@ -0,0 +1,22 @@
# Extension Runner local development stack
# Usage: docker compose -f docker-compose.runner-dev.yml up --build
services:
extension-runner:
build:
context: ./ee/runner
dockerfile: Dockerfile
image: alga-psa/extension-runner:dev-local
container_name: ${APP_NAME:-alga}_extension_runner
env_file:
- .env.runner
ports:
- "${RUNNER_DOCKER_PORT:-8085}:8080"
volumes:
- ./tmp-ext:/app/tmp-ext:rw
networks:
- runner-dev
networks:
runner-dev:
name: ${APP_NAME:-alga}_runner_dev

View File

@ -0,0 +1,55 @@
services:
setup:
build:
context: .
dockerfile: setup/Dockerfile.ubuntu
image: alga-setup:ubuntu
platform: linux/amd64
restart: "no"
environment:
EDITION: community
NODE_OPTIONS: --experimental-vm-modules
DB_NAME_SERVER: ${DB_NAME_SERVER:-server}
DB_USER_SERVER: ${DB_USER_SERVER:-app_user}
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
PGBOSS_DATABASE: ${PGBOSS_DATABASE:-server}
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${DB_HOST:-postgres}
DB_PORT: ${DB_PORT:-5432}
DB_HOST_ADMIN: ${DB_HOST_ADMIN}
DB_PORT_ADMIN: ${DB_PORT_ADMIN}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
EMAIL_ENABLE: ${EMAIL_ENABLE:-false}
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_USERNAME: ${EMAIL_USERNAME:-noreply@example.com}
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SESSION_EXPIRES: ${NEXTAUTH_SESSION_EXPIRES:-86400}
volumes:
- type: bind
source: ./setup/config.ini
target: /app/setup/config.ini
read_only: true
- type: bind
source: ./setup/entrypoint.sh
target: /app/setup/entrypoint.sh
read_only: true
- type: bind
source: ./secrets/postgres_password
target: /run/secrets/postgres_password
read_only: true
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
secrets:
- postgres_password
- db_password_server
entrypoint: ["/app/setup/entrypoint.sh"]

View File

@ -0,0 +1,113 @@
services:
# Citus Coordinator Node
postgres-citus-coordinator:
image: citusdata/citus:12.1
platform: linux/amd64
container_name: alga_citus_coordinator
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: server_test
CITUS_NODE_TYPE: coordinator
ports:
- "5433:5432"
volumes:
- citus_coordinator_data:/var/lib/postgresql/data
command: |
postgres
-c shared_preload_libraries='citus,pg_stat_statements'
-c citus.shard_count=32
-c citus.shard_replication_factor=1
-c log_statement='all'
-c log_duration=on
networks:
- alga_test_network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# Citus Worker Node 1
postgres-citus-worker-1:
image: citusdata/citus:12.1
platform: linux/amd64
container_name: alga_citus_worker_1
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: server_test
CITUS_NODE_TYPE: worker
volumes:
- citus_worker1_data:/var/lib/postgresql/data
command: |
postgres
-c shared_preload_libraries='citus,pg_stat_statements'
networks:
- alga_test_network
depends_on:
postgres-citus-coordinator:
condition: service_healthy
# Citus Worker Node 2
postgres-citus-worker-2:
image: citusdata/citus:12.1
platform: linux/amd64
container_name: alga_citus_worker_2
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: server_test
CITUS_NODE_TYPE: worker
volumes:
- citus_worker2_data:/var/lib/postgresql/data
command: |
postgres
-c shared_preload_libraries='citus,pg_stat_statements'
networks:
- alga_test_network
depends_on:
postgres-citus-coordinator:
condition: service_healthy
# Setup service to configure Citus cluster
citus-setup:
image: citusdata/citus:12.1
platform: linux/amd64
container_name: alga_citus_setup
depends_on:
postgres-citus-coordinator:
condition: service_healthy
postgres-citus-worker-1:
condition: service_started
postgres-citus-worker-2:
condition: service_started
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
PGPASSWORD: ${POSTGRES_PASSWORD:-postgres}
networks:
- alga_test_network
command: |
bash -c "
echo 'Waiting for workers to be ready...'
sleep 10
echo 'Adding worker nodes to coordinator...'
psql -h postgres-citus-coordinator -U postgres -d server_test -c \"SELECT citus_add_node('postgres-citus-worker-1', 5432);\" || true
psql -h postgres-citus-coordinator -U postgres -d server_test -c \"SELECT citus_add_node('postgres-citus-worker-2', 5432);\" || true
echo 'Verifying cluster setup...'
psql -h postgres-citus-coordinator -U postgres -d server_test -c \"SELECT * FROM citus_get_active_worker_nodes();\"
echo 'Citus cluster setup complete!'
"
volumes:
citus_coordinator_data:
citus_worker1_data:
citus_worker2_data:
networks:
alga_test_network:
driver: bridge

View File

@ -0,0 +1,59 @@
# Docker Compose configuration for running tests with MinIO storage
# This extends the base configuration with a MinIO service for S3-compatible storage testing
#
# Usage: docker compose -f docker-compose.base.yaml -f docker-compose.test-minio.yaml up
version: '3.8'
services:
# MinIO - S3-compatible object storage for testing
minio:
image: minio/minio:latest
container_name: ${APP_NAME:-alga}_minio_test
ports:
- "9000:9000" # MinIO API
- "9001:9001" # MinIO Console
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
MINIO_DOMAIN: minio
command: server /data --console-address ":9001"
volumes:
- minio_test_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
networks:
- default
# MinIO Client - for bucket initialization
minio-init:
image: minio/mc:latest
container_name: ${APP_NAME:-alga}_minio_init
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set minio http://minio:9000 minioadmin minioadmin;
mc mb minio/test-documents --ignore-existing;
mc mb minio/test-previews --ignore-existing;
mc mb minio/test-thumbnails --ignore-existing;
mc anonymous set download minio/test-documents;
mc anonymous set download minio/test-previews;
mc anonymous set download minio/test-thumbnails;
echo 'MinIO buckets initialized successfully';
exit 0;
"
networks:
- default
volumes:
minio_test_data:
driver: local
networks:
default:
name: ${APP_NAME:-alga}_test_network

28
docker-compose.test.yml Normal file
View File

@ -0,0 +1,28 @@
version: '3.8'
services:
ai-automation:
build:
context: .
dockerfile: ./tools/ai-automation/Dockerfile.test
container_name: ${APP_NAME:-sebastian}_ai_automation
environment:
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
DISPLAY: :99
ports:
- "4000:4000"
security_opt:
- seccomp=unconfined
cap_add:
- SYS_ADMIN
networks:
- app-network
networks:
app-network:
name: ${APP_NAME:-sebastian}_app-network
driver: bridge

241
docker-compose.yaml Normal file
View File

@ -0,0 +1,241 @@
version: '3.8'
x-environment: &shared-environment
# ---- APP -------
VERSION: ${VERSION}
APP_NAME: ${APP_NAME}
APP_ENV: ${APP_ENV:-development}
NODE_ENV: ${APP_ENV:-development}
HOST: ${HOST}
VERIFY_EMAIL_ENABLED: ${VERIFY_EMAIL_ENABLED:-false}
EDITION: ${EDITION:-community}
# ---- REDIS ----
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
# ---- DATABASE ----
DB_TYPE: ${DB_TYPE:-postgres}
DB_HOST: ${PGBOUNCER_HOST:-pgbouncer}
DB_PORT: ${PGBOUNCER_PORT:-6432}
DB_NAME: server
DB_NAME_HOCUSPOCUS: ${DB_NAME_HOCUSPOCUS:-server}
DB_USER_HOCUSPOCUS: ${DB_USER_HOCUSPOCUS:-app_user}
DB_NAME_SERVER: server
DB_USER_SERVER: app_user
DB_USER_ADMIN: ${DB_USER_ADMIN:-postgres}
POSTGRES_USER: postgres
# ---- LOGGING ----
LOG_LEVEL: ${LOG_LEVEL:-INFO}
LOG_IS_FORMAT_JSON: ${LOG_IS_FORMAT_JSON:-false}
LOG_IS_FULL_DETAILS: ${LOG_IS_FULL_DETAILS:-false}
LOG_ENABLED_FILE_LOGGING: ${LOG_ENABLED_FILE_LOGGING}
LOG_DIR_PATH: ${LOG_DIR_PATH}
LOG_ENABLED_EXTERNAL_LOGGING: ${LOG_ENABLED_EXTERNAL_LOGGING}
LOG_EXTERNAL_HTTP_HOST: ${LOG_EXTERNAL_HTTP_HOST}
LOG_EXTERNAL_HTTP_PORT: ${LOG_EXTERNAL_HTTP_PORT}
LOG_EXTERNAL_HTTP_PATH: ${LOG_EXTERNAL_HTTP_PATH}
LOG_EXTERNAL_HTTP_LEVEL: ${LOG_EXTERNAL_HTTP_LEVEL}
LOG_EXTERNAL_HTTP_TOKEN: ${LOG_EXTERNAL_HTTP_TOKEN}
# ---- HOCUSPOCUS ----
HOCUSPOCUS_PORT: ${HOCUSPOCUS_PORT}
HOCUSPOCUS_URL: ${HOCUSPOCUS_URL}
REQUIRE_HOCUSPOCUS: ${REQUIRE_HOCUSPOCUS:-false}
# ---- EMAIL ----
EMAIL_ENABLE: ${EMAIL_ENABLE:-false}
EMAIL_FROM: ${EMAIL_FROM:-noreply@example.com}
EMAIL_HOST: ${EMAIL_HOST}
EMAIL_PORT: ${EMAIL_PORT:-587}
EMAIL_USERNAME: ${EMAIL_USERNAME:-noreply@example.com}
# ---- CRYPTO ----
CRYPTO_SALT_BYTES: ${SALT_BYTES}
CRYPTO_ITERATION: ${ITERATION}
CRYPTO_KEY_LENGTH: ${KEY_LENGTH}
CRYPTO_ALGORITHM: ${ALGORITHM}
# ---- TOKEN ----
TOKEN_EXPIRES: ${TOKEN_EXPIRES}
# ---- AI / LLM ----
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
OPENROUTER_API: ${OPENROUTER_API:-}
# ---- AUTH ----
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
# Required for edge auth initialization (NextAuth secret must be present in env, not only as a Docker secret file)
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET}
NEXTAUTH_SESSION_EXPIRES: ${NEXTAUTH_SESSION_EXPIRES:-86400}
# ---- SECRET PROVIDER ----
# Composite secret provider configuration
# Default: env -> filesystem chain for reads, filesystem for writes
# Override these in production for vault integration
SECRET_READ_CHAIN: ${SECRET_READ_CHAIN:-env,filesystem}
SECRET_WRITE_PROVIDER: ${SECRET_WRITE_PROVIDER:-filesystem}
# ---- DEPLOY INFO ----
PROJECT_NAME: ${PROJECT_NAME}
EXPOSE_DB_PORT: ${EXPOSE_DB_PORT:-5432}
EXPOSE_HOCUSPOCUS_PORT: ${EXPOSE_HOCUSPOCUS_PORT:-1234}
EXPOSE_REDIS_PORT: ${EXPOSE_REDIS_PORT:-6379}
EXPOSE_SERVER_PORT: ${EXPOSE_SERVER_PORT:-3000}
secrets:
db_password_server:
file: ./secrets/db_password_server
db_password_hocuspocus:
file: ./secrets/db_password_hocuspocus
postgres_password:
file: ./secrets/postgres_password
redis_password:
file: ./secrets/redis_password
email_password:
file: ./secrets/email_password
crypto_key:
file: ./secrets/crypto_key
token_secret_key:
file: ./secrets/token_secret_key
nextauth_secret:
file: ./secrets/nextauth_secret
google_oauth_client_id:
file: ./secrets/google_oauth_client_id
google_oauth_client_secret:
file: ./secrets/google_oauth_client_secret
alga_auth_key:
file: ./secrets/alga_auth_key
ninjaone_client_id:
file: ./secrets/ninjaone_client_id
ninjaone_client_secret:
file: ./secrets/ninjaone_client_secret
services:
server:
extends:
file: ./server/docker-compose.yaml
service: server
container_name: ${APP_NAME:-sebastian}_server
networks:
- app-network
environment:
<<: *shared-environment
volumes:
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
- type: bind
source: ./secrets/tenants
target: /run/secrets/tenants
# ngrok volume for webhook URL syncing (only mounted with docker-compose.ngrok.yaml)
- ngrok-data:/app/ngrok:ro
secrets:
- postgres_password
- db_password_server
- db_password_hocuspocus
- redis_password
- email_password
- crypto_key
- token_secret_key
- nextauth_secret
- google_oauth_client_id
- google_oauth_client_secret
- alga_auth_key
depends_on:
postgres:
condition: service_started
pgbouncer:
condition: service_started
redis:
condition: service_started
hocuspocus:
condition: service_started
required: false
setup:
condition: service_completed_successfully
setup:
build:
context: .
dockerfile: setup/Dockerfile
args:
SERVER_IMAGE_REPO: ${ALGA_SETUP_IMAGE_REPO:-ghcr.io/nine-minds/alga-psa-ce}
ALGA_IMAGE_TAG: ${ALGA_IMAGE_TAG:-latest}
# The setup image uses a prebuilt base image that is published as amd64-only.
platform: linux/amd64
networks:
- app-network
environment:
<<: *shared-environment
DB_HOST: ${DB_HOST:-postgres}
DB_PORT: ${DB_PORT:-5432}
NODE_OPTIONS: --experimental-vm-modules
volumes:
- type: bind
source: ./secrets/postgres_password
target: /run/secrets/postgres_password
read_only: true
- type: bind
source: ./secrets/db_password_server
target: /run/secrets/db_password_server
read_only: true
secrets:
- postgres_password
- db_password_server
entrypoint: ["/opt/setup/entrypoint.sh"]
depends_on:
postgres:
condition: service_started
hocuspocus:
extends:
file: ./hocuspocus/docker-compose.yaml
service: hocuspocus
container_name: ${APP_NAME:-sebastian}_hocuspocus
networks:
- app-network
environment:
<<: *shared-environment
secrets:
- db_password_hocuspocus
- redis_password
depends_on:
redis:
condition: service_started
postgres:
image: pgvector/pgvector:0.8.0-pg15
container_name: ${APP_NAME:-sebastian}_postgres
networks:
- app-network
environment:
<<: *shared-environment
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets:
- postgres_password
ports:
- "${EXPOSE_DB_PORT:-5432}:5432"
redis:
image: 'resend-custom-domains-redis:latest'
container_name: ${APP_NAME:-sebastian}_redis
entrypoint: ["/app/redis/entrypoint.sh"]
networks:
- app-network
environment:
<<: *shared-environment
secrets:
- redis_password
ports:
- '${EXPOSE_REDIS_PORT:-6379}:6379'
networks:
app-network:
driver: bridge
volumes:
# Used by docker-compose.ngrok.yaml, but referenced in the base server definition.
ngrok-data:

View File

@ -0,0 +1,6 @@
# Ignore everything except the files we need
*
!config/
!config/settings.json
!config/extensions.json
!start-dev-env.sh

View File

@ -0,0 +1,143 @@
# Alga PSA Development Environment - Code Server
# Based on the official code-server image with Node.js LTS and development tools
FROM codercom/code-server:latest
# Switch to root to install packages
USER root
# Install prerequisites and Node.js LTS
RUN apt-get update && \
apt-get install -y \
curl \
ca-certificates \
wget \
gnupg \
lsb-release \
git \
build-essential \
python3 \
python3-pip \
jq \
vim \
nano \
htop \
tree \
unzip \
sudo \
ffmpeg \
&& rm -rf /var/lib/apt/lists/*
# Give coder user sudo privileges for development tasks
RUN echo 'coder ALL=(ALL) NOPASSWD: ALL' >> /etc/sudoers
# Increase file watcher limits for development
RUN echo 'fs.inotify.max_user_watches=524288' >> /etc/sysctl.conf && \
echo 'fs.inotify.max_user_instances=256' >> /etc/sysctl.conf
# Add NodeSource repository and install Node.js LTS (18.x)
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - && \
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*
# Install global npm packages
RUN npm install -g \
@anthropic-ai/claude-code \
npm-check-updates \
typescript \
ts-node \
nodemon \
prettier \
eslint \
zstd
# Install mirrord for remote development
RUN curl -fsSL https://raw.githubusercontent.com/metalbear-co/mirrord/main/scripts/install.sh | bash
# Install kubectl
RUN curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" && \
install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl && \
rm kubectl
# Install helm
RUN curl -fsSL https://get.helm.sh/helm-v3.12.0-linux-amd64.tar.gz | tar xz && \
mv linux-amd64/helm /usr/local/bin/ && \
rm -rf linux-amd64
# Install Docker CLI (for building images from within the environment)
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list > /dev/null && \
apt-get update && \
apt-get install -y docker-ce-cli && \
rm -rf /var/lib/apt/lists/*
# Install VS Code extensions that work well in the browser environment
# Use || true to continue if an extension fails to install
RUN code-server --install-extension ms-vscode.vscode-typescript-next || true && \
code-server --install-extension bradlc.vscode-tailwindcss || true && \
code-server --install-extension esbenp.prettier-vscode || true && \
code-server --install-extension redhat.vscode-yaml || true && \
code-server --install-extension ms-kubernetes-tools.vscode-kubernetes-tools || true && \
code-server --install-extension dbaeumer.vscode-eslint || true
# Create necessary directories
RUN mkdir -p /home/coder/.config/code-server && \
mkdir -p /home/coder/.local/share/code-server/User && \
mkdir -p /home/coder/.vscode-server/extensions && \
mkdir -p /home/coder/alga-psa
# Copy configuration files (will be mounted from configmap)
COPY --chown=coder:coder docker/dev-env/config/settings.json /home/coder/.local/share/code-server/User/settings.json
COPY --chown=coder:coder docker/dev-env/config/extensions.json /home/coder/.vscode-server/extensions.json
# Copy and setup startup script
COPY docker/dev-env/start-dev-env.sh /usr/local/bin/start-dev-env.sh
RUN chmod +x /usr/local/bin/start-dev-env.sh
# Pre-install npm dependencies to speed up environment startup
# Copy package files first for better Docker layer caching
COPY --chown=coder:coder package*.json /home/coder/alga-psa/
COPY --chown=coder:coder server/package*.json /home/coder/alga-psa/server/
COPY --chown=coder:coder tools/ai-automation/package*.json /home/coder/alga-psa/tools/ai-automation/
COPY --chown=coder:coder server/src/invoice-templates/assemblyscript/package*.json /home/coder/alga-psa/server/src/invoice-templates/assemblyscript/
# Ensure coder owns the entire directory structure
RUN chown -R coder:coder /home/coder/alga-psa
# Switch to coder user for npm installs
USER coder
# Set the default workspace
WORKDIR /home/coder/alga-psa
# Download and install VS Code CLI in the coder user's home directory
RUN cd /home/coder && \
curl -Lk 'https://code.visualstudio.com/sha/download?build=stable&os=cli-alpine-x64' --output vscode_cli.tar.gz && \
tar -xf vscode_cli.tar.gz && \
rm vscode_cli.tar.gz && \
echo "✅ VS Code CLI installed in /home/coder/code"
# Continue in the alga-psa directory
WORKDIR /home/coder/alga-psa
# Install dependencies during build
RUN echo "📦 Pre-installing root dependencies..." && \
npm ci --prefer-offline --no-audit && \
echo "📦 Pre-installing server dependencies..." && \
cd server && npm ci --prefer-offline --no-audit && \
cd .. && \
echo "🤖 Pre-installing AI automation dependencies..." && \
cd tools/ai-automation && npm ci --prefer-offline --no-audit && \
cd ../.. && \
echo "📄 Pre-installing AssemblyScript template dependencies..." && \
cd server/src/invoice-templates/assemblyscript && npm ci --prefer-offline --no-audit && \
cd ../..
# Switch back to root for final setup
USER root
# Expose the code-server port
EXPOSE 8080
# Use our custom startup script
ENTRYPOINT ["/usr/local/bin/start-dev-env.sh"]

52
docker/dev-env/README.md Normal file
View File

@ -0,0 +1,52 @@
# Alga PSA Development Environment - Code Server
This directory contains the Docker configuration for the Alga PSA code-server development environment.
## Overview
The code-server image provides a full-featured VS Code environment running in the browser, pre-configured for Alga PSA development.
## Key Features
- **Pre-installed Dependencies**: npm dependencies are installed during the Docker build process to speed up environment startup
- **Development Tools**: Includes Node.js LTS, npm, git, kubectl, helm, and other essential tools
- **VS Code Extensions**: Pre-configured with TypeScript, Tailwind CSS, ESLint, and other useful extensions
- **Auto-configuration**: Git configuration and file watcher limits are automatically set
## Building the Image
There are two ways to build the code-server image:
### Option 1: Using the CLI (Recommended)
```bash
nu main.nu build-code-server --push
nu main.nu build-code-server --tag v1.0.0 --push
```
### Option 2: Using the build script directly
```bash
cd docker/dev-env
./build-code-server.sh [TAG]
```
If no tag is specified, it defaults to `latest`.
**Important**: The Docker build is executed from the project root directory to access all package.json files. All paths in the Dockerfile are relative to the project root.
## Speed Optimizations
The Dockerfile has been optimized to pre-install npm dependencies during the build phase:
1. **Layer Caching**: Package files are copied first, allowing Docker to cache the dependency installation layer
2. **Pre-installation**: Dependencies for the root project, server, and AI automation tools are installed during build
3. **Smart Updates**: The startup script only runs npm install if dependencies have changed
This reduces the startup time from several minutes to seconds for new environments.
## Files
- `Dockerfile.code-server`: The main Dockerfile for building the code-server image
- `start-dev-env.sh`: Startup script that configures the environment and starts code-server
- `build-code-server.sh`: Build script for creating and pushing the image
- `config/settings.json`: VS Code settings for the development environment
- `config/extensions.json`: List of VS Code extensions to install

View File

@ -0,0 +1,51 @@
#!/bin/bash
set -e
# Build and push the Alga PSA code-server image
# Default values
REGISTRY="harbor.nineminds.com"
NAMESPACE="nineminds"
IMAGE_NAME="alga-code-server"
TAG="${1:-latest}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Building Alga PSA Code Server image...${NC}"
echo -e "${YELLOW}Registry: ${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG}${NC}"
# Build the image from the project root to have access to package.json files
cd ../..
echo -e "${GREEN}Building from project root: $(pwd)${NC}"
echo -e "${YELLOW}Note: Building from project root, all paths in Dockerfile are relative to project root${NC}"
# Build the Docker image
echo -e "${YELLOW}Build output will be streamed to terminal...${NC}"
docker build \
--platform linux/amd64 \
-f docker/dev-env/Dockerfile.code-server \
-t "${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG}" \
.
# Check if build succeeded
if [ $? -eq 0 ]; then
echo -e "${GREEN}Build completed successfully!${NC}"
else
echo -e "${RED}Build failed!${NC}"
exit 1
fi
# Ask if user wants to push
read -p "Do you want to push the image to the registry? (y/N) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo -e "${YELLOW}Pushing image to registry...${NC}"
docker push "${REGISTRY}/${NAMESPACE}/${IMAGE_NAME}:${TAG}"
echo -e "${GREEN}Push completed successfully!${NC}"
fi
echo -e "${GREEN}Done!${NC}"

View File

@ -0,0 +1,24 @@
{
"recommendations": [
"ms-vscode.vscode-typescript-next",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode",
"ms-vscode.vscode-json",
"ms-vscode.vscode-yaml",
"ms-kubernetes-tools.vscode-kubernetes-tools",
"ms-vscode-remote.remote-containers",
"dbaeumer.vscode-eslint",
"christian-kohler.path-intellisense",
"christian-kohler.npm-intellisense",
"formulahendry.auto-rename-tag",
"ms-vscode.vscode-json",
"redhat.vscode-yaml",
"ms-vscode.vscode-markdown",
"shd101wyy.markdown-preview-enhanced",
"ms-vscode.vscode-todo-highlight",
"gruntfuggly.todo-tree",
"ms-vscode.hexeditor",
"ms-vscode.vscode-docker",
"ms-kubernetes-tools.vscode-kubernetes-tools"
]
}

View File

@ -0,0 +1,73 @@
{
"workbench.colorTheme": "Default Dark+",
"workbench.iconTheme": "vs-seti",
"workbench.startupEditor": "none",
"editor.fontSize": 14,
"editor.fontFamily": "'Fira Code', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace",
"editor.fontLigatures": true,
"editor.tabSize": 2,
"editor.insertSpaces": true,
"editor.rulers": [80, 120],
"editor.minimap.enabled": true,
"editor.formatOnSave": true,
"editor.formatOnPaste": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true,
"source.organizeImports": true
},
"files.autoSave": "afterDelay",
"files.autoSaveDelay": 1000,
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"files.exclude": {
"**/node_modules": true,
"**/dist": true,
"**/build": true,
"**/.git": false,
"**/.DS_Store": true,
"**/Thumbs.db": true
},
"search.exclude": {
"**/node_modules": true,
"**/dist": true,
"**/build": true,
"**/coverage": true
},
"terminal.integrated.defaultProfile.linux": "bash",
"terminal.integrated.fontSize": 13,
"terminal.integrated.cursorBlinking": true,
"terminal.integrated.copyOnSelection": true,
"git.autofetch": true,
"git.enableSmartCommit": true,
"git.confirmSync": false,
"git.decorations.enabled": true,
"typescript.preferences.includePackageJsonAutoImports": "auto",
"typescript.suggest.autoImports": true,
"javascript.suggest.autoImports": true,
"eslint.enable": true,
"eslint.validate": ["javascript", "typescript", "javascriptreact", "typescriptreact"],
"prettier.enable": true,
"prettier.requireConfig": true,
"extensions.autoUpdate": false,
"extensions.autoCheckUpdates": false,
"telemetry.telemetryLevel": "off",
"update.mode": "none",
"json.schemas": [
{
"fileMatch": ["package.json"],
"url": "https://json.schemastore.org/package.json"
},
{
"fileMatch": ["tsconfig.json", "tsconfig.*.json"],
"url": "https://json.schemastore.org/tsconfig.json"
}
],
"tailwindCSS.includeLanguages": {
"typescript": "typescript",
"typescriptreact": "typescriptreact"
},
"emmet.includeLanguages": {
"typescript": "typescriptreact",
"javascript": "javascriptreact"
}
}

69
docker/dev-env/start-dev-env.sh Executable file
View File

@ -0,0 +1,69 @@
#!/bin/bash
set -e
echo "🚀 Starting Alga PSA Development Environment"
echo "PR: ${ALGA_PR_NUMBER:-unknown}"
echo "Branch: ${ALGA_BRANCH:-unknown}"
# Increase file watcher limits for development
echo "⚙️ Configuring file watcher limits..."
sudo sysctl -w fs.inotify.max_user_watches=524288 || echo "Warning: Could not set max_user_watches"
sudo sysctl -w fs.inotify.max_user_instances=256 || echo "Warning: Could not set max_user_instances"
# Configure git if environment variables are set
if [ -n "$GIT_AUTHOR_NAME" ]; then
git config --global user.name "$GIT_AUTHOR_NAME"
fi
if [ -n "$GIT_AUTHOR_EMAIL" ]; then
git config --global user.email "$GIT_AUTHOR_EMAIL"
fi
# Set up workspace permissions
sudo chown -R coder:coder /home/coder/alga-psa
# Navigate to project directory
cd /home/coder/alga-psa
# Install/update dependencies if package.json exists
# Check if node_modules exists and package.json has changed
if [ -f "package.json" ]; then
if [ ! -d "node_modules" ] || [ "package.json" -nt "node_modules" ]; then
echo "📦 Installing/updating Node.js dependencies..."
npm install
else
echo "✅ Root dependencies already installed"
fi
fi
if [ -f "server/package.json" ]; then
if [ ! -d "server/node_modules" ] || [ "server/package.json" -nt "server/node_modules" ]; then
echo "📦 Installing/updating server dependencies..."
cd server && npm install && cd ..
else
echo "✅ Server dependencies already installed"
fi
fi
# Install tools dependencies if they exist
if [ -f "tools/ai-automation/package.json" ]; then
if [ ! -d "tools/ai-automation/node_modules" ] || [ "tools/ai-automation/package.json" -nt "tools/ai-automation/node_modules" ]; then
echo "🤖 Installing/updating AI automation dependencies..."
cd tools/ai-automation && npm install && cd ../..
else
echo "✅ AI automation dependencies already installed"
fi
fi
# Install AssemblyScript template dependencies if they exist
if [ -f "server/src/invoice-templates/assemblyscript/package.json" ]; then
if [ ! -d "server/src/invoice-templates/assemblyscript/node_modules" ] || [ "server/src/invoice-templates/assemblyscript/package.json" -nt "server/src/invoice-templates/assemblyscript/node_modules" ]; then
echo "📄 Installing/updating AssemblyScript template dependencies..."
cd server/src/invoice-templates/assemblyscript && npm install && cd ../../../..
else
echo "✅ AssemblyScript template dependencies already installed"
fi
fi
# Start code-server
echo "🖥️ Starting code-server..."
exec /usr/bin/entrypoint.sh --bind-addr 0.0.0.0:8080 /home/coder/alga-psa

944
docs/AI_coding_standards.md Normal file
View File

@ -0,0 +1,944 @@
# Note to AI editor Claude / GPT-4 / O1 / etc
- If you need to see any additional files before you are sure you have enough context, ask the user to provide the file to the context before continuing.
- If you would like to search for the contents to files, offer to use the run command and grep command to search for the contents.
- Do not proceed to updating files until you have enough context to do so.
- When working in the billing domain, prefer the renamed terminology (`contract lines`, `contracts`) and alias any remaining helper imports that include `plan`/`bundle` to the new schema names in your edits.
- Default invoice template selections must flow through the `invoice_template_assignments` table. Do **not** add new usages of `invoice_templates.is_default` or `companies.invoice_template_id`; those fields are legacy-only until they are removed.
# Failure Handling Philosophy
- Fail fast when assumptions are violated instead of silently attempting fallbacks.
- Throw exceptions with actionable, descriptive messages to surface what went wrong.
- Validate assumptions as early as possible and reject inputs that do not meet strict criteria.
# UI coding standards
Prefer radix components over other libraries
## UI Components
**IMPORTANT: All interactive elements (buttons, inputs, selects, etc.) MUST have unique `id` attributes for the reflection UI system. See Component ID Guidelines section for naming conventions.**
- Use component from `packages/ui/src/components` folder
- [Button](../packages/ui/src/components/Button.tsx)
- [Card](../packages/ui/src/components/Card.tsx)
- [Checkbox](../packages/ui/src/components/Checkbox.tsx)
- [CustomSelect](../packages/ui/src/components/CustomSelect.tsx)
- [CustomTabs](../packages/ui/src/components/CustomTabs.tsx)
- [Dialog](../packages/ui/src/components/Dialog.tsx)
- [Drawer](../packages/ui/src/components/Drawer.tsx)
- [Input](../packages/ui/src/components/Input.tsx)
- [Label](../packages/ui/src/components/Label.tsx)
- [Switch](../packages/ui/src/components/Switch.tsx)
- [SwitchWithLabel](../packages/ui/src/components/SwitchWithLabel.tsx)
- [Table](../packages/ui/src/components/Table.tsx)
- [TextArea](../packages/ui/src/components/TextArea.tsx)
### Theme & Dark Mode
For full theming guidelines, CSS variable usage, and provider architecture, see [Theming Documentation](ui/theming.md).
**Key rules:**
- Use CSS variable tokens (`rgb(var(--color-text-700))`, `bg-primary-50`) — never hardcode hex/rgb values
- Use `useAppTheme` from `@alga-psa/ui/hooks` — not `useTheme` from next-themes directly
- Adapt dynamic entity colors with `adaptColorsForDarkMode()` from `@alga-psa/ui/lib/colorUtils`
- Test all UI changes in both light and dark themes
**Container backgrounds — always use CSS variables:**
```tsx
// Good — adapts to dark mode via globals.css
<div className="bg-[rgb(var(--color-card))]">
<div className="bg-[rgb(var(--color-background))]">
// Bad — stays white in dark mode, text becomes invisible
<div className="bg-white">
<div className="bg-gray-50">
```
**Selection/highlight states — always add `dark:` variants:**
```tsx
// Good — readable in both themes
<button className={selected
? 'bg-blue-50 border-blue-500 dark:bg-blue-500/20 dark:border-blue-400'
: 'border-[rgb(var(--color-border-200))]'
}>
// Bad — light bg becomes unreadable on dark background
<button className={selected ? 'bg-blue-50 border-blue-500' : ''}>
```
**Semantic status colors — use the theme-aware CSS variable system:**
```tsx
// Good — uses badge/status CSS variables that adapt per theme
'border-[rgb(var(--badge-success-border))] bg-[rgb(var(--badge-success-bg))] text-[rgb(var(--badge-success-text))]'
// Good — opacity approach for translucent tints on dark backgrounds
'dark:bg-[rgb(var(--color-primary-400)/0.30)]'
// Bad — hardcoded colors that don't adapt
'bg-green-100 text-green-800'
'bg-red-50 text-red-600'
```
**Common mistakes to avoid:**
- `bg-white` on cards/panels — use `bg-[rgb(var(--color-card))]`
- `bg-blue-50`, `bg-purple-100`, etc. without `dark:` variant — add `dark:bg-blue-500/20` or similar
- `text-gray-700` on elements that may appear on dark backgrounds — use `text-[rgb(var(--color-text-700))]`
- Hardcoded status colors (`text-red-600`, `text-green-600`) — use CSS variable tokens or add `dark:` variants (`dark:text-red-400`, `dark:text-green-400`)
## Loading States for Remote Content
- When embedding remote experiences (extension iframes, external dashboards, etc.), always surface a branded loading state until the surface reports it is ready.
- Wrap the remote surface in a `relative` container and gate its visibility with an `isLoading` flag driven by the `onLoad`/`onError` lifecycle events.
- Reuse the shared overlay styles defined in `server/src/app/globals.css` (`extension-loading-overlay`, `extension-loading-indicator`, `extension-loading-text`, `extension-loading-subtext`) to maintain consistent visuals.
- Use the `LoadingIndicator` component with `layout="stacked"` for the primary status message and reserve the subtext paragraph for short explanations (<40 characters) so the layout stays balanced.
- Example pattern:
```tsx
<div className="relative h-full" aria-busy={isLoading}>
{isLoading && (
<div className="extension-loading-overlay" role="status">
<LoadingIndicator
layout="stacked"
className="extension-loading-indicator"
text="Starting extension"
textClassName="extension-loading-text"
spinnerProps={{ size: 'sm', color: 'border-primary-400' }}
/>
<p className="extension-loading-subtext">Connecting to the runtime workspace&hellip;</p>
</div>
)}
<iframe
onLoad={() => setIsLoading(false)}
onError={() => {
setHasError(true);
setIsLoading(false);
}}
className={isLoading ? 'opacity-0' : 'opacity-100'}
/>
</div>
```
## Dialog Component Usage
When implementing dialogs in the application, follow these guidelines:
1. **Use Custom Dialog Component**
- Always use the custom Dialog component from '@alga-psa/ui/components/Dialog'
- Never import Dialog directly from '@radix-ui/react-dialog'
```tsx
// Good
import { Dialog, DialogContent } from '@alga-psa/ui/components/Dialog';
// Bad
import * as Dialog from '@radix-ui/react-dialog';
```
2. **Dialog Structure — sticky footer pattern**
Action buttons (Save/Cancel/Delete/Next/Back/etc.) belong in the `footer` prop, NOT inside `DialogContent`. The Dialog renders `footer` in a sticky `border-t` strip below the scrollable body, so buttons stay pinned when the body is tall.
```tsx
const footer = (
<div className="flex justify-end space-x-2">
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button
type="button"
onClick={() => (document.getElementById('my-form') as HTMLFormElement | null)?.requestSubmit()}
>Save</Button>
</div>
);
return (
<Dialog
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title="Dialog Title"
className="max-w-lg"
footer={footer}
>
<DialogContent>
<form id="my-form" onSubmit={handleSubmit}>
{/* fields */}
</form>
</DialogContent>
</Dialog>
);
```
**Critical:** Because `footer` is rendered OUTSIDE the `<form>`, a `type="submit"` button in the footer will not trigger the form's `onSubmit`. Give the form a unique `id` and switch the Save button to `type="button"` + `onClick={() => form.requestSubmit()}`. `requestSubmit()` still runs native HTML validation and fires `onSubmit`. Enter-in-field still submits because the form's own submit event handles that.
For dialogs without a form (e.g. confirmation dialogs, pickers), put plain `onClick` handlers on the footer buttons.
3. **`DialogFooter` is deprecated**
- Do not use `DialogFooter` in new code — it places buttons inside the scrollable body. Use the `footer` prop.
- Existing `DialogFooter` usages should be migrated when touched.
4. **Wizards and multi-step dialogs**
- Put Next/Back/Finish/Skip buttons in the `footer` prop.
- Make `footer` a computed value (or `undefined`) so it changes per step: e.g. `footer={step === 'upload' ? undefined : stepFooter}`.
5. **Props and Features**
- `isOpen`: Boolean to control dialog visibility
- `onClose`: Callback function when dialog should close
- `title`: Dialog title shown in the draggable header
- `className`: Use responsive Tailwind classes (max-w-sm, max-w-md, max-w-lg, max-w-xl, max-w-2xl)
- `footer`: ReactNode rendered in the sticky footer strip
- `draggable`: Defaults to true, set to false to disable dragging
- `hideCloseButton`: Set to true to hide the X close button
- `allowOverflow`: Set to true for dialogs that host dropdowns/popovers
4. **Width Guidelines**
- Use responsive max-width classes instead of fixed pixel widths
- Common sizes:
- `max-w-sm` (384px) - Very small dialogs
- `max-w-md` (448px) - Small dialogs (confirmations, simple forms)
- `max-w-lg` (512px) - Medium dialogs (standard forms)
- `max-w-xl` (576px) - Large dialogs (complex forms)
- `max-w-2xl` (672px) - Extra large dialogs (multi-section forms)
6. **Spacing and Padding**
- The Dialog body has built-in padding (`px-6 pt-3 pb-6`) and handles scrolling itself (`flex-1 min-h-0 overflow-y-auto`). Do NOT add `max-h-[80vh]` or `overflow-y-auto` hacks to `DialogContent` — they fight the flex layout and break footer stickiness.
- For forms with focus rings, add `mt-2` to the first form element container to prevent cut-off.
7. **Handling Close Events**
- The Dialog's onClose is called with boolean false when the X button is clicked
- Handle both MouseEvent and boolean types if needed:
```tsx
const handleClose = (e?: React.MouseEvent | boolean) => {
if (typeof e === 'boolean' && !e) {
// Handle close from Dialog's X button
}
// Your close logic
};
```
8. **Confirmation Dialogs**
- For simple confirmations, use the ConfirmationDialog component
- For custom confirmations with unsaved changes:
```tsx
const hasChanges = () => {
// Only return true if user has actually entered data
return formField.trim() !== '' || otherField !== initialValue;
};
```
## DataTable Action Menus
When implementing action menus in DataTable components, follow these guidelines:
1. **Component Structure**
- Use Radix UI's DropdownMenu components from '@alga-psa/ui/components/DropdownMenu':
```tsx
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@alga-psa/ui/components/DropdownMenu';
```
2. **Trigger Button Implementation**
- Use the Button component from '@alga-psa/ui/components/Button'
- Import MoreVertical icon from 'lucide-react'
```tsx
<DropdownMenuTrigger asChild>
<Button
id="contract-line-actions-menu" // Follow pattern: {object}-actions-menu
variant="ghost"
className="h-8 w-8 p-0"
onClick={(e) => e.stopPropagation()}
>
<span className="sr-only">Open menu</span>
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
```
3. **ID Naming Convention**
Follow the component ID guidelines with these specific patterns:
- Menu trigger: `{object}-actions-menu`
- Menu items: `{action}-{object}-menu-item`
Example:
```tsx
<Button id="contract-line-actions-menu">
<DropdownMenuItem id="edit-contract-line-menu-item">
```
4. **Event Handling**
- Always use stopPropagation() to prevent row selection when clicking menu items
- Handle async operations with proper error management
```tsx
onClick={(e) => {
e.stopPropagation();
handleAction();
}}
```
5. **Styling Guidelines**
- Use theme-aware styling for destructive actions:
```tsx
// For destructive actions (delete, remove)
<DropdownMenuItem
className="text-red-600 dark:text-red-400 focus:text-red-600 dark:focus:text-red-400"
>
Delete
</DropdownMenuItem>
```
- Position dropdown content:
```tsx
<DropdownMenuContent align="end">
```
6. **Menu Content Organization**
- Order items by frequency of use
- Place destructive actions last
- Use clear, concise action names
Example structure:
```tsx
<DropdownMenuContent align="end">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem className="text-red-600 dark:text-red-400 focus:text-red-600 dark:focus:text-red-400">
Delete
</DropdownMenuItem>
</DropdownMenuContent>
```
7. **Accessibility**
- Include sr-only text for screen readers
- Ensure keyboard navigation works properly
- Maintain focus states for all interactive elements
Lucide icons can (and should) be used from the `lucide` package.
## Server Action Authentication
**Recommended Pattern:** Use the `withAuth` wrapper from `@alga-psa/auth` for all server actions that need authentication:
```typescript
import { withAuth, hasPermission } from '@alga-psa/auth';
import { createTenantKnex } from '@alga-psa/db';
export const myAction = withAuth(async (user, { tenant }, arg1: string): Promise<Result> => {
const { knex } = await createTenantKnex();
if (!await hasPermission(user, 'resource', 'action')) {
throw new Error('Permission denied');
}
return knex('table').where({ tenant }).select('*');
});
```
**Why `withAuth`?**
- Sets tenant context via AsyncLocalStorage (works with Turbopack)
- Handles authentication checks consistently
- Provides typed `user` (IUserWithRoles) and `tenant` context
- Eliminates 15-20 lines of boilerplate per action
**Available wrappers:**
- `withAuth(action)` - Requires authentication, throws if not authenticated
- `withOptionalAuth(action)` - Allows unauthenticated access (user/ctx may be null)
- `withAuthCheck(action)` - Auth check only, no tenant context (for non-DB actions)
**Legacy Pattern (avoid in new code):**
```typescript
// OLD PATTERN - do not use in new code
import { getCurrentUser } from '@alga-psa/users/actions';
export async function myAction(): Promise<Result> {
const currentUser = await getCurrentUser();
if (!currentUser) throw new Error('Not authenticated');
if (!currentUser.tenant) throw new Error('No tenant');
const { knex, tenant } = await createTenantKnex(currentUser.tenant);
// ... more boilerplate validation ...
}
```
## Server Communication
We use server actions that are located in the `/server/src/lib/actions` folder and package-specific actions in `packages/*/src/actions/`.
## Package Build System
The monorepo uses a hybrid build strategy. Some `@alga-psa/*` packages are **pre-built** (webpack resolves from `dist/`), others are **source-transpiled** (webpack compiles from `src/`). See [Package Build System](architecture/package-build-system.md) for full details.
**Key rules for AI editors:**
- **Do NOT change tsconfig paths** — TypeScript always resolves types from `src/` regardless of build mode.
- **`npm run dev` automatically builds all pre-built packages** via `npx nx build-deps server` before starting the dev server. No manual rebuild needed after pulling.
- **After editing source in a pre-built package during a running dev session**, rebuild it: `cd packages/<pkg> && npx tsup` (HMR only applies to source-transpiled packages).
- **When creating a new tsup config**, use the shared preset at `packages/build-tools/tsup-preset.ts`:
```typescript
import { defineConfig } from 'tsup';
import { makeConfig } from '../build-tools/tsup-preset';
export default defineConfig(makeConfig({ jsxEnabled: true }));
```
- **Pre-built packages** (resolve from `dist/`): `types`, `core`, `validation`, `event-schemas`, `clients`, `sla`, `assets`, `tags`.
- **Source-transpiled packages** (resolve from `src/`): `formatting`, `ui`, `billing`, `tickets`, `projects`, `scheduling`, `documents`, `auth`, `integrations`, `notifications`, `users`, and composition layers.
# ee folder
The ee folder contains the server code for the enterprise edition of the application. It is a parallel structure
containing its own migrations that are overlaid on top of the base server migrations. ee specific database changes
should be made in the migrations in the ee folder.
# Database
server migrations are stored in the `/server/migrations` folder.
seeds are stored in the `/server/seeds` folder.
information about the database can be found in the `/server/src/lib/db` folder.
Migrations and seeds are using the Knex.js library.
Always use commands like "cd server && npx knex migrate:make <name> --knexfile knexfile.cjs --env migration" to create a new migration. Do the same for seeds.
The knexfile is located in the /server/knexfile.cjs file and is used to configure the database connection.
Use createTenantKnex() from the /server/src/lib/db/index.ts file to create a database connection and return the tenant as a string.
**Correct Usage Pattern:**
```typescript
// CORRECT: Destructure both knex and tenant
const { knex, tenant } = await createTenantKnex();
// Example query
const documents = await knex('documents')
.where('tenant', tenant)
.select('*');
```
**Transaction Pattern:**
```typescript
import { withTransaction } from '@alga-psa/db';
// CORRECT: Pass knex as first parameter
const { knex } = await createTenantKnex();
await withTransaction(knex, async (trx) => {
// Use trx for all operations within the transaction
await trx('documents').insert({...});
await trx('document_associations').insert({...});
});
```
**Common Mistakes:**
```typescript
// ❌ WRONG: Missing destructuring
const knex = await createTenantKnex();
// ❌ WRONG: Missing knex parameter
await withTransaction(async (trx) => {...});
// ❌ WRONG: Don't use getConnection()
const knex = await getConnection();
```
Migrations should have a .cjs extension and should be located in the /server/migrations folder.
Run migrations with the migration environment (env) flag.
Every query should filter on the tenant column (including joins) to ensure compatibility with citusdb.
## Local EE migrations
- Do not physically copy EE migrations into `server/migrations/` locally.
- Use the temp-dir overlay runner which points Knex at a merged directory via `MIGRATIONS_DIR`.
- Commands:
- From repo root: `npm -w server run migrate:ee`
- From `server/`: `npm run migrate:ee`
- Details and rollback guidance: see `docs/migrations/local-ee-migrations.md`.
## JSON/JSONB Column Handling with Knex
When working with PostgreSQL JSON and JSONB columns in Knex.js, follow these guidelines:
1. **JSONB Column Behavior**
- PostgreSQL JSONB columns automatically serialize/deserialize JSON data
- Knex automatically handles the conversion between JavaScript objects/arrays and JSON strings
- When you store data in a JSONB column, PostgreSQL converts it to binary JSON format
- When you retrieve data from a JSONB column, PostgreSQL returns it as parsed JavaScript objects/arrays
2. **Storage Pattern**
```typescript
// Store arrays/objects as JSON strings for JSONB columns
await knex('table_name')
.insert({
json_column: JSON.stringify(arrayOrObject)
});
```
3. **Retrieval Pattern**
```typescript
// JSONB columns are automatically parsed - no need to JSON.parse()
const result = await knex('table_name')
.select('json_column')
.first();
// result.json_column is already a JavaScript object/array
const parsedData = result.json_column || []; // Use directly
```
4. **Common Mistake to Avoid**
```typescript
// WRONG - Don't JSON.parse() data from JSONB columns
const data = JSON.parse(result.json_column); // This will fail!
// CORRECT - JSONB data is already parsed
const data = result.json_column || [];
```
5. **Complete Example**
```typescript
// Storing an array in JSONB
const labelFilters = ['INBOX', 'SENT'];
await knex('google_email_provider_config')
.insert({
label_filters: JSON.stringify(labelFilters) // Store as JSON string
});
// Retrieving from JSONB
const config = await knex('google_email_provider_config')
.select('label_filters')
.first();
// Use directly - already parsed by PostgreSQL/Knex
const filters = config.label_filters || []; // No JSON.parse() needed
```
6. **Error Symptoms**
- If you see `SyntaxError: Unexpected token` when calling `JSON.parse()` on JSONB data, you're trying to parse already-parsed data
- If you see `invalid input syntax for type json` when inserting, you may be passing objects instead of JSON strings
7. **Migration Pattern**
```sql
-- Define JSONB column with default
table.jsonb('json_column').defaultTo('[]');
```
## CitusDB Compatibility
1. **CitusDB UPDATE Restrictions**
- CitusDB does not allow column references with any functions (even type casts) in UPDATE queries
- This includes IMMUTABLE functions and type casts
- Solution: Select values first, then update with parameterized queries
Example:
```typescript
// Bad - Will fail in CitusDB
await knex.raw(`
UPDATE table_name
SET new_date = old_date::date
WHERE id = 1
`);
// Good - Select and update separately
const records = await knex('table_name')
.select('id', 'old_date', 'tenant')
.where(...);
for (const record of records) {
await knex('table_name')
.where('id', record.id)
.andWhere('tenant', record.tenant)p
.update({
new_date: knex.raw('?::date', [record.old_date])
});
}
```
2. **Date/Time Handling in CitusDB**
- Always use parameterized values for type casting
- Include tenant in WHERE clauses for updates
- Handle NULL values with separate updates
Example:
```typescript
// First get the records
const records = await knex('table_name')
.select('id', 'date_column', 'tenant')
.whereNotNull('date_column');
// Then update with parameterized values
for (const record of records) {
await knex('table_name')
.where('id', record.id)
.andWhere('tenant', record.tenant)
.update({
new_date: knex.raw('?::date', [record.date_column])
});
}
```
3. **Tenant Column Requirements**
- Always include tenant column in WHERE clauses
- Include tenant in JOIN conditions
- Add tenant to unique constraints and indexes
Example:
```sql
CREATE UNIQUE INDEX my_unique_index
ON my_table (tenant, column1, column2);
```
**Tenant Column in New Tables:**
- Always name the column `tenant` (not `tenant_id`)
- Always use UUID data type for tenant columns
- **IMPORTANT:** Always include tenant in the primary key for all tables
- Set NOT NULL constraint on tenant columns
- Add foreign key reference to tenants table when appropriate
Example for creating a new table with proper tenant column:
```sql
-- Create table with tenant column
CREATE TABLE my_new_table (
entry_id uuid NOT NULL,
tenant uuid NOT NULL,
-- other columns
CONSTRAINT my_new_table_pkey PRIMARY KEY (entry_id, tenant),
CONSTRAINT my_new_table_tenant_foreign FOREIGN KEY (tenant)
REFERENCES tenants(tenant)
);
```
For existing tables that need tenant in primary key:
```sql
-- Modify existing table to include tenant in primary key
ALTER TABLE existing_table DROP CONSTRAINT existing_table_pkey;
ALTER TABLE existing_table ADD CONSTRAINT existing_table_pkey
PRIMARY KEY (id, tenant);
```
4. **Tenant Context in Distributed Queries**
- Connection-specific tenant context (`app.current_tenant`) does not propagate to all shards
- Queries without shard key (tenant) are broadcast to all shards
- Each shard connection needs its own tenant context
- Security policies checking `app.current_tenant` will fail on shards without context
Example of potential issues:
```typescript
// This could fail if broadcast to all shards
const results = await knex('some_table')
.select('*')
.where('some_column', 'value');
// Always include tenant to avoid broadcast
const results = await knex('some_table')
.select('*')
.where('tenant', currentTenant)
.andWhere('some_column', 'value');
```
5. **GUID Handling in CitusDB**
- Use UUIDs for GUIDs
- Use `gen_random_uuid()` function for generating new UUIDs
Example:
```sql
INSERT INTO my_table (id, tenant, ...)
VALUES (gen_random_uuid(), 'tenant_value', ...);
```
6. **Schema Changes on Distributed Tables**
- Standard `ALTER TABLE` commands from the coordinator may fail even when data is valid
- Use `run_command_on_shards()` to apply schema changes directly to each shard
- After applying to shards, you must also sync the coordinator's catalog metadata
- This is especially important for NOT NULL constraints
Example workflow for adding NOT NULL constraint:
```sql
-- Step 1: Verify and update any NULL values on each shard
SELECT run_command_on_shards('table_name',
'UPDATE %s SET column = ''default_value'' WHERE column IS NULL');
-- Step 2: Check for remaining NULLs
SELECT run_command_on_shards('table_name',
'SELECT COUNT(*) FROM %s WHERE column IS NULL');
-- Step 3: Apply NOT NULL constraint on each shard
SELECT run_command_on_shards('table_name',
'ALTER TABLE %s ALTER COLUMN column SET NOT NULL');
-- Step 4: Sync the coordinator's system catalog
-- This is CRITICAL - without this, the coordinator still thinks the column is nullable
UPDATE pg_attribute
SET attnotnull = true
WHERE attrelid = 'table_name'::regclass
AND attname = 'column';
-- Step 5: Now standard ALTER TABLE commands work from the coordinator
ALTER TABLE table_name
ALTER COLUMN column SET NOT NULL,
ALTER COLUMN column SET DEFAULT 'default_value';
```
**Important notes:**
- The `%s` placeholder in `run_command_on_shards()` is automatically replaced with each shard table name (e.g., `table_name_111544`)
- The `pg_attribute` update in Step 4 is essential to sync coordinator metadata with shard state
- Without Step 4, ALTER TABLE from the coordinator will continue to fail with "contains null values"
- After completing Steps 3 and 4, standard DDL commands can be used normally
## Foreign Key Constraints
- Foreign keys from reference tables to distributed tables are not supported.
- `ON DELETE SET NULL` is not supported and should be handled at the application level.
## Tenants
We use row level security and store the tenant in the `tenants` table.
Most tables require the tenant to be specified in the `tenant` column when inserting.
## Dates and times in the database:
Dates and times should use the ISO8601String type in the types.d.tsx file. In the database, we should use the postgres timestamp type.
## Date Handling Standards
1. **Use Centralized Date Utilities**
- Always use `toPlainDate` from `server/src/lib/utils/dateTimeUtils` for date conversions
- Never use `Temporal.PlainDate.from` directly in components
- Example:
```tsx
// Good
import { toPlainDate } from 'server/src/lib/utils/dateTimeUtils';
const date = toPlainDate(someDate);
// Bad
import { Temporal } from '@js-temporal/polyfill';
const date = Temporal.PlainDate.from(someDate);
```
2. **Date Type Handling**
- Use ISO8601String type for dates in interfaces and API responses
- Keep Temporal.PlainDate objects for internal state when date arithmetic is needed
- Convert to strings when sending to API or database
Example:
```tsx
// Component state
const [startDate, setStartDate] = useState<Temporal.PlainDate | null>(null);
// API call
await createPeriod({
start_date: startDate?.toString() || '',
end_date: endDate?.toString() || ''
});
```
3. **Date Comparisons**
- Use Temporal.PlainDate.compare for date comparisons
- Ensure dates are in the correct format before comparison
Example:
```tsx
if (Temporal.PlainDate.compare(startDate, endDate) >= 0) {
setError('Start date must be before end date');
}
```
4. **Date Display**
- Use toLocaleString() for displaying dates to users
- Format dates consistently across the application
Example:
```tsx
render: (date: ISO8601String) => toPlainDate(date).toLocaleString()
```
## Internationalization (i18n)
The application uses **react-i18next** for internationalization. Both the **client portal** and **MSP portal** are internationalized with feature-based namespace splitting and lazy loading.
See [docs/architecture/i18n.md](./architecture/i18n.md) for the full architecture guide.
**Configuration:**
- Supported locales: en, fr, es, de, nl, it, pl (+ xx, yy pseudo-locales for QA)
- 27 namespace files per language, ~9,959 keys total
- Translation files: `server/public/locales/{locale}/{namespace}.json`
- Central config: `packages/core/src/lib/i18n/config.ts`
- Feature flag: `msp-i18n-enabled` gates MSP translation rollout
### Namespace Usage Guidelines
**Use `common` namespace for:**
- Shared components used across the entire app (both portals)
- Generic UI elements (buttons, forms, dialogs, status labels, validation)
**Use `client-portal` namespace for:**
- Client portal UI chrome (nav, dashboard, auth, profile)
**Use `features/*` namespaces for:**
- Feature areas shared by both portals (no duplication)
- `features/tickets`, `features/projects`, `features/billing`, `features/documents`, `features/appointments`
**Use `msp/core` for:**
- MSP portal shell (nav, sidebar, header) — loads on every MSP route
**Use `msp/<feature>` for:**
- MSP-specific feature pages (settings, clients, assets, time-entry, etc.)
- Each namespace loads only on its relevant route(s)
```tsx
'use client';
import { useTranslation } from '@alga-psa/ui/lib/i18n/client';
// MSP feature component
export function TimeEntryForm() {
const { t } = useTranslation('msp/time-entry');
return (
<div>
<h1>{t('page.title')}</h1>
<button>{t('actions.save')}</button>
</div>
);
}
// Shared feature component (used by both portals)
export function TicketList() {
const { t } = useTranslation('features/tickets');
return (
<div>
<h1>{t('list.title')}</h1>
<button>{t('actions.create')}</button>
</div>
);
}
// Shared component used everywhere
export function ClientLocations({ clientId }: Props) {
const { t } = useTranslation('common');
return (
<div>
<h3>{t('clients.locations.listTitle')}</h3>
<Button>{t('clients.locations.buttons.add')}</Button>
</div>
);
}
```
### Basic Usage Patterns
**With interpolation:**
```tsx
const { t } = useTranslation('features/tickets');
<p>{t('pagination.showing', { from: 1, to: 10, total: 100 })}</p>
```
**With fallback values:**
```tsx
<span>{t('tickets.messages.error', 'An error occurred')}</span>
```
**Formatting utilities (locale-aware):**
```tsx
import { useFormatters } from '@alga-psa/ui/lib/i18n/client';
const { formatDate, formatNumber, formatCurrency, formatRelativeTime } = useFormatters();
<p>{formatCurrency(99.99, 'USD')}</p>
<p>{formatDate(entry.date, { month: 'short', day: 'numeric' })}</p>
<p>{formatRelativeTime(comment.created_at)}</p>
```
### Best Practices
1. **All user-facing text must use translation keys** — both client portal and MSP portal
2. **Choose the correct namespace** — see the guide above or `ROUTE_NAMESPACES` in `packages/core/src/lib/i18n/config.ts`
3. **Never hardcode user-facing text**:
```tsx
// Bad
<button>Save Location</button>
// Good
<button>{t('clients.locations.buttons.save')}</button>
```
4. **Use hierarchical keys**: `tickets.messages.loadingError` not `ticketsLoadingError`
5. **Use `useFormatters()`** instead of hardcoded date/number/currency formatting
6. **Use interpolation**, not string concatenation: `{{variable}}` in translation strings
7. **Test with pseudo-locale `xx`** to catch missed extractions (all strings should show `11111`)
8. **Run validation** after adding keys: `node scripts/validate-translations.cjs`
9. **Register routes in `ROUTE_NAMESPACES`** when adding new pages that use translations
# Testing Standards
All tests should follow the conventions outlined in [docs/testing-standards.md](./reference/testing-standards.md).
**Quick Reference:**
- **Unit tests**: `server/src/test/unit/` - Isolated tests with mocked dependencies
- **Integration tests**: `server/src/test/integration/` - Multi-component tests with real database
- **Infrastructure tests**: `server/src/test/infrastructure/` - Complete business workflows
- **E2E tests**: `server/src/test/e2e/` - API endpoints and full user flows
**Naming conventions:**
- Unit: `<feature>.test.ts` or `<ComponentName>.test.tsx`
- Integration: `<feature>Integration.test.ts`
- Infrastructure: `<feature>.test.ts` or `<feature>_<aspect>.test.ts` (when split)
- E2E: `<feature>.e2e.test.ts`
**Key principles:**
- Tests are centralized in `server/src/test/`, not colocated with source code
- Mirror source structure using subdirectories within test directories
- Split large test suites by concern using underscore notation (e.g., `billing_tax.test.ts`)
- Use `TestContext` helpers for infrastructure tests
- Use `setupE2ETestEnvironment()` for E2E tests
See the full [Testing Standards](./reference/testing-standards.md) document for complete guidelines, templates, and the decision tree for test placement.
# Time Entry Work Item Types
They can be:
- Ticket
- Project task
There is a work_item_type column in the time_entries table that can be used to determine the type of work item.
There is also a work_item_id column that can be used to reference the work item.
You will need to join against either the tickets or project_tasks table to get the details of the work item, including the company_id.
### Component ID Guidelines (from the UI reflection system)
1. **Use Kebab Case (Dashes, Not Underscores)**
- Hard Rule: Always use this-style-of-id rather than this_style_of_id
- Examples:
* add-ticket-button
* quick-add-ticket-dialog
* my-form-field
2. **Make Each ID Uniquely Identifying**
- Each ID should uniquely identify a single UI element within its scope
- Avoid short, ambiguous names like button1 or dialog2
- Include both the type of element and its purpose
- Good: add-employee-button
- Bad: button1
3. **Keep IDs Human-Readable**
- IDs will be used in test scripts, automation harnesses, and debugging logs
- A quick glance should communicate an element's function or meaning
- Good: delete-user-dialog
- Bad: dlg-du-1
4. **Avoid Encoding Variable Data**
- Do not include dynamic, user-generated content (like user IDs or timestamps)
- Store variable data in another attribute (e.g., data-user-id="123")
- Maintain variable data in the component's internal data
5. **Match UI Terminology**
- Keep IDs consistent with visible labels or component names
- Example: If UI shows "Quick Add Ticket" dialog, use quick-add-ticket-dialog
6. **Keep It Short but Descriptive**
- Balance length and clarity
- Prefer: submit-application-button
- Avoid: submit-this-application-to-the-server-now-button
7. **Maintain Consistency**
- Use common patterns across the codebase
- Apply same principles to all component types
- Enable predictable ID patterns for automated tooling
8. **Example Patterns**
- Buttons: {action}-{object}-button
* add-ticket-button
* delete-user-button
* save-form-button
- Dialogs: {purpose}-{object}-dialog
* quick-add-ticket-dialog
* confirmation-dialog
* edit-profile-dialog
- Form Fields: {object}-{field}-field or {object}-input
* ticket-title-field
* ticket-description-field
* user-email-input
- Data Grids: {object}-grid or {object}-{purpose}-grid
* tickets-grid
* users-report-grid

95
docs/DELETION_RULES.md Normal file
View File

@ -0,0 +1,95 @@
# Alga PSA Deletion Rules
This document explains how client and contact deletion works in Alga PSA.
## Client Deletion Rules
### You CAN delete a client if they have:
- Addresses/locations only (these will be automatically cleaned up)
- Tax settings (these will be automatically cleaned up)
- Tags (these will be automatically cleaned up)
### You CANNOT delete a client if they have:
- **Contacts** - Remove or reassign contacts first
- **Tickets** (including closed tickets) - For audit trail preservation
- **Projects** - Complete or reassign projects first
- **Documents** - Move or delete documents first
- **Invoices or billing history** - For legal/compliance requirements
- **Interactions** - Communication history must be preserved
- **Assets or devices** - Reassign or remove assets first
### Alternative: Mark as Inactive
If a client has business records, consider **marking as inactive** instead:
- Hides the client from active lists
- Preserves all historical data
- Prevents new business activities
- Can be reactivated if needed
- Related contacts can also be marked inactive
## Contact Deletion Rules
### You CAN delete a contact if they have:
- No ticket history
- No communication records
- Not set as billing contact
### You CANNOT delete a contact if they are:
- **Billing contact** for their client - Assign a different billing contact first
- **Referenced in tickets** (including closed) - For support history
- **Referenced in interactions** - For communication history
- **Associated with documents** - For audit trails
- **Assigned to projects** - Reassign or complete projects first
### Alternative: Mark as Inactive
Instead of deletion, mark contacts as **inactive**:
- Hides from active contact lists
- Preserves all business relationships
- Maintains audit trails
- Prevents new activities
- Can be reactivated if needed
## Why These Rules Exist
These deletion rules ensure:
1. **Data Integrity** - Prevents orphaned records and broken relationships
2. **Audit Compliance** - Maintains required business records
3. **Legal Protection** - Preserves invoices and communications
4. **Business Continuity** - Protects operational history and reporting
5. **User Experience** - Clear, predictable behavior
## Best Practices
### Instead of Deleting:
1. **Mark clients as inactive** when they have business history
2. **Mark contacts as inactive** rather than deleting
3. **Clean up test data** before it accumulates business records
### When Deletion is Appropriate:
1. **Test/demo clients** with no real business data
2. **Duplicate entries** created by mistake (before they gain dependencies)
3. **Initial setup cleanup** during implementation
## Technical Implementation
- All deletions happen within **database transactions** for safety
- **Permission checks** ensure only authorized users can delete/mark inactive
- **Cascade cleanup** automatically removes safe-to-delete related data
- **Detailed error messages** explain exactly what's blocking deletion
## Error Messages You Might See
**Client with dependencies:**
> "Cannot delete client with active business records. Consider marking as inactive instead to preserve data integrity."
**Contact with history:**
> "Cannot delete contact with business history: 5 tickets, communication history. Consider marking the contact as inactive instead."
**Billing contact:**
> "Cannot delete this contact because they are set as the billing contact. Please assign a different billing contact first."
---
*This document describes the actual deletion behavior implemented in Alga PSA.*

View File

@ -0,0 +1,220 @@
# Alga PSA Accounting Integrations & Mapping Guide
## Audience & Scope
This document serves product, engineering, implementation, and support teams. It explains how Alga PSA connects to accounting systems (QuickBooks Online/Desktop, Xero), how mapping data is managed, how exports are produced, and how to guide customers through the related workflows. It consolidates technical architecture references, UI behaviors, and operator/user instructions.
---
## Terminology
- **Adapter** Concrete integration for an external accounting system (e.g., `quickbooks_csv`, `quickbooks_online`, `quickbooks_desktop`, `xero`).
- **Realm / Connection ID** Adapter-specific identifier that scopes catalog data (QBO realm ID, Xero tenant ID, etc.). CSV exports typically use no realm.
- **Mapping** Tenant-scoped record linking an Alga entity (client, service, tax code, payment term) to an external identifier plus optional metadata.
- **Canonical export payload** Normalized invoice data produced by `AccountingExportService` prior to adapter formatting.
- **Batch** Logical export unit grouped by tenant, adapter, and filter set, tracked in `accounting_export_batches`.
---
## System Overview
1. Finance or onboarding staff use **Settings → Integrations → Accounting** to select an accounting package.
2. Today, **QuickBooks CSV** is available (manual import/export). **QuickBooks Online (OAuth)** and **Xero (OAuth)** are shown as **Coming soon** and are disabled in the UI.
3. The Mapping UI (generic `AccountingMappingManager`) loads adapter-provided modules and persists mappings in `tenant_external_entity_mappings`.
4. When exports run, `AccountingExportService` assembles canonical payloads from invoices/charges, resolves mappings, validates readiness, and persists `accounting_export_batches` + line-level status in `accounting_export_lines` and `accounting_export_errors`.
5. Adapters transform canonical payloads into API requests (OAuth adapters) or files (CSV) and update batch/line status.
Key architecture artifacts come from:
- UI unification plan (`ee/docs/plans/2025-10-28-accounting-mapping-ui-unification-plan.md`)
- Export abstraction plan (`ee/docs/plans/2025-10-26-accounting-export-abstraction-plan.md`)
- Generic mapping components under `server/src/components/accounting-mappings/`
- CSV module factory: `server/src/components/integrations/csv/csvMappingModules.ts`
---
## Mapping Subsystem
### Data Model
- `tenant_external_entity_mappings` (Postgres) stores `integration_type`, `alga_entity_type`, `alga_entity_id`, `external_entity_id`, optional `external_realm_id`, `metadata`, status fields, and timestamps.
- Unique constraints prevent duplicate mappings per tenant/entity/realm combination.
- Metadata enables adapter-specific payload data (e.g., Xero tax components).
### Server Actions (`server/src/lib/actions/externalMappingActions.ts`)
- Expose tenant-scoped CRUD (`getExternalEntityMappings`, `createExternalEntityMapping`, `updateExternalEntityMapping`, `deleteExternalEntityMapping`).
- Enforce RBAC (`billing_settings` read/update) and wrap operations in transactions via `withTransaction`.
- Allow filtering by adapter, entity type, entity ID, and realm.
- Used directly by mapping modules unless overridden (e.g., Playwright harness, specialized metadata handling).
### Generic React Components (`server/src/components/accounting-mappings/`)
- `AccountingMappingManager` renders tabbed modules and handles empty states. Props:
- `modules`: array of `AccountingMappingModule` config objects.
- `context`: `AccountingMappingContext` including optional `realmId`.
- Optional `realmLabel`, `tabStyles`, `defaultTabId`.
- `AccountingMappingModuleView` resolves overrides, loads mapping/catalog data, renders table actions, and orchestrates dialog/delete workflows. Supports:
- Automatic enrichment of display names.
- Adapter/realm-aware CRUD.
- Playwright overrides through `window.__ALGA_PLAYWRIGHT_ACCOUNTING__`.
- `AccountingMappingDialog` provides add/edit UI, optional JSON metadata editing, manual entry fallback when catalog data is unavailable, and realm context readout.
- `types.ts` defines configuration contracts: `AccountingMappingModule`, `AccountingMappingContext`, `AccountingMappingOverrides`, and metadata toggles.
### Module Configuration Pattern
Each adapter defines a factory that returns `AccountingMappingModule[]`. For CSV, see `createCsvMappingModules()` in `server/src/components/integrations/csv/csvMappingModules.ts`.
- Modules declare:
- `id`, `adapterType`, `algaEntityType`, `externalEntityType`.
- `labels` (tab names, table column headers, dialog copy, delete confirmations).
- `elements` for deterministic DOM ids (support QA scripts).
- `load(context)` which fetches mappings and catalog options. For CSV, Alga provides the catalog options (clients/services/tax codes/payment terms) and the external value is typically manually entered.
- `create`, `update`, `remove` operations that wrap the server actions and set adapter-specific defaults (`sync_status: 'manual_link'`, metadata persistence).
- Optional `metadata.enableJsonEditor` (enables JSON textarea in dialog).
- Optional `resolveOverrides` returning `AccountingMappingOverrides` for test harness or niche adapter logic.
### Overrides & Testing Hooks
- Playwright tests register overrides via `window.__ALGA_PLAYWRIGHT_ACCOUNTING__[adapterType][moduleId]` to stub load/create/update/delete during e2e tests.
- Modules can set `overridesKey` to reuse a shared override set across tabs when needed.
### Existing Adapter Modules
- **QuickBooks CSV**: `createCsvMappingModules()` surfaces Client, Items/Services, Tax Codes, and Payment Terms mappings. The external identifier is entered manually (no OAuth catalog lookup).
- **QuickBooks Online (OAuth)** and **Xero (OAuth)**: shown as **Coming soon** in the Accounting Integrations setup screen. When enabled, they will provide catalog-backed selectors and adapter-specific metadata where required.
### Realm Handling
- `AccountingMappingContext.realmId` is optional. OAuth adapters will pass realm/tenant identifiers; CSV exports omit it (single-tenant manual flow).
- The dialog renders the realm value read-only when provided to reduce accidental mismatches.
---
## Accounting Export Architecture
### Canonical Schema (Export Abstraction Plan Phase 1-3)
- `AccountingExportService` assembles invoices/charges into canonical structures containing invoice headers, line items, taxes, and mapping resolutions.
- Stores outputs in `accounting_export_batches` and `accounting_export_lines` with statuses (`validating`, `ready`, `delivered`, `failed`), timestamps (`validated_at`, `delivered_at`), and external references.
- Maintains currency precision, service period metadata, tracking dimensions, and mapping lookups.
### Service & Workflow Integration
- Batch creation/execution exposed through workflow actions (`accounting_export.create_batch`, `accounting_export.execute_batch`) allowing Temporal workflows/Automation Hub to orchestrate exports.
- Events (`ACCOUNTING_EXPORT_COMPLETED`, `ACCOUNTING_EXPORT_FAILED`) emitted on completion for downstream automation/notifications.
- Status updates handle retries and preserve timestamps unless overwritten.
### Adapter Interface (`server/src/lib/adapters/accounting/accountingExportAdapter.ts`)
- Defines common contract: `capabilities`, `transform(canonicalBatch)`, `deliver(transformedBatch)`, optional `postProcess`.
- `AccountingAdapterRegistry` registers QuickBooks Online/Desktop and Xero adapters, resolved via `adapter_type`.
### QuickBooks Online Adapter Highlights (Phase 4)
- Transforms canonical batches into QBO invoice DTOs using `QboClientService`.
- Resolves service, tax, and payment term mappings through the generic resolver and persists SyncToken metadata (stored in mapping `metadata`).
- Deprecates legacy workflow helpers (`lookup_qbo_item_id`, `create_qbo_invoice`) in favor of export service orchestration.
- Pending work: granular rate limiting and partial-failure retry logic.
### QuickBooks Desktop (Planned)
- Generates IIF/CSV files capturing GL transactions (`TRNS`/`SPL` rows).
- Will expose download links via export dashboard and rely on GL account mappings (new mapping module planned).
### Xero Adapter Highlights (Phase 5)
- Uses `XeroClientService` for OAuth token refresh and catalog access (`listAccounts`, `listItems`, `listTaxRates`, `listTrackingCategories`).
- Supports multi-component tax lines, tracking category metadata, and error normalization into export line records.
- Manual retry trigger UI remains outstanding but service already flags failed lines for rerun.
---
## User Workflows
### Prerequisites
1. Ensure tenant has Accounting feature toggle enabled.
2. Select an accounting integration in **Settings → Integrations → Accounting**:
- **QuickBooks CSV**: available now (manual import/export).
- **QuickBooks Online (OAuth)** and **Xero (OAuth)**: coming soon (disabled in UI).
3. Confirm user role grants `Billing Settings` permissions.
### Managing Mappings
1. Navigate to **Settings → Accounting Integrations**.
2. Select **QuickBooks CSV**. The mapping tabs are rendered by `AccountingMappingManager`.
3. For each tab:
- Click **Add … Mapping**.
- Choose an Alga entity (client/service/tax code/payment term). Locked when editing an existing mapping.
- Enter the external identifier (manual entry for CSV).
- Save; dialog displays validation errors from server actions.
4. To edit or delete:
- Use the row action menu.
- Confirm deletion in modal. Deleting removes mapping record from `tenant_external_entity_mappings`.
5. Refresh data via tab reload (automatic after create/update/delete).
### Running Exports
1. From **Billing → Accounting Exports**:
- Create a new batch for **QuickBooks CSV** (or other adapters when enabled).
- Execute the batch; file-based adapters return a downloadable artifact.
2. From **Settings → Integrations → Accounting → QuickBooks CSV**:
- Choose invoice filters (date range + statuses) and click **Export CSV**.
- The export creates (or reuses) an `accounting_export_batch`, validates mappings, and generates the file.
2. Fix issues and retry:
- Missing mappings transition the batch to `needs_attention` and the UI lists what to map.
- After adding mappings, retrying validates again and the same batch can proceed once it becomes `ready`.
3. Download artifact:
- CSV exports return a downloadable CSV compatible with QuickBooks invoice import.
4. Address failures:
- Inspect `accounting_export_lines` for errors (UI surfaces message).
- Resolve root cause (often missing mapping, invalid tax rate, or authentication).
- Re-run batch after correcting data; failed lines can be retried.
### Troubleshooting Checklist
- **Missing mapping error** Create mapping in relevant tab; rerun export.
- **Realm mismatch** Verify connection ID shown in dialog matches authorized accounting tenant.
- **Metadata parse failure** Validate JSON structure in mapping dialog if your adapter expects metadata.
---
## Operational Considerations
- **Permissions** `hasPermission(user, 'billing_settings', 'read|update')` gates mapping actions. Support teams need elevated roles to assist tenants.
- **Feature flags** Rollout of unified mapping UI may be staged; confirm feature toggle status before enabling for tenants.
- **Logging** Server actions log create/update/delete events with tenant context. Export flows log batch lifecycle and adapter responses.
- **Auditing** `tenant_external_entity_mappings` retains timestamps; `accounting_export_batches` captures `triggered_by` user id for traceability.
- **Backfills & migrations** Mappings are canonicalized to `alga_entity_type = 'client'` (customers) and `alga_entity_type = 'tax_code'` (tax). Migrations normalize legacy values in `tenant_external_entity_mappings`.
- **Testing** Use Playwright harness overrides for deterministic UI tests; Vitest covers module factories; integration tests execute export flows against sqlite/pg fixtures. Sandbox runs against QBO/Xero demo companies capture regression fixtures.
---
## Roadmap & Open Items
- Complete remaining tasks in the UI unification plan:
- Enable OAuth-based adapters (QuickBooks Online, Xero) in the Accounting Integrations setup screen.
- Add optional catalog-backed selectors for external entities (when OAuth is available).
- Publish `docs/accounting_exports.md` once the Accounting Exports dashboard ships.
- Export abstraction plan outstanding work:
- Implement QuickBooks Online rate limiting and partial failure retries.
- Deliver QuickBooks Desktop file export and download UI.
- Surface export dashboard, invoice detail integration, and notification flows.
- Build manual retry UI for Xero adapter failures.
- Documentation backlog:
- Publish customer-facing admin guide (this doc provides internal baseline).
- Add per-adapter troubleshooting appendix once dashboard UX hardens.
---
## Key Reference Files
- `server/src/components/accounting-mappings/AccountingMappingManager.tsx`
- `server/src/components/accounting-mappings/AccountingMappingModuleView.tsx`
- `server/src/components/accounting-mappings/AccountingMappingDialog.tsx`
- `server/src/components/accounting-mappings/types.ts`
- `server/src/components/integrations/csv/CSVMappingManager.tsx`
- `server/src/components/integrations/csv/csvMappingModules.ts`
- `server/src/lib/actions/externalMappingActions.ts`
- `server/src/lib/adapters/accounting/accountingExportAdapter.ts`
- `server/src/lib/services/accountingExportService.ts`
- `server/src/lib/adapters/accounting/quickBooksCSVAdapter.ts`
- `ee/docs/plans/2025-10-28-accounting-mapping-ui-unification-plan.md`
- `ee/docs/plans/2025-10-26-accounting-export-abstraction-plan.md`
---
## Appendix: Adding a New Adapter
1. **Define adapter constants** (`adapterType`, realm semantics).
2. **Implement module factory** returning `AccountingMappingModule[]`; reuse server actions or build adapter-specific actions as needed.
3. **Expose manager** in UI (e.g., `<AccountingMappingManager modules={createAdapterModules()} context={{ realmId }} />`).
4. **Implement export adapter** conforming to `accountingExportAdapter` contract; register it in `AccountingAdapterRegistry`.
5. **Wire credential management** (OAuth/token exchange) and catalog loaders.
6. **Extend Playwright overrides** for new adapter module IDs.
7. **Document user-facing setup** within release notes and support knowledge base.
---
## Appendix: User-Facing Walkthrough Template
Use the following outline when crafting tenant-facing guides:
1. Prerequisites (permissions, connector setup, sandbox links).
2. Mapping checklist per entity type with screenshots.
3. Export run book (filters, expected processing time, verification).
4. Troubleshooting table (common errors, resolutions, escalation path).
5. Change log capturing adapter updates, credential reauthorization windows, and contact info.

View File

@ -0,0 +1,378 @@
# AI Automation VNC Setup Guide
## Overview
The AI automation system supports VNC (Virtual Network Computing) for visual debugging and monitoring of browser automation tasks. This allows developers to see the browser in real-time as Puppeteer performs automation tasks.
## Architecture
The VNC system consists of several components working together:
```
┌─────────────────┐ ┌──────────────┐ ┌─────────────┐
│ Web Browser │───▶│ NoVNC Client │───▶│ WebSocket │
│ (User Client) │ │ (JavaScript) │ │ Proxy │
└─────────────────┘ └──────────────┘ └─────────────┘
┌─────────────────┐ ┌──────────────┐ ┌─────────────┐
│ Chromium │◀───│ Fluxbox │◀───│ x11vnc │
│ (Puppeteer) │ │ Window Mgr │ │ VNC Server │
└─────────────────┘ └──────────────┘ └─────────────┘
┌─────────────────────┴─────────────────────┐
│ Xvfb │
│ (Virtual X Server) │
│ Display :99 │
└───────────────────────────────────────────┘
```
## Components
### 1. Xvfb (X Virtual Framebuffer)
- **Purpose**: Provides a virtual X11 display server
- **Display**: `:99` (configurable)
- **Resolution**: 1280x1024x16 (optimized for performance)
- **Configuration**: Hardware acceleration disabled for Kubernetes compatibility
### 2. Fluxbox (Window Manager)
- **Purpose**: Manages windows and provides desktop environment
- **Features**: Lightweight, minimal resource usage
- **Menu**: Right-click context menu with applications
- **Config**: Automatically generates basic menu on startup
### 3. x11vnc (VNC Server)
- **Purpose**: Shares the X display over VNC protocol
- **Port**: 5901 (internal)
- **Security**: No password (localhost only)
- **Features**: Caching enabled for better performance
### 4. Websockify (WebSocket Proxy)
- **Purpose**: Bridges VNC protocol to WebSocket for web browsers
- **Port**: 5900 (exposed)
- **Client**: NoVNC JavaScript client
### 5. NoVNC (Web VNC Client)
- **Purpose**: HTML5 VNC client for browsers
- **Access**: Available at `/vnc/` endpoint
- **Features**: Auto-connect, scaling, touch support
## Environment Configuration
### Required Environment Variables
| Variable | Value | Purpose |
|----------|-------|---------|
| `VNC_ENABLED` | `true` | Enables VNC mode in container |
| `DISPLAY` | `:99` | X11 display number |
### Docker Configuration
The Dockerfile includes all necessary VNC components:
```dockerfile
# Install VNC components
RUN apt-get install -qq -y --no-install-recommends \
xvfb \
x11vnc \
fluxbox \
x11-utils \
xauth \
python3 \
python3-pip
# Install websockify
RUN pip3 install --no-cache-dir websockify==0.10.0
# Copy NoVNC client
RUN mkdir -p /usr/share/novnc && \
curl -fsSL https://github.com/novnc/noVNC/archive/v1.3.0.tar.gz | \
tar -xz --strip-components=1 -C /usr/share/novnc
```
## Startup Process
### 1. Container Initialization
When `VNC_ENABLED=true`, the container uses the improved VNC startup script:
```bash
./vnc-startup-improved.sh npm run dev
```
### 2. Service Startup Sequence
1. **Directory Setup**: Creates required directories with proper permissions
2. **Xvfb Launch**: Starts virtual X server with fallback configurations
3. **Window Manager**: Launches Fluxbox with auto-generated menu
4. **VNC Server**: Starts x11vnc with optimized settings
5. **WebSocket Proxy**: Launches websockify for web access
6. **Application**: Starts the Node.js AI automation server
### 3. Browser Mode Detection
The application automatically detects VNC mode and configures Puppeteer accordingly:
```typescript
// In src/index.ts
const useHeadedMode = process.env.VNC_ENABLED === 'true';
await puppeteerManager.init({
headless: !useHeadedMode,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
```
## Access Methods
### 1. Direct VNC Access
Connect using any VNC client to `<pod-ip>:5900` or through port forwarding:
```bash
kubectl port-forward pod/<pod-name> 5900:5900 -n <namespace>
```
### 2. Web Browser Access
Access through the web interface at:
- Auto-connect: `http://<host>:5900/autoconnect.html`
- Manual connect: `http://<host>:5900/vnc.html`
- Debug interface: `http://<host>:5900/debug.html`
### 3. Kubernetes Port Forwarding
For development access:
```bash
# Forward VNC port
kubectl port-forward deployment/<deployment-name> 5900:5900 -n <namespace>
# Forward API port (if needed)
kubectl port-forward deployment/<deployment-name> 4000:4000 -n <namespace>
```
## Troubleshooting
### Common Issues
#### 1. Black Screen
**Symptoms**: VNC connects but shows only black screen
**Causes**:
- Xvfb not running
- Window manager crashed
- No applications visible
**Solutions**:
```bash
# Check processes
kubectl exec <pod> -- ps aux | grep -E "(Xvfb|fluxbox|x11vnc)"
# Restart window manager
kubectl exec <pod> -- pkill fluxbox
kubectl exec <pod> -- DISPLAY=:99 fluxbox &
# Check logs
kubectl exec <pod> -- cat /tmp/xvfb/fluxbox.log
```
#### 2. VNC Connection Refused
**Symptoms**: Cannot connect to VNC port
**Causes**:
- x11vnc not running
- Port not exposed
- Firewall blocking connection
**Solutions**:
```bash
# Check x11vnc process
kubectl exec <pod> -- pgrep x11vnc
# Check port binding
kubectl exec <pod> -- netstat -ln | grep 5900
# Restart x11vnc
kubectl exec <pod> -- pkill x11vnc
kubectl exec <pod> -- x11vnc -display :99 -nopw -listen localhost -rfbport 5901 &
```
#### 3. Browser Still Headless
**Symptoms**: VNC works but browser not visible
**Causes**:
- Application not detecting VNC mode
- Browser launched before X server ready
**Solutions**:
```bash
# Check environment
kubectl exec <pod> -- env | grep VNC_ENABLED
# Check browser processes
kubectl exec <pod> -- ps aux | grep chromium
# Restart application
kubectl delete pod <pod-name>
```
### Log Locations
| Component | Log Location |
|-----------|--------------|
| Xvfb | `/tmp/xvfb/xvfb.log` |
| Fluxbox | `/tmp/xvfb/fluxbox.log` |
| x11vnc | `/tmp/xvfb/x11vnc.log` |
| Websockify | Container stdout |
| Application | Container stdout |
### Diagnostic Commands
```bash
# Check all VNC processes
kubectl exec <pod> -- ps aux | grep -E "(Xvfb|fluxbox|x11vnc|websockify)"
# Test X display
kubectl exec <pod> -- DISPLAY=:99 xdpyinfo
# Check window list
kubectl exec <pod> -- DISPLAY=:99 xwininfo -root -tree
# Monitor VNC connections
kubectl exec <pod> -- tail -f /tmp/xvfb/x11vnc.log
```
## Performance Optimization
### Resource Configuration
For optimal performance in Kubernetes:
```yaml
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "1"
```
### Display Settings
The startup script automatically adjusts display settings based on available memory:
- **High memory** (>512MB): 1280x1024x16
- **Medium memory** (256-512MB): 1024x768x16
- **Low memory** (<256MB): 640x480x8
### VNC Optimization
- **Caching**: Enabled with `-ncache 10 -ncache_cr`
- **Compression**: WebSocket compression enabled
- **Color depth**: 16-bit for balance of quality and performance
## Security Considerations
### Network Security
- VNC server bound to localhost only
- No VNC password (relies on network isolation)
- WebSocket proxy provides controlled access
### Kubernetes Security
- No privileged containers required
- Runs as non-root user (`appuser`)
- No host network access needed
### Access Control
- VNC access controlled by Kubernetes network policies
- API access controlled by service exposure
- No sensitive data displayed in VNC session
## Development Workflow
### Local Development
1. **Build image with VNC**:
```bash
nu cli/main.nu build-ai-api --push --use-latest
```
2. **Deploy with VNC enabled**:
```bash
helm upgrade --install <release> ./helm \
--set ai.api.env.VNC_ENABLED=true
```
3. **Access VNC**:
```bash
kubectl port-forward deployment/<deployment> 5900:5900
# Open browser to http://localhost:5900/autoconnect.html
```
### Debugging Browser Automation
1. **Connect to VNC** to see live browser session
2. **Use developer tools** in the automation browser
3. **Monitor console logs** in real-time
4. **Step through automation** visually
### Testing Changes
1. **Make code changes**
2. **Rebuild and redeploy** with VNC enabled
3. **Connect via VNC** to see changes in action
4. **Debug issues** visually
## Integration with Nginx Proxy
The AI automation system includes nginx as a reverse proxy for VNC access:
```nginx
# VNC WebSocket proxy
location /vnc/ {
proxy_pass http://ai-api:5900/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# VNC static files
location /vnc/websockify {
proxy_pass http://ai-api:5900/websockify;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
```
This allows accessing VNC through the main application URL at `/vnc/` path.
## Future Enhancements
### Planned Features
- **Multi-user support**: Multiple VNC sessions
- **Recording**: Session recording for debugging
- **Shared sessions**: Collaborative debugging
- **Mobile optimization**: Better touch interface
### Performance Improvements
- **GPU acceleration**: When available in Kubernetes
- **Adaptive quality**: Dynamic compression based on bandwidth
- **Connection pooling**: Reuse VNC connections
---
## Quick Start Checklist
- [ ] Set `VNC_ENABLED=true` in deployment
- [ ] Ensure VNC port (5900) is exposed
- [ ] Build image with latest VNC scripts
- [ ] Deploy to Kubernetes
- [ ] Port forward: `kubectl port-forward <pod> 5900:5900`
- [ ] Open browser to `http://localhost:5900/autoconnect.html`
- [ ] Verify browser appears in VNC session
- [ ] Test automation tasks visually
For additional support, check the troubleshooting section or container logs.

View File

@ -0,0 +1,491 @@
# AI Automation VNC - Technical Implementation Guide
## Code Architecture
### File Structure
```
tools/ai-automation/
├── src/
│ ├── index.ts # Main application entry point
│ ├── browserSessionManager.ts # Browser session management
│ └── puppeteerManager.ts # Puppeteer lifecycle management
├── vnc-startup-improved.sh # Enhanced VNC startup script
├── vnc-autoconnect.html # Auto-connecting VNC client
├── vnc-index.html # VNC client index page
├── vnc-debug.html # VNC debugging interface
└── Dockerfile # Container configuration
```
## Implementation Details
### 1. Browser Mode Detection
The system automatically detects VNC mode and configures browser accordingly:
```typescript
// src/index.ts (lines 601-611)
async function startServer() {
console.log('Initializing Puppeteer...');
// Use headed mode when VNC is enabled for visual debugging
const useHeadedMode = process.env.VNC_ENABLED === 'true';
console.log(`VNC_ENABLED: ${process.env.VNC_ENABLED}, using headed mode: ${useHeadedMode}`);
await puppeteerManager.init({
headless: !useHeadedMode,
args: [
'--no-sandbox',
'--disable-setuid-sandbox'
]
}, 5);
}
```
### 2. Browser Session Management
The `BrowserSessionManager` handles both headless and headed modes:
```typescript
// src/browserSessionManager.ts (lines 39-58)
public async createSession(sessionId: string, mode: 'headless' | 'headed' = 'headless'): Promise<BrowserSession> {
console.log(`[SESSION] Creating new ${mode} browser session: ${sessionId}`);
const launchOptions = {
headless: mode === 'headless',
args: mode === 'headed'
? [
'--window-size=1900,1200',
'--disable-web-security',
'--disable-features=VizDisplayCompositor'
]
: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--window-size=1900,1200'
],
protocolTimeout: 60000,
dumpio: false,
slowMo: mode === 'headed' ? 50 : 100
};
const browser = await puppeteer.launch(launchOptions);
// ... rest of session creation
}
```
### 3. VNC Startup Script
The enhanced startup script (`vnc-startup-improved.sh`) provides robust VNC initialization:
#### Key Features:
- **Resource Detection**: Automatically adjusts configuration based on available memory
- **Fallback Configurations**: Multiple Xvfb configurations for compatibility
- **Error Handling**: Comprehensive error checking and recovery
- **Process Management**: Proper cleanup and restart capabilities
#### Configuration Levels:
```bash
# Memory-based configuration selection
case "$config_level" in
"minimal") # <256MB RAM
configs=(
"-screen 0 640x480x8 -ac -extension GLX"
"-screen 0 640x480x8 -ac"
"-screen 0 320x240x8 -ac"
)
;;
"reduced") # 256-512MB RAM
configs=(
"-screen 0 1024x768x16 -ac -nolisten tcp -extension GLX"
"-screen 0 800x600x16 -ac -extension GLX"
"-screen 0 800x600x8 -ac"
)
;;
*) # >512MB RAM
configs=(
"-screen 0 1280x1024x16 -ac -nolisten tcp -extension GLX +extension RANDR"
"-screen 0 1024x768x16 -ac -extension GLX"
"-screen 0 800x600x16 -ac"
"-screen 0 640x480x8 -ac"
)
;;
esac
```
### 4. Window Manager Configuration
Fluxbox is configured with an auto-generated menu:
```bash
# vnc-startup-improved.sh (lines 166-181)
mkdir -p ~/.fluxbox
if [ ! -f ~/.fluxbox/menu ]; then
cat > ~/.fluxbox/menu << 'EOF'
[begin] (Fluxbox)
[exec] (Terminal) {x-terminal-emulator}
[exec] (File Manager) {thunar}
[submenu] (Applications)
[exec] (Web Browser) {chromium}
[exec] (Text Editor) {nano}
[end]
[submenu] (System)
[exec] (Reload Config) {fluxbox-remote reload}
[restart] (Restart)
[exit] (Exit)
[end]
[end]
EOF
fi
```
### 5. Docker Configuration
The Dockerfile properly sets up the VNC environment:
```dockerfile
# Environment variables for VNC
ENV DISPLAY=:99
ENV XVFB_WHD=1280x1024x16
ENV XVFB_COLORDEPTH=16
ENV XVFB_ARGS="-ac -nolisten tcp -dpi 96 +extension RANDR"
# Force software rendering for Kubernetes compatibility
ENV LIBGL_ALWAYS_SOFTWARE=1
ENV GALLIUM_DRIVER=llvmpipe
ENV LP_NO_RAST=false
ENV LIBGL_DRI3_DISABLE=1
ENV LIBGL_ALWAYS_INDIRECT=1
# Install VNC components
RUN apt-get install -qq -y --no-install-recommends \
xvfb \
x11vnc \
fluxbox \
x11-utils \
xauth \
python3 \
python3-pip
# Install websockify for WebSocket proxy
RUN pip3 install --no-cache-dir websockify==0.10.0
# Copy VNC client files
COPY vnc-*.html /usr/share/novnc/
# Make startup scripts executable
RUN chmod +x vnc-startup*.sh || true
# Conditional startup based on VNC_ENABLED
CMD if [ "$VNC_ENABLED" = "true" ]; then \
if [ -f "./vnc-startup-improved.sh" ]; then \
echo "Using improved VNC startup script..." && \
./vnc-startup-improved.sh npm run dev; \
else \
echo "Using standard VNC startup script..." && \
./vnc-startup.sh; \
fi \
else \
export DISPLAY=:99 && \
Xvfb :99 -screen 0 1024x768x16 -ac > /tmp/xvfb.log 2>&1 & \
sleep 2 && \
npm run dev; \
fi
```
## Process Management
### Service Dependencies
The VNC system has a specific startup order:
1. **Xvfb** - Must start first to provide display
2. **Fluxbox** - Window manager depends on Xvfb
3. **x11vnc** - VNC server connects to display
4. **Websockify** - WebSocket proxy for web access
5. **Application** - Node.js app launches browser
### Process Monitoring
Each component includes health checks:
```bash
check_process() {
local process_name=$1
if pgrep -f "$process_name" > /dev/null; then
local pid=$(pgrep -f "$process_name" | head -1)
log_info "$process_name is running (PID: $pid)"
return 0
else
log_error "$process_name is NOT running"
return 1
fi
}
```
### Error Recovery
The system includes automatic retry logic:
```bash
# x11vnc startup with retries
for attempt in $(seq 1 $MAX_RETRIES); do
log_info "Starting x11vnc (attempt $attempt/$MAX_RETRIES)..."
x11vnc -display :${DISPLAY_NUM} \
-nopw \
-listen localhost \
-xkb \
-ncache 10 \
-ncache_cr \
-forever \
-shared \
-rfbport ${VNC_PORT} \
-o /tmp/xvfb/x11vnc.log \
> /tmp/xvfb/x11vnc_stdout.log 2>&1 &
sleep 3
if check_process "x11vnc"; then
vnc_started=true
break
else
log_warn "x11vnc failed to start, attempt $attempt"
sleep $RETRY_DELAY
fi
done
```
## Network Configuration
### Port Mapping
| Service | Internal Port | External Port | Protocol |
|---------|---------------|---------------|----------|
| x11vnc | 5901 | - | VNC |
| websockify | 5900 | 5900 | WebSocket |
| Node.js API | 4000 | 4000 | HTTP |
### Nginx Proxy Configuration
For production deployments, nginx routes VNC traffic:
```nginx
server {
listen 8080;
server_name _;
# VNC WebSocket proxy
location /vnc/ {
proxy_pass http://127.0.0.1:5900/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
# API endpoints
location /api/ {
proxy_pass http://127.0.0.1:4000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Default to web interface
location / {
proxy_pass http://127.0.0.1:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
## Memory Management
### Resource Optimization
The system optimizes resource usage based on available memory:
```bash
check_system_resources() {
log_info "Checking system resources..."
# Memory check
local mem_available=$(awk '/MemAvailable/ {print int($2/1024)}' /proc/meminfo)
log_info "Available memory: ${mem_available}MB"
if [ "$mem_available" -lt 256 ]; then
echo "minimal"
elif [ "$mem_available" -lt 512 ]; then
echo "reduced"
else
echo "standard"
fi
}
```
### X Server Configuration
Software rendering is forced to avoid driver issues:
```bash
# Force software rendering for compatibility
export LIBGL_ALWAYS_SOFTWARE=1
export GALLIUM_DRIVER=llvmpipe
export LP_NO_RAST=false
export LIBGL_DRI3_DISABLE=1
export LIBGL_ALWAYS_INDIRECT=1
```
## Debugging and Monitoring
### Log Files
All components write to structured log files:
```bash
/tmp/xvfb/
├── xvfb.log # Xvfb output
├── xdpyinfo.log # Display test results
├── fluxbox.log # Window manager logs
├── x11vnc.log # VNC server logs
└── x11vnc_stdout.log # VNC server stdout
```
### Health Check Endpoints
The application provides health check endpoints:
```typescript
app.get('/api/browser/status', async (req, res) => {
try {
const page = puppeteerManager.getPage();
const url = page.url();
res.json({
status: 'ok',
url: url,
vnc_enabled: process.env.VNC_ENABLED === 'true'
});
} catch (error) {
res.status(500).json({
status: 'error',
message: error.message
});
}
});
```
### Performance Metrics
Monitor key metrics for performance:
- **Memory usage**: Container memory consumption
- **CPU usage**: X server and browser CPU usage
- **Network**: VNC traffic and WebSocket connections
- **Response time**: Browser automation response times
## Security Implementation
### Sandboxing
Browser security is maintained through sandboxing:
```typescript
args: [
'--no-sandbox', // Required for containers
'--disable-setuid-sandbox', // Required for non-root
'--disable-web-security', // For headed mode flexibility
'--disable-features=VizDisplayCompositor' // Performance optimization
]
```
### Network Isolation
VNC access is restricted:
- x11vnc binds to localhost only
- websockify provides controlled WebSocket access
- No direct VNC protocol exposure outside container
### User Permissions
Container runs as non-root user:
```dockerfile
RUN useradd -m appuser
USER appuser
```
## Testing and Validation
### Automated Tests
Health checks verify VNC functionality:
```bash
# Test X display
DISPLAY=:99 xdpyinfo > /dev/null 2>&1
# Test window manager
pgrep fluxbox > /dev/null
# Test VNC server
netstat -ln | grep 5901 > /dev/null
# Test WebSocket proxy
netstat -ln | grep 5900 > /dev/null
```
### Manual Testing
Validation checklist:
1. Container starts without errors
2. All VNC processes are running
3. VNC client can connect
4. Browser window is visible
5. Automation tasks work in headed mode
6. No memory leaks during extended operation
## Performance Tuning
### VNC Optimization
x11vnc is configured for optimal performance:
```bash
x11vnc -display :${DISPLAY_NUM} \
-nopw \ # No password for speed
-listen localhost \ # Local only for security
-xkb \ # Keyboard support
-ncache 10 \ # Client-side caching
-ncache_cr \ # Cache collision reduction
-forever \ # Keep running
-shared \ # Multiple clients
-rfbport ${VNC_PORT}
```
### Browser Performance
Puppeteer is optimized for VNC:
```typescript
slowMo: mode === 'headed' ? 50 : 100 // Slower for visual debugging
```
### Resource Limits
Recommended Kubernetes resource limits:
```yaml
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "1"
```
This documentation provides complete technical details for implementing, maintaining, and troubleshooting the VNC system in the AI automation platform.

View File

@ -0,0 +1,661 @@
# API Rate Limiting and Webhooks
This document covers two related public API behaviors:
- Authenticated `/api/v1/*` requests are protected by per-key rate limiting.
- Ticket and project lifecycle events can be delivered to tenant-managed outbound webhooks.
## Rate Limiting
### Scope
Rate limiting applies to authenticated public REST API requests that use
`x-api-key`.
It does not apply to:
- health/version endpoints
- internal runner/storage/scheduler/invoicing/client/service endpoints
- mobile auth endpoints
### Default Limits
- Burst capacity: `120` requests
- Sustained refill: `60` requests per minute
That is equivalent to roughly `1` request per second sustained with a burst of
`120`.
Limits are tracked per `(tenant, api_key_id)`.
### Success Headers
Successful authenticated responses include:
- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
Example:
```http
HTTP/1.1 200 OK
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 87
Content-Type: application/json
```
### 429 Responses
When a key exceeds its bucket, the API returns `429 Too Many Requests` with:
- `Retry-After`: integer seconds until a token is available
- `X-RateLimit-Limit`
- `X-RateLimit-Remaining`
- `X-RateLimit-Reset`: ISO 8601 timestamp
Example:
```http
HTTP/1.1 429 Too Many Requests
Retry-After: 12
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 2026-05-05T14:22:31.000Z
Content-Type: application/json
```
```json
{
"error": {
"message": "Too many requests",
"code": "RATE_LIMITED",
"details": {
"retry_after_ms": 12000,
"remaining": 0
}
}
}
```
### Observation Mode Versus Enforcement
Rollout can run in observation mode with `RATE_LIMIT_ENFORCE=false`.
In observation mode:
- the same limit calculation still runs
- the same rate-limit headers still appear
- throttled requests are logged for analysis
- the request is allowed through instead of returning `429`
In enforcement mode (`RATE_LIMIT_ENFORCE=true`), throttled requests return
`429`.
### Client Guidance
- Treat `429` as retryable.
- Use `Retry-After` first if present.
- Back off per key, not globally across unrelated tenants/keys.
- Do not assume a successful request means the next one will also succeed
immediately if `X-RateLimit-Remaining` is low.
## Ticket Webhooks
### Supported Events
Ticket webhooks support these event types in v1:
- `ticket.created`
- `ticket.updated`
- `ticket.status_changed`
- `ticket.assigned`
- `ticket.closed`
- `ticket.comment.added`
### Delivery Envelope
Every outbound webhook is delivered as JSON:
```json
{
"event_id": "6e8d9668-e7af-4a71-b734-9e3cb74b06b7",
"event_type": "ticket.assigned",
"occurred_at": "2026-05-05T14:10:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"ticket_id": "22222222-2222-2222-2222-222222222222",
"ticket_number": "T-1042",
"title": "Printer offline",
"status_id": "33333333-3333-3333-3333-333333333333",
"status_name": "Open",
"priority_id": "44444444-4444-4444-4444-444444444444",
"priority_name": "High",
"client_id": "55555555-5555-5555-5555-555555555555",
"client_name": "Acme Manufacturing",
"contact_name_id": "66666666-6666-6666-6666-666666666666",
"contact_name": "Jordan Smith",
"contact_email": "jordan@example.com",
"assigned_to": "77777777-7777-7777-7777-777777777777",
"assigned_to_name": "Pat Lee",
"assigned_team_id": null,
"board_id": "88888888-8888-8888-8888-888888888888",
"board_name": "Support",
"category_id": "99999999-9999-9999-9999-999999999999",
"subcategory_id": null,
"is_closed": false,
"entered_at": "2026-05-05T13:55:00.000Z",
"updated_at": "2026-05-05T14:10:00.000Z",
"closed_at": null,
"due_date": null,
"tags": ["printer", "onsite"],
"url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222"
}
}
```
### Example Payloads
#### `ticket.created`
```json
{
"event_id": "11111111-aaaa-bbbb-cccc-111111111111",
"event_type": "ticket.created",
"occurred_at": "2026-05-05T14:00:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"ticket_id": "22222222-2222-2222-2222-222222222222",
"ticket_number": "T-1042",
"title": "Printer offline",
"status_name": "Open",
"priority_name": "High",
"client_name": "Acme Manufacturing",
"tags": [],
"url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222"
}
}
```
#### `ticket.updated`
```json
{
"event_id": "11111111-aaaa-bbbb-cccc-222222222222",
"event_type": "ticket.updated",
"occurred_at": "2026-05-05T14:05:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"ticket_id": "22222222-2222-2222-2222-222222222222",
"ticket_number": "T-1042",
"title": "Printer offline at front desk",
"status_name": "Open",
"tags": ["printer"],
"url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222",
"changes": {
"title": {
"previous": "Printer offline",
"new": "Printer offline at front desk"
}
}
}
}
```
#### `ticket.status_changed`
```json
{
"event_id": "11111111-aaaa-bbbb-cccc-333333333333",
"event_type": "ticket.status_changed",
"occurred_at": "2026-05-05T14:07:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"ticket_id": "22222222-2222-2222-2222-222222222222",
"ticket_number": "T-1042",
"status_id": "33333333-3333-3333-3333-333333333334",
"status_name": "In Progress",
"previous_status_id": "33333333-3333-3333-3333-333333333333",
"previous_status_name": "Open",
"tags": [],
"url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222"
}
}
```
#### `ticket.assigned`
```json
{
"event_id": "11111111-aaaa-bbbb-cccc-444444444444",
"event_type": "ticket.assigned",
"occurred_at": "2026-05-05T14:10:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"ticket_id": "22222222-2222-2222-2222-222222222222",
"assigned_to": "77777777-7777-7777-7777-777777777777",
"assigned_to_name": "Pat Lee",
"status_name": "In Progress",
"tags": [],
"url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222"
}
}
```
#### `ticket.closed`
```json
{
"event_id": "11111111-aaaa-bbbb-cccc-555555555555",
"event_type": "ticket.closed",
"occurred_at": "2026-05-05T16:20:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"ticket_id": "22222222-2222-2222-2222-222222222222",
"status_name": "Closed",
"is_closed": true,
"closed_at": "2026-05-05T16:20:00.000Z",
"tags": [],
"url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222"
}
}
```
#### `ticket.comment.added`
```json
{
"event_id": "11111111-aaaa-bbbb-cccc-666666666666",
"event_type": "ticket.comment.added",
"occurred_at": "2026-05-05T14:25:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"ticket_id": "22222222-2222-2222-2222-222222222222",
"ticket_number": "T-1042",
"status_name": "In Progress",
"tags": [],
"url": "https://algapsa.com/msp/tickets/22222222-2222-2222-2222-222222222222",
"comment": {
"text": "Scheduled onsite visit for 3 PM.",
"author": "Pat Lee",
"timestamp": "2026-05-05T14:25:00.000Z",
"is_internal": false
}
}
}
```
Comment payloads never include attachments.
## Project Webhooks
### Supported Events
Project webhooks fire for both project-level and project-task lifecycle changes:
- `project.created`
- `project.updated`
- `project.status_changed`
- `project.assigned`
- `project.closed`
- `project.completed`**deprecated alias** for `project.closed`. Existing subscribers continue to receive it; new integrations should subscribe to `project.closed`.
- `project.task.created`
- `project.task.updated`
- `project.task.status_changed`
- `project.task.assigned`
- `project.task.completed`
### Payload Allowlist and Correlation Keys
Project-level and task-level events share a **single `project` allowlist** in the `payload_fields` configuration; you cannot allowlist task fields independently of project fields.
Two keys are always retained regardless of the allowlist:
- `project_id` — present on every project-family delivery.
- `task_id` — additionally retained on every `project.task.*` delivery.
The supported allowlist values are documented inline in `payload_fields` on the `POST /api/v1/webhooks` request schema (and in the source of truth at `server/src/lib/webhooks/payloadFields.ts`).
### Delivery Envelope
Every outbound webhook is delivered as JSON. Project-level event example:
```json
{
"event_id": "abc12300-1111-2222-3333-444444444444",
"event_type": "project.assigned",
"occurred_at": "2026-05-12T10:00:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"project_id": "33333333-3333-3333-3333-333333333333",
"project_name": "Acme Onboarding",
"wbs_code": "01.02.03",
"description": "Initial cutover engagement.",
"status_id": "55555555-5555-5555-5555-555555555555",
"status_name": "In Progress",
"is_closed": false,
"client_id": "66666666-6666-6666-6666-666666666666",
"client_name": "Acme Manufacturing",
"contact_name_id": "77777777-7777-7777-7777-777777777777",
"contact_name": "Jordan Smith",
"contact_email": "jordan@example.com",
"assigned_to": "88888888-8888-8888-8888-888888888888",
"assigned_to_name": "Pat Lee",
"start_date": "2026-05-01",
"end_date": "2026-07-31",
"budgeted_hours": 120,
"url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333"
}
}
```
Project-task envelope (`project.task.*` events) carries a flattened task record alongside the parent project identifiers:
```json
{
"event_id": "abc12300-aaaa-bbbb-cccc-555555555555",
"event_type": "project.task.assigned",
"occurred_at": "2026-05-12T10:30:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"project_id": "33333333-3333-3333-3333-333333333333",
"project_name": "Acme Onboarding",
"client_id": "66666666-6666-6666-6666-666666666666",
"client_name": "Acme Manufacturing",
"task_id": "99999999-9999-9999-9999-999999999999",
"phase_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"phase_name": "Discovery",
"task_name": "Inventory legacy mailboxes",
"status_id": "55555555-5555-5555-5555-555555555556",
"status_name": "In Progress",
"is_closed": false,
"assigned_to": "88888888-8888-8888-8888-888888888888",
"assigned_to_name": "Pat Lee",
"estimated_hours": 8,
"actual_hours": 0,
"due_date": "2026-05-20",
"priority_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"priority_name": "High",
"wbs_code": "01.02.03.01",
"url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333?taskId=99999999-9999-9999-9999-999999999999",
"tags": ["onboarding"]
}
}
```
### Example Payloads
#### `project.created`
```json
{
"event_id": "abc12300-aaaa-bbbb-cccc-100000000000",
"event_type": "project.created",
"occurred_at": "2026-05-12T09:00:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"project_id": "33333333-3333-3333-3333-333333333333",
"project_name": "Acme Onboarding",
"status_name": "Planning",
"client_name": "Acme Manufacturing",
"url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333"
}
}
```
#### `project.updated`
`changes` is a key-by-key diff of fields that changed in this update; absent fields did not change.
```json
{
"event_id": "abc12300-aaaa-bbbb-cccc-200000000000",
"event_type": "project.updated",
"occurred_at": "2026-05-12T09:15:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"project_id": "33333333-3333-3333-3333-333333333333",
"project_name": "Acme Onboarding — Phase 1",
"url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333",
"changes": {
"project_name": {
"previous": "Acme Onboarding",
"new": "Acme Onboarding — Phase 1"
},
"end_date": {
"previous": "2026-07-31",
"new": "2026-08-31"
}
}
}
}
```
#### `project.status_changed`
```json
{
"event_id": "abc12300-aaaa-bbbb-cccc-300000000000",
"event_type": "project.status_changed",
"occurred_at": "2026-05-12T09:30:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"project_id": "33333333-3333-3333-3333-333333333333",
"status_id": "55555555-5555-5555-5555-555555555555",
"status_name": "In Progress",
"previous_status_id": "55555555-5555-5555-5555-555555555550",
"previous_status_name": "Planning",
"url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333"
}
}
```
#### `project.assigned`
See the [Delivery Envelope](#delivery-envelope-1) example above.
#### `project.closed`
`project.closed` fires when `is_closed` transitions to `true`.
```json
{
"event_id": "abc12300-aaaa-bbbb-cccc-400000000000",
"event_type": "project.closed",
"occurred_at": "2026-08-30T17:00:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"project_id": "33333333-3333-3333-3333-333333333333",
"status_name": "Completed",
"is_closed": true,
"url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333"
}
}
```
#### `project.completed`
Same payload as `project.closed`, with `event_type` set to `project.completed`. Delivered for backwards compatibility alongside `project.closed`; new integrations should subscribe to `project.closed`.
#### `project.task.created`
```json
{
"event_id": "abc12300-aaaa-bbbb-cccc-500000000000",
"event_type": "project.task.created",
"occurred_at": "2026-05-12T10:05:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"project_id": "33333333-3333-3333-3333-333333333333",
"project_name": "Acme Onboarding",
"task_id": "99999999-9999-9999-9999-999999999999",
"task_name": "Inventory legacy mailboxes",
"phase_name": "Discovery",
"status_name": "Not Started",
"url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333?taskId=99999999-9999-9999-9999-999999999999",
"tags": []
}
}
```
#### `project.task.updated`
```json
{
"event_id": "abc12300-aaaa-bbbb-cccc-600000000000",
"event_type": "project.task.updated",
"occurred_at": "2026-05-12T10:10:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"project_id": "33333333-3333-3333-3333-333333333333",
"task_id": "99999999-9999-9999-9999-999999999999",
"due_date": "2026-05-22",
"url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333?taskId=99999999-9999-9999-9999-999999999999",
"tags": ["onboarding"],
"changes": {
"due_date": { "previous": "2026-05-20", "new": "2026-05-22" },
"tags": { "previous": [], "new": ["onboarding"] }
}
}
}
```
When `changes.tags` is present it carries the **authoritative post-change tag set**; clients should prefer it over the cached `tags` snapshot.
#### `project.task.status_changed`
```json
{
"event_id": "abc12300-aaaa-bbbb-cccc-700000000000",
"event_type": "project.task.status_changed",
"occurred_at": "2026-05-12T10:20:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"project_id": "33333333-3333-3333-3333-333333333333",
"task_id": "99999999-9999-9999-9999-999999999999",
"status_id": "55555555-5555-5555-5555-555555555556",
"status_name": "In Progress",
"previous_status_id": "55555555-5555-5555-5555-555555555551",
"previous_status_name": "Not Started",
"url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333?taskId=99999999-9999-9999-9999-999999999999",
"tags": ["onboarding"]
}
}
```
#### `project.task.assigned`
See the project-task [Delivery Envelope](#delivery-envelope-1) example above.
#### `project.task.completed`
`project.task.completed` fires when a task transitions to a closed status.
```json
{
"event_id": "abc12300-aaaa-bbbb-cccc-800000000000",
"event_type": "project.task.completed",
"occurred_at": "2026-05-19T16:45:00.000Z",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"data": {
"project_id": "33333333-3333-3333-3333-333333333333",
"task_id": "99999999-9999-9999-9999-999999999999",
"status_name": "Done",
"is_closed": true,
"url": "https://algapsa.com/msp/projects/33333333-3333-3333-3333-333333333333?taskId=99999999-9999-9999-9999-999999999999",
"tags": ["onboarding"]
}
}
```
## Shared Webhook Mechanics
Signature, verification, delivery semantics, retry, and outbound rate limits apply to all webhook events regardless of family (ticket, project, …).
### Signature Headers
Each delivery includes these headers:
- `X-Alga-Signature: t=<unix-seconds>,v1=<hex-hmac-sha256>`
- `X-Alga-Webhook-Id`
- `X-Alga-Event-Id`
- `X-Alga-Event-Type`
- `X-Alga-Delivery-Id`
- `X-Alga-Delivery-Attempt`
The signature is computed over:
```text
${timestamp}.${raw_request_body}
```
### Verification Recipe
#### Node.js
```js
import crypto from 'node:crypto';
function verifySignature(secret, rawBody, signatureHeader) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((item) => item.split('='))
);
const payload = `${parts.t}.${rawBody}`;
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
return expected === parts.v1;
}
```
#### Python
```python
import hmac
import hashlib
def verify_signature(secret: str, raw_body: str, signature_header: str) -> bool:
parts = dict(item.split("=", 1) for item in signature_header.split(","))
payload = f"{parts['t']}.{raw_body}".encode("utf-8")
expected = hmac.new(
secret.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts["v1"])
```
Reject payloads whose timestamp is too old for your replay window. A
five-minute window is recommended.
### Delivery Semantics
- Delivery is at least once.
- `event_id` is the idempotency key.
- Ordering is not guaranteed across different webhook subscriptions.
- Ordering is not guaranteed across different event types for the same ticket.
- Consumers should make handlers idempotent and safe to replay.
### Retry Behavior
Failed non-test deliveries are retried with this schedule:
| Attempt after failure | Delay |
| --- | --- |
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 12 hours |
After the fifth failed attempt, the delivery is abandoned.
### Per-Webhook Outbound Rate Limit
Webhook delivery also has an outbound cap per webhook. The default is
`100 deliveries per minute` per `(tenant, webhook_id)`.
Test deliveries sent through `POST /api/v1/webhooks/{id}/test` do not consume
that outbound bucket.

View File

@ -0,0 +1,212 @@
# API Getting Started Guide
This guide will help you get started with using the Alga PSA APIs. It's designed for API consumers who want to integrate with our system.
## Note: This document describes pre-release APIs. Many features are not yet available, and APIs are subject to change.
## 1. Introduction
The Alga PSA APIs provide programmatic access to our Professional Services Automation platform. You can use these APIs to:
- Manage tenants and their configurations
- Access and update business data
- Automate workflows
- Integrate with your existing systems
Our APIs follow REST principles and use JSON for request/response payloads. All API access is over HTTPS, and all data is sent and received as JSON.
> 📝 **Note:** The Alga PSA hosted environment is available at `algapsa.com`. If you are running an on-premise installation, replace this with your configured domain.
## 2. Obtaining API Access
### Prerequisites
- An active Alga PSA account
- Appropriate permissions to generate API keys
### Generating an API Key
API keys can be generated through our web interface:
1. Log in to your Alga PSA account
2. Click on your profile in the top-right corner
3. Navigate to your User Profile settings
4. Scroll down to the "API Keys" section
5. Click "Generate New API Key"
6. Provide a description (e.g., "Integration Testing")
7. Optionally set an expiration date
8. Save the generated API key securely - it will only be shown once and cannot be retrieved later
## 3. Authentication
All API requests must include your API key in the `x-api-key` header:
```http
GET /api/v1/endpoint
Host: algapsa.com
x-api-key: your-api-key-here
```
Example using cURL:
```bash
curl -H "x-api-key: your-api-key-here" https://algapsa.com/api/v1/endpoint
```
Example using JavaScript/Node.js:
```javascript
const response = await fetch('https://algapsa.com/api/v1/endpoint', {
headers: {
'x-api-key': 'your-api-key-here'
}
});
```
Example using Python:
```python
import requests
headers = {
'x-api-key': 'your-api-key-here'
}
response = requests.get('https://algapsa.com/api/v1/endpoint', headers=headers)
```
## 4. Making Your First API Call
Let's verify your API access by making a simple request:
```bash
curl -H "x-api-key: your-api-key-here" https://algapsa.com/api/health
```
Expected successful response:
```json
{
"status": "ok",
"version": "1.0.0"
}
```
## 5. Understanding Responses
### Success Responses
Successful responses will have a 2xx status code and return JSON data:
```json
{
"data": {
"id": "123",
"name": "Example Resource"
}
}
```
### Error Responses
Error responses will have a 4xx or 5xx status code and include error details:
```json
{
"error": {
"message": "Invalid API key",
"code": "AUTH001"
}
}
```
Common HTTP Status Codes:
- 200: Success
- 201: Created
- 400: Bad Request
- 401: Unauthorized (invalid or missing API key)
- 403: Forbidden (valid API key but insufficient permissions)
- 404: Not Found
- 429: Too Many Requests
- 500: Internal Server Error
## 6. Available APIs
### Community Edition APIs
Our Community Edition includes core functionality APIs for:
- Asset Management
- Billing
- Companies/Clients
- Contacts
- Documents
- Projects
- Support Ticketing
- Time Management
### Enterprise Edition APIs
Enterprise Edition includes additional APIs for:
- Tenant Provisioning
- Advanced Workflows
- Custom Integrations
For detailed API documentation, refer to:
- [API Overview](api_overview.md)
- [Tenant Provisioning API](tenant_provisioning_api.md)
## 7. Best Practices
### Security
- Keep your API keys secure and never share them
- Rotate API keys periodically
- Use separate API keys for different integrations
- Set appropriate expiration dates for API keys
### Performance
- Implement proper error handling
- Cache responses when appropriate
- Handle rate limits gracefully
- Use pagination for large data sets
### Integration Tips
- Start with a test environment
- Implement proper logging
- Set up monitoring for API usage
- Keep track of API versions and changes
## 8. Rate Limiting
To ensure fair usage and system stability:
- Implement exponential backoff when encountering rate limits
- Watch for 429 (Too Many Requests) status codes
- Consider implementing request queuing for high-volume operations
Example handling of rate limits:
```javascript
async function makeRequest(url, apiKey, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, {
headers: { 'x-api-key': apiKey }
});
if (response.status === 429) {
// Wait for 2^i seconds before retrying
await new Promise(resolve =>
setTimeout(resolve, Math.pow(2, i) * 1000)
);
continue;
}
return await response.json();
} catch (error) {
if (i === retries - 1) throw error;
}
}
}
```
## 9. Support and Resources
- **Documentation:** Visit our [API Overview](api_overview.md) for detailed documentation
- **Support:** Contact our API support team at api-support@algapsa.com
- **Updates:** Subscribe to our API changelog for updates
- **Community:** Join our developer community for discussions and best practices
## 10. Next Steps
1. Generate your API key
2. Make your first API call to verify access
3. Review the detailed API documentation
4. Start building your integration
5. Monitor your API usage
6. Join our developer community
Remember to always test your integration thoroughly in a non-production environment before deploying to production.

Some files were not shown because too many files have changed in this diff Show More