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
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:
commit
284313f908
1
.crew/audit/prune.jsonl
Normal file
1
.crew/audit/prune.jsonl
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"action":"prune","keep":10,"kept":[],"removed":[],"auditedAt":"2026-06-10T12:39:57.533Z"}
|
||||||
222
.crew/state/metrics/2026-06-10.jsonl
Normal file
222
.crew/state/metrics/2026-06-10.jsonl
Normal 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
82
.dockerignore
Normal 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
44
.env.e2e
Normal 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
372
.env.example
Normal 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
15
.env.localtest
Normal 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
21
.env.runner
Normal 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
13
.env.runner.example
Normal 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
241
.github/README.md
vendored
Normal 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
45
.github/docker-compose.yaml
vendored
Normal 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
10
.github/known-cycles.json
vendored
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
17
.github/workflows/bidi-control-guard.yml
vendored
Normal file
17
.github/workflows/bidi-control-guard.yml
vendored
Normal 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
45
.github/workflows/circular-deps.yml
vendored
Normal 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
|
||||||
145
.github/workflows/citus-migration-smoke.yml
vendored
Normal file
145
.github/workflows/citus-migration-smoke.yml
vendored
Normal 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
|
||||||
899
.github/workflows/e2e-fresh-install-tests.yaml
vendored
Normal file
899
.github/workflows/e2e-fresh-install-tests.yaml
vendored
Normal 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 ~5–10 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
42
.github/workflows/ext-v2-guard.yml
vendored
Normal 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
141
.github/workflows/integration-tests.yml
vendored
Normal 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
118
.github/workflows/mobile-checks.yml
vendored
Normal 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
74
.github/workflows/mobile-distribute.yml
vendored
Normal 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
|
||||||
27
.github/workflows/secrets-env-backup-guard.yml
vendored
Normal file
27
.github/workflows/secrets-env-backup-guard.yml
vendored
Normal 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
|
||||||
|
|
||||||
75
.github/workflows/temporal-readiness.yml
vendored
Normal file
75
.github/workflows/temporal-readiness.yml
vendored
Normal 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
80
.github/workflows/typecheck.yml
vendored
Normal 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
139
.github/workflows/unit-tests.yml
vendored
Normal 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
|
||||||
158
.github/workflows/validate-tenant-management.yaml
vendored
Normal file
158
.github/workflows/validate-tenant-management.yaml
vendored
Normal 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
|
||||||
45
.github/workflows/validate-translations.yml
vendored
Normal file
45
.github/workflows/validate-translations.yml
vendored
Normal 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
|
||||||
69
.github/workflows/workflows-ee-build-guard.yml
vendored
Normal file
69
.github/workflows/workflows-ee-build-guard.yml
vendored
Normal 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
231
.gitignore
vendored
Normal 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
238
.impeccable/design.json
Normal 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
6
.npmrc
Normal 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
|
||||||
27
.nxignore
Normal file
27
.nxignore
Normal 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
152
ARCHITECTURE_FIX.md
Normal 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
277
DESIGN.md
Normal 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
84
Dockerfile
Normal 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
158
Dockerfile.build
Normal 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
46
Dockerfile.dev
Normal 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
17
Dockerfile.pgvector-clean
Normal 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
10
Dockerfile.test
Normal 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
21
LICENSE.md
Normal 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
41
Makefile
Normal 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
48
PRODUCT.md
Normal 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
241
README.md
Normal 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
347
admin-theme-generator.py
Normal 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
1
admin-theme.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"primaryColor": "", "secondaryColor": ""}
|
||||||
105
cli/CONFIG.md
Normal file
105
cli/CONFIG.md
Normal 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
831
cli/build.nu
Normal 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
738
cli/cleanup-tenant.nu
Normal 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
9
cli/colors.nu
Normal 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
217
cli/config.nu
Normal 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
1238
cli/dev-env.nu
Normal file
File diff suppressed because it is too large
Load Diff
1097
cli/hosted-env.nu
Normal file
1097
cli/hosted-env.nu
Normal file
File diff suppressed because it is too large
Load Diff
812
cli/main.nu
Executable file
812
cli/main.nu
Executable 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
167
cli/migrate.nu
Normal 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
49
cli/portal-domain.nu
Normal 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
214
cli/tenant.nu
Normal 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
80
cli/utils.nu
Normal 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
181
cli/workflows.nu
Normal 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
145
context.md
Normal 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 1–220) — **Client→own client_id + visibility group board filter** on ticket queries
|
||||||
|
11. `packages/client-portal/src/actions/client-portal-actions/visibilityGroupActions.ts` (lines ~130–165) — **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 1–80) — Client document access gated by resolved client_id
|
||||||
|
14. `server/src/app/api/documents/view/[fileId]/route.ts` (lines 100–400) — **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 110–200) — **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 720–850) — **Quote approval workflow**: status gates (draft→pending_approval→approved), separate `requireQuoteApprovePermission()`
|
||||||
|
18. `packages/billing/src/actions/recurringApprovalBlockers.ts` (lines 1–60) — **Billing blocked by time approval status**: invoice generation checks `time_entries.approval_status` != 'APPROVED'
|
||||||
|
19. `packages/projects/src/actions/projectTaskCommentActions.ts` (lines 145–175) — **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 1–80) — Billing metrics scoped to user's client_id via contact chain
|
||||||
|
23. `server/src/lib/api/controllers/ApiBaseController.ts` (lines 1–130) — 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 120–330)
|
||||||
|
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
19
devbox.json
Normal 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
284
devbox.lock
Normal 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
152
docker-compose.base.yaml
Normal 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
357
docker-compose.ce.yaml
Normal 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
|
||||||
223
docker-compose.e2e-local.yaml
Normal file
223
docker-compose.e2e-local.yaml
Normal 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
|
||||||
87
docker-compose.e2e-simple.yaml
Normal file
87
docker-compose.e2e-simple.yaml
Normal 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
|
||||||
255
docker-compose.e2e-with-worker.yaml
Normal file
255
docker-compose.e2e-with-worker.yaml
Normal 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
345
docker-compose.e2e.yaml
Normal 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
531
docker-compose.ee.yaml
Normal 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
|
||||||
58
docker-compose.imap-test.yaml
Normal file
58
docker-compose.imap-test.yaml
Normal 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
158
docker-compose.imap.ce.yaml
Normal 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
|
||||||
49
docker-compose.minio-dev.yaml
Normal file
49
docker-compose.minio-dev.yaml
Normal 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
112
docker-compose.ngrok.yaml
Normal 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
|
||||||
30
docker-compose.override.yaml
Normal file
30
docker-compose.override.yaml
Normal 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}
|
||||||
30
docker-compose.playwright-deps.yaml
Normal file
30
docker-compose.playwright-deps.yaml
Normal 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
|
||||||
130
docker-compose.playwright-workflow-deps.yml
Normal file
130
docker-compose.playwright-workflow-deps.yml
Normal 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:
|
||||||
24
docker-compose.playwright.yml
Normal file
24
docker-compose.playwright.yml
Normal 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
|
||||||
136
docker-compose.prebuilt.base.yaml
Normal file
136
docker-compose.prebuilt.base.yaml
Normal 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
|
||||||
378
docker-compose.prebuilt.ce.yaml
Normal file
378
docker-compose.prebuilt.ce.yaml
Normal 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:
|
||||||
366
docker-compose.prebuilt.ee.yaml
Normal file
366
docker-compose.prebuilt.ee.yaml
Normal 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
50
docker-compose.prod.yaml
Normal 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}
|
||||||
22
docker-compose.runner-dev.yml
Normal file
22
docker-compose.runner-dev.yml
Normal 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
|
||||||
55
docker-compose.setup-ubuntu.override.yaml
Normal file
55
docker-compose.setup-ubuntu.override.yaml
Normal 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"]
|
||||||
113
docker-compose.test-citus.yaml
Normal file
113
docker-compose.test-citus.yaml
Normal 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
|
||||||
59
docker-compose.test-minio.yaml
Normal file
59
docker-compose.test-minio.yaml
Normal 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
28
docker-compose.test.yml
Normal 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
241
docker-compose.yaml
Normal 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:
|
||||||
6
docker/dev-env/.dockerignore
Normal file
6
docker/dev-env/.dockerignore
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Ignore everything except the files we need
|
||||||
|
*
|
||||||
|
!config/
|
||||||
|
!config/settings.json
|
||||||
|
!config/extensions.json
|
||||||
|
!start-dev-env.sh
|
||||||
143
docker/dev-env/Dockerfile.code-server
Normal file
143
docker/dev-env/Dockerfile.code-server
Normal 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
52
docker/dev-env/README.md
Normal 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
|
||||||
51
docker/dev-env/build-code-server.sh
Executable file
51
docker/dev-env/build-code-server.sh
Executable 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}"
|
||||||
24
docker/dev-env/config/extensions.json
Normal file
24
docker/dev-env/config/extensions.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
73
docker/dev-env/config/settings.json
Normal file
73
docker/dev-env/config/settings.json
Normal 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
69
docker/dev-env/start-dev-env.sh
Executable 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
944
docs/AI_coding_standards.md
Normal 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…</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
95
docs/DELETION_RULES.md
Normal 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.*
|
||||||
220
docs/accounting-integration-overview.md
Normal file
220
docs/accounting-integration-overview.md
Normal 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.
|
||||||
378
docs/ai-automation/ai-automation-vnc-setup.md
Normal file
378
docs/ai-automation/ai-automation-vnc-setup.md
Normal 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.
|
||||||
491
docs/ai-automation/ai-automation-vnc-technical.md
Normal file
491
docs/ai-automation/ai-automation-vnc-technical.md
Normal 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.
|
||||||
661
docs/api/api-rate-limiting-and-webhooks.md
Normal file
661
docs/api/api-rate-limiting-and-webhooks.md
Normal 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.
|
||||||
212
docs/api/api_getting_started_guide.md
Normal file
212
docs/api/api_getting_started_guide.md
Normal 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
Loading…
x
Reference in New Issue
Block a user