Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions infra/one-click/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1382,15 +1382,16 @@ 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
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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}
Expand Down
50 changes: 50 additions & 0 deletions infra/one-click/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
2 changes: 2 additions & 0 deletions openspec/changes/archectl-flow-scheduler-mode/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-31
65 changes: 65 additions & 0 deletions openspec/changes/archectl-flow-scheduler-mode/design.md
Original file line number Diff line number Diff line change
@@ -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/<daemon>.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.)
29 changes: 29 additions & 0 deletions openspec/changes/archectl-flow-scheduler-mode/proposal.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions openspec/changes/archectl-flow-scheduler-mode/tasks.md
Original file line number Diff line number Diff line change
@@ -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).
Loading