.github/9
ISSUE_TEMPLATE/3bug_report.md config.yml feature_request.md
workflows/3ci.yml clean-clone.yml release.yml
CODEOWNERS dependabot.yml pull_request_template.md
apps/630
api/250
src/243
common/30
__tests__/3authorize.test.ts bigint-serialization.interceptor.test.ts files-limits.test.ts
filters/1all-exceptions.filter.ts
guards/3auth.guard.ts internal-service.guard.ts rate-limit.guard.ts
interceptors/2actor-context.interceptor.ts bigint-serialization.interceptor.ts
middleware/2idempotency.test.ts idempotency.ts
pipes/1zod-validation.pipe.ts
actor-context.ts audit.service.ts cache.test.ts cache.ts cookies.ts core.module.ts deprecation.ts email.service.ts errors.ts event-bus.ts log-scrubber.test.ts log-scrubber.ts logger.ts permission-cache-bridge.ts prisma.service.ts redis.service.ts trace-metadata.ts types.ts
config/1env.ts
lib/11
__tests__/5crypto.test.ts filter-builder.test.ts jwt.test.ts pagination.test.ts positions.test.ts
authorize.ts crypto.ts filter-builder.ts jwt.ts pagination.ts positions.ts
modules/197
ai/28
__tests__/4ai-writing.test.ts database-ai.test.ts generate-executor.test.ts prompts.test.ts
generator/4executor.ts generate.controller.ts generate.dto.ts generate.service.ts
ai-chat.service.ts ai-rate-limit.ts ai-writing.controller.ts ai-writing.dto.ts ai-writing.service.ts ai.controller.ts ai.module.ts ai.service.ts budget-guard.service.ts database-ai.controller.ts database-ai.dto.ts database-ai.service.ts db-prompts.ts dto.ts gateway-provider.ts ledger.service.ts prompts.ts rag.controller.ts rag.dto.ts rag.service.ts
api-keys/3api-keys.controller.ts api-keys.module.ts api-keys.service.ts
audit/2audit-log.controller.ts audit.module.ts
auth/4auth.controller.ts auth.module.ts auth.service.ts dto.ts
automations/5automations.controller.ts automations.module.ts automations.service.ts dto.ts webhook-incoming.controller.ts
blocks/5
__tests__/1batch-version-cas.test.ts
block.service.ts blocks.controller.ts blocks.module.ts dto.ts
comments/4comments.controller.ts comments.module.ts comments.service.ts dto.ts
databases/9
__tests__/3normalize-select-options.test.ts option-id-canonicalization.test.ts record-open-query-shape.test.ts
database.service.ts databases.controller.ts databases.module.ts dto.ts property-cache-schema.ts query.service.ts
docs/2docs.controller.ts docs.module.ts
files/10
__tests__/1quota.test.ts
dto.ts files.controller.ts files.module.ts files.service.ts quota.ts record.ts s3.ts uploads.controller.ts uploads.service.ts
forms/18debug.controller.ts form.dto.ts forms.controller.ts forms.module.ts forms.service.ts markdown.test.ts markdown.ts optional-auth.guard.ts public-forms.controller.ts public-forms.service.ts question.dto.ts questions.service.ts rate-limit.ts recaptcha.ts slug.ts submissions.controller.ts submissions.service.ts submit-schema.ts
health/3health.controller.ts health.module.ts health.service.ts
integrations/3integrations.controller.ts integrations.module.ts integrations.service.ts
internal/12
__tests__/2flush-query-shape.test.ts internal.endpoints.test.ts
ai-test-seam.controller.ts ai-test-seam.dto.ts ai-test-seam.service.ts internal.controller.ts internal.dto.ts internal.module.ts internal.service.ts test-seam.controller.ts test-seam.dto.ts test-seam.service.ts
notifications/3notifications.controller.ts notifications.module.ts notifications.service.ts
oauth/6
__tests__/1oauth.scopes.test.ts
oauth.controller.ts oauth.module.ts oauth.service.ts scopes.ts token-keys.ts
org/15audit.service.ts embed-preview.controller.ts guards.ts members.service.ts org-admin.controller.ts org-policy.service.ts org.module.ts overview.service.ts policy-effective.controller.ts policy.schema.ts policy.test.ts retention.service.ts totp.test.ts totp.ts two-factor.service.ts
orgs/5dto.ts orgs.controller.ts orgs.module.ts orgs.service.ts util.ts
pages/4dto.ts page.service.ts pages.controller.ts pages.module.ts
scim/15
__tests__/3email-mapping.test.ts etag.test.ts filters.test.ts
etag.ts filters.ts groups.service.ts schemas.ts scim-admin.controller.ts scim-admin.service.ts scim-auth.guard.ts scim-auth.service.ts scim-errors.ts scim.controller.ts scim.module.ts users.service.ts
search/4
__tests__/1search.service.test.ts
search.controller.ts search.module.ts search.service.ts
sessions/3sessions.controller.ts sessions.module.ts sessions.service.ts
share/3share.controller.ts share.module.ts share.service.ts
sso/16
__tests__/4enforcement.test.ts oidc.verify.test.ts provisioning.test.ts saml.validate.test.ts
enforcement.ts oidc.controller.ts oidc.service.ts oidc.ts provisioning.service.ts saml.controller.ts saml.ts schemas.ts sso-admin.controller.ts sso-admin.service.ts sso-secrets.ts sso.module.ts
teams/4dto.ts teams.controller.ts teams.module.ts teams.service.ts
users/4dto.ts users.controller.ts users.module.ts users.service.ts
webhooks/3webhooks.controller.ts webhooks.module.ts webhooks.service.ts
workspaces/4dto.ts workspaces.controller.ts workspaces.module.ts workspaces.service.ts
types/1xmldom.d.ts
app.module.ts constants.ts main.ts
_probe_rawbody.cjs .env.example Dockerfile package.json README.md tsconfig.json vitest.config.ts
realtime/33
src/17auth.ts comments.ts config.ts event-source.ts http-store.ts index.ts metrics.ts notifications.ts page-updated-source.ts presence.ts prisma-store.ts protocol.ts rate-limit.ts room-key.ts rooms.ts server.ts update-log.ts
test/11auth.test.ts config.test.ts event-source.test.ts http-store.test.ts metrics-merge.test.ts page-updated-source.test.ts presence.test.ts protocol.test.ts rate-limit.test.ts rooms.test.ts task-0094-builders.test.ts
.env.example Dockerfile package.json README.md tsconfig.json
web/255
src/244
app/45
__tests__/1theme-tokens.test.ts
(app)/26
w/25
[workspace]/25
ask/1page.tsx
databases/1
[id]/1page.tsx
favorites/1page.tsx
files/1page.tsx
notifications/1page.tsx
page/4
[id]/4
[block]/1page.tsx
share/1page.tsx
layout.tsx page.tsx
search/1page.tsx
settings/10
appearance/1page.tsx
automations/3
[id]/1page.tsx
new/1page.tsx
page.tsx
billing/1page.tsx
developers/1page.tsx
members/1page.tsx
permissions/1page.tsx
teams/1page.tsx
page.tsx
shared/1page.tsx
templates/1page.tsx
trash/1page.tsx
layout.tsx page.tsx
layout.tsx
(auth)/4
login/1page.tsx
register/1page.tsx
reset-password/1page.tsx
layout.tsx
api/2
metrics/1route.ts
v1/1
health/1route.ts
f/1
[slug]/1page.tsx
health/1
ready/1route.ts
org/2
[orgId]/2
admin/2
integrations/1page.tsx
page.tsx
share/1
[id]/1page.tsx
error.tsx global-error.tsx globals.css layout.tsx not-found.tsx page.tsx providers.tsx
components/118
admin/10
org-admin/8ApiKeysTab.tsx AuditTab.tsx MembersTab.tsx OverviewTab.tsx PoliciesTab.tsx RetentionTab.tsx WebhooksTab.tsx WorkspacePicker.tsx
ScimCard.tsx SsoTabs.tsx
ai/6ai-panel.tsx ask-ai-submenu.tsx db-ai-dialog.tsx generate-page-dialog.tsx writing-actions-menu.tsx writing-diff.tsx
automations/5action-editor.tsx condition-editor.tsx lib.ts run-history.tsx trigger-editor.tsx
collaboration/6CursorLayer.tsx OfflineBanner.tsx PresenceBar.tsx README.md ReviewFailedDialog.tsx StatusChip.tsx
comments/3comment-panel.tsx comment-thread.tsx mention-input.tsx
database/27
__tests__/2cell-editor.test.tsx cell-renderer.test.tsx
AiExtractDialog.tsx AiFormulaDialog.tsx AiGenerateDialog.tsx board-view.tsx buttons-panel.tsx calendar-view.tsx cell-editor.tsx cell-renderer.tsx chart-view.tsx database-page-view.tsx database-toolbar.tsx database-view-renderer.tsx db-embedded.tsx filter-builder.tsx form-builder-view.tsx formula-editor.tsx gallery-view.tsx group-builder.tsx list-view.tsx property-editor.tsx record-header.tsx record-panel.tsx sort-builder.tsx table-view.tsx timeline-view.tsx
dialogs/7
__tests__/1move-to-dialog.test.tsx
create-dialog.tsx dialog.tsx file-permissions-dialog.tsx invite-dialog.tsx move-to-dialog.tsx share-dialog.tsx
editor/19
__tests__/3code-block-text.test.ts code-block.test.tsx expandable.test.tsx
block-card.tsx block-renderer.tsx block-save-queue.ts block-toolbar.tsx context-menu.tsx expandable.tsx history-popover.tsx inline-editor.tsx insert-dialog.tsx page-canvas.tsx page-spinner.tsx page-view.tsx selection-toolbar.tsx slash-menu.tsx template-dialog.tsx type-menu.tsx
files/4FileBlock.tsx FileBrowser.tsx FilePicker.tsx PreviewModal.tsx
form/4form-field.tsx form-renderer.tsx form-responses-view.tsx form-view.tsx
layout/8
__tests__/1page-tree-icon.test.tsx
command-palette.tsx factory-logo.tsx page-tree-node.tsx sidebar.tsx theme-provider.tsx topbar.tsx workspace-shell.tsx
mentions/3
__tests__/1mention-model.test.ts
mention-model.ts MentionPopup.tsx
search/3ask-ai-search.tsx results-list.tsx search-dialog.tsx
settings/10
api-keys/4ApiKeyCreateDialog.tsx ApiKeyRevealDialog.tsx ApiKeyTable.tsx ScopesBadge.tsx
webhooks/5CurlExamples.tsx DeliveryInspector.tsx EventExplorer.tsx OverviewTab.tsx WebhooksTab.tsx
settings-shell.tsx
ui/3confirm-dialog.tsx form.tsx toaster.tsx
hooks/21
__tests__/4realtime-frame-handler.test.ts use-blocks.test.ts use-database-options.test.ts use-workspace.test.tsx
realtime-frame-handler.ts use-ai-stream.ts use-auth.ts use-blocks.ts use-database.ts use-db-ai-stream.ts use-forms.ts use-members.ts use-mention-sources.ts use-notifications.ts use-offline-status.ts use-pages.ts use-presence-announcer.ts use-presence-toasts.ts use-presence.ts use-realtime.ts use-workspace.ts
lib/50
__tests__/21ai-database.test.ts ai-generate.test.ts ai-sse.test.ts ai-writing.test.ts api-keys.test.ts api-members.test.ts auth-refresh.test.ts auth-store.test.ts batch-queue-drop.test.ts block-content.test.ts clipboard-import.test.ts code-highlight.test.ts offline-status.test.ts presence-announcer.test.ts presence-color.test.ts presence-cursor.test.ts presence-emitter.test.ts realtime-url.test.ts search.test.ts utils.test.ts writing-diff.test.ts
files/7
__tests__/2picker.test.ts upload.test.ts
api.ts picker.ts upload-store.ts upload.ts use-file-uploads.ts
ai-database.ts ai.ts api-keys.ts api.ts auth-store.ts batch-queue.ts block-content.ts clipboard-import.ts code-highlight.ts constants.ts offline-status.ts org-admin.ts presence-announcer.ts presence-color.ts presence-cursor.ts presence-editor-views.ts presence-emitter.ts query-client.ts realtime.ts search.ts types.ts utils.ts
stores/8
__tests__/1block-patch-store.test.ts
block-patch-store.ts comment-pill-store.ts database-store.ts editor-store.ts offlineQueue.ts presence-store.ts ui-store.ts
instrumentation.ts middleware.ts
.env.example Dockerfile next-env.d.ts next.config.mjs package.json postcss.config.mjs README.md tailwind.config.ts tsconfig.json tsconfig.tsbuildinfo vitest.config.ts
worker/92
src/87
__tests__/13ai-embedding.test.ts ai-request.test.ts ai-usage-budget.test.ts audit-export.test.ts email-decision.test.ts encrypt.test.ts health-search-index-lag.test.ts mime.test.ts retention.test.ts s3-sign.test.ts search-index.test.ts smtp.test.ts webhook-sign.test.ts
health/1server.ts
lib/12
__tests__/1clamav.test.ts
clamav.ts encrypt.ts job-otel.ts mime.ts notify.ts prisma.ts redis-stream.ts redis.ts s3.ts sharp.ts smtp.ts
processors/56
automations/28
__tests__/3condition-evaluator.spec.ts executors.spec.ts run-pipeline.spec.ts
executors/17assign-user.ts call-integration.ts change-status.ts classify.ts create-notification.ts create-page.ts email-cap.ts helpers.ts integration-registry.ts permission.ts record-write.ts registry.ts run-ai-action.ts send-email.ts send-webhook.ts types.ts update-record.ts
condition-evaluator.ts dead-letter.ts log.ts matcher.ts pipeline.ts run-state.ts schedule.ts watchdog.ts
files/15
__tests__/4errors.test.ts scan-gate.test.ts status.test.ts temp.test.ts
steps/4finalize.ts metadata.ts scan.ts thumbnails.ts
cleanup.ts errors.ts generateThumbs.ts processor.ts status.ts temp.ts types.ts
search-index/4builders.ts grants.ts index.ts processor.ts
ai-embedding.ts ai.processor.ts audit-export.ts automations.processor.ts email-decision.processor.ts email.processor.ts maintenance.processor.ts retention.ts webhooks.processor.ts
config.ts events.ts index.ts logger.ts queues.ts
.env.example Dockerfile package.json README.md tsconfig.json
docs/73
api/17ai.md authentication.md automations.md blocks.md comments.md databases.md files.md forms.md openapi.yaml org-admin.md pages.md scim.md search.md sso.md versioning.md webhooks.md workspaces.md
architecture/10ai-architecture.md automation.md backend-architecture.md database-architecture.md frontend-architecture.md realtime-architecture.md search-architecture.md security-architecture.md storage.md system-architecture.md
database/2database-architecture.md redis-keys.md
development/8admin-guide.md coding-standards.md deployment.md development-guide.md files-pipeline.md perf-report.md performance.md testing.md
product/4functional-requirements.md non-functional-requirements.md product-requirements.md ux-requirements.md
requirements/1requirements-matrix.md
security/5audit-report.md files.md forms.md license-allowlist.md security-architecture.md
tasks/16phase-00-foundation.md phase-01-authentication.md phase-02-workspace.md phase-03-pages.md phase-04-editor.md phase-05-databases.md phase-06-relations.md phase-07-collaboration.md phase-08-search.md phase-09-files.md phase-10-forms.md phase-11-automation.md phase-12-ai.md phase-13-api.md phase-14-enterprise.md phase-15-production.md
ui/9ai.md automations.md design-system.md developers.md files.md forms.md org-admin.md scim.md sso.md
support-matrix.md
infrastructure/71
caddy/1Caddyfile
docker/6
init/101-extensions.sql
opensearch/1init-indices.json
.env.docker docker-compose.yml opensearch-init.sh README.md
kubernetes/40
observability/20
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1sync-sources.mjs
alertmanager.yaml grafana.yaml kustomization.yaml namespace.yaml node-exporter.yaml otel-collector.yaml prometheus.yaml
postgres/7configmap.yaml cronjob-backup.yaml cronjob-opensearch-snapshot.yaml pgbouncer.yaml replica.yaml statefulset.yaml walg-iam-policy.yaml
api.yaml configmap.yaml ingress.yaml kustomization.yaml namespace.yaml networkpolicy.yaml pdb.yaml README.md realtime.yaml redis.yaml secret.example.yaml web.yaml worker.yaml
nginx/2nginx.conf README.md
observability/14
alertmanager/1alertmanager.yaml
grafana/8
dashboards/6ai.json api-overview.json infra.json realtime.json search.json workers-queues.json
provisioning/2
dashboards/1dashboards.yaml
datasources/1datasource.yaml
prometheus/3alerts.yaml recording-rules.yaml scrape.yaml
scripts/1dashboard-completeness.mjs
README.md
terraform/8compute.tf data.tf main.tf network.tf outputs.tf README.md security.tf variables.tf
packages/289
ai/24
src/20
providers/7anthropic.ts google.ts http.ts index.ts openai-compatible.ts rag.test.ts writing.test.ts
schemas/2database.ts generate.ts
adapters.test.ts config.test.ts config.ts gateway.test.ts gateway.ts index.ts tools.test.ts tools.ts types.ts usage.test.ts usage.ts
package.json README.md tsconfig.json vitest.config.ts
auth/11
src/7index.ts passwords.test.ts passwords.ts sessions.test.ts sessions.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
collaboration/17
src/13awareness.ts codec.test.ts codec.ts index.ts lww.test.ts lww.ts mapping.ts offline.ts outbox.test.ts outbox.ts provider.test.ts provider.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
config/7
src/4eslint.base.js index.ts prettier.base.json tsconfig.base.json
package.json README.md tsconfig.json
database-client/5
src/2index.test.ts index.ts
package.json README.md tsconfig.json
database-engine/33
src/29
filter/2filter.test.ts index.ts
formula/8evaluator.ts formula.test.ts functions.ts index.ts lexer.ts parser.ts types.ts validate.ts
query/6group.ts index.ts paginate.ts pivot.ts query.test.ts sort.ts
relations/2index.ts relations.test.ts
rollups/2index.ts rollups.test.ts
values/5dates.ts index.ts registry.ts types.ts values.test.ts
views/3buttons.ts index.ts views.test.ts
index.ts
package.json README.md tsconfig.json vitest.config.ts
editor/25
src/21
marks/1highlight.ts
nodes/2equation.ts mention.ts
autosave.test.ts autosave.ts clipboard.test.ts clipboard.ts codec.test.ts codec.ts context-menu.test.ts context-menu.ts diff.test.ts diff.ts factory.ts history.test.ts history.ts index.ts positions.ts slash.test.ts slash.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
observability/20
src/16
__tests__/8db-redis-metrics.test.ts metrics.test.ts otel.test.ts pino-otel.test.ts prometheus.test.ts resource.test.ts scrubber.test.ts traceparent.test.ts
index.ts metrics.ts otel.ts pino-otel.ts prometheus.ts resource.ts scrubber.ts traceparent.ts
package.json README.md tsconfig.json vitest.config.ts
permissions/11
src/7index.ts levels.test.ts levels.ts resolve.test.ts resolve.ts rules.test.ts rules.ts
package.json README.md tsconfig.json vitest.config.ts
search/22
src/18chunking.test.ts chunking.ts client.test.ts client.ts embeddings.test.ts embeddings.ts hybrid.test.ts hybrid.ts index.ts indexer.test.ts indexer.ts mapping.test.ts mapping.ts queries.test.ts queries.ts rrf.test.ts rrf.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
shared/61
src/57
dto/17ai.dto.ts auth.dto.test.ts auth.dto.ts block.dto.ts browser-safety.test.ts comment.dto.ts database.dto.test.ts database.dto.ts file.dto.ts formula-ast.ts index.ts notification.dto.ts page.dto.ts search.dto.ts share.dto.ts webhook.dto.ts workspace.dto.ts
events/8audit-catalog.test.ts audit-catalog.ts audit-csv.test.ts audit-csv.ts audit-scope.test.ts audit-scope.ts catalog.test.ts catalog.ts
realtime/6comment-events.ts frames.test.ts frames.ts notification-policy.ts notification-prefs.ts task-0094.test.ts
automation.test.ts automation.ts constants.ts crypto.test.ts crypto.ts env.ts errors.ts events.test.ts events.ts forms.test.ts forms.ts index.ts markdown.test.ts markdown.ts mentions.test.ts mentions.ts net.test.ts net.ts positions.test.ts positions.ts presence.test.ts presence.ts security-headers.ts webhook-delivery.test.ts webhook-delivery.ts webhook-events.ts
package.json README.md tsconfig.json vitest.config.ts
storage/16
src/12client.test.ts client.ts index.ts keys.test.ts keys.ts mime.test.ts mime.ts presign.test.ts presign.ts thumbnails.test.ts thumbnails.ts types.ts
package.json README.md tsconfig.json vitest.config.ts
ui/37
src/33
components/19avatar.tsx badge.tsx button.tsx checkbox.tsx command.tsx context-menu.tsx dialog.tsx dropdown-menu.tsx empty-state.tsx index.ts popover.tsx separator.tsx skeleton.tsx slider.tsx spinner.tsx switch.tsx tabs.tsx toast.tsx tooltip.tsx
color.ts command-filter.test.ts command-filter.ts focus.test.ts focus.ts formatters.test.ts formatters.ts index.ts theme.test.ts theme.ts toast-store.test.ts toast-store.ts tokens.test.ts tokens.ts
package.json README.md tsconfig.json vitest.config.ts
prisma/16
migrations/13
20260927000000_init/1migration.sql
20260928000000_search_documents_content_hash/1migration.sql
20260929000000_workspace_usage_storage_bytes/1migration.sql
20260930000000_form_submitted_notification_type/1migration.sql
20261001000000_ai_phase12/1migration.sql
20261002000000_webhooks_secret_encrypted_dlq/1migration.sql
20261002111436_webhook_delivery_latency/1migration.sql
20261002120000_org_admin_2fa_retention_unique/1migration.sql
20261002215124_audit_export/1migration.sql
20261003210000_perf_indexes_hot_paths/1migration.sql
20261004004500_perf_indexes_hot_paths_2/1migration.sql
migration_lock.toml README.md
schema.prisma seed.ts verify.ts
scripts/7backup-health.ts docs-completeness.mjs generate-openapi.mjs minio-init.ts opensearch-reindex.ts storage-orphans.ts verify-clean-clone.sh
tests/169
e2e/63
.auth/1user.json
fixtures/7
files/6clip.mp4 clip.wav clip.webm hero-1400.png note.docx note.txt
realtime.ts
mocks/2oidc-issuer.ts saml-idp.ts
playwright-report/3
data/2a6557bae9c2714a658968d4eb8534195c08ebda8.png e3092d07d00a16063e46385c02a559a9054fa847.webm
index.html
reports/1files-security.json
specs/34ai-database.spec.ts ai-generate.spec.ts ai-rag.spec.ts ai-security.spec.ts ai-writing.spec.ts ai.spec.ts api-keys.spec.ts appearance-theme.spec.ts auth.spec.ts automation.spec.ts collab-two-tab.spec.ts comments.spec.ts databases.spec.ts default-workspace-alias.spec.ts developers.spec.ts editor.spec.ts files.spec.ts final-flow.spec.ts forms.spec.ts oauth.spec.ts org-admin.spec.ts pages.spec.ts performance.spec.ts presence-cursors.spec.ts qa-paste-toggle.spec.ts realtime.spec.ts scim.spec.ts search-index-lag.spec.ts search.spec.ts sharing.spec.ts sso.spec.ts task-0094-comments-notifications.spec.ts webhooks.spec.ts workspace.spec.ts
test-results-final-flow/3
final-flow-Release-v1-0-fi-6c06b-w-state-carries-sequential--chromium-desktop/2test-failed-1.png trace.zip
.last-run.json
utils/4api.ts dnd.ts search-lag.ts wait.ts
.gitignore fixtures.ts global-setup.ts package.json playwright.config.ts playwright.final-flow.config.ts README.md tsconfig.json
fixtures/1
files/1make-sparse.ts
integration/60
ai-capability/2ai-app.capability.test.ts gateway.capability.test.ts
drill/2seed-drill.mjs verify-restore.mjs
fixtures/5
files/5clip.mp4 clip.wav doc.pdf hero-1400.png note.txt
helpers/3api.ts containers.ts seed.ts
realtime/11control-channel.test.ts convergence.test.ts flush-rebase-http.test.ts handshake.test.ts heartbeat-limits.test.ts lifecycle.test.ts presence.test.ts rebase.test.ts resume.test.ts rt-helpers.ts task-0094-fanout.test.ts
ai-chat.integration.test.ts ai-database.integration.test.ts ai-generate.integration.test.ts ai-rag.integration.test.ts ai-writing.integration.test.ts api-keys.integration.test.ts audit-completeness.integration.test.ts audit-export-s3.integration.test.ts auth.integration.test.ts automation-pipeline.integration.test.ts automations.integration.test.ts blocks.integration.test.ts cache-invalidation.integration.test.ts comments.integration.test.ts databases.integration.test.ts files-lifecycle.integration.test.ts files-pipeline.integration.test.ts files-security.integration.test.ts files.integration.test.ts forms-responses.integration.test.ts forms.integration.test.ts oauth.integration.test.ts openapi.contract.test.ts org-admin.integration.test.ts package.json pages.integration.test.ts permissions.integration.test.ts README.md replica-routing.integration.test.ts retention-engine.integration.test.ts scim.integration.test.ts search.integration.test.ts sso.integration.test.ts tsconfig.json vitest.config.ts webhooks.integration.test.ts workspace.integration.test.ts
performance/28
fixtures/1search-100k.ts
helpers/2seed-100k.mjs seed-1k-load.mjs
reports/7api-load-smoke.json database-load-smoke.json editor-local.json realtime-load.json search-full.json search-last.json search-reduced.json
api-load.js api.js archive.tar database-load.js database.js editor-local.mjs extract-100k-env.mjs package.json README.md realtime-1k.js realtime-load.js realtime.js run-realtime-load.sh search-search.k6.js search-summary.json seed-100k.mjs threshold-breach-demo.js verify-zero-op-loss.mjs
security/16
helpers/1app.ts
audit-report.test.ts auth-paths.test.ts deps.test.ts formula-fuzz.test.ts gitleaks-config.test.ts headers.test.ts images.test.ts oracle.ts package.json permission-fuzz.test.ts rate-limits.test.ts README.md tsconfig.json upload-abuse.test.ts vitest.config.ts
README.md
.dockerignore .env .env.bak-task0125 .env.example .eslintrc.json .gitignore .npmrc .prettierignore .prettierrc.json AGENTS.md ARCHITECTURE.md CHANGELOG.md CONTRIBUTING.md gitleaks.toml LICENSE package.json pnpm-lock.yaml pnpm-workspace.yaml PRODUCT-SPEC.md README.md ROADMAP.md SECURITY.md tsconfig.json turbo.json
automation.spec.tstests/e2e/specs/automation.spec.tsraw← specs35,492 B← Back to the app
1/**
2 * AI Software Factory — Phase 11 automations E2E (TASK-0114).
3 *
4 * FULL STACK REQUIRED — this spec drives the REAL end-to-end path:
5 *   browser (Next.js :3000) + API (:4000) + worker (tsx, real BullMQ
6 *   `automations` worker + `factory:events` stream consumer + 5-min
7 *   schedule reconciler) + Postgres + Redis.
8 *
9 *   pnpm docker:up && pnpm dev   (api :4000, web :3000, worker)
10 *   DATABASE_URL=... pnpm test:e2e -- automation
11 *
12 * There is intentionally NO `webServer` block (the whole stack must be
13 * running — see playwright.config.ts header).
14 *
15 * Scenarios (ROADMAP Phase 11 exit criteria):
16 *   1. record.created → UPDATE_RECORD + CREATE_NOTIFICATION → SUCCEEDED
17 *      with the property changed in < 10 s; the run + its FULL LOGS are
18 *      visible in the UI (settings → Automations → row → Runs → expand).
19 *   2. condition gate — a property condition (option NAME) skips the
20 *      non-matching record (output.skipped = "conditions_not_met") and
21 *      executes the matching one.
22 *   3. schedule (cron) trigger fires → a run with triggerType "schedule"
23 *      (the worker-side reconciler registers the repeatable; we invoke it
24 *      directly via the worker source so the test does not wait for the
25 *      5-minute cycle — the cron resolution still happens in real BullMQ).
26 *   4. pause stops execution — PAUSED automations match nothing;
27 *      re-activating resumes matching.
28 *   5. failure → FAILED run with the error surfaced in the UI run history,
29 *      and the UI Retry button re-runs the run. (The 8×-retry →
30 *      DEAD_LETTER path is covered hermetically in the integration spec —
31 *      no real network target in this environment produces a retryable
32 *      transport error, so a full E2E DLQ cycle cannot be hit
33 *      deterministically; recorded in the Phase 11 gap report.)
34 *   6. self-trigger loop prevention through the REAL stream + consumer +
35 *      worker: an UPDATE_RECORD emits record.updated, which re-matches the
36 *      same automation → the second run is skipped with
37 *      output.skipped = "self_trigger" and no third run follows.
38 *   7. manual run (DRAFT automation, POST /automations/:id/run → 202)
39 *      executes and its full per-action logs render in the UI.
40 *
41 * Data-testid contract (verified against apps/web, TASK-0114 UI):
42 *   automation-list, automation-row-{id}, automation-toggle-{id}
43 *   (role=switch), automation-runs-{id}, automation-run-{runId}
44 *   (status text lowercase, "dead-letter" for DEAD_LETTER),
45 *   automation-run-log-{runId}, automation-retry-run-{runId}.
46 *
47 * API note: databases are created through the page path
48 * (POST /workspaces/:id/pages { type: "database" } → { id, databaseId }) —
49 * the workspace-scoped database-create route does not exist in the API
50 * (pre-existing gap, also breaks the pre-existing databases integration
51 * suite; recorded in the Phase 11 gap report).
52 */
53import { spawn } from "node:child_process";
54import { randomBytes } from "node:crypto";
55import { writeFileSync, rmSync } from "node:fs";
56import path from "node:path";
57import type { APIRequestContext, Page } from "@playwright/test";
58import { test, expect } from "../fixtures";
59 
60// ---------------------------------------------------------------------------
61// Helpers (spec-local: tolerant of envelope shapes, exact on what is asserted)
62// ---------------------------------------------------------------------------
63 
64type Json = Record<string, unknown>;
65interface Run {
66  id: string;
67  status: string;
68  triggerType: string | null;
69  retryCount: number;
70  error: { code?: string; message?: string } | null;
71  createdAt: string;
72  startedAt: string | null;
73  finishedAt: string | null;
74  output?: unknown;
75}
76 
77const j = async (res: { json(): Promise<unknown> }): Promise<Json> =>
78  (await res.json().catch(() => ({}))) as Json;
79 
80async function expectOk(res: { status(): number; text(): Promise<string> }, what: string): Promise<Json> {
81  const status = res.status();
82  if (status < 200 || status >= 300) throw new Error(`${what} → ${status}: ${await res.text()}`);
83  return j(res);
84}
85 
86async function createDatabasePage(ctx: APIRequestContext, wsId: string, title: string): Promise<{ pageId: string; databaseId: string }> {
87  // The REAL database-creation surface: a page with type "database"
88  // (page.service.ts creates the Database row + seeds the Title property +
89  // embeds the database block). Returns the BARE page with databaseId.
90  const res = await ctx.post(`/api/v1/workspaces/${wsId}/pages`, { data: { title, type: "database" } });
91  const body = await expectOk(res, `create database page "${title}"`);
92  const databaseId = body.databaseId;
93  if (typeof databaseId !== "string" || !databaseId) throw new Error(`createDatabasePage: no databaseId in response: ${JSON.stringify(body)}`);
94  return { pageId: String(body.id), databaseId };
95}
96 
97async function addPropertyRaw(ctx: APIRequestContext, dbId: string, body: { name: string; type: string; config?: unknown }): Promise<void> {
98  await expectOk(
99    await ctx.post(`/api/v1/databases/${dbId}/properties`, { data: body }),
100    `add property ${body.name}`
101  );
102}
103 
104/** Create a property then look up its id by name (the list envelope is tolerated). */
105async function findPropertyId(ctx: APIRequestContext, dbId: string, name: string): Promise<string> {
106  const res = await ctx.get(`/api/v1/databases/${dbId}/properties`);
107  const body = await expectOk(res, `list properties of ${dbId}`);
108  const list = (Array.isArray(body) ? body : (body.data ?? body.properties ?? [])) as Json[];
109  const p = list.find((x) => x.name === name);
110  if (!p?.id) throw new Error(`property "${name}" not found in ${dbId}: ${JSON.stringify(list.map((x) => x.name))}`);
111  return String(p.id);
112}
113 
114async function createRecordRaw(ctx: APIRequestContext, dbId: string, properties: Record<string, unknown>): Promise<Json> {
115  const res = await ctx.post(`/api/v1/databases/${dbId}/records`, { data: { properties } });
116  const body = await expectOk(res, `create record in ${dbId}`);
117  const rec = (body.record ?? body) as Json;
118  if (!rec.id) throw new Error(`createRecordRaw: no record id: ${JSON.stringify(body)}`);
119  return rec;
120}
121 
122async function getRecord(ctx: APIRequestContext, recordId: string): Promise<Json> {
123  const res = await ctx.get(`/api/v1/records/${recordId}`);
124  return expectOk(res, `get record ${recordId}`);
125}
126 
127/** The SELECT value stored on a record property: the optionId (envelope-tolerant). */
128function selectValue(record: Json, propertyId: string): string | null {
129  const props = record.properties as Record<string, unknown> | undefined;
130  const v = props?.[propertyId];
131  if (v == null) return null;
132  if (typeof v === "object" && "optionId" in (v as Json)) return String((v as Json).optionId);
133  return String(v);
134}
135 
136/**
137 * On narrow (mobile) viewports the app shell keeps a fixed 256px sidebar and
138 * the automations table (min-width ~460px) overflows UNDER it, so the
139 * run-history rows render partially beneath the sidebar and their click point
140 * is intercepted by the aside. The intended mobile operation is the topbar
141 * "Toggle sidebar" button (collapses the aside to nothing, freeing the full
142 * width) — perform it on narrow viewports so the run rows are clickable.
143 * No-op on desktop (the sidebar stays expanded).
144 *
145 * NOTE: the app shell not auto-collapsing the sidebar on narrow viewports (and
146 * letting wide tables overflow under it) is a PRE-EXISTING app-shell
147 * mobile-layout limitation, not an automations bug — recorded in the Phase 11
148 * gap report. This helper exercises the intended collapse rather than masking
149 * the layout with a JS click.
150 */
151async function collapseSidebarIfNarrow(page: Page): Promise<void> {
152  const vp = page.viewportSize();
153  if (!vp || vp.width >= 768) return; // desktop — leave the sidebar as-is
154  if ((await page.locator("aside").count()) === 0) return; // already collapsed
155  await page.getByRole("button", { name: "Toggle sidebar" }).click();
156  await page.waitForTimeout(300);
157}
158 
159// --- automation API ---------------------------------------------------------
160 
161interface AutomationDetail {
162  id: string;
163  name: string;
164  status: string;
165  databaseId: string | null;
166  triggers: string[];
167  hasCondition: boolean;
168  actions: string[];
169}
170 
171async function createAutomation(ctx: APIRequestContext, wsId: string, input: Json): Promise<AutomationDetail> {
172  const res = await ctx.post(`/api/v1/workspaces/${wsId}/automations`, { data: input });
173  const body = await expectOk(res, `create automation "${input.name}"`);
174  if (!body.id) throw new Error(`createAutomation: no id: ${JSON.stringify(body)}`);
175  return body as unknown as AutomationDetail;
176}
177 
178async function setTriggers(ctx: APIRequestContext, autoId: string, triggers: Json[]): Promise<void> {
179  await expectOk(await ctx.put(`/api/v1/automations/${autoId}/triggers`, { data: { triggers } }), `set triggers on ${autoId}`);
180}
181 
182async function setCondition(ctx: APIRequestContext, autoId: string, condition: Json | null): Promise<void> {
183  await expectOk(await ctx.put(`/api/v1/automations/${autoId}/conditions`, { data: { condition } }), `set condition on ${autoId}`);
184}
185 
186async function setActions(ctx: APIRequestContext, autoId: string, actions: Json[]): Promise<void> {
187  await expectOk(await ctx.put(`/api/v1/automations/${autoId}/actions`, { data: { actions } }), `set actions on ${autoId}`);
188}
189 
190async function setStatus(ctx: APIRequestContext, autoId: string, status: string): Promise<void> {
191  await expectOk(await ctx.post(`/api/v1/automations/${autoId}/status`, { data: { status } }), `set status ${status} on ${autoId}`);
192}
193 
194async function listRuns(ctx: APIRequestContext, autoId: string): Promise<Run[]> {
195  const res = await ctx.get(`/api/v1/automations/${autoId}/runs?limit=200`);
196  const body = await expectOk(res, `list runs of ${autoId}`);
197  return (body.data as Run[]) ?? [];
198}
199 
200/**
201 * listRuns that is tolerant of 429 RATE_LIMITED: the API rate-limits per
202 * user/min (RATE_LIMIT_API, default 100), and this suite polls the runs
203 * endpoint in a tight loop — a 429 must not fail the test, it must just
204 * wait out the window (Retry-After) and re-poll. Returns null on a 429 so
205 * the caller can skip this poll cycle.
206 */
207async function listRunsTolerant(ctx: APIRequestContext, autoId: string): Promise<Run[] | null> {
208  const res = await ctx.get(`/api/v1/automations/${autoId}/runs?limit=200`);
209  if (res.status() === 429) {
210    const retryAfter = Number(res.headers()["retry-after"] ?? 60);
211    const waitMs = Math.min(Math.max(retryAfter, 1) * 1000, 65_000);
212    await sleep(waitMs);
213    return null;
214  }
215  const body = await expectOk(res, `list runs of ${autoId}`);
216  return (body.data as Run[]) ?? [];
217}
218 
219async function getRun(ctx: APIRequestContext, autoId: string, runId: string): Promise<Json> {
220  const res = await ctx.get(`/api/v1/automations/${autoId}/runs/${runId}`);
221  return expectOk(res, `get run ${runId}`);
222}
223 
224const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
225 
226/** Poll listRuns until pred() matches; returns the matching run (or the last state on timeout). */
227async function waitForRun(
228  ctx: APIRequestContext,
229  autoId: string,
230  pred: (runs: Run[]) => Run | undefined,
231  opts: { timeoutMs?: number; intervalMs?: number; what?: string } = {}
232): Promise<Run> {
233  const { timeoutMs = 30_000, intervalMs = 1_000 } = opts;
234  const t0 = Date.now();
235  for (;;) {
236    const runs = await listRunsTolerant(ctx, autoId);
237    if (runs === null) continue; // 429 — waited out Retry-After, re-poll
238    const m = pred(runs);
239    if (m) return m;
240    if (Date.now() - t0 > timeoutMs) {
241      throw new Error(
242        `${opts.what ?? "waitForRun"}: timeout after ${timeoutMs} ms; runs = ${JSON.stringify(runs.map((r) => ({ id: r.id.slice(-6), status: r.status })))}`
243      );
244    }
245    await sleep(intervalMs);
246  }
247}
248 
249// --- schedule reconciler (worker source, via a child tsx process) ------------
250//
251// The worker registers SCHEDULE repeatables at boot + every 5 minutes. The
252// test invokes the REAL reconciler so a cron `* * * * *` automation is
253// registered immediately; the cron resolution + fire still happen inside the
254// real BullMQ queue (no time manipulation).
255//
256// The reconciler lives in the worker package (TS source, no build for the
257// monorepo test path). Playwright's strip-only TS transform cannot load it
258// into the spec process (parameter properties etc.), so — exactly like the
259// integration spec's SIGKILL/crash-recovery test — we run it in a CHILD tsx
260// process whose script is written next to the spec (so @prisma/client,
261// ioredis and bullmq resolve from the e2e package's node_modules). The child
262// imports the worker source via file:// URL, runs ONE reconcile, prints
263// `{added, removed}` and exits.
264async function reconcileSchedules(): Promise<{ added: number; removed: number }> {
265  const dsn = process.env.DATABASE_URL;
266  const redisUrl = process.env.REDIS_URL ?? "redis://localhost:6379";
267  if (!dsn) throw new Error("reconcileSchedules: DATABASE_URL is not set (the schedule E2E test needs the DB DSN).");
268  // CJS spec (no "type":"module") — use __dirname (matches files.spec.ts).
269  const here = __dirname;
270  const workerSrc = path.resolve(here, "..", "..", "..", "apps", "worker", "src");
271  const childScript = path.join(here, `.reconcile-child-${randomBytes(4).toString("hex")}.mts`);
272  // tsx is a repo-root devDependency (root node_modules), 3 levels up from here.
273  const tsxCli = path.resolve(here, "..", "..", "..", "node_modules", "tsx", "dist", "cli.mjs");
274  writeFileSync(
275    childScript,
276    [
277      "import { PrismaClient } from \"@prisma/client\";",
278      "import { Redis } from \"ioredis\";",
279      "const dir = process.env.WORKER_SRC_DIR!;",
280      "const { reconcileScheduleAutomations } = await import(\"file://\" + dir + \"/processors/automations/schedule.ts\");",
281      "const prisma = new PrismaClient({ datasources: { db: { url: process.env.DATABASE_URL! } } });",
282      "const redis = new Redis(process.env.REDIS_URL!, { maxRetriesPerRequest: null });",
283      "const r = await reconcileScheduleAutomations(redis, prisma);",
284      // Marker-prefixed line: the worker source pulls in the pino-pretty
285      // logger, whose ASYNC flush can interleave indented field lines AFTER
286      // a synchronous console.log, so the last stdout line is unreliable.
287      // The parent scans for the marker prefix instead.
288      "console.log(\"RECONCILE_DONE \" + JSON.stringify(r));",
289      "await redis.quit();",
290      "await prisma.$disconnect();",
291      ""
292    ].join("\n"),
293    "utf8"
294  );
295  try {
296    const out: string[] = [];
297    const err: string[] = [];
298    await new Promise<void>((resolve, reject) => {
299      const child = spawn(process.execPath, [tsxCli, childScript], {
300        cwd: here,
301        stdio: ["ignore", "pipe", "pipe"],
302        env: { ...process.env, DATABASE_URL: dsn, REDIS_URL: redisUrl, WORKER_SRC_DIR: workerSrc }
303      });
304      const t = setTimeout(() => {
305        child.kill("SIGKILL");
306        reject(new Error(`reconcileSchedules: child timed out; stderr=${err.join("")}`));
307      }, 30_000);
308      child.stdout.on("data", (d) => out.push(String(d)));
309      child.stderr.on("data", (d) => err.push(String(d)));
310      child.on("close", (code) => {
311        clearTimeout(t);
312        if (code === 0) resolve();
313        else reject(new Error(`reconcileSchedules: child exited ${code}; stderr=${err.join("")}`));
314      });
315    });
316    const stdout = out.join("");
317    const line = stdout.split("\n").find((l) => l.startsWith("RECONCILE_DONE "));
318    if (!line) {
319      throw new Error(`reconcileSchedules: no RECONCILE_DONE marker in child stdout; stdout=${JSON.stringify(stdout.slice(-500))}; stderr=${err.join("").slice(-500)}`);
320    }
321    return JSON.parse(line.slice("RECONCILE_DONE ".length)) as { added: number; removed: number };
322  } finally {
323    rmSync(childScript, { force: true });
324  }
325}
326 
327/** GET /notifications (per-user) — find one matching body. */
328async function findNotification(ctx: APIRequestContext, bodyIncludes: string): Promise<Json | null> {
329  const res = await ctx.get(`/api/v1/notifications?limit=100`);
330  const body = await expectOk(res, "list notifications");
331  const list = (Array.isArray(body) ? body : (body.data ?? [])) as Json[];
332  return list.find((n) => typeof n.body === "string" && n.body.includes(bodyIncludes)) ?? null;
333}
334 
335// ---------------------------------------------------------------------------
336// 1. record.created → UPDATE_RECORD + CREATE_NOTIFICATION → SUCCEEDED < 10 s
337// ---------------------------------------------------------------------------
338 
339test("record.created triggers UPDATE_RECORD + notification; property changed in < 10 s; run + logs visible in UI", async ({
340  api,
341  authPage,
342  freshWorkspace
343}) => {
344  const ws = await freshWorkspace("Auto E2E A");
345  const { databaseId } = await createDatabasePage(api, ws.id, "Tickets");
346  await addPropertyRaw(api, databaseId, {
347    name: "Stage",
348    type: "SELECT",
349    config: { options: [{ name: "A" }, { name: "B" }] }
350  });
351  const statusPropId = await findPropertyId(api, databaseId, "Stage");
352 
353  const auto = await createAutomation(api, ws.id, {
354    name: "E2E: promote new tickets",
355    databaseId,
356    triggers: [{ type: "RECORD_CREATED", config: { databaseId } }],
357    actions: [
358      { type: "UPDATE_RECORD", config: { recordId: "from_trigger", propertyValues: { Stage: "B" } } },
359      { type: "CREATE_NOTIFICATION", config: { to: "automation_creator", type: "DATABASE_CHANGE", body: "E2E auto: ticket promoted to B" } }
360    ]
361  });
362  await setStatus(api, auto.id, "ACTIVE");
363 
364  // The record that fires the automation (created with A; the automation sets B).
365  const createdMs = Date.now();
366  const rec = await createRecordRaw(api, databaseId, { [statusPropId]: "A" });
367  const recordId = String(rec.id);
368 
369  const run = await waitForRun(
370    api,
371    auto.id,
372    (runs) => runs.find((r) => r.status === "SUCCEEDED" || r.status === "FAILED" || r.status === "DEAD_LETTER"),
373    { timeoutMs: 10_000, what: "terminal run within the 10 s exit criterion" }
374  );
375  expect(run.status, `run ${run.id} terminal status (error=${JSON.stringify(run.error)})`).toBe("SUCCEEDED");
376  const elapsedMs = Date.now() - createdMs;
377  expect(elapsedMs, `record-created → SUCCEEDED must be < 10 s (was ${elapsedMs} ms)`).toBeLessThan(10_000);
378 
379  // The effect: Status flipped to B on the same record.
380  const after = await getRecord(api, recordId);
381  expect(selectValue(after, statusPropId)).toBe("B");
382 
383  // The notification reached the automation's creator (the demo user owns the workspace).
384  expect(await findNotification(api, "E2E auto: ticket promoted to B")).not.toBeNull();
385 
386  // --- UI: list → row → run history → full logs -----------------------------
387  // Navigate by WORKSPACE **id** (not slug): the API's GET /workspaces/:id
388  // resolves by id only (findUnique on id) and 403s on a slug, which would
389  // bounce the [workspace] layout back to /w/default (matching the pre-existing
390  // spec convention — files/databases.spec navigate with ws.id).
391  await authPage.goto(`/w/${ws.id}/settings/automations`);
392  await expect(authPage.getByTestId("automation-list")).toBeVisible({ timeout: 15_000 });
393  await expect(authPage.getByTestId(`automation-row-${auto.id}`)).toBeVisible({ timeout: 15_000 });
394  // Latest-run cell on the row shows "succeeded".
395  await expect(authPage.getByTestId(`automation-row-${auto.id}`).getByText("succeeded")).toBeVisible();
396 
397  await collapseSidebarIfNarrow(authPage);
398  await authPage.getByTestId(`automation-runs-${auto.id}`).click();
399  await expect(authPage.getByTestId(`automation-run-${run.id}`)).toBeVisible({ timeout: 15_000 });
400  await authPage.getByTestId(`automation-run-${run.id}`).click();
401  const logPanel = authPage.getByTestId(`automation-run-log-${run.id}`);
402  await expect(logPanel).toBeVisible({ timeout: 15_000 });
403  // Full run logs: per-action lines + the terminal line.
404  await expect(logPanel).toContainText("action 1/2 UPDATE_RECORD started");
405  await expect(logPanel).toContainText("action 2/2 CREATE_NOTIFICATION started");
406  await expect(logPanel).toContainText("run completed");
407});
408 
409// ---------------------------------------------------------------------------
410// 2. condition gate (option NAME semantics)
411// ---------------------------------------------------------------------------
412 
413test("condition gate: non-matching record skipped, matching record executes", async ({ api, freshWorkspace }) => {
414  const ws = await freshWorkspace("Auto E2E B");
415  const { databaseId } = await createDatabasePage(api, ws.id, "Leads");
416  await addPropertyRaw(api, databaseId, {
417    name: "Stage",
418    type: "SELECT",
419    config: { options: [{ name: "A" }, { name: "B" }] }
420  });
421  const statusPropId = await findPropertyId(api, databaseId, "Stage");
422 
423  const auto = await createAutomation(api, ws.id, {
424    name: "E2E: gate on Stage = B",
425    databaseId,
426    triggers: [{ type: "RECORD_CREATED", config: { databaseId } }],
427    condition: { field: { ref: "property", name: "Stage" }, operator: "eq", value: "B" },
428    actions: [{ type: "UPDATE_RECORD", config: { recordId: "from_trigger", propertyValues: { Stage: "A" } } }]
429  });
430  await setStatus(api, auto.id, "ACTIVE");
431 
432  // Non-matching (A) → a run is still created (the matcher matched the
433  // trigger) but SUCCEEDS with output.skipped = "conditions_not_met".
434  await createRecordRaw(api, databaseId, { [statusPropId]: "A" });
435  const skipped = await waitForRun(api, auto.id, (runs) => runs.find((r) => r.status === "SUCCEEDED"), {
436    timeoutMs: 15_000,
437    what: "skipped run"
438  });
439  const skippedDetail = await getRun(api, auto.id, skipped.id);
440  expect(skippedDetail.output, "output.skipped for the gated run").toEqual({ skipped: "conditions_not_met" });
441 
442  // Matching (B) → the action runs.
443  await createRecordRaw(api, databaseId, { [statusPropId]: "B" });
444  const ran = await waitForRun(api, auto.id, (runs) => runs.find((r) => r.id !== skipped.id && r.status === "SUCCEEDED"), {
445    timeoutMs: 15_000,
446    what: "executed run"
447  });
448  const ranDetail = await getRun(api, auto.id, ran.id);
449  expect((ranDetail.output as Json)?.skipped, "the matching run must NOT be skipped").toBeUndefined();
450  const actions = (ranDetail.output as Json)?.actions as Json[];
451  expect(actions?.length).toBe(1);
452  expect(actions?.[0]?.type).toBe("UPDATE_RECORD");
453});
454 
455// ---------------------------------------------------------------------------
456// 3. schedule (cron) trigger fires
457// ---------------------------------------------------------------------------
458 
459test("schedule trigger: cron automation fires and creates a schedule run", async ({ api, freshWorkspace }) => {
460  test.setTimeout(120_000);
461  const ws = await freshWorkspace("Auto E2E C");
462  const auto = await createAutomation(api, ws.id, {
463    name: "E2E: hourly ping",
464    triggers: [{ type: "SCHEDULE", config: { cron: "* * * * *" } }],
465    actions: [{ type: "CREATE_NOTIFICATION", config: { to: "automation_creator", type: "AUTOMATION", body: "E2E cron tick" } }]
466  });
467  await setStatus(api, auto.id, "ACTIVE");
468 
469  // Register the repeatable via the real worker reconciler (normally boot /
470  // every 5 min — not worth waiting in a test).
471  const { added } = await reconcileSchedules();
472  expect(added, "the reconciler must register the new repeatable").toBeGreaterThanOrEqual(1);
473 
474  const run = await waitForRun(
475    api,
476    auto.id,
477    (runs) => runs.find((r) => r.triggerType === "schedule" && (r.status === "SUCCEEDED" || r.status === "FAILED" || r.status === "DEAD_LETTER")),
478    { timeoutMs: 95_000, intervalMs: 2_000, what: "cron fire (≤ 60 s resolution + execution)" }
479  );
480  expect(run.status).toBe("SUCCEEDED");
481  expect(await findNotification(api, "E2E cron tick")).not.toBeNull();
482 
483  // Cleanup: pause + reconcile again so the repeatable is removed (the
484  // workspace teardown would otherwise leave a harmless orphan that skips
485  // on the missing automation).
486  await setStatus(api, auto.id, "PAUSED");
487  await reconcileSchedules();
488});
489 
490// ---------------------------------------------------------------------------
491// 4. pause stops execution; re-activate resumes
492// ---------------------------------------------------------------------------
493 
494test("PAUSED automation stops matching; re-activating resumes", async ({ api, freshWorkspace }) => {
495  const ws = await freshWorkspace("Auto E2E D");
496  const { databaseId } = await createDatabasePage(api, ws.id, "Items");
497  const auto = await createAutomation(api, ws.id, {
498    name: "E2E: pausable",
499    databaseId,
500    triggers: [{ type: "RECORD_CREATED", config: { databaseId } }],
501    actions: [{ type: "CREATE_NOTIFICATION", config: { to: "automation_creator", type: "AUTOMATION", body: "E2E pause test tick" } }]
502  });
503  await setStatus(api, auto.id, "ACTIVE");
504 
505  // Active: the first record matches.
506  await createRecordRaw(api, databaseId, {});
507  const first = await waitForRun(api, auto.id, (runs) => runs.find((r) => r.status === "SUCCEEDED"), {
508    timeoutMs: 15_000,
509    what: "first (active) run"
510  });
511 
512  // Paused: a second record must NOT create a new run (10 s of consumer time).
513  await setStatus(api, auto.id, "PAUSED");
514  await sleep(1_000); // let the status settle before the event is published
515  await createRecordRaw(api, databaseId, {});
516  await sleep(10_000);
517  const whilePaused = await listRuns(api, auto.id);
518  expect(whilePaused, "no new run while PAUSED").toHaveLength(1);
519  expect(whilePaused[0]?.id).toBe(first.id);
520 
521  // Re-activated: a third record matches again.
522  await setStatus(api, auto.id, "ACTIVE");
523  await createRecordRaw(api, databaseId, {});
524  await waitForRun(api, auto.id, (runs) => runs.find((r) => r.id !== first.id && r.status === "SUCCEEDED"), {
525    timeoutMs: 15_000,
526    what: "run after re-activation"
527  });
528});
529 
530// ---------------------------------------------------------------------------
531// 5. failure → FAILED + UI error + retry re-runs
532// ---------------------------------------------------------------------------
533 
534test("failing action (dead TEST-NET webhook) → FAILED run with error in UI; retry re-runs the run", async ({
535  api,
536  authPage,
537  freshWorkspace
538}) => {
539  test.setTimeout(90_000);
540  const ws = await freshWorkspace("Auto E2E E");
541  const { databaseId } = await createDatabasePage(api, ws.id, "Outbox");
542  const auto = await createAutomation(api, ws.id, {
543    name: "E2E: failing webhook",
544    databaseId,
545    triggers: [{ type: "RECORD_CREATED", config: { databaseId } }],
546    actions: [{ type: "SEND_WEBHOOK", config: { url: "https://192.0.2.1/hook" } }]
547  });
548  await setStatus(api, auto.id, "ACTIVE");
549 
550  // 192.0.2.1 is TEST-NET (public-range, passes the SSRF guard) but
551  // unreachable: undici gives up after its 10 s connect timeout and the
552  // pipeline classifies the transport error as fatal → FAILED, no retries.
553  await createRecordRaw(api, databaseId, {});
554  const run = await waitForRun(
555    api,
556    auto.id,
557    (runs) => runs.find((r) => r.status === "FAILED" || r.status === "SUCCEEDED" || r.status === "DEAD_LETTER"),
558    { timeoutMs: 45_000, what: "terminal (failed) run" }
559  );
560  expect(run.status, `terminal status (error=${JSON.stringify(run.error)})`).toBe("FAILED");
561  expect(run.error, "the run error must be stored").not.toBeNull();
562 
563  // --- UI: the failed run + its error + the Retry button --------------------
564  // Navigate by WORKSPACE **id** (not slug): the API's GET /workspaces/:id
565  // resolves by id only (findUnique on id) and 403s on a slug, which would
566  // bounce the [workspace] layout back to /w/default (matching the pre-existing
567  // spec convention — files/databases.spec navigate with ws.id).
568  await authPage.goto(`/w/${ws.id}/settings/automations`);
569  await expect(authPage.getByTestId(`automation-row-${auto.id}`)).toBeVisible({ timeout: 15_000 });
570  await collapseSidebarIfNarrow(authPage);
571  await authPage.getByTestId(`automation-runs-${auto.id}`).click();
572  await expect(authPage.getByTestId(`automation-run-${run.id}`)).toBeVisible({ timeout: 15_000 });
573  await expect(authPage.getByTestId(`automation-run-${run.id}`).getByText("failed")).toBeVisible();
574  await authPage.getByTestId(`automation-run-${run.id}`).click();
575  const logPanel = authPage.getByTestId(`automation-run-log-${run.id}`);
576  await expect(logPanel).toBeVisible({ timeout: 15_000 });
577  await expect(logPanel).toContainText("failed");
578  const retryButton = authPage.getByTestId(`automation-retry-run-${run.id}`);
579  await expect(retryButton).toBeVisible();
580 
581  // Retry re-queues the SAME run row (retryCount reset) → it runs again.
582  const before = (await getRun(api, auto.id, run.id)) as Json;
583  const firstFinishedAt = before.finishedAt as string;
584  await retryButton.click();
585  // The run goes back to PENDING/RUNNING, then fails again (the target is
586  // still dead) — with a FRESH startedAt (proof it actually re-ran).
587  await waitForRun(
588    api,
589    auto.id,
590    (runs) => runs.find((r) => r.id === run.id && r.status === "FAILED" && (r.finishedAt ?? "") > firstFinishedAt),
591    { timeoutMs: 60_000, what: "re-run after UI retry" }
592  );
593});
594 
595// ---------------------------------------------------------------------------
596// 6. self-trigger loop prevention through the real stream + worker
597// ---------------------------------------------------------------------------
598 
599test("self-trigger: an automation's own record.updated re-match is skipped, no loop", async ({ api, freshWorkspace }) => {
600  test.setTimeout(90_000);
601  const ws = await freshWorkspace("Auto E2E F");
602  const { databaseId } = await createDatabasePage(api, ws.id, "Loop");
603  await addPropertyRaw(api, databaseId, {
604    name: "Stage",
605    type: "SELECT",
606    config: { options: [{ name: "A" }, { name: "B" }] }
607  });
608  const statusPropId = await findPropertyId(api, databaseId, "Stage");
609 
610  const auto = await createAutomation(api, ws.id, {
611    name: "E2E: self-trigger guard",
612    databaseId,
613    // NOTE: a BARE RECORD_UPDATED trigger (no `properties` filter). The
614    // matcher only narrows on `changedProperties` when the trigger carries a
615    // non-empty `properties` list; user-originated record.updated events carry
616    // the property **ids** (query.service.ts `changedFields` → Object.keys of
617    // the id-keyed patch), whereas the trigger filter / condition / executor
618    // contract is **property names** — so a property-scoped trigger would fail
619    // closed on a user edit. Without the filter, both the user edit and the
620    // automation's own echo match, and the loop guard skips the echo
621    // (self_trigger). The id/name mismatch for property-scoped user triggers
622    // is a pre-existing contract gap (recorded in the Phase 11 gap report).
623    triggers: [{ type: "RECORD_UPDATED", config: { databaseId } }],
624    actions: [{ type: "UPDATE_RECORD", config: { recordId: "from_trigger", propertyValues: { Stage: "A" } } }]
625  });
626  await setStatus(api, auto.id, "ACTIVE");
627 
628  // Run 1 (depth 0): a USER edit publishes record.updated — the matcher
629  // enqueues the run. The action flips Status back to A, which emits ANOTHER
630  // record.updated — stamped with THIS automationId at depth 1. The consumer
631  // re-matches the same automation (run 2), and the loop guard must skip it
632  // with "self_trigger". No run 3 may follow (a skipped run executes no
633  // actions → emits nothing).
634  const rec = await createRecordRaw(api, databaseId, { [statusPropId]: "A" });
635  await expectOk(
636    await api.patch(`/api/v1/records/${String(rec.id)}`, { data: { properties: { [statusPropId]: "B" } } }),
637    "user edit (B) that starts the chain"
638  );
639  // Wait until BOTH runs exist AND the newest (the echo) is terminal — a
640  // two-run count alone would let us read the echo's output before it ran.
641  const isTerminal = (r: Run) =>
642    r.status === "SUCCEEDED" || r.status === "FAILED" || r.status === "DEAD_LETTER";
643  await waitForRun(api, auto.id, (runs) => (runs.length >= 2 && isTerminal(runs[0]) ? runs[0] : undefined), {
644    timeoutMs: 30_000,
645    what: "the second run (the re-matched record.updated echo) reaching terminal"
646  });
647  const runsNow = await listRuns(api, auto.id);
648  expect(runsNow.length, "exactly 2 runs: the update ran, its own echo was skipped").toBe(2);
649  // The echo run is the newest one (createdAt desc); it must be the skip.
650  const skipped = await getRun(api, auto.id, runsNow[0].id);
651  expect(skipped.output, "self_trigger skip output").toEqual({ skipped: "self_trigger" });
652 
653  // Let the consumer prove there is no third run.
654  await sleep(8_000);
655  expect((await listRuns(api, auto.id)).length, "no third run (loop prevented)").toBe(2);
656});
657 
658// ---------------------------------------------------------------------------
659// 7. manual run (DRAFT) + full logs in the UI
660// ---------------------------------------------------------------------------
661 
662test("manual run on a DRAFT automation executes; full per-action logs render in the UI", async ({
663  api,
664  authPage,
665  freshWorkspace
666}) => {
667  const ws = await freshWorkspace("Auto E2E G");
668  const { databaseId } = await createDatabasePage(api, ws.id, "Manual");
669  await addPropertyRaw(api, databaseId, {
670    name: "Stage",
671    type: "SELECT",
672    config: { options: [{ name: "A" }, { name: "B" }] }
673  });
674  const statusPropId = await findPropertyId(api, databaseId, "Stage");
675  const seed = await createRecordRaw(api, databaseId, { [statusPropId]: "A" });
676 
677  // DRAFT (created, never activated) — manual runs are allowed on drafts.
678  const auto = await createAutomation(api, ws.id, {
679    name: "E2E: manual draft",
680    databaseId,
681    actions: [{ type: "UPDATE_RECORD", config: { recordId: String(seed.id), propertyValues: { Stage: "B" } } }]
682  });
683  expect(auto.status).toBe("DRAFT");
684 
685  const res = await api.post(`/api/v1/automations/${auto.id}/run`, { data: {} });
686  expect(res.status(), "manual run → 202").toBe(202);
687  const body = await j(res);
688  const runId = String(body.runId);
689  expect(runId).toBeTruthy();
690 
691  const run = await waitForRun(
692    api,
693    auto.id,
694    (runs) => runs.find((r) => r.id === runId && (r.status === "SUCCEEDED" || r.status === "FAILED")),
695    { timeoutMs: 30_000, what: "manual run terminal" }
696  );
697  expect(run.status).toBe("SUCCEEDED");
698  expect(selectValue(await getRecord(api, String(seed.id)), statusPropId)).toBe("B");
699 
700  // UI: the DRAFT row exists, its run history shows the manual run + logs.
701  // Navigate by WORKSPACE **id** (not slug): the API's GET /workspaces/:id
702  // resolves by id only (findUnique on id) and 403s on a slug, which would
703  // bounce the [workspace] layout back to /w/default (matching the pre-existing
704  // spec convention — files/databases.spec navigate with ws.id).
705  await authPage.goto(`/w/${ws.id}/settings/automations`);
706  await expect(authPage.getByTestId(`automation-row-${auto.id}`)).toBeVisible({ timeout: 15_000 });
707  await collapseSidebarIfNarrow(authPage);
708  await authPage.getByTestId(`automation-runs-${auto.id}`).click();
709  await expect(authPage.getByTestId(`automation-run-${runId}`)).toBeVisible({ timeout: 15_000 });
710  await authPage.getByTestId(`automation-run-${runId}`).click();
711  const logPanel = authPage.getByTestId(`automation-run-log-${runId}`);
712  await expect(logPanel).toBeVisible({ timeout: 15_000 });
713  await expect(logPanel).toContainText("action 1/1 UPDATE_RECORD started");
714  await expect(logPanel).toContainText("action 1/1 UPDATE_RECORD done");
715  await expect(logPanel).toContainText("run completed");
716});
717