diff --git a/infra/one-click/main.go b/infra/one-click/main.go index e5f646fd..2a3ad183 100644 --- a/infra/one-click/main.go +++ b/infra/one-click/main.go @@ -1382,6 +1382,7 @@ set_env ARCHE_VERSION %s set_env ARCHE_RELEASE_VERSION %s set_env ARCHE_WEB_IMAGE %s set_env OPENCODE_IMAGE %s +set_env ARCHE_FLOW_SCHEDULER_MODE daemon set -a . /opt/%s/.env set +a @@ -1389,8 +1390,8 @@ log "Pulling ${ARCHE_WEB_IMAGE}" docker pull "${ARCHE_WEB_IMAGE}" log "Pulling ${OPENCODE_IMAGE}" docker pull "${OPENCODE_IMAGE}" -log 'Recreating web service' -docker compose up -d web +log 'Recreating web and flows services' +docker compose up -d web flows docker compose ps log 'Update complete' `, appName, appName, appName, shellQuote(b64(compose)), shellQuote(version), shellQuote(version), shellQuote(appImageForVersion(version)), shellQuote(workspaceImageForVersion(version)), appName) @@ -1901,6 +1902,7 @@ ARCHE_GATEWAY_TOKEN_SECRET=%s ARCHE_COOKIE_SECURE=true ARCHE_SESSION_TTL_DAYS=7 +ARCHE_FLOW_SCHEDULER_MODE=daemon ARCHE_SEED_ADMIN_EMAIL=%s ARCHE_SEED_ADMIN_PASSWORD=%s @@ -2012,6 +2014,29 @@ services: - traefik.http.routers.arche.tls.certresolver=letsencrypt - traefik.http.services.arche-web.loadbalancer.server.port=%d + flows: + image: "${ARCHE_WEB_IMAGE}" + restart: unless-stopped + command: + - ./node_modules/.bin/tsx + - src/flow-daemon.ts + env_file: + - .env + depends_on: + postgres: + condition: service_healthy + docker-socket-proxy: + condition: service_healthy + networks: + - default + - arche-internal + volumes: + - /opt/arche/users:/opt/arche/users + - /opt/arche/kb-content:/kb-content + - /opt/arche/kb-config:/kb-config + healthcheck: + disable: true + networks: default: arche-internal: @@ -2122,6 +2147,7 @@ ARCHE_GATEWAY_TOKEN_SECRET=${ARCHE_GATEWAY_TOKEN_SECRET} ARCHE_COOKIE_SECURE=true ARCHE_SESSION_TTL_DAYS=7 +ARCHE_FLOW_SCHEDULER_MODE=daemon ARCHE_SEED_ADMIN_EMAIL=${ADMIN_EMAIL} ARCHE_SEED_ADMIN_PASSWORD=${ARCHE_SEED_ADMIN_PASSWORD} diff --git a/infra/one-click/main_test.go b/infra/one-click/main_test.go index 9aefebc7..a8a4ebd5 100644 --- a/infra/one-click/main_test.go +++ b/infra/one-click/main_test.go @@ -193,6 +193,56 @@ func TestValidateTemplatesAcceptsPRVersion(t *testing.T) { } } +func TestRenderEnvFileSetsFlowSchedulerMode(t *testing.T) { + t.Parallel() + + envFile := renderEnvFile(input{token: "sample", email: "admin@example.com", version: "v1.2.3"}, stateSecrets{ + PostgresPassword: "postgres-password", + SessionPepper: "session-pepper", + EncryptionKey: "encryption-key", + InternalToken: "internal-token", + GatewayTokenSecret: "gateway-token", + ConnectorOAuthStateSecret: "oauth-state-token", + AdminPassword: "admin-password", + }, envPlaceholderDomain, envPlaceholderPublicBaseURL) + if !strings.Contains(envFile, "ARCHE_FLOW_SCHEDULER_MODE=daemon") { + t.Fatalf("renderEnvFile() should set ARCHE_FLOW_SCHEDULER_MODE=daemon:\n%s", envFile) + } +} + +func TestRenderBootstrapScriptWritesFlowSchedulerMode(t *testing.T) { + t.Parallel() + + script := renderBootstrapScript(input{token: "sample", email: "admin@example.com", version: "v1.2.3"}) + if !strings.Contains(script, "ARCHE_FLOW_SCHEDULER_MODE=daemon") { + t.Fatalf("renderBootstrapScript() should write ARCHE_FLOW_SCHEDULER_MODE=daemon to the env file:\n%s", script) + } +} + +func TestRenderUpdateScriptSetsFlowSchedulerModeAndStartsFlows(t *testing.T) { + t.Parallel() + + script := renderUpdateScript("v1.2.3", "services:\n web:\n") + if !strings.Contains(script, "set_env ARCHE_FLOW_SCHEDULER_MODE daemon") { + t.Fatalf("renderUpdateScript() should set ARCHE_FLOW_SCHEDULER_MODE via set_env:\n%s", script) + } + if !strings.Contains(script, "docker compose up -d web flows") { + t.Fatalf("renderUpdateScript() should bring up the flows service alongside web:\n%s", script) + } +} + +func TestRenderComposeRunsFlowDaemon(t *testing.T) { + t.Parallel() + + compose := renderCompose() + if !strings.Contains(compose, "\n flows:\n") { + t.Fatalf("renderCompose() should declare a flows service:\n%s", compose) + } + if !strings.Contains(compose, "src/flow-daemon.ts") { + t.Fatalf("renderCompose() should run the flow-daemon entrypoint:\n%s", compose) + } +} + func TestValidateVersionAcceptsPRVersion(t *testing.T) { t.Parallel() diff --git a/openspec/changes/archectl-flow-scheduler-mode/.openspec.yaml b/openspec/changes/archectl-flow-scheduler-mode/.openspec.yaml new file mode 100644 index 00000000..ecf3b45d --- /dev/null +++ b/openspec/changes/archectl-flow-scheduler-mode/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-31 diff --git a/openspec/changes/archectl-flow-scheduler-mode/design.md b/openspec/changes/archectl-flow-scheduler-mode/design.md new file mode 100644 index 00000000..5621a222 --- /dev/null +++ b/openspec/changes/archectl-flow-scheduler-mode/design.md @@ -0,0 +1,65 @@ +## Context + +See `proposal.md` — Why for the startup crash and the daemon/runner pairing. + +Current mechanics that shape this design (verified in code): + +- `getFlowSchedulerMode()` accepts `daemon` | `inline` | `off` and **throws in production** when the variable is missing or invalid; `registerNodeInstrumentation()` starts the inline scheduler only when the mode is `inline`. +- `startFlowDaemon()` (`apps/web/src/flow-daemon.ts`) exits unless the mode is `daemon`; the one-click Ansible-free path therefore needs both the variable and a runner. +- The Ansible remote template is the reference topology: `.env.j2` writes `daemon` for `deploy_mode == 'remote'`, and `compose.yml.j2` runs dedicated `reaper` and `flows` services using the web image (`./node_modules/.bin/tsx src/.ts`), with the same env file, networks, host mounts, `depends_on` on healthy postgres + docker-socket-proxy, and healthchecks disabled. +- archectl always deploys the remote-equivalent topology: web in the compose stack on a fresh VM, sibling workspace containers via docker-socket-proxy, host paths `/opt/arche/{users,kb-content,kb-config}`. +- The production web image (`apps/web/Containerfile`) ships `src/`, `node_modules/`, and `tsconfig.json`, so the `tsx` daemon entrypoint runs in the same image archectl already uses. +- archectl's update path rewrites compose from the rendered template and amends the env file with the idempotent `set_env` helper (delete matching key, append), then recreates only the listed services. + +## Goals / Non-Goals + +**Goals:** + +- Fresh archectl deployments boot with a valid production flow-scheduler configuration and a running flow dispatcher. +- Existing archectl deployments converge to the same state on their next update, without manual env editing. +- The one-click artifacts stay a single source of truth: one rendered compose, one rendered env, validated by `validateTemplates`. + +**Non-Goals:** + +- Changing the web app's scheduler modes or `getFlowSchedulerMode()` behavior (the throw-on-missing production guard is correct and is what surfaced this bug). +- Adding a `reaper` service to archectl's compose. archectl currently runs no reaper at all (the Ansible remote path does); that is a real but separate parity gap — fixing it here would grow this change into a topology redesign. Tracked as a follow-up. +- Introducing a `deploy_mode` concept or an inline/daemon toggle to archectl; one-click deployments are remote-equivalent, so the value is a constant. +- Making `inline` the archectl value. It would boot, but it runs flow dispatch on the web server process — the exact load the dedicated-runner topology exists to move off the request-serving path. + +## Decisions + +### D1: Constant `ARCHE_FLOW_SCHEDULER_MODE=daemon` in all three rendered artifacts + +- Matches the Ansible remote value for the same topology. `renderEnvFile` is the validated template, the bootstrap heredoc is what actually lands on the VM, and `set_env` in the update script converges existing deployments; all three must agree or a path drifts. +- `set_env` is idempotent (deletes any existing line first), so repeated updates never duplicate the key. + +### D2: A `flows` service in the generated compose, mirroring the Ansible remote service + +- Same image (`${ARCHE_WEB_IMAGE}`), command `./node_modules/.bin/tsx src/flow-daemon.ts`, `env_file: .env`, both networks, the three host mounts (users, kb-content, kb-config), `depends_on` postgres + docker-socket-proxy healthy, and healthcheck disabled — byte-for-byte the same posture as the Ansible `flows` service so the two deploy paths stay symmetric. +- The daemon needs the host mounts because flow execution reads KB config/content and writes user workspace data, same as web. +- Alternative considered: `inline` with no new service — rejected in Non-Goals; it changes the runtime topology rather than repairing it. + +### D3: The update script starts `flows` alongside `web` + +- Existing deployments recreate services by name (`docker compose up -d web`), so a service that is never named is never created. The update path names both: `docker compose up -d web flows`. +- Order is safe: compose creates `flows` only after its `depends_on` services are healthy, and the daemon waits for KB config/user paths before ticking. + +### D4: No `validateTemplates` extension + +- The new service references only `${ARCHE_WEB_IMAGE}`, which the env template already defines; the existing compose-vs-env placeholder validation therefore covers the new service unchanged. Template validation asserts the scheduler variable is present (test-level), since that is the invariant that bit production. + +## Risks / Trade-offs + +- [Two dispatchers if a user manually sets `inline` later] → The scheduler is lease-guarded (`claimNextDueFlow` takes a lease), so a stray inline tick cannot double-dispatch a run; worst case is duplicate no-op claim attempts. +- [`flows` container restart loops on broken KB mounts] → Same failure mode as web in that scenario; `restart: unless-stopped` plus the bootstrap's pre-created host paths keep this off the happy path. +- [Existing deployments update while a flow run is mid-flight] → `up -d web flows` recreates only `web` (unchanged config) and creates `flows`; compose does not stop the running `web` process unless its config changed, so in-flight runs managed by web's spawner are not torn down by this change. + +## Migration Plan + +1. Fresh deployments: bootstrap writes the variable and starts the stack including `flows` (`docker compose up -d` already starts all services). +2. Existing deployments: next `archectl update` rewrites compose (now with `flows`), appends the env variable idempotently, pulls images, and recreates `web` + creates `flows`. +3. Rollback: revert the deploy tooling; previously deployed env files keep the variable (harmless — `daemon` with no runner simply means flows pause), and removing the `flows` service on a subsequent update is automatic since compose is fully rewritten each update. + +## Open Questions + +- (none — the reaper parity gap is recorded as a follow-up, not an open question for this change.) diff --git a/openspec/changes/archectl-flow-scheduler-mode/proposal.md b/openspec/changes/archectl-flow-scheduler-mode/proposal.md new file mode 100644 index 00000000..063ba52c --- /dev/null +++ b/openspec/changes/archectl-flow-scheduler-mode/proposal.md @@ -0,0 +1,29 @@ +## Why + +archectl (one-click) deployments crash at web startup: `getFlowSchedulerMode()` (`apps/web/src/lib/flows/scheduler.ts`) requires `ARCHE_FLOW_SCHEDULER_MODE` in production and throws when it is missing, but archectl never writes that variable — not in the env template (`renderEnvFile`), not in the bootstrap `.env` heredoc (`renderBootstrapScript`), and not in the update script (`renderUpdateScript`). The Ansible deploy path sets it correctly (`infra/deploy/ansible/roles/app/templates/.env.j2:74`: `daemon` for remote deployments), so only the archectl path is broken. + +Setting the variable alone is not enough: `daemon` means the web process does **not** tick flows and a dedicated flow-daemon runner is expected (`startFlowDaemon()` exits unless the mode is `daemon`). The Ansible remote topology pairs the variable with a separate `flows` compose service running `tsx src/flow-daemon.ts` on the same web image; archectl's generated compose has no such service, so writing `daemon` without one would leave flows never dispatching. + +## What Changes + +- `renderEnvFile` (`infra/one-click/main.go`): the generated `.env` template gains `ARCHE_FLOW_SCHEDULER_MODE=daemon`. +- `renderBootstrapScript`: the bootstrap `.env` heredoc written to `/opt/arche/.env` gains the same line, so fresh one-click deployments boot with a valid production configuration. +- `renderUpdateScript`: the update script sets `ARCHE_FLOW_SCHEDULER_MODE=daemon` via the existing idempotent `set_env` helper, so existing deployments converge on their next update. +- `renderCompose`: the generated compose stack gains a `flows` service mirroring the Ansible remote template — same `${ARCHE_WEB_IMAGE}` running `./node_modules/.bin/tsx src/flow-daemon.ts`, same `.env`, networks, host mounts, and health/depends-on posture — so the configured `daemon` mode actually has a runner. +- `renderUpdateScript` also brings `flows` up alongside `web` on updates, so existing deployments get the runner when they converge. +- Go tests cover the variable in all three rendered artifacts and the new compose service. + +## Capabilities + +### New Capabilities +- `archectl-deployment`: Behavioral contract for the deployment artifacts archectl generates — the generated env file must satisfy every production-required web setting, the compose stack must run a scheduler runner consistent with the configured scheduler mode, and updates must converge existing deployments to the current artifact set. + +### Modified Capabilities +- (none — archectl-generated deployment artifacts were previously unspecified.) + +## Impact + +- `infra/one-click/main.go` — `renderEnvFile`, `renderBootstrapScript`, `renderUpdateScript`, `renderCompose`. +- `infra/one-click/main_test.go` — coverage for the scheduler mode in rendered env/bootstrap/update artifacts and the compose `flows` service. +- No application code changes (the web app already implements the three modes); no DB, env-var-infra, or Ansible changes; `validateTemplates` needs no new vars (the compose service references only existing variables). +- Existing archectl deployments are healed on their next `archectl update` (env var added idempotently, flows service created and started). diff --git a/openspec/changes/archectl-flow-scheduler-mode/specs/archectl-deployment/spec.md b/openspec/changes/archectl-flow-scheduler-mode/specs/archectl-deployment/spec.md new file mode 100644 index 00000000..e54dd13c --- /dev/null +++ b/openspec/changes/archectl-flow-scheduler-mode/specs/archectl-deployment/spec.md @@ -0,0 +1,38 @@ +## Purpose + +Defines the behavioral contract for the deployment artifacts archectl generates: the rendered env file must satisfy every production-required web setting, the rendered compose stack must run a flow-scheduler runner consistent with the configured scheduler mode, and the update path must converge existing deployments to the current artifacts. + +## ADDED Requirements + +### Requirement: Generated env files satisfy production-required settings +The env file archectl renders — both the validated template and the bootstrap file written to the target host — SHALL define every setting the web application requires in production, including `ARCHE_FLOW_SCHEDULER_MODE`. For the one-click remote-equivalent topology the flow scheduler mode SHALL be `daemon`, matching the Ansible remote deployment path. + +#### Scenario: Fresh deployment boots without scheduler-mode errors +- **WHEN** archectl bootstraps a new server and the web container starts in production +- **THEN** the resolved flow scheduler mode is `daemon` and startup does not fail for a missing scheduler mode + +#### Scenario: Rendered template and bootstrap file agree +- **WHEN** the env template and the bootstrap env heredoc are rendered +- **THEN** both declare the same `ARCHE_FLOW_SCHEDULER_MODE` value + +### Requirement: The compose stack runs a runner consistent with the scheduler mode +Because the configured mode is `daemon`, the generated compose stack SHALL include a flow daemon service running the web image's flow-daemon entrypoint with the same env file, networks, and workspace host mounts as the web service, and it SHALL NOT start until its database and container-proxy dependencies are healthy. + +#### Scenario: Flow dispatch has a runner +- **WHEN** the stack is started from archectl's generated compose +- **THEN** a `flows` service runs the flow-daemon entrypoint, which ticks due flows because the configured mode is `daemon` + +#### Scenario: Web process does not double-tick +- **WHEN** the flow scheduler mode is `daemon` +- **THEN** the web service does not start an inline flow scheduler + +### Requirement: Updates converge existing deployments +The update script SHALL set `ARCHE_FLOW_SCHEDULER_MODE` in the existing env file idempotently and SHALL create or recreate the flow daemon service alongside the web service, so a deployment updated from any previous version ends up with both the scheduler mode and its runner. + +#### Scenario: Pre-existing deployment gains the scheduler mode +- **WHEN** an existing deployment whose env file lacks `ARCHE_FLOW_SCHEDULER_MODE` runs the update script +- **THEN** the env file gains `ARCHE_FLOW_SCHEDULER_MODE=daemon` and no duplicate key is created on repeated updates + +#### Scenario: Pre-existing deployment gains the runner +- **WHEN** an existing deployment runs the update script after the compose template gained the flow daemon service +- **THEN** the `flows` service is created and running after the update completes diff --git a/openspec/changes/archectl-flow-scheduler-mode/tasks.md b/openspec/changes/archectl-flow-scheduler-mode/tasks.md new file mode 100644 index 00000000..819a600e --- /dev/null +++ b/openspec/changes/archectl-flow-scheduler-mode/tasks.md @@ -0,0 +1,23 @@ +## 1. Rendered env artifacts + +- [x] 1.1 Add `ARCHE_FLOW_SCHEDULER_MODE=daemon` to the generated `.env` template in `renderEnvFile` (`infra/one-click/main.go`), grouped with the session/TTL settings. +- [x] 1.2 Add the same line to the bootstrap `.env` heredoc in `renderBootstrapScript` so fresh deployments write it to `/opt/arche/.env`. + +## 2. Flow daemon runner + +- [x] 2.1 Add a `flows` service to `renderCompose` mirroring the Ansible remote template: `${ARCHE_WEB_IMAGE}` running `./node_modules/.bin/tsx src/flow-daemon.ts`, `env_file: .env`, `restart: unless-stopped`, both networks, the three host mounts (`/opt/arche/users`, `/opt/arche/kb-content`, `/opt/arche/kb-config`), `depends_on` for healthy postgres and docker-socket-proxy, healthcheck disabled. + +## 3. Update convergence + +- [x] 3.1 In `renderUpdateScript`, set `ARCHE_FLOW_SCHEDULER_MODE=daemon` via the existing `set_env` helper so existing deployments gain the variable idempotently. +- [x] 3.2 Recreate `flows` alongside `web` in the update script's compose up call so the runner is created on existing deployments. + +## 4. Tests + +- [x] 4.1 Extend `infra/one-click/main_test.go`: `renderEnvFile` output contains `ARCHE_FLOW_SCHEDULER_MODE=daemon`; bootstrap script's env heredoc contains it; update script sets it via `set_env` and brings up `flows` with `web`; rendered compose declares the `flows` service with the daemon command. +- [x] 4.2 Run `go test ./...` from `infra/one-click/` — green. + +## 5. Final verification + +- [x] 5.1 Run `openspec validate archectl-flow-scheduler-mode --strict` — change validates. +- [ ] 5.2 Confirm `bash scripts/check-podman-images.sh` is unaffected (no app image changes; skip per repo guidance for tooling-only changes if it fails for unrelated local reasons).