Skip to content
Merged
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
68 changes: 56 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
website_e2e: ${{ steps.scope.outputs.website_e2e }}
posthog: ${{ steps.scope.outputs.posthog }}
scripts_tests: ${{ steps.scope.outputs.scripts_tests }}
growth_lifecycle: ${{ steps.scope.outputs.growth_lifecycle }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
Expand Down Expand Up @@ -214,6 +215,42 @@ jobs:
run: git diff --exit-code -- apps/website/content/docs/*/api/api-docs.json
- run: npx nx build website

growth-lifecycle:
name: Growth lifecycle — Node 22
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.growth_lifecycle == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx nx lint growth
- run: npx nx test growth
- run: npx nx run growth:test-operator-cli
- run: npx nx build growth
- run: npx nx test google-mailbox-poller
- run: npx nx lint google-mailbox-poller

lifecycle:
name: Lifecycle — Node 24
needs: ci-scope
if: github.event_name == 'push' || needs.ci-scope.outputs.growth_lifecycle == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 24
cache: npm
- run: npm ci
- run: npx nx lint lifecycle
- run: npx nx test lifecycle
- run: npx nx run lifecycle:check
- run: npx nx build lifecycle

cockpit:
name: Cockpit — build / test
needs: ci-scope
Expand Down Expand Up @@ -584,6 +621,8 @@ jobs:
- website-e2e
- posthog-sync-plan
- scripts-tests
- growth-lifecycle
- lifecycle
if: ${{ always() && github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
steps:
Expand All @@ -604,6 +643,8 @@ jobs:
RESULT_WEBSITE_E2E: ${{ needs.website-e2e.result }}
RESULT_POSTHOG: ${{ needs.posthog-sync-plan.result }}
RESULT_SCRIPTS_TESTS: ${{ needs.scripts-tests.result }}
RESULT_GROWTH_LIFECYCLE: ${{ needs.growth-lifecycle.result }}
RESULT_LIFECYCLE: ${{ needs.lifecycle.result }}
SCOPE_LIBRARY: ${{ needs.ci-scope.outputs.library }}
SCOPE_ANGULAR_COMPATIBILITY: ${{ needs.ci-scope.outputs.angular_compatibility }}
SCOPE_WEBSITE: ${{ needs.ci-scope.outputs.website }}
Expand All @@ -617,6 +658,7 @@ jobs:
SCOPE_WEBSITE_E2E: ${{ needs.ci-scope.outputs.website_e2e }}
SCOPE_POSTHOG: ${{ needs.ci-scope.outputs.posthog }}
SCOPE_SCRIPTS_TESTS: ${{ needs.ci-scope.outputs.scripts_tests }}
SCOPE_GROWTH_LIFECYCLE: ${{ needs.ci-scope.outputs.growth_lifecycle }}
run: |
set -euo pipefail

Expand Down Expand Up @@ -675,6 +717,8 @@ jobs:
require_scoped "website_e2e" "Website — e2e" "$RESULT_WEBSITE_E2E" "$SCOPE_WEBSITE_E2E"
require_scoped "posthog" "PostHog — dashboards-as-code drift check" "$RESULT_POSTHOG" "$SCOPE_POSTHOG"
require_scoped "scripts_tests" "Scripts — generator / proxy vitest suites" "$RESULT_SCRIPTS_TESTS" "$SCOPE_SCRIPTS_TESTS"
require_scoped "growth_lifecycle" "Growth lifecycle — Node 22" "$RESULT_GROWTH_LIFECYCLE" "$SCOPE_GROWTH_LIFECYCLE"
require_scoped "growth_lifecycle" "Lifecycle — Node 24" "$RESULT_LIFECYCLE" "$SCOPE_GROWTH_LIFECYCLE"

if [[ "$failed" -ne 0 ]]; then
exit 1
Expand All @@ -686,18 +730,18 @@ jobs:
name: Deploy → Vercel
timeout-minutes: 30 # fail fast instead of blocking the main concurrency group on a hang
needs:
[
library,
website,
cockpit,
cockpit-examples-build,
cockpit-smoke,
cockpit-deploy-smoke,
examples-chat-smoke,
examples-chat-e2e,
cockpit-e2e-summary,
website-e2e,
]
- library
- website
- cockpit
- cockpit-examples-build
- cockpit-smoke
- cockpit-deploy-smoke
- examples-chat-smoke
- examples-chat-e2e
- cockpit-e2e-summary
- website-e2e
- growth-lifecycle
- lifecycle
runs-on: ubuntu-latest
# Only deploy on pushes to main, not on pull requests
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
Expand Down
4 changes: 4 additions & 0 deletions apps/lifecycle/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.dawn/
wrangler.toml
.vercel/
.env.local
83 changes: 83 additions & 0 deletions apps/lifecycle/DOGFOOD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Lifecycle preview dogfood harness

This harness performs the provider-free subset of the lifecycle preview gate.
It creates one closed growth fixture, probes the deployed Dawn surface, and
removes only the exact growth and Dawn selectors from the private manifest.
Keep delivery, campaign enrollment, and campaign execution disabled throughout
the run.

The operator must prepare a private JSON manifest matching the schema in
`scripts/dogfood-harness.mts`. Do not commit, paste, or log that file. It holds
exact synthetic UUID, event-key, idempotency-key, and Dawn thread selectors.
Both `expected_count` values are positive and fixed before setup: growth is
exactly four records and Dawn equals the four closed thread selectors.

Set these values only in the invoking shell:

- `DATABASE_URL`
- `LIFECYCLE_DOGFOOD_INSTANCE_A_ORIGIN`
- `LIFECYCLE_DOGFOOD_INSTANCE_B_ORIGIN`
- `LIFECYCLE_SERVICE_SECRET`

The lifecycle origins must be canonical bare HTTPS origins with no trailing
slash, credentials, path, query, or fragment. The manifest names each exact
Vercel deployment through its `dpl_...` deployment ID. After bearer
authentication, each deployment's `/healthz` response must return its own
Vercel-provided `VERCEL_DEPLOYMENT_ID` in the
`x-threadplane-deployment-id` header. The harness refuses to probe or delete
Dawn state unless both values match the manifest.

The manifest also contains the exact database sentinel
`threadplane:growth-target:<Vercel store id>`. Provision that value as the
target database's database comment. Before growth work, the harness reads it
with `shobj_description(oid, 'pg_database')` for `current_database()` and
requires an exact match. No URL-derived identifier or operator-supplied target
label is accepted.

Run the phases separately and stop on any nonzero exit:

```bash
npx nx run lifecycle:dogfood -- setup --manifest /absolute/private/manifest.json
npx nx run lifecycle:dogfood -- probe --manifest /absolute/private/manifest.json
npx nx run lifecycle:dogfood -- cleanup --manifest /absolute/private/manifest.json
```

Output contains only aliases, bounded counts, closed status values, and the
health response body hash. It never emits URLs, credentials, connection
strings, fixture selectors, provider identifiers, raw response bodies, or raw
error messages. Setup refuses a nonempty fixture preflight. Cleanup preflights
both stores before either is mutated, then deletes growth dependents before
owners. Every Dawn dispatch includes the exact fixture marker in persisted
state. Cleanup reads `/threads/:id/state` and validates that marker before using
Dawn's exact `DELETE /threads/:id` route. A retry can clean the remaining one to
four positively marked threads after a partial deletion; wrong or unmarked
state is never deleted. A fresh instance-B read must return zero fixtures.

The v1 harness deliberately reports mailbox recovery/resume and true
AbortSignal propagation as `BLOCKED`. The current app has no deterministic,
provider-free recovery completion fixture and no safe long-running route seam.
An idle cancel-route check is not represented as abort evidence.

## Disposable rollback integration test

The transaction rollback regression is isolated from the normal lifecycle test
target and is skipped unless `LIFECYCLE_DOGFOOD_ROLLBACK_INTEGRATION` is exactly
`true`. Point `TEST_DATABASE_URL` only at the explicitly disposable, migrated
test database; never use either preview growth or Dawn storage. Set
`LIFECYCLE_DOGFOOD_TEST_DATABASE_SENTINEL` to the exact database comment
sentinel provisioned on that disposable database. It must use the distinct
test-only namespace `threadplane:growth-test-target:<resource id>`. The test
verifies that database-owned value before attempting any temporary DDL. Then
run:

```bash
npx nx run lifecycle:test-dogfood-integration
```

The test installs uniquely named temporary trigger/function DDL. The trigger
adds a fifth exact fixture row during setup, forcing the postflight check to
throw. The test then verifies that the setup transaction left zero exact
fixture rows. A second trigger forces destructive cleanup postflight to fail
after deletion; the test verifies that rollback preserved all four original
rows. Teardown drops only DDL whose installation was attempted after the
database identity check, then safely removes the exact fixture.
20 changes: 20 additions & 0 deletions apps/lifecycle/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Threadplane lifecycle service

This Node 24 service builds Dawn 0.8.21's supported Hono target and serves it through an app-owned Vercel function. The Vercel adapter requires the exact `LIFECYCLE_SERVICE_SECRET` bearer token on every Dawn path. Dawn route middleware repeats the same check for execution routes.

The service has two database boundaries:

- `DATABASE_URL` is the growth CRM/control-plane database used by `@threadplane-internal/growth`.
- `DAWN_DATABASE_URL` is a separate database or isolated schema/database endpoint used only for Dawn threads, checkpoints, and permission state.

Neither variable falls back to the other. Preview and production must use different Neon resources for both boundaries. Configure no lifecycle secret with a `NEXT_PUBLIC_` prefix.

Recipient delivery also requires `GROWTH_PUBLIC_ACTION_ORIGIN`, a server-only bare HTTPS origin for the Website deployment that owns `/api/unsubscribe`. In preview, use a dedicated public custom-domain alias for the exact Website preview deployment while keeping generated preview URLs protected; the signed action token is the application-layer authorization. In production, use the canonical Website origin. Paths, query strings, fragments, credentials, and HTTP origins are rejected. The lifecycle service uses this value only to construct opaque, contact-bound unsubscribe action URLs; it never derives the origin from a request or hardcodes the production site.

Set `GROWTH_DATABASE_ENVIRONMENT` to exactly `preview`, `production`, or `test` in every process that handles verified Resend events. A verified webhook whose `environment` provider tag is missing or differs from that value is acknowledged without opening a growth transaction or changing delivery/suppression state.

The app's Vercel project must use `apps/lifecycle` as its root directory, enable access to files outside that directory for the npm/Nx monorepo build, and select Node 24. `npx nx build lifecycle` generates the Dawn Hono artifact, rewrites its generated store binding to `DAWN_DATABASE_URL`, verifies the expected `app.mjs` fetch export, and drives an authenticated local request through the adapter.

Keep `LIFECYCLE_CRON_ENABLED` unset or set to anything other than the exact value `true` until the preview dogfood checklist in `docs/superpowers/runbooks/2026-08-31-growth-lifecycle-cutover.md` passes. In particular, verify outer auth on all Dawn surfaces, named-thread dispatch, duplicate invocation behavior, recovery pause/resume, cancellation/AbortSignal propagation, and Dawn persistence across fresh instances. Send findings to Dawn task `01a05e2f-7e93-7bd0-af74-f13d5a7719cd` for generalized backport.

Use [DOGFOOD.md](./DOGFOOD.md) for the provider-free setup, probe, and exact cleanup commands. The harness binds the growth target to a database-owned comment sentinel and binds each authenticated lifecycle health response to Vercel's `VERCEL_DEPLOYMENT_ID`; it also validates lifecycle origins in memory before making requests.
4 changes: 4 additions & 0 deletions apps/lifecycle/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import dawnApp from '../.dawn/build/app.mjs';
import { createLifecycleVercelAdapter } from '../src/vercel-adapter.js';

export default createLifecycleVercelAdapter(dawnApp);
6 changes: 6 additions & 0 deletions apps/lifecycle/dawn.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { DawnConfig } from '@dawn-ai/core';

export default {
appDir: 'src/app',
build: { targets: ['hono'] },
} satisfies DawnConfig;
3 changes: 3 additions & 0 deletions apps/lifecycle/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import baseConfig from '../../eslint.config.mjs';

export default [{ ignores: ['apps/lifecycle/.dawn/**'] }, ...baseConfig];
23 changes: 23 additions & 0 deletions apps/lifecycle/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@threadplane-internal/lifecycle",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": ">=24.0.0"
},
"dependencies": {
"@anthropic-ai/sdk": "0.79.0",
"@dawn-ai/cli": "0.8.21",
"@dawn-ai/core": "0.8.21",
"@dawn-ai/langgraph": "0.8.21",
"@dawn-ai/postgres-storage": "0.8.21",
"@dawn-ai/sdk": "0.8.21",
"@neondatabase/serverless": "0.10.4",
"@threadplane-internal/growth": "0.0.0",
"hono": "4.13.5",
"parse5": "8.0.1",
"resend": "6.10.0",
"zod": "4.4.3"
}
}
64 changes: 64 additions & 0 deletions apps/lifecycle/project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
{
"name": "lifecycle",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/lifecycle/src",
"projectType": "application",
"tags": [
"scope:internal",
"scope:growth-lifecycle",
"type:app",
"runtime:node24"
],
"targets": {
"test": {
"executor": "@nx/vitest:test",
"options": {
"configFile": "apps/lifecycle/vitest.config.ts"
}
},
"test-dogfood-integration": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"command": "npx vitest run --config apps/lifecycle/vitest.dogfood-integration.config.ts --reporter=verbose"
}
},
"check": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"cwd": "apps/lifecycle",
"commands": [
"npx -y node@24 ../../node_modules/@dawn-ai/cli/dist/index.js check",
"npx -y node@24 ../../node_modules/typescript/bin/tsc --noEmit -p tsconfig.json"
],
"parallel": false
}
},
"build": {
"executor": "nx:run-commands",
"cache": false,
"outputs": ["{projectRoot}/.dawn/build"],
"options": {
"cwd": "apps/lifecycle",
"commands": [
"npx -y node@24 ../../node_modules/@dawn-ai/cli/dist/index.js build --clean",
"npx -y node@24 ../../node_modules/tsx/dist/cli.mjs scripts/verify-vercel-adapter.mts"
],
"parallel": false
}
},
"dogfood": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"cwd": "apps/lifecycle",
"command": "npx -y node@24 ../../node_modules/tsx/dist/cli.mjs scripts/dogfood-harness.mts",
"forwardAllArgs": true
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
1 change: 1 addition & 0 deletions apps/lifecycle/public/.gitkeep
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Loading
Loading