From a16e7a2bc4d95c61bae387a0d8e949233b330b8e Mon Sep 17 00:00:00 2001 From: Jannik Jung Date: Mon, 15 Jun 2026 13:20:23 -0600 Subject: [PATCH 01/18] fix: harden Tend for open-source launch --- .github/workflows/ci.yml | 83 +++ .github/workflows/release.yml | 3 + CHANGELOG.md | 9 +- CONTRIBUTING.md | 6 + README.md | 19 +- docs/ARCHITECTURE.md | 2 +- docs/DATA.md | 10 +- docs/DEVELOPMENT.md | 15 +- docs/INSTALL.md | 21 +- docs/IOS.md | 10 +- docs/SECURITY.md | 7 +- package.json | 24 +- pnpm-lock.yaml | 501 ++++++++----------- scripts/dev.ts | 50 ++ scripts/package-binary.ts | 3 + server.ts | 3 + server/cli/backup.ts | 172 ++++++- server/cli/service.ts | 171 +++++-- server/cli/setup.ts | 37 +- server/dispatcher.ts | 55 +- server/domain.ts | 8 +- server/mobile/projection.ts | 2 +- server/mobile/sync.ts | 8 +- server/repositories/cards.ts | 25 +- server/repositories/feedEvents.ts | 10 +- server/repositories/mirrorWrites.ts | 43 ++ server/repositories/mobileCommandReceipts.ts | 5 +- server/repositories/routineActionGroups.ts | 13 +- server/repositories/sweeps.ts | 26 +- server/repositories/workItems.ts | 13 +- server/routes/api.ts | 14 +- server/routes/shared.ts | 41 +- server/runtime.ts | 24 +- server/sqlite.ts | 44 +- server/store.ts | 30 +- server/util.ts | 32 +- src/App.tsx | 2 +- src/app/api.ts | 40 +- src/feed/selectors.ts | 2 +- src/styles.css | 6 + src/workspace/PromptWorkspace.tsx | 36 +- test/cli-contract.test.ts | 2 +- 42 files changed, 1131 insertions(+), 496 deletions(-) create mode 100644 scripts/dev.ts create mode 100644 server/repositories/mirrorWrites.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2f6ab5..3bac5a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Audit dependencies + run: pnpm audit --audit-level high + - name: Run project checks run: pnpm check @@ -55,3 +58,83 @@ jobs: - name: Package local binary run: pnpm attention:package + + mobile-database: + name: Supabase migration and bridge + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.11 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Enable pnpm + run: | + corepack enable + corepack prepare pnpm@9.15.4 --activate + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Start local Supabase + run: pnpm exec supabase start + + - name: Reset and test database + run: | + pnpm exec supabase db reset --local + pnpm exec supabase test db + + - name: Run mobile bridge integration + shell: bash + run: | + eval "$(pnpm exec supabase status -o env)" + TEND_SUPABASE_E2E=1 \ + TEND_TEST_SUPABASE_URL="$API_URL" \ + TEND_TEST_SUPABASE_ANON_KEY="$ANON_KEY" \ + TEND_TEST_SUPABASE_SERVICE_ROLE_KEY="$SERVICE_ROLE_KEY" \ + TEND_TEST_SUPABASE_JWT_SECRET="$JWT_SECRET" \ + bun test test/mobile-supabase-e2e.test.ts + + - name: Stop local Supabase + if: always() + run: pnpm exec supabase stop --no-backup + + ios: + name: Native iPhone tests + runs-on: macos-15 + timeout-minutes: 30 + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_26.3.app/Contents/Developer + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + working-directory: ios + run: xcodegen generate + + - name: Run unit and UI tests + shell: bash + run: | + SIMULATOR_ID="$(xcrun simctl list devices available -j | python3 -c 'import json, sys; devices = json.load(sys.stdin)["devices"]; print(next(device["udid"] for runtime in devices.values() for device in runtime if device.get("isAvailable") and device["name"].startswith("iPhone")))')" + xcodebuild test \ + -project ios/Tend.xcodeproj \ + -scheme Tend \ + -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ + CODE_SIGNING_ALLOWED=NO \ + DEVELOPMENT_TEAM= diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c432569..4cd0ff9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Audit dependencies + run: pnpm audit --audit-level high + - name: Run project checks run: pnpm check diff --git a/CHANGELOG.md b/CHANGELOG.md index bf15759..cb39482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,14 @@ a promise of ongoing maintenance. trails, and source-backed feed influence receipts. - Advance the CLI contract to `0.2` with context binding, publication, health, and feed-safe read commands. -- Advance the SQLite schema to `12` for mirrored context binding and update records. +- Add the native Tend iPhone companion, private Supabase projection, and idempotent mobile command + bridge. +- Advance the SQLite schema to `14` for mirrored mobile command receipts and deterministic audit + event ordering. +- Harden local mutations, identifiers, backup/restore, background-process ownership, and + transactional multi-record writes. +- Add Supabase and native iOS CI coverage, reproducible source prerequisites, and complete packaged + documentation. ## 0.1.0 - Initial Local-First OSS Snapshot diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe8b277..52dc999 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,12 @@ that an agent can perform goes through the same domain invariants as the UI. ## Local Setup +Install Bun 1.3.11 or newer and Node.js 22 or newer, then enable the repository's pinned pnpm +version: + ```sh +corepack enable +corepack prepare pnpm@9.15.4 --activate pnpm install pnpm start ``` @@ -34,6 +39,7 @@ pnpm build pnpm attention:build pnpm attention:smoke pnpm attention:package +pnpm audit ``` ## Architecture Expectations diff --git a/README.md b/README.md index 37fd1d3..339e4e8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ Attention is not a hosted service. The app stores state on your machine. Codex D agent runtime and owns access to Gmail, GitHub, Slack, browser automation, and other local connectors. +## Naming + +**Attention** is the open-source project, local runtime, web app, and CLI. **Tend** is the optional +native iPhone companion and the name retained by the maintainer launcher, `bin/tend-live`. The +repository URL remains `EveryInc/tend`, while user-facing runtime commands use `attention`. + ## Mental Model ```mermaid @@ -78,9 +84,16 @@ xattr -d com.apple.quarantine ./attention Use the source path when you want to inspect, modify, or extend the app: +Prerequisites: + +- Bun 1.3.11 or newer +- Node.js 22 or newer +- pnpm 9.15.4, enabled through Corepack or installed directly + ```sh git clone https://github.com/EveryInc/tend.git cd tend +corepack enable pnpm install pnpm start ``` @@ -146,16 +159,20 @@ Back up and restore: ```sh attention backup export ./attention-backup +attention stop attention backup import ./attention-backup ``` +Exports require a new destination and never delete an existing path. Imports are staged before the +current data is swapped, and Attention refuses to import while the same runtime is active. + See [docs/DATA.md](./docs/DATA.md) for the full storage map. ## iPhone App The optional native iPhone app mirrors every configured feed through a private Supabase project. The Mac remains authoritative: the phone reviews cached projections and records commands, while the -canonical Tend runtime validates and imports those commands through the same domain rules used by +canonical Attention runtime validates and imports those commands through the same domain rules used by the web app and CLI. The SwiftUI project, database migration, and production setup are documented in diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c67020a..0f49491 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -75,7 +75,7 @@ No patch stream is required for v0. ## Native Mobile Bridge -The iPhone app is a review client, not a second Tend runtime. One worker inside the canonical +The iPhone app is a review client, not a second Attention runtime. One worker inside the canonical `tend-live` process discovers all active feeds, builds privacy-filtered projections, and replaces the user's Supabase snapshot in one database transaction. The phone reads those projections and submits commands through authenticated RPCs. diff --git a/docs/DATA.md b/docs/DATA.md index 9751127..5427584 100644 --- a/docs/DATA.md +++ b/docs/DATA.md @@ -45,6 +45,7 @@ Attention does not store Gmail, GitHub, Slack, browser, or other connector crede ```sh attention backup export attention backup export ./attention-backup +attention stop attention backup import ./attention-backup ``` @@ -57,6 +58,11 @@ attention-backup/ manifest.json ``` -`attention.db` is the SQLite runtime authority. `data/` contains readable file mirrors and immutable raw evidence snapshots. Importing this backup restores both. +`attention.db` is a consistent SQLite snapshot of the runtime authority. `data/` contains readable +file mirrors and immutable raw evidence snapshots. Export writes through a temporary staging +directory and refuses to overwrite or delete an existing destination. -Older data-directory-only backups are still accepted. When importing a legacy data-only backup, Attention replaces `data/` and removes the existing SQLite files so the next local runtime start rehydrates `attention.db` from the imported file mirrors. +Import first copies the backup into a temporary staging directory. Attention refuses to import while +the same runtime home is active, then swaps the staged database and data into place with rollback if +the swap fails. Older data-directory-only backups are still accepted; the next local runtime start +rehydrates `attention.db` from those imported file mirrors. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f4ebaca..6e15d62 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -7,6 +7,8 @@ development. ## Scripts ```sh +corepack enable +corepack prepare pnpm@9.15.4 --activate pnpm install pnpm start pnpm check @@ -63,11 +65,14 @@ Domain tests live under `test/`. Add coverage for new invariants before exposing Start and validate the local Supabase stack: ```sh -npx --yes supabase@latest start -npx --yes supabase@latest db reset --local -npx --yes supabase@latest test db +pnpm exec supabase start +pnpm exec supabase db reset --local +pnpm exec supabase test db ``` +The repository pins the Supabase CLI as a development dependency, so these are the same commands +used in CI. + Generate the Xcode project and run the `Tend` scheme: ```sh @@ -114,6 +119,10 @@ Use `pnpm attention:package` after the smoke check when preparing a local releas a platform-specific tarball and checksum under `dist-bin/releases/`. The tarball includes the compiled binary, built `dist/` UI assets, license, and release docs. +CI also starts a local Supabase stack, resets and pgTAP-tests the database, runs the real mobile +bridge integration test, generates the Xcode project, and runs the native `Tend` unit and UI test +targets on a macOS runner. + ## Releases Release policy lives in [`docs/RELEASING.md`](./RELEASING.md). Keep `package.json`, diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 451fe99..94b29c6 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -1,5 +1,16 @@ # Install +## Prerequisites + +Source development requires Bun 1.3.11 or newer, Node.js 22 or newer, and pnpm 9.15.4. + +```sh +bun --version +node --version +corepack enable +corepack prepare pnpm@9.15.4 --activate +``` + ## From Source ```sh @@ -50,8 +61,8 @@ pnpm attention:package The package command writes `dist-bin/releases/attention---.tar.gz` plus a `.sha256` checksum. The archive contains the `attention` executable, built `dist/` UI assets, README, -license, contributor notes, install/agent/data/security/releasing docs, changelog, and the -operator/capability references. +license, contributor notes, all public install/architecture/agent/data/development/iPhone/security/ +releasing docs, changelog, and the operator/capability references. The packaged executable resolves UI assets from the sibling `dist/` directory, so it can be launched from inside the extracted folder or by absolute path from another working directory. @@ -90,7 +101,11 @@ the full server, version contract, and API readiness check to be green. ```sh pnpm attention -- backup export ./attention-backup +pnpm attention -- stop pnpm attention -- backup import ./attention-backup ``` -Backups include `attention.db`, the readable `data/` mirrors, and a manifest. Legacy data-directory-only backups can still be imported; Attention removes the existing SQLite files so the imported mirrors become the source for rehydration on the next start. +Backups include a consistent SQLite snapshot, the readable `data/` mirrors, and a manifest. Export +requires a destination that does not already exist. Import stages and validates the backup before +swapping data, preserves the previous runtime until the swap succeeds, and refuses to run while the +same Attention home is active. Legacy data-directory-only backups can still be imported. diff --git a/docs/IOS.md b/docs/IOS.md index f4564cc..2ccce23 100644 --- a/docs/IOS.md +++ b/docs/IOS.md @@ -1,19 +1,19 @@ -# Native Tend For iPhone +# Tend, The Native Attention Companion The SwiftUI app under `ios/` provides a fast, feed-by-feed review surface for every feed in the -canonical Tend runtime. The phone never runs Codex or a connector. It reads a private cloud mirror, +canonical Attention runtime. The phone never runs Codex or a connector. It reads a private cloud mirror, records exact commands, and shows the work that the Mac performs later. ## Data Flow ```mermaid flowchart LR - SQLite["Local Tend SQLite authority"] --> Worker["One worker inside tend-live"] + SQLite["Local Attention SQLite authority"] --> Worker["One worker inside tend-live"] Worker --> Supabase["Private Supabase mirror and command mailbox"] Supabase --> Phone["Native SwiftUI app"] Phone --> Supabase Supabase --> Worker - Worker --> Domain["Tend domain validation"] + Worker --> Domain["Attention domain validation"] Domain --> SQLite ``` @@ -68,7 +68,7 @@ TEND_MOBILE_USER_ID=THE_AUTH_USER_UUID TEND_MOBILE_WORKER_ID=canonical-mac ``` -Protect the file before starting Tend: +Protect the file before starting Attention: ```sh chmod 600 ~/.config/tend/mobile.env diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 78387c7..ed63cd9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -11,11 +11,14 @@ Attention is local-first and binds its development server to `127.0.0.1` by defa ## Localhost -The API is a local HTTP endpoint. Treat it as a trusted-local interface and avoid exposing it on a public network. +The API is a local HTTP endpoint and must not be exposed on a public network. Browser mutations +require JSON, a loopback same-origin request, and a per-process mutation token fetched by the local +UI. These checks prevent an unrelated website from silently posting to a running Attention server; +they are not a substitute for keeping the listener on loopback. ## On Your Mind -Chronicle context may include privacy-filtered OCR. Tend stores the full filtered windows only in +Chronicle context may include privacy-filtered OCR. Attention stores the full filtered windows only in the local SQLite database, readable file mirror, and dedicated `/mind` detail API. Publication receipts, cards, and feed-safe CLI reads omit full OCR. diff --git a/package.json b/package.json index 5299e99..9d5f724 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "api": "bun server.ts", "web": "bun web.ts", "dev": "vite --host 127.0.0.1", - "start": "concurrently -k -n api,web -c brown,blue \"bun run api\" \"pnpm dev\"", + "start": "bun scripts/dev.ts", "build": "tsc && vite build", "typecheck": "tsc --noEmit", "lint": "oxlint .", @@ -26,22 +26,32 @@ "dependencies": { "@tanstack/react-query": "^5.101.0", "@tanstack/react-router": "^1.170.11", - "@vitejs/plugin-react": "^5.1.1", - "concurrently": "^9.2.1", "hono": "^4.10.7", "react": "^18.3.1", - "react-dom": "^18.3.1", - "typescript": "^5.9.3", - "vite": "^7.2.7" + "react-dom": "^18.3.1" }, "devDependencies": { "@types/bun": "^1.3.14", "@types/node": "^24.10.2", "@types/react": "^18.3.27", "@types/react-dom": "^18.3.7", - "oxlint": "^1.68.0" + "@vitejs/plugin-react": "^5.2.0", + "oxlint": "^1.68.0", + "supabase": "2.106.0", + "typescript": "^5.9.3", + "vite": "^7.3.5" + }, + "engines": { + "bun": ">=1.3.11", + "node": ">=22", + "pnpm": "9.15.4" }, "packageManager": "pnpm@9.15.4", + "pnpm": { + "overrides": { + "esbuild": "0.28.1" + } + }, "bin": { "attention": "./attention.ts" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96e0f33..11848d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + esbuild: 0.28.1 + importers: .: @@ -14,12 +17,6 @@ importers: '@tanstack/react-router': specifier: ^1.170.11 version: 1.170.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@vitejs/plugin-react': - specifier: ^5.1.1 - version: 5.2.0(vite@7.3.5(@types/node@24.12.4)) - concurrently: - specifier: ^9.2.1 - version: 9.2.1 hono: specifier: ^4.10.7 version: 4.12.23 @@ -29,12 +26,6 @@ importers: react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) - typescript: - specifier: ^5.9.3 - version: 5.9.3 - vite: - specifier: ^7.2.7 - version: 7.3.5(@types/node@24.12.4) devDependencies: '@types/bun': specifier: ^1.3.14 @@ -48,9 +39,21 @@ importers: '@types/react-dom': specifier: ^18.3.7 version: 18.3.7(@types/react@18.3.30) + '@vitejs/plugin-react': + specifier: ^5.2.0 + version: 5.2.0(vite@7.3.5(@types/node@24.12.4)) oxlint: specifier: ^1.68.0 version: 1.68.0 + supabase: + specifier: 2.106.0 + version: 2.106.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.5 + version: 7.3.5(@types/node@24.12.4) packages: @@ -137,158 +140,158 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -551,6 +554,46 @@ packages: cpu: [x64] os: [win32] + '@supabase/cli-darwin-arm64@2.106.0': + resolution: {integrity: sha512-xi/IAjjbebh6xA9Nkkb7ZwC5ACKJ38OqMoYk1y20ScW5Wt+pxTCcOcaWDWu+2u2Pwc3vTYnXhbknVcQt9UcChQ==} + cpu: [arm64] + os: [darwin] + + '@supabase/cli-darwin-x64@2.106.0': + resolution: {integrity: sha512-AT2s/8/3Ffb7IkcUXK6sA/iPCzJhh4Fh/XqzAGNGsVd1xCA/ZGniqU8BL03TqPkN1sR0iDaShjMLLIlHEflyPQ==} + cpu: [x64] + os: [darwin] + + '@supabase/cli-linux-arm64-musl@2.106.0': + resolution: {integrity: sha512-2LExuOHuU6odXorNTvq4M/OvueW+wOs3KDsaCpk+scQMnB6LbmqNidEjXAsKpZu3zyE8MmmtdbJBlRNN/KLdcw==} + cpu: [arm64] + os: [linux] + + '@supabase/cli-linux-arm64@2.106.0': + resolution: {integrity: sha512-L/pX7fkV31x7zPrUOcD6KMtZ03j16o41XxgkACFoOAE9lFz8KaJOBSHvn2QLeWEa2kLz7jwpisvpshPSMjuxOw==} + cpu: [arm64] + os: [linux] + + '@supabase/cli-linux-x64-musl@2.106.0': + resolution: {integrity: sha512-wz7heB3wwzmRdLRgqngd2VjPPNIdv6/Y8Q0dX/ZvjcL9/Nk8BYSZ7yjypapLpra+swRnbEJ4jAU6eAShiaB1Hg==} + cpu: [x64] + os: [linux] + + '@supabase/cli-linux-x64@2.106.0': + resolution: {integrity: sha512-+ihtqFNURc8ssULgQoHQBR4AtUmxez9dtqjclqOV0y5r65SX7Ginmiby4XDW9LPlqHT+MB7OyD1vk0T6pQUtPA==} + cpu: [x64] + os: [linux] + + '@supabase/cli-windows-arm64@2.106.0': + resolution: {integrity: sha512-QkEfRDiuuQwcfASo2DIJZ59AFV6xpqhPNo4r0kSVt38StYk3iL7ZcSOkZvGQWLlJ8kXKPBWEMhLnfHQF9r+Xfg==} + cpu: [arm64] + os: [win32] + + '@supabase/cli-windows-x64@2.106.0': + resolution: {integrity: sha512-1tQFbrH1KJhWJqHrY087XPz1WRi20eKG1pebG+6PjSD6XN+j0+x1PTOtBZrylXgY1fOg8BqgU8kl3KX8hI5l9w==} + cpu: [x64] + os: [win32] + '@tanstack/history@1.162.0': resolution: {integrity: sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==} engines: {node: '>=20.19'} @@ -621,14 +664,6 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - baseline-browser-mapping@2.10.33: resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} engines: {node: '>=6.0.0'} @@ -645,26 +680,6 @@ packages: caniuse-lite@1.0.30001793: resolution: {integrity: sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==} - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - cliui@8.0.1: - resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} - engines: {node: '>=12'} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - concurrently@9.2.1: - resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} - engines: {node: '>=18'} - hasBin: true - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -686,11 +701,8 @@ packages: electron-to-chromium@1.5.364: resolution: {integrity: sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==} - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -716,22 +728,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} - get-caller-file@2.0.5: - resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} - engines: {node: 6.* || 8.* || >= 10.*} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - hono@4.12.23: resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} engines: {node: '>=16.9.0'} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - isbot@5.1.40: resolution: {integrity: sha512-yNeeynhhtIVRBk12tBV4eHNxwB42HzR4Q3Ea7vCOiJhImGaAIdIMrbJtacQlBizGLjUPw+akkFI5Dn9T70XoVQ==} engines: {node: '>=18'} @@ -805,18 +805,11 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} - require-directory@2.1.1: - resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} - engines: {node: '>=0.10.0'} - rollup@4.61.0: resolution: {integrity: sha512-T9mWdbWfQtp0B5lv/HX+wrhYsmXRlcWnXXmJbXqKJhlRaoS6KMhq0gpyzW4UJfclcxrEdLnTgjT2NjruLONu0g==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -834,41 +827,18 @@ packages: resolution: {integrity: sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==} engines: {node: '>=10'} - shell-quote@1.8.3: - resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} - engines: {node: '>= 0.4'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} + supabase@2.106.0: + resolution: {integrity: sha512-jV0PJi5LqXUMZ4kriRLvST3DX1YDhrxOgnFJLOn6oUw3KQEFJPXhA60yLQ9aWG1E1uZCp4vUJwSuSGq3HPfaSQ==} + hasBin: true tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tree-kill@1.2.2: - resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} - hasBin: true - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -928,25 +898,9 @@ packages: yaml: optional: true - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - y18n@5.0.8: - resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} - engines: {node: '>=10'} - yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} - yargs-parser@21.1.1: - resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} - engines: {node: '>=12'} - - yargs@17.7.2: - resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} - engines: {node: '>=12'} - snapshots: '@babel/code-frame@7.29.7': @@ -1061,82 +1015,82 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@esbuild/aix-ppc64@0.27.7': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.27.7': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.27.7': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.27.7': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.27.7': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.27.7': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.27.7': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.27.7': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.27.7': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.27.7': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.27.7': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.27.7': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.27.7': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.27.7': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.27.7': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.27.7': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.27.7': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.27.7': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.27.7': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.27.7': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.27.7': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.27.7': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.27.7': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.27.7': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.27.7': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.27.7': + '@esbuild/win32-x64@0.28.1': optional: true '@jridgewell/gen-mapping@0.3.13': @@ -1292,6 +1246,30 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.61.0': optional: true + '@supabase/cli-darwin-arm64@2.106.0': + optional: true + + '@supabase/cli-darwin-x64@2.106.0': + optional: true + + '@supabase/cli-linux-arm64-musl@2.106.0': + optional: true + + '@supabase/cli-linux-arm64@2.106.0': + optional: true + + '@supabase/cli-linux-x64-musl@2.106.0': + optional: true + + '@supabase/cli-linux-x64@2.106.0': + optional: true + + '@supabase/cli-windows-arm64@2.106.0': + optional: true + + '@supabase/cli-windows-x64@2.106.0': + optional: true + '@tanstack/history@1.162.0': {} '@tanstack/query-core@5.101.0': {} @@ -1380,12 +1358,6 @@ snapshots: transitivePeerDependencies: - supports-color - ansi-regex@5.0.1: {} - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - baseline-browser-mapping@2.10.33: {} browserslist@4.28.2: @@ -1402,32 +1374,6 @@ snapshots: caniuse-lite@1.0.30001793: {} - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - cliui@8.0.1: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 7.0.0 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - concurrently@9.2.1: - dependencies: - chalk: 4.1.2 - rxjs: 7.8.2 - shell-quote: 1.8.3 - supports-color: 8.1.1 - tree-kill: 1.2.2 - yargs: 17.7.2 - convert-source-map@2.0.0: {} cookie-es@3.1.1: {} @@ -1440,36 +1386,34 @@ snapshots: electron-to-chromium@1.5.364: {} - emoji-regex@8.0.0: {} - - esbuild@0.27.7: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -1482,14 +1426,8 @@ snapshots: gensync@1.0.0-beta.2: {} - get-caller-file@2.0.5: {} - - has-flag@4.0.0: {} - hono@4.12.23: {} - is-fullwidth-code-point@3.0.0: {} - isbot@5.1.40: {} js-tokens@4.0.0: {} @@ -1556,8 +1494,6 @@ snapshots: dependencies: loose-envify: 1.4.0 - require-directory@2.1.1: {} - rollup@4.61.0: dependencies: '@types/estree': 1.0.9 @@ -1589,10 +1525,6 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.61.0 fsevents: 2.3.3 - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -1605,37 +1537,24 @@ snapshots: seroval@1.5.4: {} - shell-quote@1.8.3: {} - source-map-js@1.2.1: {} - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 + supabase@2.106.0: + optionalDependencies: + '@supabase/cli-darwin-arm64': 2.106.0 + '@supabase/cli-darwin-x64': 2.106.0 + '@supabase/cli-linux-arm64': 2.106.0 + '@supabase/cli-linux-arm64-musl': 2.106.0 + '@supabase/cli-linux-x64': 2.106.0 + '@supabase/cli-linux-x64-musl': 2.106.0 + '@supabase/cli-windows-arm64': 2.106.0 + '@supabase/cli-windows-x64': 2.106.0 tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tree-kill@1.2.2: {} - - tslib@2.8.1: {} - typescript@5.9.3: {} undici-types@7.16.0: {} @@ -1652,7 +1571,7 @@ snapshots: vite@7.3.5(@types/node@24.12.4): dependencies: - esbuild: 0.27.7 + esbuild: 0.28.1 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.15 @@ -1662,24 +1581,4 @@ snapshots: '@types/node': 24.12.4 fsevents: 2.3.3 - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - y18n@5.0.8: {} - yallist@3.1.1: {} - - yargs-parser@21.1.1: {} - - yargs@17.7.2: - dependencies: - cliui: 8.0.1 - escalade: 3.2.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - string-width: 4.2.3 - y18n: 5.0.8 - yargs-parser: 21.1.1 diff --git a/scripts/dev.ts b/scripts/dev.ts new file mode 100644 index 0000000..367f60a --- /dev/null +++ b/scripts/dev.ts @@ -0,0 +1,50 @@ +export {}; + +const commands = [ + [process.execPath, "server.ts"], + [process.execPath, "node_modules/vite/bin/vite.js", "--host", "127.0.0.1"], +]; + +const processes = commands.map((command) => Bun.spawn(command, { + cwd: process.cwd(), + env: process.env, + stderr: "inherit", + stdin: "inherit", + stdout: "inherit", +})); + +let stopping = false; +let requestedSignal: NodeJS.Signals | null = null; + +function stop(signal: NodeJS.Signals = "SIGTERM"): void { + if (stopping) return; + stopping = true; + for (const subprocess of processes) { + try { + subprocess.kill(signal); + } catch { + // The process may already have exited. + } + } +} + +process.on("SIGINT", () => { + requestedSignal = "SIGINT"; + stop("SIGINT"); +}); +process.on("SIGTERM", () => { + requestedSignal = "SIGTERM"; + stop("SIGTERM"); +}); + +const firstExit = await Promise.race(processes.map(async (subprocess) => ({ + exitCode: await subprocess.exited, + pid: subprocess.pid, +}))); +stop(); +await Promise.all(processes.map((subprocess) => subprocess.exited)); + +if (!requestedSignal && firstExit.exitCode !== 0) { + console.error(`Development process ${firstExit.pid} exited with code ${firstExit.exitCode}.`); +} +process.exit(requestedSignal === "SIGINT" ? 0 : requestedSignal === "SIGTERM" ? 143 : firstExit.exitCode); diff --git a/scripts/package-binary.ts b/scripts/package-binary.ts index 2f02900..72806ec 100644 --- a/scripts/package-binary.ts +++ b/scripts/package-binary.ts @@ -33,9 +33,12 @@ await cp(path.join(root, "CONTRIBUTING.md"), path.join(stageDir, "CONTRIBUTING.m await cp(path.join(root, "LICENSE"), path.join(stageDir, "LICENSE")); await copyDocs([ "docs/INSTALL.md", + "docs/ARCHITECTURE.md", "docs/AGENT_CONTRACT.md", "docs/SKILL.md", "docs/DATA.md", + "docs/DEVELOPMENT.md", + "docs/IOS.md", "docs/SECURITY.md", "docs/RELEASING.md", "CHANGELOG.md", diff --git a/server.ts b/server.ts index 0075ac9..de05501 100644 --- a/server.ts +++ b/server.ts @@ -10,6 +10,7 @@ import { createLocalRuntime, resolveArtifactsDir, resolveDataDir, resolveDbPath, import { DrainDispatcher } from "./server/dispatcher"; import { mobileCloudConfigFromEnv, SupabaseMobileCloudClient } from "./server/mobile/client"; import { MobileSyncWorker } from "./server/mobile/sync"; +import { makeToken } from "./server/util"; declare const Bun: { serve(options: { port: number; hostname: string; idleTimeout: number; fetch: (...args: any[]) => any }): { stop(force?: boolean): void }; @@ -23,6 +24,7 @@ const artifactsDir = resolveArtifactsDir(root); const dataDir = resolveDataDir(root); const { sqlite, store } = await createLocalRuntime(dataDir, resolveDbPath(root)); const domain = new AttentionDomain(store); +const mutationToken = process.env.ATTENTION_MUTATION_TOKEN ?? makeToken(); const realtime = createRealtimeHub(); const feedEventBridge = createFeedEventBridge(store, realtime.notify); await feedEventBridge.start(); @@ -40,6 +42,7 @@ app.route("/", apiRoutes({ dataDir, domain, mobileStatus: () => mobileSync?.currentStatus() ?? { enabled: false }, + mutationToken, notify: realtime.notify, port, root, diff --git a/server/cli/backup.ts b/server/cli/backup.ts index a0cfd95..87fb1d4 100644 --- a/server/cli/backup.ts +++ b/server/cli/backup.ts @@ -1,38 +1,151 @@ +import { Database } from "bun:sqlite"; import { existsSync } from "node:fs"; -import { cp, mkdir, rm, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, rename, rm, stat, writeFile } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { attentionDataDir, attentionDbPath, attentionHome } from "../paths"; -import { initRuntime, print } from "./shared"; +import { SQLITE_SCHEMA_VERSION } from "../sqlite"; +import { withMutationLock } from "../util"; +import { apiUrl, initRuntime, print } from "./shared"; export async function backupExportCommand(targetPath: string): Promise { - const sqlite = await initRuntime(); - sqlite.close(); - await mkdir(path.dirname(targetPath), { recursive: true }); - if (existsSync(targetPath)) await rm(targetPath, { recursive: true, force: true }); - await mkdir(targetPath, { recursive: true }); - await cp(attentionDataDir(), path.join(targetPath, "data"), { recursive: true }); - if (existsSync(attentionDbPath())) await cp(attentionDbPath(), path.join(targetPath, "attention.db")); - await writeFile(path.join(targetPath, "manifest.json"), JSON.stringify({ - name: "attention-backup", - format: 2, - exportedAt: new Date().toISOString(), - dataDir: attentionDataDir(), - dbPath: attentionDbPath(), - }, null, 2)); - print({ ok: true, exported: { dataDir: attentionDataDir(), dbPath: attentionDbPath() }, to: targetPath }); + const target = path.resolve(targetPath); + if (existsSync(target)) { + throw new Error(`Backup target already exists: ${target}. Choose a new empty path.`); + } + if (isWithin(attentionDataDir(), target)) { + throw new Error("Backup target cannot be inside Attention's data directory."); + } + + await mkdir(path.dirname(target), { recursive: true }); + const stage = await mkdtemp(path.join(path.dirname(target), `.${path.basename(target)}-`)); + try { + await withMutationLock(attentionDataDir(), async () => { + const sqlite = await initRuntime(); + try { + await sqlite.backupTo(path.join(stage, "attention.db")); + await cp(attentionDataDir(), path.join(stage, "data"), { recursive: true }); + await rm(path.join(stage, "data", ".mutation-lock"), { recursive: true, force: true }); + await writeFile(path.join(stage, "manifest.json"), JSON.stringify({ + name: "attention-backup", + format: 2, + exportedAt: new Date().toISOString(), + dataDir: attentionDataDir(), + dbPath: attentionDbPath(), + }, null, 2)); + await rename(stage, target); + } finally { + sqlite.close(); + } + }); + } catch (error) { + await rm(stage, { recursive: true, force: true }); + throw error; + } + print({ ok: true, exported: { dataDir: attentionDataDir(), dbPath: attentionDbPath() }, to: target }); } export async function backupImportCommand(sourcePath: string): Promise { - if (!existsSync(sourcePath)) throw new Error(`Backup path does not exist: ${sourcePath}`); - const bundledData = path.join(sourcePath, "data"); - const bundledDb = path.join(sourcePath, "attention.db"); - const sourceData = existsSync(bundledData) ? bundledData : sourcePath; - await mkdir(attentionHome(), { recursive: true }); - if (existsSync(attentionDataDir())) await rm(attentionDataDir(), { recursive: true, force: true }); - await cp(sourceData, attentionDataDir(), { recursive: true }); - await removeSqliteFiles(); - if (existsSync(bundledDb)) await cp(bundledDb, attentionDbPath()); - print({ ok: true, imported: sourcePath, to: { dataDir: attentionDataDir(), dbPath: attentionDbPath(), sqlite: existsSync(bundledDb) ? "restored" : "will_rehydrate_from_file_mirrors" } }); + const source = path.resolve(sourcePath); + if (!existsSync(source)) throw new Error(`Backup path does not exist: ${source}`); + await assertRuntimeStopped(); + + const bundledData = path.join(source, "data"); + const bundledDb = path.join(source, "attention.db"); + const sourceData = existsSync(bundledData) ? bundledData : source; + if (!(await stat(sourceData)).isDirectory()) throw new Error(`Backup data is not a directory: ${sourceData}`); + + const home = path.resolve(attentionHome()); + await mkdir(path.dirname(home), { recursive: true }); + const stage = await mkdtemp(path.join(os.tmpdir(), "attention-import-")); + const rollback = path.join(stage, "rollback"); + const stagedData = path.join(stage, "data"); + const stagedDb = path.join(stage, "attention.db"); + try { + await cp(sourceData, stagedData, { recursive: true }); + await rm(path.join(stagedData, ".mutation-lock"), { recursive: true, force: true }); + if (existsSync(bundledDb)) { + await cp(bundledDb, stagedDb); + validateSqliteBackup(stagedDb); + } + await mkdir(home, { recursive: true }); + await mkdir(rollback, { recursive: true }); + + const currentFiles = [ + attentionDataDir(), + attentionDbPath(), + `${attentionDbPath()}-shm`, + `${attentionDbPath()}-wal`, + ]; + const moved: Array<{ from: string; to: string }> = []; + try { + for (const current of currentFiles) { + if (!existsSync(current)) continue; + const backup = path.join(rollback, path.basename(current)); + await rename(current, backup); + moved.push({ from: backup, to: current }); + } + await rename(stagedData, attentionDataDir()); + if (existsSync(stagedDb)) await rename(stagedDb, attentionDbPath()); + } catch (error) { + await rm(attentionDataDir(), { recursive: true, force: true }); + await removeSqliteFiles(); + for (const item of moved.reverse()) { + if (existsSync(item.from)) await rename(item.from, item.to); + } + throw error; + } + } finally { + await rm(stage, { recursive: true, force: true }); + } + + print({ + ok: true, + imported: source, + to: { + dataDir: attentionDataDir(), + dbPath: attentionDbPath(), + sqlite: existsSync(bundledDb) ? "restored" : "will_rehydrate_from_file_mirrors", + }, + }); +} + +function validateSqliteBackup(dbPath: string): void { + const db = new Database(dbPath, { readonly: true }); + try { + const rows = db.query("PRAGMA integrity_check;").all() as Array<{ integrity_check: string }>; + if (rows.length !== 1 || rows[0]?.integrity_check !== "ok") { + throw new Error(`Backup SQLite integrity check failed: ${rows.map((row) => row.integrity_check).join("; ") || "no result"}`); + } + let schemaRow: { value: string } | null; + try { + schemaRow = db.query("SELECT value FROM meta WHERE key = 'schema_version'").get() as { value: string } | null; + } catch { + throw new Error("Backup SQLite database is not an Attention runtime."); + } + const schemaVersion = Number(schemaRow?.value); + if (!Number.isInteger(schemaVersion) || schemaVersion < 1) { + throw new Error("Backup SQLite database is missing a valid Attention schema version."); + } + if (schemaVersion > SQLITE_SCHEMA_VERSION) { + throw new Error(`Backup schema ${schemaVersion} is newer than this Attention build supports (${SQLITE_SCHEMA_VERSION}).`); + } + } finally { + db.close(); + } +} + +async function assertRuntimeStopped(): Promise { + try { + const response = await fetch(`${apiUrl()}/api/status`, { signal: AbortSignal.timeout(750) }); + if (!response.ok) return; + const status = await response.json() as { dataDir?: string }; + if (status.dataDir && path.resolve(status.dataDir, "..") === path.resolve(attentionHome())) { + throw new Error("Stop Attention before importing a backup into its active runtime."); + } + } catch (error) { + if (error instanceof Error && error.message.startsWith("Stop Attention")) throw error; + } } async function removeSqliteFiles(): Promise { @@ -42,3 +155,8 @@ async function removeSqliteFiles(): Promise { rm(`${attentionDbPath()}-wal`, { force: true }), ]); } + +function isWithin(parent: string, candidate: string): boolean { + const relative = path.relative(path.resolve(parent), path.resolve(candidate)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} diff --git a/server/cli/service.ts b/server/cli/service.ts index 8c5d1b6..43d2b4a 100644 --- a/server/cli/service.ts +++ b/server/cli/service.ts @@ -1,24 +1,26 @@ import { existsSync } from "node:fs"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; -import { attentionHome, attentionLogDir } from "../paths"; +import { attentionDataDir, attentionHome, attentionLogDir } from "../paths"; import { apiPort, apiUrl, print } from "./shared"; export async function startBackgroundCommand(): Promise { await withServiceLock(async () => { if (await serviceHealthy()) { - print(`Attention is already healthy (pid ${await readPid() ?? "unknown"}, url ${apiUrl()}, home ${attentionHome()}).`); + print(`Attention is already healthy (pid ${(await readPidRecord())?.pid ?? "unknown"}, url ${apiUrl()}, home ${attentionHome()}).`); return; } - const stalePid = await readPid(); - if (stalePid && processAlive(stalePid)) { - throw new Error(`Attention pid ${stalePid} exists but is not healthy. Run: attention restart`); + const staleRecord = await readPidRecord(); + if (staleRecord && processAlive(staleRecord.pid)) { + if (await ownsAttentionProcess(staleRecord)) { + throw new Error(`Attention pid ${staleRecord.pid} exists but is not healthy. Run: attention restart`); + } } await rm(pidFile(), { force: true }); await launchDetached(); for (let index = 0; index < 60; index += 1) { if (await serviceHealthy()) { - print(`Attention is healthy (pid ${await readPid() ?? "unknown"}, url ${apiUrl()}, home ${attentionHome()}).`); + print(`Attention is healthy (pid ${(await readPidRecord())?.pid ?? "unknown"}, url ${apiUrl()}, home ${attentionHome()}).`); return; } await Bun.sleep(250); @@ -29,18 +31,29 @@ export async function startBackgroundCommand(): Promise { export async function stopCommand(): Promise { await withServiceLock(async () => { - const pid = await readPid(); - if (!pid) { + const record = await readPidRecord(); + if (!record) { print("Attention is not running as a background service."); return; } - terminate(pid); - await rm(pidFile(), { force: true }); + if (!processAlive(record.pid)) { + await rm(pidFile(), { force: true }); + print("Attention is not running as a background service."); + return; + } + if (!await ownsAttentionProcess(record)) { + throw new Error(`Refusing to stop pid ${record.pid}: it is not the Attention process recorded by this runtime.`); + } + await terminate(record.pid); for (let index = 0; index < 40; index += 1) { - if (!await serviceHealthy()) break; + if (!processAlive(record.pid) && !await serviceHealthy()) { + await rm(pidFile(), { force: true }); + print(`Stopped Attention pid ${record.pid}.`); + return; + } await Bun.sleep(250); } - print(`Stopped Attention pid ${pid}.`); + throw new Error(`Attention pid ${record.pid} did not stop cleanly; the pid record was preserved.`); }); } @@ -50,9 +63,9 @@ export async function restartCommand(): Promise { } export async function healthCommand(): Promise { - const pid = await readPid(); - if (!await serviceHealthy()) throw new Error(`Attention${pid ? ` pid ${pid}` : ""} is not healthy at ${apiUrl()}.`); - print(`Attention is healthy (pid ${pid ?? "unknown"}, url ${apiUrl()}, home ${attentionHome()}).`); + const record = await readPidRecord(); + if (!await serviceHealthy()) throw new Error(`Attention${record ? ` pid ${record.pid}` : ""} is not healthy at ${apiUrl()}.`); + print(`Attention is healthy (pid ${record?.pid ?? "unknown"}, url ${apiUrl()}, home ${attentionHome()}).`); } export async function logsCommand(): Promise { @@ -62,7 +75,8 @@ export async function logsCommand(): Promise { async function launchDetached(): Promise { await mkdir(attentionHome(), { recursive: true }); await mkdir(attentionLogDir(), { recursive: true }); - const proc = Bun.spawn(backgroundCommand(), { + const foregroundCommand = [...currentCliCommand(), "start", "--foreground"]; + const proc = Bun.spawn(backgroundCommand(foregroundCommand), { cwd: process.cwd(), detached: true, env: { @@ -76,11 +90,16 @@ async function launchDetached(): Promise { windowsHide: true, }); proc.unref(); - await writeFile(pidFile(), `${proc.pid}\n`); + await writeFile(pidFile(), `${JSON.stringify({ + pid: proc.pid, + command: foregroundCommand, + home: path.resolve(attentionHome()), + apiPort: apiPort(), + startedAt: new Date().toISOString(), + }, null, 2)}\n`); } -function backgroundCommand(): string[] { - const command = [...currentCliCommand(), "start", "--foreground"]; +function backgroundCommand(command: string[]): string[] { if (process.platform === "win32") { return ["cmd.exe", "/d", "/s", "/c", `${quoteWindowsCommand(command)} >> ${quoteWindowsArg(logFile())} 2>&1`]; } @@ -101,9 +120,25 @@ function currentCliCommand(): string[] { } async function serviceHealthy(): Promise { - const health = await checkUrl(`${apiUrl()}/api/health`); - const ui = await checkUrl(apiUrl()); - return health && ui; + const status = await fetchStatus(); + return status?.ok === true + && typeof status.dataDir === "string" + && path.resolve(status.dataDir) === path.resolve(attentionDataDir()) + && await checkUrl(apiUrl()); +} + +async function fetchStatus(): Promise<{ ok?: boolean; dataDir?: string } | null> { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 1000); + try { + const response = await fetch(`${apiUrl()}/api/status`, { signal: controller.signal }); + if (!response.ok) return null; + return await response.json() as { ok?: boolean; dataDir?: string }; + } catch { + return null; + } finally { + clearTimeout(timeout); + } } async function checkUrl(url: string): Promise { @@ -133,28 +168,104 @@ async function withServiceLock(callback: () => Promise): Promise { } } -async function readPid(): Promise { +type ServicePidRecord = { + pid: number; + command: string[]; + home: string; + apiPort: number; + startedAt?: string; +}; + +async function readPidRecord(): Promise { try { - return (await readFile(pidFile(), "utf8")).trim() || null; + const contents = (await readFile(pidFile(), "utf8")).trim(); + if (!contents) return null; + if (/^\d+$/.test(contents)) { + return { + pid: Number(contents), + command: [...currentCliCommand(), "start", "--foreground"], + home: path.resolve(attentionHome()), + apiPort: apiPort(), + }; + } + const record = JSON.parse(contents) as Partial; + if ( + typeof record.pid !== "number" || + !Number.isInteger(record.pid) || + !Array.isArray(record.command) || + record.command.some((item) => typeof item !== "string") + ) { + throw new Error("Invalid Attention pid record."); + } + return { + pid: record.pid, + command: record.command, + home: typeof record.home === "string" ? record.home : path.resolve(attentionHome()), + apiPort: typeof record.apiPort === "number" ? record.apiPort : apiPort(), + startedAt: record.startedAt, + }; } catch { return null; } } -function processAlive(pid: string): boolean { +function processAlive(pid: number): boolean { try { - process.kill(Number(pid), 0); + process.kill(pid, 0); return true; } catch { return false; } } -function terminate(pid: string): void { - const numericPid = Number(pid); +async function ownsAttentionProcess(record: ServicePidRecord): Promise { + if (path.resolve(record.home) !== path.resolve(attentionHome()) || record.apiPort !== apiPort()) return false; + const commandLine = await readProcessCommandLine(record.pid); + if (!commandLine) return false; + const identity = record.command.find((item) => item.endsWith(".ts")) + ?? record.command[0]; + const normalize = (value: string) => process.platform === "win32" ? value.toLowerCase() : value; + const normalizedCommand = normalize(commandLine); + return Boolean(identity) + && normalizedCommand.includes(normalize(identity)) + && normalizedCommand.includes("start") + && normalizedCommand.includes("--foreground"); +} + +async function readProcessCommandLine(pid: number): Promise { + try { + const command = process.platform === "win32" + ? ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`] + : ["ps", "-p", String(pid), "-ww", "-o", "command="]; + const subprocess = Bun.spawn(command, { stderr: "pipe", stdout: "pipe" }); + const [commandLine, exitCode] = await Promise.all([ + new Response(subprocess.stdout).text(), + subprocess.exited, + ]); + return exitCode === 0 ? commandLine.trim() || null : null; + } catch { + return null; + } +} + +async function terminate(numericPid: number): Promise { + if (process.platform === "win32") { + const subprocess = Bun.spawn(["taskkill.exe", "/PID", String(numericPid), "/T", "/F"], { + stderr: "pipe", + stdout: "pipe", + windowsHide: true, + }); + const [output, exitCode] = await Promise.all([ + new Response(subprocess.stderr).text(), + subprocess.exited, + ]); + if (exitCode !== 0 && processAlive(numericPid)) { + throw new Error(`Failed to stop Attention pid ${numericPid}: ${output.trim() || `taskkill exited with code ${exitCode}`}`); + } + return; + } try { - if (process.platform !== "win32") process.kill(-numericPid, "SIGTERM"); - else process.kill(numericPid, "SIGTERM"); + process.kill(-numericPid, "SIGTERM"); } catch { try { process.kill(numericPid, "SIGTERM"); diff --git a/server/cli/setup.ts b/server/cli/setup.ts index f5968ad..2619af7 100644 --- a/server/cli/setup.ts +++ b/server/cli/setup.ts @@ -6,16 +6,17 @@ export function setupCodexCommand(): void { print(setupCodexPrompt()); } -export function setupCodexPrompt(options: { binaryPath?: string; skillPath?: string; attentionHome?: string } = {}): string { - const binaryPath = options.binaryPath ?? resolveAttentionBinaryPath(); - const skillPath = options.skillPath ?? resolveSkillPath(binaryPath); - const cliPrefix = commandPrefix(binaryPath, options.attentionHome ?? process.env.ATTENTION_HOME); +export function setupCodexPrompt(options: { binaryPath?: string; command?: string[]; skillPath?: string; attentionHome?: string } = {}): string { + const command = options.command ?? (options.binaryPath ? [options.binaryPath] : resolveAttentionCommand()); + const entryPath = command.at(-1) ?? path.resolve("attention"); + const skillPath = options.skillPath ?? resolveSkillPath(entryPath); + const cliPrefix = commandPrefix(command, options.attentionHome ?? process.env.ATTENTION_HOME); return `Start one fresh Codex thread per feed and use this prompt: Connect this Codex Desktop thread to local Attention. Feed: inbox -Local Attention binary: ${binaryPath} +Local Attention entry point: ${entryPath} Skill/reference: ${skillPath} CLI prefix: ${cliPrefix} @@ -23,24 +24,32 @@ Read the skill/reference file if available. Use the local Attention CLI contract `; } -function resolveAttentionBinaryPath(): string { - for (const candidate of [process.argv[1], process.argv[0], process.execPath]) { - if (candidate && !candidate.startsWith("/$bunfs/") && existsSync(candidate)) return path.resolve(candidate); +function resolveAttentionCommand(): string[] { + const sourceEntry = process.argv[1]; + if ( + sourceEntry + && !sourceEntry.startsWith("/$bunfs/") + && /\.(?:[cm]?[jt]s|tsx)$/.test(sourceEntry) + && existsSync(sourceEntry) + ) { + return [path.resolve(process.execPath), path.resolve(sourceEntry)]; } - if (process.execPath && existsSync(process.execPath)) return path.resolve(process.execPath); - return path.resolve(process.argv[1] ?? "attention"); + for (const candidate of [process.argv[0], process.execPath]) { + if (candidate && !candidate.startsWith("/$bunfs/") && existsSync(candidate)) return [path.resolve(candidate)]; + } + return [path.resolve(sourceEntry ?? "attention")]; } -function resolveSkillPath(binaryPath: string): string { - const packaged = path.join(path.dirname(binaryPath), "docs", "SKILL.md"); +function resolveSkillPath(entryPath: string): string { + const packaged = path.join(path.dirname(entryPath), "docs", "SKILL.md"); if (existsSync(packaged)) return packaged; const source = path.resolve("docs", "SKILL.md"); if (existsSync(source)) return source; return packaged; } -function commandPrefix(binaryPath: string, attentionHome?: string): string { - const executable = shellQuote(binaryPath); +function commandPrefix(command: string[], attentionHome?: string): string { + const executable = command.map(shellQuote).join(" "); return attentionHome ? `ATTENTION_HOME=${shellQuote(attentionHome)} ${executable}` : executable; } diff --git a/server/dispatcher.ts b/server/dispatcher.ts index e3f260a..ff64b2b 100644 --- a/server/dispatcher.ts +++ b/server/dispatcher.ts @@ -105,9 +105,11 @@ export class DrainDispatcher { private async recoverStaleRunning(): Promise { for (const feedId of await this.store.listFeedIds()) { - const drain = await this.store.readDrainState(feedId); - if (drain.status !== "running" || this.running.has(feedId)) continue; - await this.store.writeDrainState(feedId, { ...drain, status: "idle", lastError: drain.lastError ?? "Drain interrupted by a server restart." }); + await this.store.serialize(async () => { + const drain = await this.store.readDrainState(feedId); + if (drain.status !== "running" || this.running.has(feedId)) return; + await this.store.writeDrainState(feedId, { ...drain, status: "idle", lastError: drain.lastError ?? "Drain interrupted by a server restart." }); + }); } } @@ -135,9 +137,16 @@ export class DrainDispatcher { this.running.add(feedId); const prompt = drainPrompt(feedId, threadId); const startedAt = isoNow(); - const drain = await this.store.readDrainState(feedId); - await this.store.writeDrainState(feedId, { ...drain, status: "running", lastDispatchedAt: startedAt, lastError: undefined }); - await this.store.appendEvent({ feedId, type: "drain.dispatched", detail: { threadId, reason: decision.reason, queued: decision.queued, oldestQueuedAt: decision.oldestQueuedAt } }); + try { + await this.store.serialize(async () => { + const drain = await this.store.readDrainState(feedId); + await this.store.writeDrainState(feedId, { ...drain, status: "running", lastDispatchedAt: startedAt, lastError: undefined }); + await this.store.appendEvent({ feedId, type: "drain.dispatched", detail: { threadId, reason: decision.reason, queued: decision.queued, oldestQueuedAt: decision.oldestQueuedAt } }); + }); + } catch (error) { + this.running.delete(feedId); + throw error; + } void this.runAndSettle(feedId, threadId, prompt, startedAt).catch((error) => console.error(`[dispatcher] drain ${feedId} settle failed:`, error)); } @@ -154,22 +163,24 @@ export class DrainDispatcher { this.running.delete(feedId); } const succeeded = exitCode === 0 && !failureDetail; - const drain = await this.store.readDrainState(feedId); - const consecutiveFailures = succeeded ? 0 : (drain.consecutiveFailures ?? 0) + 1; - const cooldownMs = succeeded ? 0 : Math.min(5 * 60_000 * 2 ** (consecutiveFailures - 1), 30 * 60_000); - await this.store.writeDrainState(feedId, { - ...drain, - status: "idle", - lastExitCode: exitCode, - lastCompletedAt: isoNow(), - lastError: succeeded ? undefined : failureDetail ?? `Drain exited with code ${exitCode}.`, - consecutiveFailures, - cooldownUntil: succeeded ? undefined : new Date(Date.now() + cooldownMs).toISOString(), - }); - await this.store.appendEvent({ - feedId, - type: succeeded ? "drain.completed" : "drain.failed", - detail: { threadId, exitCode, startedAt, error: succeeded ? undefined : failureDetail, consecutiveFailures }, + await this.store.serialize(async () => { + const drain = await this.store.readDrainState(feedId); + const consecutiveFailures = succeeded ? 0 : (drain.consecutiveFailures ?? 0) + 1; + const cooldownMs = succeeded ? 0 : Math.min(5 * 60_000 * 2 ** (consecutiveFailures - 1), 30 * 60_000); + await this.store.writeDrainState(feedId, { + ...drain, + status: "idle", + lastExitCode: exitCode, + lastCompletedAt: isoNow(), + lastError: succeeded ? undefined : failureDetail ?? `Drain exited with code ${exitCode}.`, + consecutiveFailures, + cooldownUntil: succeeded ? undefined : new Date(Date.now() + cooldownMs).toISOString(), + }); + await this.store.appendEvent({ + feedId, + type: succeeded ? "drain.completed" : "drain.failed", + detail: { threadId, exitCode, startedAt, error: succeeded ? undefined : failureDetail, consecutiveFailures }, + }); }); } diff --git a/server/domain.ts b/server/domain.ts index 1aa52e5..7a89f70 100644 --- a/server/domain.ts +++ b/server/domain.ts @@ -35,7 +35,7 @@ import { containsFullEmail } from "../shared/emailThread"; import { AttentionStore, FEED_PROMPT_NAMES } from "./store"; import { demoCards, feedConfig } from "./templates"; import { detectMonologue } from "./monologue"; -import { digest, isoNow, makeId, makeToken, slugify } from "./util"; +import { digest, isoNow, makeId, makeToken, safeIdentifier, slugify } from "./util"; import { actionDigest, cleanupDigest, configuredApprovalAction, requiredSourceMailbox, routineActionDigest, verifySourceMailbox } from "./workflow/approvals"; import { queuedWork } from "./workflow/workItems"; import { mobileActionConfirmation, projectMobileCard, projectMobileRoutineAction } from "./mobile/projection"; @@ -850,7 +850,7 @@ export class AttentionDomain { async submitVoiceInstruction(anchorFeedId: string, requested: VoiceTarget, instruction: string) { if (!instruction.trim()) throw new Error("Instruction is required."); const target = await this.store.validateVoiceTarget(requested); - return this.store.serialize(async () => { + return this.store.serializeAtomic(async () => { if (target.kind === "sweep") { const feed = await this.store.readFeed(target.feedId); const visibleCardIds = feed.cards @@ -1490,7 +1490,7 @@ export class AttentionDomain { if (!MOBILE_COMMAND_ID_PATTERN.test(command.id) || !command.feedId.trim() || !command.cardId.trim()) { throw new Error("Mobile command id, feedId, and cardId are required."); } - return this.store.serialize(async () => { + return this.store.serializeAtomic(async () => { if (await this.store.hasMobileCommandReceipt(command.id)) { const receipt = await this.store.readMobileCommandReceipt(command.id); if ( @@ -2152,6 +2152,8 @@ export class AttentionDomain { } async upsertCard(feedId: string, input: Partial & Pick): Promise { + safeIdentifier(feedId, "Feed id"); + safeIdentifier(input.id, "Card id"); validateCardBlocks(input.blocks); const sourceRunIds = validateSourceRunIds(input.sourceRunIds); return this.store.serialize(async () => { diff --git a/server/mobile/projection.ts b/server/mobile/projection.ts index 4e3bfd5..cad60a6 100644 --- a/server/mobile/projection.ts +++ b/server/mobile/projection.ts @@ -215,7 +215,7 @@ function visibleCardActions(card: Card): CardAction[] { shortcut: "x", }; if (card.actions?.length) { - return card.actions.some((action) => action.behavior === "default_cleanup") + return card.actions.some((action) => action.behavior === "default_cleanup" || action.label.trim().toLowerCase() === "archive") ? card.actions : [archive, ...card.actions]; } diff --git a/server/mobile/sync.ts b/server/mobile/sync.ts index 2d09746..980ab32 100644 --- a/server/mobile/sync.ts +++ b/server/mobile/sync.ts @@ -1,4 +1,4 @@ -import type { MobileCommandProgress, MobileSyncStatus } from "../../shared/mobile"; +import type { MobileCommandProgress, MobileCommandResult, MobileSyncStatus } from "../../shared/mobile"; import type { AttentionDomain } from "../domain"; import type { AttentionStore } from "../store"; import type { MobileCloudClient } from "./client"; @@ -71,14 +71,16 @@ export class MobileSyncWorker { const commands = await this.client.claimCommands(); this.setStatus({ ...this.status, lastPullAt: now.toISOString() }); for (const command of commands) { + let result: MobileCommandResult; try { - const result = await this.domain.applyMobileCommand(command); - await this.client.completeCommand(command.id, "applied", { workId: result.workId }); + result = await this.domain.applyMobileCommand(command); } catch (error) { await this.client.completeCommand(command.id, "rejected", { error: sanitizeText(error instanceof Error ? error.message : String(error)), }); + continue; } + await this.client.completeCommand(command.id, "applied", { workId: result.workId }); } if (commands.length) { const refreshed = await projectMobileWorkspace(this.store, this.options.now()); diff --git a/server/repositories/cards.ts b/server/repositories/cards.ts index 021e0fc..2dd4e0a 100644 --- a/server/repositories/cards.ts +++ b/server/repositories/cards.ts @@ -2,7 +2,8 @@ import { existsSync } from "node:fs"; import { mkdir, readdir, rm } from "node:fs/promises"; import path from "node:path"; import type { Card } from "../../shared/types"; -import { readJson, writeJson } from "../util"; +import { readJson, safeIdentifier, writeJson } from "../util"; +import type { MirrorWriteCoordinator } from "./mirrorWrites"; export interface CardRepository { init(feedIds: string[]): Promise; @@ -43,16 +44,20 @@ export class FileCardRepository implements CardRepository { } private cardPath(feedId: string): string { - return path.join(this.dataDir, "feeds", feedId, "cards"); + return path.join(this.dataDir, "feeds", safeIdentifier(feedId, "Feed id"), "cards"); } private cardFile(feedId: string, cardId: string): string { - return path.join(this.cardPath(feedId), `${cardId}.json`); + return path.join(this.cardPath(feedId), `${safeIdentifier(cardId, "Card id")}.json`); } } export class MirroredCardRepository implements CardRepository { - constructor(private readonly primary: CardRepository, private readonly mirror: CardRepository) {} + constructor( + private readonly primary: CardRepository, + private readonly mirror: CardRepository, + private readonly mirrorWrites?: MirrorWriteCoordinator, + ) {} async init(feedIds: string[]): Promise { await this.mirror.init(feedIds); @@ -74,24 +79,28 @@ export class MirroredCardRepository implements CardRepository { async write(card: Card): Promise { await this.primary.write(card); - await this.mirror.write(card); + await this.writeMirror(() => this.mirror.write(card)); } async remove(feedId: string, cardId: string): Promise { await this.primary.remove(feedId, cardId); - await this.mirror.remove(feedId, cardId); + await this.writeMirror(() => this.mirror.remove(feedId, cardId)); } private async syncFeed(feedId: string): Promise { const primary = await this.primary.list(feedId); const mirror = await this.mirror.list(feedId); const primaryIds = new Set(primary.map((card) => card.id)); - const mirrorIds = new Set(mirror.map((card) => card.id)); for (const card of mirror.filter((item) => !primaryIds.has(item.id))) { await this.primary.write(card); } - for (const card of primary.filter((item) => !mirrorIds.has(item.id))) { + for (const card of primary) { await this.mirror.write(card); } } + + private async writeMirror(callback: () => Promise): Promise { + if (this.mirrorWrites) await this.mirrorWrites.write(callback); + else await callback(); + } } diff --git a/server/repositories/feedEvents.ts b/server/repositories/feedEvents.ts index c99075e..3ba4bf2 100644 --- a/server/repositories/feedEvents.ts +++ b/server/repositories/feedEvents.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import { appendFile, mkdir, readFile } from "node:fs/promises"; import path from "node:path"; import type { FeedEvent } from "../../shared/types"; +import type { MirrorWriteCoordinator } from "./mirrorWrites"; export interface FeedEventRepository { init(feedIds: string[]): Promise; @@ -35,7 +36,11 @@ export class FileFeedEventRepository implements FeedEventRepository { } export class MirroredFeedEventRepository implements FeedEventRepository { - constructor(private readonly primary: FeedEventRepository, private readonly mirror: FeedEventRepository) {} + constructor( + private readonly primary: FeedEventRepository, + private readonly mirror: FeedEventRepository, + private readonly mirrorWrites?: MirrorWriteCoordinator, + ) {} async init(feedIds: string[]): Promise { await this.mirror.init(feedIds); @@ -45,7 +50,8 @@ export class MirroredFeedEventRepository implements FeedEventRepository { async append(event: FeedEvent): Promise { await this.primary.append(event); - await this.mirror.append(event); + if (this.mirrorWrites) await this.mirrorWrites.write(() => this.mirror.append(event)); + else await this.mirror.append(event); } list(feedId: string): Promise { diff --git a/server/repositories/mirrorWrites.ts b/server/repositories/mirrorWrites.ts new file mode 100644 index 0000000..9f793dc --- /dev/null +++ b/server/repositories/mirrorWrites.ts @@ -0,0 +1,43 @@ +type MirrorWrite = () => Promise; + +export class MirrorWriteCoordinator { + private pending: MirrorWrite[] | null = null; + + begin(): void { + if (this.pending) throw new Error("A mirror transaction is already active."); + this.pending = []; + } + + async write(callback: MirrorWrite): Promise { + if (this.pending) { + this.pending.push(callback); + return; + } + await callback(); + } + + async transaction(callback: () => Promise): Promise { + this.begin(); + try { + const result = await callback(); + await this.commit(); + return result; + } catch (error) { + this.pending = null; + throw error; + } + } + + private async commit(): Promise { + const pending = this.pending; + if (!pending) throw new Error("No mirror transaction is active."); + this.pending = null; + for (const write of pending) { + try { + await write(); + } catch (error) { + console.error("SQLite committed, but a filesystem mirror write failed:", error); + } + } + } +} diff --git a/server/repositories/mobileCommandReceipts.ts b/server/repositories/mobileCommandReceipts.ts index 2f125f8..0648f9b 100644 --- a/server/repositories/mobileCommandReceipts.ts +++ b/server/repositories/mobileCommandReceipts.ts @@ -2,6 +2,7 @@ import { existsSync } from "node:fs"; import path from "node:path"; import type { MobileCommandReceipt } from "../../shared/mobile"; import { readJson, writeJson } from "../util"; +import type { MirrorWriteCoordinator } from "./mirrorWrites"; export interface MobileCommandReceiptRepository { init(): Promise; @@ -36,6 +37,7 @@ export class MirroredMobileCommandReceiptRepository implements MobileCommandRece constructor( private readonly primary: MobileCommandReceiptRepository, private readonly mirror: MobileCommandReceiptRepository, + private readonly mirrorWrites?: MirrorWriteCoordinator, ) {} async init(): Promise { @@ -53,6 +55,7 @@ export class MirroredMobileCommandReceiptRepository implements MobileCommandRece async write(receipt: MobileCommandReceipt): Promise { await this.primary.write(receipt); - await this.mirror.write(receipt); + if (this.mirrorWrites) await this.mirrorWrites.write(() => this.mirror.write(receipt)); + else await this.mirror.write(receipt); } } diff --git a/server/repositories/routineActionGroups.ts b/server/repositories/routineActionGroups.ts index e076adf..f3d635f 100644 --- a/server/repositories/routineActionGroups.ts +++ b/server/repositories/routineActionGroups.ts @@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises"; import path from "node:path"; import type { RoutineActionGroup } from "../../shared/types"; import { readJson, writeJson } from "../util"; +import type { MirrorWriteCoordinator } from "./mirrorWrites"; export interface RoutineActionGroupRepository { init(feedIds: string[]): Promise; @@ -46,7 +47,11 @@ export class FileRoutineActionGroupRepository implements RoutineActionGroupRepos } export class MirroredRoutineActionGroupRepository implements RoutineActionGroupRepository { - constructor(private readonly primary: RoutineActionGroupRepository, private readonly mirror: RoutineActionGroupRepository) {} + constructor( + private readonly primary: RoutineActionGroupRepository, + private readonly mirror: RoutineActionGroupRepository, + private readonly mirrorWrites?: MirrorWriteCoordinator, + ) {} async init(feedIds: string[]): Promise { await this.mirror.init(feedIds); @@ -68,18 +73,18 @@ export class MirroredRoutineActionGroupRepository implements RoutineActionGroupR async write(group: RoutineActionGroup): Promise { await this.primary.write(group); - await this.mirror.write(group); + if (this.mirrorWrites) await this.mirrorWrites.write(() => this.mirror.write(group)); + else await this.mirror.write(group); } private async syncFeed(feedId: string): Promise { const primary = await this.primary.list(feedId); const mirror = await this.mirror.list(feedId); const primaryIds = new Set(primary.map((group) => group.id)); - const mirrorIds = new Set(mirror.map((group) => group.id)); for (const group of mirror.filter((item) => !primaryIds.has(item.id))) { await this.primary.write(group); } - for (const group of primary.filter((item) => !mirrorIds.has(item.id))) { + for (const group of primary) { await this.mirror.write(group); } } diff --git a/server/repositories/sweeps.ts b/server/repositories/sweeps.ts index 800c300..45ab5b0 100644 --- a/server/repositories/sweeps.ts +++ b/server/repositories/sweeps.ts @@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises"; import path from "node:path"; import type { SweepBatch, SweepFeedbackTrace, SweepState } from "../../shared/types"; import { readJson, writeJson } from "../util"; +import type { MirrorWriteCoordinator } from "./mirrorWrites"; export interface SweepRepository { init(feedIds: string[]): Promise; @@ -105,7 +106,11 @@ export class FileSweepRepository implements SweepRepository { } export class MirroredSweepRepository implements SweepRepository { - constructor(private readonly primary: SweepRepository, private readonly mirror: SweepRepository) {} + constructor( + private readonly primary: SweepRepository, + private readonly mirror: SweepRepository, + private readonly mirrorWrites?: MirrorWriteCoordinator, + ) {} async init(feedIds: string[]): Promise { await this.mirror.init(feedIds); @@ -123,7 +128,7 @@ export class MirroredSweepRepository implements SweepRepository { async writeState(feedId: string, state: SweepState): Promise { await this.primary.writeState(feedId, state); - await this.mirror.writeState(feedId, state); + await this.writeMirror(() => this.mirror.writeState(feedId, state)); } listBatches(feedId: string): Promise { @@ -136,7 +141,7 @@ export class MirroredSweepRepository implements SweepRepository { async writeBatch(batch: SweepBatch): Promise { await this.primary.writeBatch(batch); - await this.mirror.writeBatch(batch); + await this.writeMirror(() => this.mirror.writeBatch(batch)); } listFeedback(feedId: string): Promise { @@ -149,13 +154,13 @@ export class MirroredSweepRepository implements SweepRepository { async writeFeedback(trace: SweepFeedbackTrace): Promise { await this.primary.writeFeedback(trace); - await this.mirror.writeFeedback(trace); + await this.writeMirror(() => this.mirror.writeFeedback(trace)); } private async syncFeed(feedId: string): Promise { const [primaryHasState, mirrorHasState] = await Promise.all([this.primary.hasState(feedId), this.mirror.hasState(feedId)]); if (!primaryHasState && mirrorHasState) await this.primary.writeState(feedId, await this.mirror.readState(feedId)); - if (primaryHasState && !mirrorHasState) await this.mirror.writeState(feedId, await this.primary.readState(feedId)); + if (primaryHasState) await this.mirror.writeState(feedId, await this.primary.readState(feedId)); await this.syncBatches(feedId); await this.syncFeedback(feedId); @@ -165,17 +170,20 @@ export class MirroredSweepRepository implements SweepRepository { const primary = await this.primary.listBatches(feedId); const mirror = await this.mirror.listBatches(feedId); const primaryIds = new Set(primary.map((batch) => batch.id)); - const mirrorIds = new Set(mirror.map((batch) => batch.id)); for (const batch of mirror.filter((item) => !primaryIds.has(item.id))) await this.primary.writeBatch(batch); - for (const batch of primary.filter((item) => !mirrorIds.has(item.id))) await this.mirror.writeBatch(batch); + for (const batch of primary) await this.mirror.writeBatch(batch); } private async syncFeedback(feedId: string): Promise { const primary = await this.primary.listFeedback(feedId); const mirror = await this.mirror.listFeedback(feedId); const primaryIds = new Set(primary.map((trace) => trace.id)); - const mirrorIds = new Set(mirror.map((trace) => trace.id)); for (const trace of mirror.filter((item) => !primaryIds.has(item.id))) await this.primary.writeFeedback(trace); - for (const trace of primary.filter((item) => !mirrorIds.has(item.id))) await this.mirror.writeFeedback(trace); + for (const trace of primary) await this.mirror.writeFeedback(trace); + } + + private async writeMirror(callback: () => Promise): Promise { + if (this.mirrorWrites) await this.mirrorWrites.write(callback); + else await callback(); } } diff --git a/server/repositories/workItems.ts b/server/repositories/workItems.ts index d7d2420..86221f4 100644 --- a/server/repositories/workItems.ts +++ b/server/repositories/workItems.ts @@ -3,6 +3,7 @@ import { readdir } from "node:fs/promises"; import path from "node:path"; import type { WorkItem } from "../../shared/types"; import { readJson, writeJson } from "../util"; +import type { MirrorWriteCoordinator } from "./mirrorWrites"; export interface WorkItemRepository { init(feedIds: string[]): Promise; @@ -41,7 +42,11 @@ export class FileWorkItemRepository implements WorkItemRepository { } export class MirroredWorkItemRepository implements WorkItemRepository { - constructor(private readonly primary: WorkItemRepository, private readonly mirror: WorkItemRepository) {} + constructor( + private readonly primary: WorkItemRepository, + private readonly mirror: WorkItemRepository, + private readonly mirrorWrites?: MirrorWriteCoordinator, + ) {} async init(feedIds: string[]): Promise { await this.mirror.init(feedIds); @@ -59,18 +64,18 @@ export class MirroredWorkItemRepository implements WorkItemRepository { async write(work: WorkItem): Promise { await this.primary.write(work); - await this.mirror.write(work); + if (this.mirrorWrites) await this.mirrorWrites.write(() => this.mirror.write(work)); + else await this.mirror.write(work); } private async syncFeed(feedId: string): Promise { const primary = await this.primary.list(feedId); const mirror = await this.mirror.list(feedId); const primaryIds = new Set(primary.map((work) => work.id)); - const mirrorIds = new Set(mirror.map((work) => work.id)); for (const work of mirror.filter((item) => !primaryIds.has(item.id))) { await this.primary.write(work); } - for (const work of primary.filter((item) => !mirrorIds.has(item.id))) { + for (const work of primary) { await this.mirror.write(work); } } diff --git a/server/routes/api.ts b/server/routes/api.ts index e09b18e..6119fdc 100644 --- a/server/routes/api.ts +++ b/server/routes/api.ts @@ -4,12 +4,22 @@ import path from "node:path"; import type { PostActionCompletion, VoiceTarget } from "../../shared/types"; import { mindContextPublicationReceipt } from "../domain"; import { versionInfo } from "../version"; -import { body, mutation, type LocalRouteContext } from "./shared"; +import { body, mutation, mutationAccessError, type LocalRouteContext } from "./shared"; export function apiRoutes(context: LocalRouteContext): Hono { - const { artifactsDir, dataDir, domain, mobileStatus, notify, sqlite, store } = context; + const { artifactsDir, dataDir, domain, mobileStatus, mutationToken, notify, sqlite, store } = context; const app = new Hono(); + app.use("/api/*", async (c, next) => { + const error = mutationAccessError(c, mutationToken); + if (error) return error; + await next(); + }); + + app.get("/api/session", (c) => { + c.header("cache-control", "no-store"); + return c.json({ mutationToken }); + }); app.get("/api/status", (c) => c.json({ ok: true, version: versionInfo(), dataDir, sqlite: sqlite.status() })); app.get("/api/state", async (c) => c.json(await store.readWorkspace(c.req.query("feed") ?? "inbox"))); app.get("/api/health", (c) => c.json({ ok: true })); diff --git a/server/routes/shared.ts b/server/routes/shared.ts index d1dee8f..494745f 100644 --- a/server/routes/shared.ts +++ b/server/routes/shared.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto"; import type { AttentionDomain } from "../domain"; import type { LocalSqliteStore } from "../sqlite"; import type { AttentionStore } from "../store"; @@ -15,10 +16,15 @@ export type LocalRouteContext = { sqlite: LocalSqliteStore; store: AttentionStore; mobileStatus?: () => MobileSyncStatus; + mutationToken: string; }; export async function body(c: any): Promise> { - return c.req.json().catch(() => ({})); + const value = await c.req.json(); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Request body must be a JSON object."); + } + return value as Record; } export async function mutation(c: any, notify: Notify, callback: () => Promise) { @@ -30,3 +36,36 @@ export async function mutation(c: any, notify: Notify, callback: () => Promise mirrorWrites.transaction(() => sqlite.transaction(callback)), + sourceRuns, + sources, + sweeps, + textDocuments, + workItems, + workspaceFeeds, + }); await store.init(); return { dataDir, sqlite, store }; } diff --git a/server/sqlite.ts b/server/sqlite.ts index dae6e09..9e25d27 100644 --- a/server/sqlite.ts +++ b/server/sqlite.ts @@ -17,7 +17,7 @@ import type { TextDocumentRepository, TextDocumentSeed } from "./repositories/te import type { WorkItemRepository } from "./repositories/workItems"; import type { WorkspaceFeedRepository } from "./repositories/workspaceFeeds"; -export const SQLITE_SCHEMA_VERSION = 13; +export const SQLITE_SCHEMA_VERSION = 14; export type LocalRuntimeStatus = { dbPath: string; @@ -69,6 +69,7 @@ export class LocalSqliteStore { created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS feed_events ( + event_order INTEGER NOT NULL DEFAULT 0, id TEXT PRIMARY KEY, feed_id TEXT NOT NULL, type TEXT NOT NULL, @@ -188,6 +189,7 @@ export class LocalSqliteStore { CREATE INDEX IF NOT EXISTS idx_work_items_feed_status ON work_items (feed_id, status); `); this.migrateFeedScopedPrimaryKeys(); + this.migrateFeedEventOrdering(); const now = new Date().toISOString(); this.setMeta("schema_version", String(SQLITE_SCHEMA_VERSION)); if (!this.getMeta("created_at")) this.setMeta("created_at", now); @@ -211,6 +213,28 @@ export class LocalSqliteStore { this.db = null; } + async backupTo(targetPath: string): Promise { + await mkdir(path.dirname(targetPath), { recursive: true }); + this.database().exec(`VACUUM INTO '${targetPath.replaceAll("'", "''")}';`); + } + + async transaction(callback: () => Promise): Promise { + const db = this.database(); + db.exec("BEGIN IMMEDIATE;"); + try { + const result = await callback(); + db.exec("COMMIT;"); + return result; + } catch (error) { + try { + db.exec("ROLLBACK;"); + } catch { + // Preserve the mutation error if SQLite already ended the transaction. + } + throw error; + } + } + workspaceFeeds(): WorkspaceFeedRepository { return new SqliteWorkspaceFeedRepository(() => this.database()); } @@ -367,6 +391,16 @@ export class LocalSqliteStore { })(); } } + + private migrateFeedEventOrdering(): void { + const db = this.database(); + const columns = db.query("PRAGMA table_info(feed_events)").all() as Array<{ name: string }>; + if (!columns.some((column) => column.name === "event_order")) { + db.exec("ALTER TABLE feed_events ADD COLUMN event_order INTEGER NOT NULL DEFAULT 0;"); + db.exec("UPDATE feed_events SET event_order = rowid;"); + } + db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_feed_events_order ON feed_events (event_order);"); + } } class SqliteMobileCommandReceiptRepository implements MobileCommandReceiptRepository { @@ -933,7 +967,11 @@ class SqliteFeedEventRepository implements FeedEventRepository { async append(event: FeedEvent): Promise { this.database() - .query("INSERT INTO feed_events (id, feed_id, type, at, card_id, work_id, detail_json) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO NOTHING") + .query(` + INSERT INTO feed_events (event_order, id, feed_id, type, at, card_id, work_id, detail_json) + VALUES ((SELECT COALESCE(MAX(event_order), 0) + 1 FROM feed_events), ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO NOTHING + `) .run( event.id, event.feedId, @@ -947,7 +985,7 @@ class SqliteFeedEventRepository implements FeedEventRepository { async list(feedId: string): Promise { const rows = this.database() - .query("SELECT id, feed_id, type, at, card_id, work_id, detail_json FROM feed_events WHERE feed_id = ? ORDER BY at ASC, id ASC") + .query("SELECT id, feed_id, type, at, card_id, work_id, detail_json FROM feed_events WHERE feed_id = ? ORDER BY event_order ASC") .all(feedId) as Array<{ id: string; feed_id: string; type: string; at: string; card_id: string | null; work_id: string | null; detail_json: string | null }>; return rows.map((row) => ({ id: row.id, diff --git a/server/store.ts b/server/store.ts index 5c33924..b32a07a 100644 --- a/server/store.ts +++ b/server/store.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { mkdir, readdir, rename, rm } from "node:fs/promises"; +import { mkdir, readdir, rename } from "node:fs/promises"; import path from "node:path"; import type { AppFeedback, @@ -38,7 +38,7 @@ import { setupCard, threadBinding, } from "./templates"; -import { isoNow, makeId, readJson, writeJson, writeText } from "./util"; +import { isoNow, makeId, readJson, withMutationLock, writeJson, writeText } from "./util"; import { defaultDictationCapability } from "./monologue"; import { FileCardRepository, type CardRepository } from "./repositories/cards"; import { FileFeedEventRepository, type FeedEventRepository } from "./repositories/feedEvents"; @@ -58,6 +58,8 @@ export const GLOBAL_PROMPT_NAMES = ["judge.md", "compose-card.md", "execute-work export const FEED_PROMPT_NAMES = ["judge.md", "compose-card.md"] as const; const DEFAULT_FEED_IDS = ["inbox", "company-attention"]; +type AtomicRunner = (callback: () => Promise) => Promise; + function defaultDrainState(): DrainState { return { status: "idle", consecutiveFailures: 0 }; } @@ -77,8 +79,9 @@ export class AttentionStore { private readonly textDocuments: TextDocumentRepository; private readonly workItems: WorkItemRepository; private readonly workspaceFeeds: WorkspaceFeedRepository; + private readonly runAtomic?: AtomicRunner; - constructor(dataDir: string, options: { cards?: CardRepository; events?: FeedEventRepository; mindContext?: MindContextRepository; mobileCommandReceipts?: MobileCommandReceiptRepository; revisions?: RevisionRepository; routineActionGroups?: RoutineActionGroupRepository; sourceRuns?: SourceRunRepository; sources?: SourceRepository; sweeps?: SweepRepository; textDocuments?: TextDocumentRepository; workItems?: WorkItemRepository; workspaceFeeds?: WorkspaceFeedRepository } = {}) { + constructor(dataDir: string, options: { cards?: CardRepository; events?: FeedEventRepository; mindContext?: MindContextRepository; mobileCommandReceipts?: MobileCommandReceiptRepository; revisions?: RevisionRepository; routineActionGroups?: RoutineActionGroupRepository; sourceRuns?: SourceRunRepository; sources?: SourceRepository; sweeps?: SweepRepository; textDocuments?: TextDocumentRepository; workItems?: WorkItemRepository; workspaceFeeds?: WorkspaceFeedRepository; runAtomic?: AtomicRunner } = {}) { this.dataDir = dataDir; this.cards = options.cards ?? new FileCardRepository(this.dataDir); this.events = options.events ?? new FileFeedEventRepository(this.dataDir); @@ -92,6 +95,7 @@ export class AttentionStore { this.textDocuments = options.textDocuments ?? new FileTextDocumentRepository(this.dataDir); this.workItems = options.workItems ?? new FileWorkItemRepository(this.dataDir); this.workspaceFeeds = options.workspaceFeeds ?? new FileWorkspaceFeedRepository(this.path("workspace.json")); + this.runAtomic = options.runAtomic; } async init(): Promise { @@ -556,27 +560,13 @@ export class AttentionStore { } async serialize(callback: () => Promise): Promise { - const operation = this.tail.then(() => this.withFilesystemLock(callback)); + const operation = this.tail.then(() => withMutationLock(this.dataDir, callback)); this.tail = operation.then(() => undefined, () => undefined); return operation; } - private async withFilesystemLock(callback: () => Promise): Promise { - const lockPath = this.path(".mutation-lock"); - for (let attempt = 0; attempt < 400; attempt += 1) { - try { - await mkdir(lockPath); - try { - return await callback(); - } finally { - await rm(lockPath, { recursive: true, force: true }); - } - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; - await new Promise((resolve) => setTimeout(resolve, 15)); - } - } - throw new Error("Timed out waiting for the filesystem mutation lock."); + async serializeAtomic(callback: () => Promise): Promise { + return this.serialize(() => this.runAtomic ? this.runAtomic(callback) : callback()); } private async ensureDefaultFeed(feedId: "inbox" | "company-attention"): Promise { diff --git a/server/util.ts b/server/util.ts index 2299821..51c3141 100644 --- a/server/util.ts +++ b/server/util.ts @@ -1,10 +1,38 @@ import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; -import { dirname } from "node:path"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; export const isoNow = () => new Date().toISOString(); export const makeId = (prefix: string) => `${prefix}_${randomUUID()}`; export const makeToken = () => randomBytes(24).toString("hex"); +const SAFE_IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +export function safeIdentifier(value: string, label: string): string { + if (!SAFE_IDENTIFIER_PATTERN.test(value) || value === "." || value === "..") { + throw new Error(`${label} must use only letters, numbers, dots, underscores, and hyphens.`); + } + return value; +} + +export async function withMutationLock(dataDir: string, callback: () => Promise): Promise { + await mkdir(dataDir, { recursive: true }); + const lockPath = join(dataDir, ".mutation-lock"); + for (let attempt = 0; attempt < 400; attempt += 1) { + try { + await mkdir(lockPath); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (attempt === 399) throw new Error("Timed out waiting for the filesystem mutation lock."); + await new Promise((resolve) => setTimeout(resolve, 15)); + } + } + try { + return await callback(); + } finally { + await rm(lockPath, { recursive: true, force: true }); + } +} export async function readJson(path: string): Promise { return JSON.parse(await readFile(path, "utf8")) as T; diff --git a/src/App.tsx b/src/App.tsx index 719033f..6c88763 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -325,7 +325,7 @@ export default function App({ feedId, screen, workspaceTab }: { feedId: string; <>
- { setWorkspaceFocus(target); selectDockTarget(target); }} /> + { setWorkspaceFocus(target); selectDockTarget(target); }} /> setInspector(null)} onChanged={(next) => { if (next) changeFeed(next); void refresh(next); }} /> {toast &&
{toast}{undoRevision && }
} diff --git a/src/app/api.ts b/src/app/api.ts index 81cfe85..f6322f1 100644 --- a/src/app/api.ts +++ b/src/app/api.ts @@ -1,10 +1,44 @@ +class ApiError extends Error { + constructor(message: string, readonly status: number) { + super(message); + } +} + +let mutationTokenPromise: Promise | null = null; + export async function api(url: string, init?: RequestInit): Promise { const response = await fetch(url, init); const value = await response.json(); - if (!response.ok) throw new Error(value.error ?? `Request failed: ${response.status}`); + if (!response.ok) throw new ApiError(value.error ?? `Request failed: ${response.status}`, response.status); return value as T; } -export function post(url: string, value: unknown = {}): Promise { - return api(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(value) }); +export async function post(url: string, value: unknown = {}): Promise { + for (let attempt = 0; attempt < 2; attempt += 1) { + const mutationToken = await localMutationToken(); + try { + return await api(url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-attention-mutation-token": mutationToken, + }, + body: JSON.stringify(value), + }); + } catch (error) { + if (!(error instanceof ApiError) || error.status !== 403 || attempt > 0) throw error; + mutationTokenPromise = null; + } + } + throw new Error("Local mutation authorization failed."); +} + +function localMutationToken(): Promise { + mutationTokenPromise ??= api<{ mutationToken: string }>("/api/session") + .then((session) => session.mutationToken) + .catch((error) => { + mutationTokenPromise = null; + throw error; + }); + return mutationTokenPromise; } diff --git a/src/feed/selectors.ts b/src/feed/selectors.ts index b8b2f64..0e9248c 100644 --- a/src/feed/selectors.ts +++ b/src/feed/selectors.ts @@ -32,7 +32,7 @@ export function countFor(feed: FeedView, tab: Tab): number { export function visibleCardActions(card: Card): CardAction[] { const archive: CardAction = { id: "default-cleanup", label: "Archive", behavior: "default_cleanup", variant: "secondary", shortcut: "x" }; if (card.actions?.length) { - return card.actions.some((action) => action.behavior === "default_cleanup") + return card.actions.some((action) => action.behavior === "default_cleanup" || action.label.trim().toLowerCase() === "archive") ? card.actions : [archive, ...card.actions]; } diff --git a/src/styles.css b/src/styles.css index d3fa69f..ed454d3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -306,6 +306,12 @@ pre { overflow: auto; padding: 12px; background: #f4f2eb; color: var(--ink-2); w .workspace-editor h3 { margin: 0; font-size: 15px; } .workspace-editor textarea { width: 100%; margin-top: 11px; resize: vertical; padding: 12px 13px; border: 1px solid var(--line); border-radius: 6px; outline: 0; background: #fff; color: var(--ink-2); font: 12px/1.55 var(--mono); } .workspace-editor textarea:focus { border-color: #aab8dc; box-shadow: 0 0 0 3px var(--blue-soft); } +.thread-status { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: hidden; border: 1px solid var(--line); border-radius: 9px; background: rgba(255,254,250,.72); } +.thread-status div { display: grid; gap: 4px; padding: 14px 16px; border-bottom: 1px solid var(--line); } +.thread-status div:nth-child(odd) { border-right: 1px solid var(--line); } +.thread-status div:nth-last-child(-n+2) { border-bottom: 0; } +.thread-status span { color: var(--ink-4); font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.thread-status strong { overflow-wrap: anywhere; color: var(--ink-2); font-size: 13px; } .learning-page { max-width: 900px; margin: 0 auto; padding: 31px 34px 190px; } .learning-title { max-width: 680px; margin: 27px 0 22px; } .learning-title h1, .learning-empty h1 { margin: 5px 0 9px; font: 500 39px/1.06 var(--serif); letter-spacing: -.03em; } diff --git a/src/workspace/PromptWorkspace.tsx b/src/workspace/PromptWorkspace.tsx index 4298d75..51077ac 100644 --- a/src/workspace/PromptWorkspace.tsx +++ b/src/workspace/PromptWorkspace.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { api, post } from "../app/api"; import type { Inspector, WorkspaceTab } from "../app/types"; import type { VoiceTarget, WorkspaceRevision, WorkspaceView } from "../types"; @@ -55,14 +55,31 @@ function WorkspaceEditor({ ); } -export function PromptWorkspace({ state, tab, onTab, onBack, onInspector, onSaved, onTargetFocus }: { state: WorkspaceView; tab: WorkspaceTab; onTab: (tab: WorkspaceTab) => void; onBack: () => void; onInspector: (value: Inspector) => void; onSaved: (message: string) => void; onTargetFocus: (target: VoiceTarget) => void }) { +export function PromptWorkspace({ state, refreshVersion, tab, onTab, onBack, onInspector, onSaved, onTargetFocus }: { state: WorkspaceView; refreshVersion: number; tab: WorkspaceTab; onTab: (tab: WorkspaceTab) => void; onBack: () => void; onInspector: (value: Inspector) => void; onSaved: (message: string) => void; onTargetFocus: (target: VoiceTarget) => void }) { const [feedWorkspace, setFeedWorkspace] = useState(null); const [globalWorkspace, setGlobalWorkspace] = useState(null); + const feedRequest = useRef(0); + const globalRequest = useRef(0); const feedId = state.active.config.id; - const reloadFeed = useCallback(async () => setFeedWorkspace(await api(`/api/feeds/${feedId}/how`)), [feedId]); - const reloadGlobal = useCallback(async () => setGlobalWorkspace(await api("/api/global-prompts")), []); - useEffect(() => { void reloadFeed(); }, [reloadFeed]); - useEffect(() => { if (tab === "global") void reloadGlobal(); }, [reloadGlobal, tab]); + const reloadFeed = useCallback(async () => { + const request = ++feedRequest.current; + const workspace = await api(`/api/feeds/${feedId}/how`); + if (request === feedRequest.current) setFeedWorkspace(workspace); + }, [feedId]); + const reloadGlobal = useCallback(async () => { + const request = ++globalRequest.current; + const workspace = await api("/api/global-prompts"); + if (request === globalRequest.current) setGlobalWorkspace(workspace); + }, []); + useEffect(() => { + setFeedWorkspace(null); + return () => { feedRequest.current += 1; }; + }, [feedId]); + useEffect(() => { void reloadFeed(); }, [reloadFeed, refreshVersion]); + useEffect(() => { + if (tab === "global") void reloadGlobal(); + return () => { globalRequest.current += 1; }; + }, [reloadGlobal, tab]); const save = async (callback: () => Promise, message: string, reload: () => Promise): Promise => { try { const result = await callback(); @@ -101,7 +118,12 @@ export function PromptWorkspace({ state, tab, onTab, onBack, onInspector, onSave

Home thread

-
{JSON.stringify(feedWorkspace.thread, null, 2)}
+
+
Thread{feedWorkspace.thread.homeThreadId ?? "Not bound"}
+
Bound{feedWorkspace.thread.boundAt ? new Date(feedWorkspace.thread.boundAt).toLocaleString() : "Not yet"}
+
Heartbeat{feedWorkspace.thread.heartbeat.status.replace("_", " ")}
+
Cadence{feedWorkspace.thread.heartbeat.cadence ?? "Not configured"}
+
) : !globalWorkspace ?

Loading global prompts…

: ( diff --git a/test/cli-contract.test.ts b/test/cli-contract.test.ts index 7ace40a..cc9709b 100644 --- a/test/cli-contract.test.ts +++ b/test/cli-contract.test.ts @@ -55,7 +55,7 @@ describe("CLI contract", () => { attentionHome: "/tmp/attention home", }); - expect(prompt).toContain("Local Attention binary: /tmp/attention install/attention"); + expect(prompt).toContain("Local Attention entry point: /tmp/attention install/attention"); expect(prompt).toContain("Skill/reference: /tmp/attention install/docs/SKILL.md"); expect(prompt).toContain("CLI prefix: ATTENTION_HOME='/tmp/attention home' '/tmp/attention install/attention'"); expect(prompt).toContain("Use the local Attention CLI contract, not a hosted Attention or MCP setup."); From 79f4ff36a06913053b0a08e5c6ad381bc5980b21 Mon Sep 17 00:00:00 2001 From: Jannik Jung Date: Mon, 15 Jun 2026 14:16:52 -0600 Subject: [PATCH 02/18] fix: pass native accessibility audit --- .github/workflows/ci.yml | 8 ++++++++ ios/Tend/Views/FeedReviewView.swift | 22 +++++++++++----------- ios/Tend/Views/FeedsView.swift | 12 ++++++------ ios/Tend/Views/MobileCardView.swift | 16 ++++++++-------- ios/Tend/Views/SharedComponents.swift | 6 +++--- 5 files changed, 36 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bac5a9..1f79630 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,5 +136,13 @@ jobs: -project ios/Tend.xcodeproj \ -scheme Tend \ -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ + -resultBundlePath "$RUNNER_TEMP/TendTests.xcresult" \ CODE_SIGNING_ALLOWED=NO \ DEVELOPMENT_TEAM= + + - name: Upload iOS test results + if: failure() + uses: actions/upload-artifact@v4 + with: + name: TendTests-xcresult + path: ${{ runner.temp }}/TendTests.xcresult diff --git a/ios/Tend/Views/FeedReviewView.swift b/ios/Tend/Views/FeedReviewView.swift index 76939ad..7973d78 100644 --- a/ios/Tend/Views/FeedReviewView.swift +++ b/ios/Tend/Views/FeedReviewView.swift @@ -54,7 +54,7 @@ struct FeedReviewView: View { .font(.headline) Text(cards.isEmpty ? "All clear" : "\(cards.count) left") .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } .accessibilityElement(children: .combine) } @@ -128,7 +128,7 @@ struct FeedReviewView: View { Text("Swipe left to archive") .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) .padding(.bottom, 4) } .padding(.horizontal, 14) @@ -153,12 +153,12 @@ struct FeedReviewView: View { .foregroundStyle(TendTheme.cobalt) Text(feed.purpose) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) .fixedSize(horizontal: false, vertical: true) Spacer() Text("Pass \(feed.currentPass)") .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } .padding(.top, 8) } @@ -338,7 +338,7 @@ private struct EmptyFeedView: View { .multilineTextAlignment(.center) if let nextFeed { Text("\(nextFeed.name) has \(nextFeed.reviewCount) waiting.") - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) Button("Back to feeds") { done() } @@ -346,7 +346,7 @@ private struct EmptyFeedView: View { .controlSize(.large) } else { Text("Nothing else needs review right now.") - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) Button("Back to feeds") { done() } @@ -382,13 +382,13 @@ private struct InstructionComposer: View { .lineLimit(2) Label("Use the Monologue keyboard or type normally", systemImage: "waveform") .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } ZStack(alignment: .topLeading) { if text.isEmpty { Text("Tell Codex what to notice, change, research, or do…") - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) .padding(.horizontal, 6) .padding(.vertical, 10) .allowsHitTesting(false) @@ -460,7 +460,7 @@ private struct ActionApprovalSheet: View { .font(.tendSerif(.title)) Text(card.title) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } if let confirmation = action.confirmation { @@ -484,7 +484,7 @@ private struct ActionApprovalSheet: View { VStack(alignment: .leading, spacing: 8) { Text(block.label ?? "Approved text") .font(.caption.weight(.bold)) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) .textCase(.uppercase) TextEditor(text: editBinding(for: block)) .font(.body) @@ -503,7 +503,7 @@ private struct ActionApprovalSheet: View { Text("Your tap authorizes this one exact action and the visible text above. If the card, action, recipient, mailbox, or digest changes, Tend rejects it.") .font(.footnote) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) .fixedSize(horizontal: false, vertical: true) } .padding(18) diff --git a/ios/Tend/Views/FeedsView.swift b/ios/Tend/Views/FeedsView.swift index ddf8f3e..f884db3 100644 --- a/ios/Tend/Views/FeedsView.swift +++ b/ios/Tend/Views/FeedsView.swift @@ -120,7 +120,7 @@ struct FeedsView: View { .foregroundStyle(TendTheme.cobalt) Text("Cloud credentials are not configured.") .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } .frame(maxWidth: .infinity, alignment: .leading) } else { @@ -132,7 +132,7 @@ struct FeedsView: View { .font(.subheadline.weight(.semibold)) Text("Cloud credentials are not configured.") .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) Spacer(minLength: 0) } } @@ -164,13 +164,13 @@ private struct FeedRow: View { } Text(feed.purpose) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) .fixedSize(horizontal: false, vertical: true) } Spacer(minLength: 12) Image(systemName: "chevron.right") .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } if let latest = feed.latestCardTitle { @@ -185,10 +185,10 @@ private struct FeedRow: View { .fontWeight(.semibold) .foregroundStyle(feed.reviewCount > 0 ? TendTheme.ink : TendTheme.secondaryInk) Text("\(feed.workingCount) working") - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) Spacer() Text(feed.relativeFreshness) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } .font(.caption.weight(.semibold)) } diff --git a/ios/Tend/Views/MobileCardView.swift b/ios/Tend/Views/MobileCardView.swift index 5a4cdd8..7981e9c 100644 --- a/ios/Tend/Views/MobileCardView.swift +++ b/ios/Tend/Views/MobileCardView.swift @@ -11,7 +11,7 @@ struct MobileCardView: View { VStack(alignment: .leading, spacing: 11) { Text(card.eyebrow.uppercased()) .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) .fixedSize(horizontal: false, vertical: true) Text(card.title) .font(.system(.title, design: .serif, weight: .medium)) @@ -38,7 +38,7 @@ struct MobileCardView: View { if let mailbox = card.sourceMailbox { Label("Acts as \(mailbox)", systemImage: "person.crop.circle.badge.checkmark") .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } } .padding(20) @@ -189,7 +189,7 @@ private struct MobileBlockView: View { if let value = detail.detail { Text(value) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) .multilineTextAlignment(.leading) } } @@ -214,7 +214,7 @@ private struct MobileBlockView: View { .accessibilityLabel(block.label ?? "Editable note") Text("Edits are included only when you approve the matching action.") .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } } @@ -276,7 +276,7 @@ private struct MobileBlockView: View { if let subtitle = profile.subtitle { Text(subtitle) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } } Spacer() @@ -346,7 +346,7 @@ private struct MobileBlockView: View { if let detail = row.detail { Text(detail) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } } ForEach(Array(row.values.enumerated()), id: \.offset) { index, value in @@ -372,7 +372,7 @@ private struct MobileBlockView: View { if let note = chart.note { Text(note) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } } } @@ -400,7 +400,7 @@ private struct MobileBlockView: View { if let label = block.label { Text(label.uppercased()) .font(.caption2.weight(.bold)) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) .fixedSize(horizontal: false, vertical: true) } } diff --git a/ios/Tend/Views/SharedComponents.swift b/ios/Tend/Views/SharedComponents.swift index 9e4c103..538f07f 100644 --- a/ios/Tend/Views/SharedComponents.swift +++ b/ios/Tend/Views/SharedComponents.swift @@ -15,7 +15,7 @@ struct TendWordmark: View { if let subtitle { Text(subtitle) .font(.subheadline) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } } } @@ -33,7 +33,7 @@ struct CountPill: View { .foregroundStyle(emphasis ? TendTheme.cobalt : TendTheme.ink) Text(label) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) } .accessibilityElement(children: .combine) } @@ -50,7 +50,7 @@ struct SyncBadge: View { Text(label) .font(.caption.weight(.semibold)) } - .foregroundStyle(.secondary) + .foregroundStyle(TendTheme.secondaryInk) .accessibilityLabel("Sync status: \(label)") } From 0c67a2d4889ffd46ec3525a2f0ccbe86e03ad6da Mon Sep 17 00:00:00 2001 From: Jannik Jung Date: Mon, 15 Jun 2026 14:27:21 -0600 Subject: [PATCH 03/18] fix: remove low-contrast review divider --- ios/Tend/Views/FeedReviewView.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/ios/Tend/Views/FeedReviewView.swift b/ios/Tend/Views/FeedReviewView.swift index 7973d78..f992c59 100644 --- a/ios/Tend/Views/FeedReviewView.swift +++ b/ios/Tend/Views/FeedReviewView.swift @@ -299,9 +299,6 @@ private struct ReviewActionTray: View { .padding(.top, 12) .padding(.bottom, 6) .background(.ultraThinMaterial) - .overlay(alignment: .top) { - Divider() - } } private var archiveButton: some View { From 2d47b0db7739dd77c8427ed94a6e741daea2d984 Mon Sep 17 00:00:00 2001 From: Jannik Jung Date: Mon, 15 Jun 2026 14:36:12 -0600 Subject: [PATCH 04/18] fix: strengthen native control borders --- ios/Tend/App/Theme.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Tend/App/Theme.swift b/ios/Tend/App/Theme.swift index 6bf8669..30c8e76 100644 --- a/ios/Tend/App/Theme.swift +++ b/ios/Tend/App/Theme.swift @@ -81,7 +81,7 @@ struct TendSecondaryButtonStyle: ButtonStyle { .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(TendTheme.hairline, lineWidth: 1) + .stroke(TendTheme.secondaryInk, lineWidth: 1) } } } From db72ebd501ed29ce77e6b78e51ad970d21f45278 Mon Sep 17 00:00:00 2001 From: Jannik Jung Date: Mon, 15 Jun 2026 15:14:51 -0600 Subject: [PATCH 05/18] fix: keep review controls clear of content Lay out the action tray outside the review scroll region and lazily render evidence rows so offscreen content is not exposed beneath native controls. Strengthen the secondary control outline for consistent visible contrast. --- ios/Tend/App/Theme.swift | 2 +- ios/Tend/Views/FeedReviewView.swift | 61 +++++++++++++++-------------- ios/Tend/Views/MobileCardView.swift | 2 +- 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/ios/Tend/App/Theme.swift b/ios/Tend/App/Theme.swift index 30c8e76..63e5b78 100644 --- a/ios/Tend/App/Theme.swift +++ b/ios/Tend/App/Theme.swift @@ -81,7 +81,7 @@ struct TendSecondaryButtonStyle: ButtonStyle { .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(TendTheme.secondaryInk, lineWidth: 1) + .strokeBorder(TendTheme.secondaryInk, lineWidth: 2) } } } diff --git a/ios/Tend/Views/FeedReviewView.swift b/ios/Tend/Views/FeedReviewView.swift index f992c59..bf593e8 100644 --- a/ios/Tend/Views/FeedReviewView.swift +++ b/ios/Tend/Views/FeedReviewView.swift @@ -102,39 +102,40 @@ struct FeedReviewView: View { } private func cardDeck(feed: MobileFeed, card: MobileCard) -> some View { - ScrollView { - VStack(spacing: 14) { - reviewProgress(feed: feed) - - MobileCardView( - card: card, - edits: $edits, - openURL: openURL, - openMind: { - model.selectedTab = 1 - dismiss() - } - ) - .offset(x: 0) - .simultaneousGesture( - DragGesture(minimumDistance: 24) - .onEnded { value in - guard value.translation.width < -90, - abs(value.translation.width) > abs(value.translation.height) * 1.35, - card.archiveAction != nil else { return } - archive(card) + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 14) { + reviewProgress(feed: feed) + + MobileCardView( + card: card, + edits: $edits, + openURL: openURL, + openMind: { + model.selectedTab = 1 + dismiss() } - ) + ) + .offset(x: 0) + .simultaneousGesture( + DragGesture(minimumDistance: 24) + .onEnded { value in + guard value.translation.width < -90, + abs(value.translation.width) > abs(value.translation.height) * 1.35, + card.archiveAction != nil else { return } + archive(card) + } + ) - Text("Swipe left to archive") - .font(.caption) - .foregroundStyle(TendTheme.secondaryInk) - .padding(.bottom, 4) + Text("Swipe left to archive") + .font(.caption) + .foregroundStyle(TendTheme.secondaryInk) + .padding(.bottom, 4) + } + .padding(.horizontal, 14) + .padding(.bottom, 16) } - .padding(.horizontal, 14) - .padding(.bottom, 124) - } - .safeAreaInset(edge: .bottom) { + ReviewActionTray( card: card, isSubmitting: model.isSubmitting, diff --git a/ios/Tend/Views/MobileCardView.swift b/ios/Tend/Views/MobileCardView.swift index 7981e9c..562e0af 100644 --- a/ios/Tend/Views/MobileCardView.swift +++ b/ios/Tend/Views/MobileCardView.swift @@ -151,7 +151,7 @@ private struct MobileBlockView: View { } private func itemList(icon: String) -> some View { - VStack(alignment: .leading, spacing: 11) { + LazyVStack(alignment: .leading, spacing: 11) { blockLabel ForEach(block.items ?? []) { item in switch item { From 7576e3ea930300378e37c2b16dba9273adf5cf1d Mon Sep 17 00:00:00 2001 From: Jannik Jung Date: Tue, 16 Jun 2026 10:35:40 -0600 Subject: [PATCH 06/18] fix: patch Hono CORS vulnerability Require Hono 4.12.25 so dependency auditing no longer permits versions affected by GHSA-88fw-hqm2-52qc. --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 9d5f724..642a3fe 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "dependencies": { "@tanstack/react-query": "^5.101.0", "@tanstack/react-router": "^1.170.11", - "hono": "^4.10.7", + "hono": "^4.12.25", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11848d6..250d801 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^1.170.11 version: 1.170.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1) hono: - specifier: ^4.10.7 - version: 4.12.23 + specifier: ^4.12.25 + version: 4.12.25 react: specifier: ^18.3.1 version: 18.3.1 @@ -728,8 +728,8 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} - hono@4.12.23: - resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} + hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} engines: {node: '>=16.9.0'} isbot@5.1.40: @@ -1426,7 +1426,7 @@ snapshots: gensync@1.0.0-beta.2: {} - hono@4.12.23: {} + hono@4.12.25: {} isbot@5.1.40: {} From 01153474b742953fe58f33559241ec118edcaa98 Mon Sep 17 00:00:00 2001 From: Jannik Jung Date: Tue, 16 Jun 2026 11:09:10 -0600 Subject: [PATCH 07/18] fix: complete browser work feedback loops Keep feed-level instructions cancellable and visible through completion, and give web editors and shortcut actions clear accessible names. --- src/App.tsx | 43 ++++++++++++++++++++++++++++--- src/feed/CardView.tsx | 15 ++++++++--- src/feed/selectors.ts | 13 ++++++---- src/shell/Dock.tsx | 4 +-- src/shell/InspectorPanel.tsx | 13 +++++++--- src/workspace/PromptWorkspace.tsx | 6 ++--- 6 files changed, 75 insertions(+), 19 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6c88763..95b88cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,7 +5,7 @@ import { api, post } from "./app/api"; import type { AttentionScreen, Inspector, Tab, WorkspaceTab } from "./app/types"; import { CardView } from "./feed/CardView"; import { RoutineActionGroupView } from "./feed/RoutineActionGroupView"; -import { countFor, visibleCardActions, visibleCards, visibleRoutineActions } from "./feed/selectors"; +import { countFor, visibleCardActions, visibleCards, visibleFeedWork, visibleRoutineActions } from "./feed/selectors"; import { Dock } from "./shell/Dock"; import { InspectorPanel } from "./shell/InspectorPanel"; import { TopBar } from "./shell/TopBar"; @@ -13,6 +13,7 @@ import { useActiveCard } from "./state/activeCard"; import { RealtimeProvider } from "./state/realtime"; import { preferredTarget, sameTarget } from "./state/voiceTarget"; import type { Card, CardAction, RevisionProposal, RoutineActionGroup, VoiceTarget, WorkItem, WorkspaceRevision, WorkspaceView } from "./types"; +import { FormattedText } from "./ui/FormattedText"; import { LearningReview, RevisionProposals } from "./workspace/LearningReview"; import { PromptWorkspace } from "./workspace/PromptWorkspace"; @@ -344,7 +345,7 @@ export default function App({ feedId, screen, workspaceTab }: { feedId: string; const updated = cards.filter((card) => card.status === "to_review_updated"); const fresh = cards.filter((card) => card.status !== "to_review_updated"); - const feedWork = feed.work.filter((work) => work.cardId === "__feed__" && work.status === tab); + const feedWork = visibleFeedWork(feed, tab); return withRealtime( <> @@ -374,7 +375,43 @@ export default function App({ feedId, screen, workspaceTab }: { feedId: string;
Feed instruction · {work.status}

{work.instruction}

-

{work.status === "queued" ? "Ready for the home Codex thread to drain." : "The home Codex thread is working through this feed-level instruction."}

+

+ {work.status === "queued" + ? "Ready for the home Codex thread to drain." + : work.status === "working" + ? "The home Codex thread is working through this feed-level instruction." + : "The home Codex thread completed this feed-level instruction."} +

+ {work.status === "completed" && work.response && ( +
+
+

Codex response

+

+
+
+ )} + {work.status === "queued" && ( +
+
+ Queued for Codex + Waiting for the feed thread +
+
+ +
+
+ )} + {work.status === "completed" && ( +
+
+ Done + Completed +
+
+ )} ))} {!cards.length && !routineActions.length && !feedWork.length &&

Nothing here right now.

{tab === "review" ? "A quiet feed is allowed. Wake the feed thread when you want Codex to collect or drain pending work." : "Move back to To review when you are ready for the next pass."}

} diff --git a/src/feed/CardView.tsx b/src/feed/CardView.tsx index 31c2ea5..7e33263 100644 --- a/src/feed/CardView.tsx +++ b/src/feed/CardView.tsx @@ -94,7 +94,14 @@ function Block({ feedId, cardId, block, onChanged }: { feedId: string; cardId: s return (
{block.label &&

{block.label}

} -