Merge main into list virtualization for final validation

This commit is contained in:
Brennan Benson
2026-11-12 19:23:20 -08:00
8882 changed files with 142993 additions and 746729 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
/skill-stubs/*.md text eol=lf
/skill-stubs/_shared/*.md text eol=lf
/skills/*/SKILL.md text eol=lf
/src/cli/bundled-skill-guides.ts text eol=lf
/src/cli/skills/bundled-skill-guides.ts text eol=lf
# SVG bytes feed embedded data URLs and asset hashes on every build host.
*.svg text eol=lf
# Compiler input line breaks must not depend on the checkout OS.
@@ -1,6 +1,5 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { createAccessTokenSource } from './gcloud-access-token.mjs'
import {
CloudSqlRolloutLease,
LEASE_TTL_MS,
@@ -102,26 +101,6 @@ const HOLDER = {
runAttempt: '1'
}
test('acquires a lease on an empty object with ifGenerationMatch=0', async () => {
const storage = fakeStorage()
const claim = await leaseFor(storage).acquire(HOLDER)
assert.equal(claim.state, 'created')
const upload = storage.state.requests.find((request) => request.path.startsWith('/upload/'))
assert.equal(upload.search.get('ifGenerationMatch'), '0')
assert.equal(upload.search.get('name'), OBJECT)
assert.deepEqual(storage.state.object.body, {
repository: 'stablyai/orca-cloud',
workflow: 'Deploy Relay Production Same-Cap',
run_id: '42',
run_url: 'https://github.com/stablyai/orca-cloud/actions/runs/42',
run_attempt: '1',
acquired_at: NOW,
expires_at: NOW + LEASE_TTL_MS,
holder_key: 'stablyai/orca-cloud/42'
})
})
test('refuses a live lease held by another run and never writes', async () => {
const storage = fakeStorage({
object: {
@@ -221,19 +200,6 @@ test('refuses to release a lease another run now holds', async () => {
assert.equal(storage.state.object.generation, '1500')
})
test('fails closed when the bucket answers 5xx', async () => {
const storage = fakeStorage({
faults: [{ when: (request) => request.method === 'GET', status: 503 }]
})
const error = await leaseFor(storage)
.acquire(HOLDER)
.catch((thrown) => thrown)
assert.match(error.message, /lease inspection failed: 503/)
assert.equal(storage.state.requests.filter((request) => request.method !== 'GET').length, 0)
})
test('treats an unreadable record as held, not free', async () => {
const storage = fakeStorage({
object: { generation: '1500', body: { holder_key: 'stablyai/orca/9001' } }
@@ -247,19 +213,6 @@ test('treats an unreadable record as held, not free', async () => {
assert.equal(storage.state.object.generation, '1500')
})
test('reports a 412 during acquisition as a conflict', async () => {
const storage = fakeStorage({
faults: [{ when: (request) => request.path.startsWith('/upload/'), status: 412 }]
})
const error = await leaseFor(storage)
.acquire(HOLDER)
.catch((thrown) => thrown)
assert.ok(error instanceof LeaseConflict)
assert.match(error.message, /changed concurrently/)
})
test('renewal rewrites only expires_at on the observed generation', async () => {
const storage = fakeStorage()
const lease = leaseFor(storage)
@@ -273,37 +226,3 @@ test('renewal rewrites only expires_at on the observed generation', async () =>
assert.equal(storage.state.object.body.acquired_at, NOW)
assert.equal(storage.state.object.body.holder_key, HOLDER.holderKey)
})
test('renewal stops once the object belongs to someone else', async () => {
const storage = fakeStorage({
object: {
generation: '1500',
body: storedRecord({ holderKey: 'stablyai/orca/9001', expiresAt: NOW + 60_000 })
}
})
assert.deepEqual(await leaseFor(storage).renew(HOLDER.holderKey), {
renewed: false,
reason: 'foreign'
})
})
test('the access token source re-mints only after the reuse window', () => {
let clock = 0
let mints = 0
const source = createAccessTokenSource({
run: () => `token-${++mints}`,
now: () => clock
})
assert.equal(source(), 'token-1')
clock = 39 * 60 * 1_000
assert.equal(source(), 'token-1')
clock = 41 * 60 * 1_000
assert.equal(source(), 'token-2')
})
test('the access token source rejects an empty gcloud response', () => {
const source = createAccessTokenSource({ run: () => '' })
assert.throws(() => source(), /empty token/)
})
+1 -1
View File
@@ -385,7 +385,7 @@ jobs:
ORCA_RELEASE_JAVASCRIPT_BUILD_IDENTITY: dev
NODE_OPTIONS: --max-old-space-size=4096
# Leave build identity unset: telemetry accepts only official stable/rc, so branch artifacts stay silent.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_DIAGNOSTICS_TOKEN_URL: https://orca.dev/diagnostics/token
# After the app build so nothing that cleans out/ can drop it; electron-builder ships it.
- name: Download the orcad deployment template
+16 -3
View File
@@ -62,11 +62,24 @@ jobs:
current.closed_at === pr.closed_at
}
if (!Number.isSafeInteger(pr.number) || pr.number <= 0 ||
(superseded ? !/^[a-f0-9]{40}$/.test(pr.head?.sha ?? '') :
!Number.isFinite(Date.parse(pr.closed_at)))) {
(superseded && !/^[a-f0-9]{40}$/.test(pr.head?.sha ?? ''))) {
throw new Error('Missing PR identity')
}
if (!await stillCurrent()) return
if (!superseded && !Number.isFinite(Date.parse(pr.closed_at))) {
// A close quickly followed by a reopen can deliver a payload rendered after the reopen.
const { data: current } = await github.rest.pulls.get({
...context.repo, pull_number: pr.number
})
if (current.state !== 'closed' || current.merged_at !== null) {
core.info(`PR #${pr.number} changed after this close event; skipping cancellation`)
return
}
throw new Error('Missing PR identity')
}
if (!await stillCurrent()) {
core.info(`PR #${pr.number} changed after this event; skipping cancellation`)
return
}
for (const [prefix, workflow, suffix = ''] of targets) {
const group = prefix === 'cloud-verify-refs/pull'
? `${prefix}/${pr.number}${suffix}` : `${prefix}-${pr.number}${suffix}`
+6 -5
View File
@@ -48,12 +48,14 @@ on:
- 'src/cli/**'
- 'src/main/computer/**'
- 'src/main/runtime/rpc/dispatcher.ts'
- 'src/main/runtime/rpc/errors.ts'
- 'src/main/runtime/rpc/responses/rpc-response.ts'
- 'src/main/runtime/rpc/methods/computer/computer*.ts'
- 'src/shared/computer-use-*.ts'
- 'tests/e2e/computer-linux.e2e.ts'
- 'tests/e2e/computer-windows.e2e.ts'
- 'tests/e2e/computer-windows-store.e2e.ts'
- 'tests/e2e/computer-windows-background.e2e.ts'
- 'tests/e2e/helpers/windows-semantic-controls-fixture.*'
- 'tests/e2e/helpers/computer-cli-driver.ts'
- 'tests/e2e/helpers/computer-driver.ts'
- 'tests/e2e/vitest.config.ts'
@@ -97,9 +99,8 @@ jobs:
src/main/computer/macos-native/macos-native-provider-client.test.ts
src/main/providers/windows-shell-preflight-runtime.windows.test.ts
src/main/computer/desktop-script/actions/desktop-script-provider-actions.test.ts
src/cli/format.test.ts
src/cli/handlers/computer-action-validation.test.ts
src/cli/handlers/computer-state-formatting.test.ts
src/cli/output/command-result-output.test.ts
src/cli/computer/computer-state-formatting.test.ts
src/cli/index.test.ts
src/cli/runtime/envelope-schema.test.ts
src/shared/remote-runtime/remote-runtime-client.test.ts
@@ -152,4 +153,4 @@ jobs:
- run: pnpm verify:computer-native
- run: pnpm build:cli
- run: pnpm run build:electron-vite:parallel
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts
- run: pnpm test:e2e:computer --reporter=verbose tests/e2e/computer-windows.e2e.ts tests/e2e/computer-windows-store.e2e.ts tests/e2e/computer-windows-background.e2e.ts
+1 -1
View File
@@ -235,7 +235,7 @@ jobs:
env:
NODE_OPTIONS: --max-old-space-size=4096
# Leave dev identity unset so official-only stable/rc telemetry stays silent.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_DIAGNOSTICS_TOKEN_URL: https://orca.dev/diagnostics/token
# After the app build so nothing that cleans out/ can drop it; electron-builder ships it.
- name: Download the orcad deployment template
+1 -1
View File
@@ -254,7 +254,7 @@ jobs:
ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA: ${{ inputs.javascript_source_sha }}
ORCA_RELEASE_JAVASCRIPT_BUILD_IDENTITY: dev
# Unset dev build identity so official stable/rc telemetry stays silent.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_DIAGNOSTICS_TOKEN_URL: https://orca.dev/diagnostics/token
# Inline so refs predating the wait can still use the trusted dispatch workflow.
- name: Wait for the qualified orcad template
+1 -1
View File
@@ -204,7 +204,7 @@ jobs:
timeout-minutes: 15
environment:
name: docs-production
url: https://www.onorca.dev/docs
url: https://orca.dev/docs
env:
VERCEL_TELEMETRY_DISABLED: '1'
steps:
@@ -3,7 +3,7 @@ on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
paths:
- 'src/main/git/command-runner/spawned-command-tree-kill*'
- 'src/main/git/command-runner/process-capture/spawned-command-tree-kill*'
- '.github/workflows/git-command-termination-runtime.yml'
workflow_dispatch:
permissions:
@@ -24,4 +24,4 @@ jobs:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
- name: Verify exited native child does not trigger taskkill
run: node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/git/command-runner/spawned-command-tree-kill.test.ts
run: node node_modules/vitest/vitest.mjs run --config config/vitest.config.ts src/main/git/command-runner/process-capture/spawned-command-tree-kill.test.ts
+1 -1
View File
@@ -224,7 +224,7 @@ jobs:
env:
NODE_OPTIONS: --max-old-space-size=4096
# Unset dev identity so official-only stable/rc telemetry stays silent.
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_DIAGNOSTICS_TOKEN_URL: https://orca.dev/diagnostics/token
# After the app build so nothing that cleans out/ can drop it; electron-builder ships it.
- name: Download the orcad deployment template
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
uses: ./.github/workflows/e2e.yml
with:
ref: ${{ inputs.ref || github.sha }}
test_files: '["tests/e2e/workspace-layout-oracle-headless.spec.ts","tests/e2e/workspace-layout-oracle.spec.ts","tests/e2e/cli-file-open-position.spec.ts","tests/e2e/relaunch-first-terminal.spec.ts","tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts","tests/e2e/cli-first-worktree-single-terminal.spec.ts","tests/e2e/unopened-workspace-agent-single-resume.spec.ts","tests/e2e/terminal-pane-close-layout-consistency.spec.ts","tests/e2e/golden-file-open-edit-save.spec.ts","tests/e2e/golden-source-control-open-diff.spec.ts","tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts","tests/e2e/floating-workspace-pane-height.spec.ts","tests/e2e/editor-model-host-isolation.spec.ts","tests/e2e/editor-tab-selection-restore.spec.ts","tests/e2e/finished-agent-ghost-resume.spec.ts","tests/e2e/live-background-terminal-mount-authority.spec.ts","tests/e2e/workspace-back-forward-navigation.spec.ts","tests/e2e/worktree-lineage-toggle-shortcut.spec.ts","tests/e2e/local-cli-terminal-retention.spec.ts","tests/e2e/default-tabs-mark-crash-relaunch.spec.ts","tests/e2e/terminal-cli-split-closed-before-mount.spec.ts","tests/e2e/layout-stream-loss-reopen.spec.ts","tests/e2e/terminal-tab-close-restart-persistence.spec.ts","tests/e2e/artificial-opencode-terminal-load.spec.ts","tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts","tests/e2e/headless-paired-remote-terminal-reveal-grid.spec.ts","tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts","tests/e2e/headless-paired-remote-terminal-viewer-size.spec.ts","tests/e2e/headless-serve-browser-tab-restart.spec.ts","tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts","tests/e2e/headless-serve-desktop-activation.spec.ts","tests/e2e/headless-serve-chat-tab-placement.spec.ts","tests/e2e/headless-serve-mobile-native-chat-identity.spec.ts","tests/e2e/layout-compat-old-phone-on-new-server.spec.ts","tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts","tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts","tests/e2e/paired-mobile-desktop-tab-composition.spec.ts","tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts","tests/e2e/paired-desktop-server-layout-frames.spec.ts","tests/e2e/paired-desktop-server-held-pane-reopen.spec.ts","tests/e2e/paired-desktop-server-layout-intents.spec.ts","tests/e2e/paired-desktop-server-layout-writes.spec.ts","tests/e2e/ssh-orcad-"]'
test_files: '["tests/e2e/workspace-layout-oracle-headless.spec.ts","tests/e2e/workspace-layout-oracle.spec.ts","tests/e2e/cli-file-open-position.spec.ts","tests/e2e/relaunch-first-terminal.spec.ts","tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts","tests/e2e/cli-first-worktree-single-terminal.spec.ts","tests/e2e/unopened-workspace-agent-single-resume.spec.ts","tests/e2e/terminal-pane-close-layout-consistency.spec.ts","tests/e2e/golden-file-open-edit-save.spec.ts","tests/e2e/golden-source-control-open-diff.spec.ts","tests/e2e/paired-cli-terminal-graph-sync-tab-retention.spec.ts","tests/e2e/floating-workspace-pane-height.spec.ts","tests/e2e/editor-model-host-isolation.spec.ts","tests/e2e/editor-tab-selection-restore.spec.ts","tests/e2e/finished-agent-ghost-resume.spec.ts","tests/e2e/live-background-terminal-mount-authority.spec.ts","tests/e2e/workspace-back-forward-navigation.spec.ts","tests/e2e/worktree-lineage-toggle-shortcut.spec.ts","tests/e2e/local-cli-terminal-retention.spec.ts","tests/e2e/default-tabs-mark-crash-relaunch.spec.ts","tests/e2e/terminal-cli-split-closed-before-mount.spec.ts","tests/e2e/layout-stream-loss-reopen.spec.ts","tests/e2e/terminal-tab-close-restart-persistence.spec.ts","tests/e2e/artificial-opencode-terminal-load.spec.ts","tests/e2e/headless-paired-remote-terminal-retention-memory.spec.ts","tests/e2e/headless-paired-remote-terminal-reveal-grid.spec.ts","tests/e2e/headless-paired-remote-terminal-stall-recovery.spec.ts","tests/e2e/headless-paired-remote-terminal-viewer-size.spec.ts","tests/e2e/headless-serve-browser-tab-restart.spec.ts","tests/e2e/headless-serve-cli-terminal-retention-parity.spec.ts","tests/e2e/headless-serve-desktop-activation.spec.ts","tests/e2e/headless-serve-chat-tab-placement.spec.ts","tests/e2e/headless-serve-mobile-native-chat-identity.spec.ts","tests/e2e/layout-compat-old-phone-on-new-server.spec.ts","tests/e2e/layout-compat-serve-restart-keeps-at-at-terminals.spec.ts","tests/e2e/paired-mobile-serve-restart-terminal-tabs.spec.ts","tests/e2e/paired-mobile-desktop-tab-composition.spec.ts","tests/e2e/paired-remote-terminal-serve-restart-binding.spec.ts","tests/e2e/ssh-orcad-"]'
run_changed_e2e: true
needs_build: true
artifact_suffix: -layout-owner-switch-on
+4 -4
View File
@@ -11,8 +11,8 @@ on:
- 'native/macos-shipit-scheduling/**'
- 'config/scripts/macos-shipit-scheduling*'
- 'config/electron-builder.config.cjs'
- 'src/main/startup/main-process-quit*'
- 'src/main/window/main-window-state-lifecycle*'
- 'src/main/startup/quit/main-process-quit*'
- 'src/main/window/main-window/main-window-state-lifecycle*'
- 'src/main/window/dashboard-popout-window*'
- 'src/packages/process-host/**'
- 'src/main/runtime/agent-session/lifecycle/structured-agent-cli-writers*'
@@ -55,8 +55,8 @@ jobs:
src/main/updater/mac-install/updater.mac-install.test.ts
src/main/updater/updater.headless-serve-install.test.ts
src/main/updater/mac-install/updater-mac-quit-guard.test.ts
src/main/startup/main-process-quit-update-veto.test.ts
src/main/window/main-window-state-lifecycle.test.ts
src/main/startup/quit/main-process-quit-update-veto.test.ts
src/main/window/main-window/main-window-state-lifecycle.test.ts
- name: Prove login-backed terminal writer census on the disposable runner
env:
ORCA_MACOS_LOGIN_CENSUS_CI: '1'
+26
View File
@@ -0,0 +1,26 @@
name: Main blocking work guards
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: main-blocking-work-${{ github.ref }}
cancel-in-progress: false
jobs:
ratchets:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/install-node-dependencies
# Run the blocking-work ratchets over the full tree after every main merge.
- name: Enforce ratchets
run: |
node config/scripts/static-analysis/ratchets/runtime-lifecycle/check-main-sync-fs-ratchet.mjs
node config/scripts/static-analysis/ratchets/runtime-lifecycle/check-main-blocking-api-ratchet.mjs
# The PR preflight runs spawn separately, so the discovery runner intentionally excludes it.
- name: Enforce synchronous launches
if: '!cancelled()'
run: node config/scripts/static-analysis/process-boundaries/check-main-sync-spawn-ratchet.mjs
+1 -1
View File
@@ -284,7 +284,7 @@ jobs:
"${{ steps.apk.outputs.checksum }}"
fi
# Advisory like the site bump: a failed mirror only leaves onorca.dev's "mirror link" on the prior APK.
# Advisory like the site bump: a failed mirror only leaves orca.dev's "mirror link" on the prior APK.
release-mirror-r2:
needs: android-build
if: needs.android-build.outputs.publish_release == 'true'
+116 -8
View File
@@ -34,18 +34,24 @@ on:
- 'src/main/runtime/agent-session/agent-session-unsaved-*.ts'
# The phone/host contract program's unmocked desktop sources and its declaration owner's inputs.
- 'src/main/runtime/integrations/github/runtime-github-repository-query-commands.ts'
- 'src/main/github/issue-field-options.ts'
- 'src/main/github/issues/issue-field-options.ts'
- 'config/tsconfig.test.mobile-host-declarations.json'
- 'config/tsconfig.node.json'
- 'src/types/build-constants.d.ts'
- 'src/main/native-chat/agent-session-wire/**'
# Recorded phone transcripts exercise these host decoders and their runtime dependencies.
- 'src/main/native-chat/*transcript*.ts'
- 'src/main/native-chat/transcript-discovery/**'
- 'src/main/native-chat/transcript-filesystem/**'
- 'src/main/native-chat/transcript-decoding/**'
- 'src/main/native-chat/transcript-streaming/**'
- 'src/main/native-chat/transcript-watching/**'
- 'src/main/native-chat/transcript-history/**'
- 'src/main/native-chat/subagent-entry-id-bounds.ts'
- 'src/main/native-chat/agent-session-journal/item-lifecycle/journal-payload-bounds.ts'
- 'src/main/native-chat/agent-session-journal/item-lifecycle/journal-reasoning-row.ts'
- 'src/main/claude/claude-internal-message-content.ts'
- 'src/main/claude/claude-stop-hook-feedback.ts'
- 'src/main/claude/structured/journal/claude-internal-message-content.ts'
- 'src/main/claude/structured/journal/claude-stop-hook-feedback.ts'
- 'src/main/claude/structured/journal/**'
- 'src/main/claude/__fixtures__/claude-adapter-capture-fold.jsonl'
- 'src/main/codex/app-server/codex-item-field-readers.ts'
@@ -54,7 +60,55 @@ on:
- 'src/main/codex/structured/journal/codex-goal-journal-rows.ts'
- 'src/main/codex/__fixtures__/codex-hook-outcomes.jsonl'
- 'src/main/ai-vault/session-scanner-*.ts'
- 'src/main/ai-vault/ai-vault-scan-cancellation.ts'
- 'src/main/ai-vault/discovery/agent-session-directories.ts'
- 'src/main/ai-vault/discovery/session-file-discovery.ts'
- 'src/main/ai-vault/discovery/session-json-file.ts'
- 'src/main/ai-vault/discovery/session-path-absence.ts'
- 'src/main/ai-vault/discovery/session-scanner-agent-sources.ts'
- 'src/main/ai-vault/discovery/session-scanner-candidates.ts'
- 'src/main/ai-vault/discovery/session-scanner-directory-reader.test.ts'
- 'src/main/ai-vault/discovery/session-scanner-discovery-pipeline.test.ts'
- 'src/main/ai-vault/discovery/session-scanner-discovery.ts'
- 'src/main/ai-vault/discovery/session-scanner-roots.ts'
- 'src/main/ai-vault/discovery/session-scanner-source-discovery.ts'
- 'src/main/ai-vault/parsing/session-json-object.ts'
- 'src/main/ai-vault/parsing/session-record-fields.ts'
- 'src/main/ai-vault/parsing/session-record-time.ts'
- 'src/main/ai-vault/parsing/session-scanner-accumulator.ts'
- 'src/main/ai-vault/parsing/session-scanner-agent-parser.ts'
- 'src/main/ai-vault/parsing/session-scanner-candidate-parse.ts'
- 'src/main/ai-vault/parsing/session-scanner-core-parser-wsl-stall.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-injected-title.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-jsonl-reader.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-jsonl-reader.ts'
- 'src/main/ai-vault/parsing/session-scanner-messages.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-messages.ts'
- 'src/main/ai-vault/parsing/session-scanner-parse-wsl-stall.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-record-budget.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-recoverable-empty.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-text-normalization.ts'
- 'src/main/ai-vault/parsing/session-scanner-token-values.ts'
- 'src/main/ai-vault/parsing/session-scanner-unlimited-dedup.test.ts'
- 'src/main/ai-vault/prompts/session-scanner-first-user-prompt-capture.ts'
- 'src/main/ai-vault/prompts/session-scanner-first-user-prompt.test.ts'
- 'src/main/ai-vault/prompts/session-scanner-first-user-prompt.ts'
- 'src/main/ai-vault/scanner-fixtures/session-scanner-incremental-fixtures.ts'
- 'src/main/ai-vault/scanner-fixtures/session-scanner-prime-agent-fixtures.ts'
- 'src/main/ai-vault/scanner-fixtures/session-scanner-test-fixtures.ts'
- 'src/main/ai-vault/scanners/copilot/session-scanner-copilot-parser.ts'
- 'src/main/ai-vault/scanners/opencode/session-scanner-opencode-parser.ts'
- 'src/main/ai-vault/scope/session-scanner-scope-discovery.ts'
- 'src/main/ai-vault/scope/session-scanner-scope-inventory.test.ts'
- 'src/main/ai-vault/scope/session-scanner-scope-inventory.ts'
- 'src/main/ai-vault/scope/session-scanner-scope-only.test.ts'
- 'src/main/ai-vault/scope/session-scanner-scope-walk.test.ts'
- 'src/main/ai-vault/scope/session-scanner-scope-walk.ts'
- 'src/main/ai-vault/scope/session-scanner-scope.test.ts'
- 'src/main/ai-vault/session-scanner.ts'
- 'src/shared/error-message.ts'
- 'src/shared/surrogate-safe-text-slice.ts'
- 'src/shared/trimmed-string.ts'
- 'src/main/ai-vault/scan-lifecycle/ai-vault-scan-cancellation.ts'
- 'src/main/ai-vault/scan-service/session-scanner-service-env.ts'
- 'src/main/ai-vault/scanners/grok/session-scanner-grok-user-text.ts'
- 'src/main/provider-process/provider-json-record.ts'
@@ -122,18 +176,24 @@ on:
- 'src/main/runtime/agent-session/agent-session-unsaved-*.ts'
# The phone/host contract program's unmocked desktop sources and its declaration owner's inputs.
- 'src/main/runtime/integrations/github/runtime-github-repository-query-commands.ts'
- 'src/main/github/issue-field-options.ts'
- 'src/main/github/issues/issue-field-options.ts'
- 'config/tsconfig.test.mobile-host-declarations.json'
- 'config/tsconfig.node.json'
- 'src/types/build-constants.d.ts'
- 'src/main/native-chat/agent-session-wire/**'
# Recorded phone transcripts exercise these host decoders and their runtime dependencies.
- 'src/main/native-chat/*transcript*.ts'
- 'src/main/native-chat/transcript-discovery/**'
- 'src/main/native-chat/transcript-filesystem/**'
- 'src/main/native-chat/transcript-decoding/**'
- 'src/main/native-chat/transcript-streaming/**'
- 'src/main/native-chat/transcript-watching/**'
- 'src/main/native-chat/transcript-history/**'
- 'src/main/native-chat/subagent-entry-id-bounds.ts'
- 'src/main/native-chat/agent-session-journal/item-lifecycle/journal-payload-bounds.ts'
- 'src/main/native-chat/agent-session-journal/item-lifecycle/journal-reasoning-row.ts'
- 'src/main/claude/claude-internal-message-content.ts'
- 'src/main/claude/claude-stop-hook-feedback.ts'
- 'src/main/claude/structured/journal/claude-internal-message-content.ts'
- 'src/main/claude/structured/journal/claude-stop-hook-feedback.ts'
- 'src/main/claude/structured/journal/**'
- 'src/main/claude/__fixtures__/claude-adapter-capture-fold.jsonl'
- 'src/main/codex/app-server/codex-item-field-readers.ts'
@@ -142,7 +202,55 @@ on:
- 'src/main/codex/structured/journal/codex-goal-journal-rows.ts'
- 'src/main/codex/__fixtures__/codex-hook-outcomes.jsonl'
- 'src/main/ai-vault/session-scanner-*.ts'
- 'src/main/ai-vault/ai-vault-scan-cancellation.ts'
- 'src/main/ai-vault/discovery/agent-session-directories.ts'
- 'src/main/ai-vault/discovery/session-file-discovery.ts'
- 'src/main/ai-vault/discovery/session-json-file.ts'
- 'src/main/ai-vault/discovery/session-path-absence.ts'
- 'src/main/ai-vault/discovery/session-scanner-agent-sources.ts'
- 'src/main/ai-vault/discovery/session-scanner-candidates.ts'
- 'src/main/ai-vault/discovery/session-scanner-directory-reader.test.ts'
- 'src/main/ai-vault/discovery/session-scanner-discovery-pipeline.test.ts'
- 'src/main/ai-vault/discovery/session-scanner-discovery.ts'
- 'src/main/ai-vault/discovery/session-scanner-roots.ts'
- 'src/main/ai-vault/discovery/session-scanner-source-discovery.ts'
- 'src/main/ai-vault/parsing/session-json-object.ts'
- 'src/main/ai-vault/parsing/session-record-fields.ts'
- 'src/main/ai-vault/parsing/session-record-time.ts'
- 'src/main/ai-vault/parsing/session-scanner-accumulator.ts'
- 'src/main/ai-vault/parsing/session-scanner-agent-parser.ts'
- 'src/main/ai-vault/parsing/session-scanner-candidate-parse.ts'
- 'src/main/ai-vault/parsing/session-scanner-core-parser-wsl-stall.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-injected-title.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-jsonl-reader.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-jsonl-reader.ts'
- 'src/main/ai-vault/parsing/session-scanner-messages.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-messages.ts'
- 'src/main/ai-vault/parsing/session-scanner-parse-wsl-stall.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-record-budget.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-recoverable-empty.test.ts'
- 'src/main/ai-vault/parsing/session-scanner-text-normalization.ts'
- 'src/main/ai-vault/parsing/session-scanner-token-values.ts'
- 'src/main/ai-vault/parsing/session-scanner-unlimited-dedup.test.ts'
- 'src/main/ai-vault/prompts/session-scanner-first-user-prompt-capture.ts'
- 'src/main/ai-vault/prompts/session-scanner-first-user-prompt.test.ts'
- 'src/main/ai-vault/prompts/session-scanner-first-user-prompt.ts'
- 'src/main/ai-vault/scanner-fixtures/session-scanner-incremental-fixtures.ts'
- 'src/main/ai-vault/scanner-fixtures/session-scanner-prime-agent-fixtures.ts'
- 'src/main/ai-vault/scanner-fixtures/session-scanner-test-fixtures.ts'
- 'src/main/ai-vault/scanners/copilot/session-scanner-copilot-parser.ts'
- 'src/main/ai-vault/scanners/opencode/session-scanner-opencode-parser.ts'
- 'src/main/ai-vault/scope/session-scanner-scope-discovery.ts'
- 'src/main/ai-vault/scope/session-scanner-scope-inventory.test.ts'
- 'src/main/ai-vault/scope/session-scanner-scope-inventory.ts'
- 'src/main/ai-vault/scope/session-scanner-scope-only.test.ts'
- 'src/main/ai-vault/scope/session-scanner-scope-walk.test.ts'
- 'src/main/ai-vault/scope/session-scanner-scope-walk.ts'
- 'src/main/ai-vault/scope/session-scanner-scope.test.ts'
- 'src/main/ai-vault/session-scanner.ts'
- 'src/shared/error-message.ts'
- 'src/shared/surrogate-safe-text-slice.ts'
- 'src/shared/trimmed-string.ts'
- 'src/main/ai-vault/scan-lifecycle/ai-vault-scan-cancellation.ts'
- 'src/main/ai-vault/scan-service/session-scanner-service-env.ts'
- 'src/main/ai-vault/scanners/grok/session-scanner-grok-user-text.ts'
- 'src/main/provider-process/provider-json-record.ts'
+1 -1
View File
@@ -247,7 +247,7 @@ jobs:
run: node "$(if [ -f config/scripts/orcad-distribution/template/build-orcad-template.mjs ]; then printf %s config/scripts/orcad-distribution/template/build-orcad-template.mjs; else printf %s config/scripts/build-orcad-template.mjs; fi)" --targets linux-${{ runner.arch == 'ARM64' && 'arm64' || 'x64' }}-glibc
- name: Verify a structured chat from the desktop server template
if: runner.os == 'Linux'
run: pnpm test:node-server --artifact src/main/orcad/orcad-packaged-structured-chat.integration.test.ts --maxWorkers=2
run: pnpm test:node-server --artifact src/main/orcad/testing/structured-chat/orcad-packaged-structured-chat.integration.test.ts --maxWorkers=2
# Only a Windows runner compiles it; arm64 cross-compiles here.
# Before the Node 18 check below: the build script imports TypeScript, which Node 18 cannot load.
# orcad's instance lock and stop proof read a PID's creation time through it; the x64 binary this
@@ -25,7 +25,6 @@ on:
- 'src/main/codex-usage/codex-rollout-file-lifetime.test.ts'
- 'src/main/codex-usage/scanner-incremental-append.test.ts'
- 'src/main/usage/source-input/jsonl/jsonl-file-reader.test.ts'
- 'src/main/claude-usage/scanner-incremental-append.test.ts'
permissions:
contents: read
+3 -3
View File
@@ -851,9 +851,10 @@ jobs:
src/main/pty/history/terminal-history-fish-session.node-pty.test.ts \
src/main/pty/shell-startup/zsh-scoped-histfile.live-shell.test.ts \
src/main/pty/shell-startup/zsh-startup-hook-user-config-equivalence.live-shell.test.ts \
src/main/runtime/terminal/wait/tui-idle-zsh-preexec-hook-order.live-shell.test.ts \
src/main/runtime/agent-session/environment/structured-session-cli-login-shell.live-shell.test.ts \
src/main/startup/shell-probe-terminal-detach.live-shell.test.ts \
src/main/startup/shell-probe-lifetime.live-shell.test.ts \
src/main/startup/shell-environment/shell-probe-terminal-detach.live-shell.test.ts \
src/main/startup/shell-environment/shell-probe-lifetime.live-shell.test.ts \
src/renderer/src/components/terminal-pane/pty-connection/emulation/fish-color-scheme-child-stdin.node-pty.test.ts \
src/shared/fish-wsl-cwd-report.node-pty.test.ts \
src/main/wsl/filesystem/wsl-sh-cwd-report.node-pty.test.ts \
@@ -1116,7 +1117,6 @@ jobs:
src/main/browser/cookies/browser-google-cookie-clear.electron.test.ts
src/main/browser/guest-lifecycle/popups/presentation/popup-origin-chrome.electron.test.ts
src/main/browser/guest-lifecycle/navigation/browser-link-materialization.electron.test.ts
src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-compatibility.electron.test.ts
src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-native-compatibility.electron.test.ts
src/main/browser/guest-lifecycle/popups/compatibility/browser-popup-background-compatibility.electron.test.ts
src/main/browser/extensions/api-runtime/browser-extension-api.electron.test.ts
@@ -47,8 +47,8 @@ jobs:
src/main/ipc/preflight-runnable-local-cli.test.ts
src/main/ipc/preflight-wsl-agent-detection.test.ts
src/main/ipc/preflight-windows-path-refresh.repro.test.ts
src/main/startup/hydrate-shell-path.windows.test.ts
src/main/startup/login-shell-environment.test.ts
src/main/startup/shell-environment/hydrate-shell-path.windows.test.ts
src/main/startup/shell-environment/login-shell-environment.test.ts
src/packages/process-host/src/process-tree-termination.test.ts
src/packages/process-host/src/run-process.test.ts
src/packages/process-host/src/run-process-termination-failure.test.ts
+2 -2
View File
@@ -1321,7 +1321,7 @@ jobs:
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
SHARED_JAVASCRIPT: ${{ needs.release-javascript.outputs.supported }}
ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA: ${{ needs.release-javascript.outputs.source_sha }}
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_DIAGNOSTICS_TOKEN_URL: https://orca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
# After the app build so nothing that cleans out/ can drop it; electron-builder ships it.
@@ -2357,7 +2357,7 @@ jobs:
needs:
- cut
- publish-release
# Why stable-only: onorca.dev's mirror link tracks GitHub's latest release, which skips RCs.
# Why stable-only: orca.dev's mirror link tracks GitHub's latest release, which skips RCs.
if: ${{ !cancelled() && needs.publish-release.result == 'success' && needs.cut.outputs.tag != '' && !contains(needs.cut.outputs.tag, '-rc.') && startsWith(needs.cut.outputs.tag, 'v') }}
uses: ./.github/workflows/release-mirror-r2.yml
with:
+1 -1
View File
@@ -92,7 +92,7 @@ jobs:
ORCA_CLI_BUILD_COMPILER: ${{ inputs.dev_build && 'tsrs' || 'bun' }}
ORCA_BUILD_IDENTITY: ${{ steps.identity.outputs.value }}
ORCA_RELEASE_JAVASCRIPT_BUILD_IDENTITY: ${{ inputs.dev_build && 'dev' || '' }}
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_DIAGNOSTICS_TOKEN_URL: https://orca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ !inputs.dev_build && secrets.ORCA_POSTHOG_WRITE_KEY || '' }}
run: |
pnpm run "$JAVASCRIPT_BUILD_SCRIPT"
+1 -1
View File
@@ -189,7 +189,7 @@ jobs:
NODE_OPTIONS: --max-old-space-size=4096
ORCA_BUILD_IDENTITY: ${{ steps.tag-classify.outputs.identity }}
ORCA_RELEASE_JAVASCRIPT_SOURCE_SHA: ${{ inputs.javascript_source_sha }}
ORCA_DIAGNOSTICS_TOKEN_URL: https://www.onorca.dev/diagnostics/token
ORCA_DIAGNOSTICS_TOKEN_URL: https://orca.dev/diagnostics/token
ORCA_POSTHOG_WRITE_KEY: ${{ secrets.ORCA_POSTHOG_WRITE_KEY }}
# Design D2: the parent release-cut run merged it from every node-server lane at this tag.
+4 -4
View File
@@ -1,7 +1,7 @@
name: Release Mirror (R2)
# Copy published release downloads to the public R2 bucket behind https://downloads.onorca.dev.
# onorca.dev keeps GitHub as the default download and offers this as an opt-in "mirror link"
# Copy published release downloads to the public R2 bucket behind https://downloads.orca.dev.
# orca.dev keeps GitHub as the default download and offers this as an opt-in "mirror link"
# for when GitHub release downloads return 5xx. Called by release-cut (stable desktop tags) and
# mobile-android-release after publishing; dispatch manually to backfill a tag.
@@ -13,7 +13,7 @@ on:
required: true
type: string
assets:
description: Space-separated asset names (blank = the onorca.dev download assets)
description: Space-separated asset names (blank = the orca.dev download assets)
required: false
default: ''
type: string
@@ -24,7 +24,7 @@ on:
required: true
type: string
assets:
description: Space-separated asset names (blank = the onorca.dev download assets)
description: Space-separated asset names (blank = the orca.dev download assets)
required: false
default: ''
type: string
+2 -2
View File
@@ -15,8 +15,8 @@ on:
- 'src/main/managed-server/**'
- 'src/main/ssh/migration-source/**'
- 'src/main/persistence/migrating-orcad-catalog/**'
- 'src/main/orcad/orcad-artifact-materializer.ts'
- 'src/main/orcad/orcad-host-unavailable.ts'
- 'src/main/orcad/artifact-slots/orcad-artifact-materializer.ts'
- 'src/main/orcad/diagnostics/orcad-host-unavailable.ts'
- 'src/main/node-runtime/**'
- 'src/main/ssh/remote-exec/**'
- 'src/main/ssh/remote-host/**'
+3 -3
View File
@@ -12,8 +12,8 @@ on:
- 'src/main/managed-server/**'
- 'src/main/ssh/migration-source/**'
- 'src/main/persistence/migrating-orcad-catalog/**'
- 'src/main/orcad/orcad-artifact-materializer.ts'
- 'src/main/orcad/orcad-host-unavailable.ts'
- 'src/main/orcad/artifact-slots/orcad-artifact-materializer.ts'
- 'src/main/orcad/diagnostics/orcad-host-unavailable.ts'
- 'src/main/node-runtime/**'
- 'src/main/ssh/remote-exec/**'
- 'src/main/ssh/remote-host/**'
@@ -38,7 +38,7 @@ on:
- 'config/scripts/native-build/windows/build-windows-cli-launcher.mjs'
- 'config/scripts/native-build/windows/windows-rust-executable-build.mjs'
- 'native/windows-cli-launcher/**'
- 'src/main/orcad/orcad-cli-launcher.ts'
- 'src/main/orcad/serve-startup/orcad-cli-launcher.ts'
- '.github/actions/prepare-windows-cli-launcher/**'
- 'config/patches/node-pty*'
- 'config/patches/@vscode__windows-process-tree*'
+2 -3
View File
@@ -221,8 +221,8 @@ jobs:
pnpm-lock.yaml
cloud/pnpm-lock.yaml
# These two tests import the cloud relay workspace directly. Keeping them in one job
# avoids installing and building the same workspace once per unit-test shard.
# This test imports the cloud relay workspace directly. Its dedicated job avoids
# installing and building the same workspace once per unit-test shard.
- name: Install relay integration dependencies
id: relay_dependencies
if: ${{ !cancelled() && steps.root_dependencies.outcome == 'success' }}
@@ -237,5 +237,4 @@ jobs:
ORCA_BACKGROUND_LAUNCH: '1'
run: >-
pnpm exec vitest run --config config/vitest.config.ts
tests/e2e/relay-region-compatibility.unit.test.ts
tests/e2e/relay-region-correction.unit.test.ts
@@ -7,8 +7,7 @@ on:
- 'src/main/ai-vault/scanners/opencode/**'
- 'src/main/ai-vault/scanners/opencode2/**'
- 'src/main/ai-vault/scanners/opencode/reader-transport/opencode-wsl-runtime*'
- 'src/main/native-chat/transcript-opencode*'
- 'src/main/native-chat/transcript-zcode*'
- 'src/main/native-chat/transcript-history/opencode/**'
- 'src/shared/native-chat/native-chat-types.ts'
- 'src/shared/native-chat/transcript/native-chat-transcript-projection*'
- '.github/workflows/zcode-transcript-contract.yml'
@@ -41,4 +40,4 @@ jobs:
bun-version-file: config/.bun-version
no-cache: true
- name: Verify SQLite ordering, legacy readers, and host routing
run: pnpm test src/main/native-chat/transcript-zcode.test.ts src/main/native-chat/transcript-opencode-sqlite-query.test.ts src/main/ai-vault/scanners/zcode/session-scanner-zcode.test.ts src/main/ai-vault/scanners/opencode/reader-transport/opencode-wsl-runtime-preparation.test.ts src/shared/native-chat/transcript/native-chat-transcript-projection.test.ts src/renderer/src/components/native-chat/conversation/projection/structured-agent-session-transcript-order.test.ts
run: pnpm test src/main/native-chat/transcript-history/opencode/transcript-zcode.test.ts src/main/native-chat/transcript-history/opencode/transcript-opencode-sqlite-query.test.ts src/main/ai-vault/scanners/zcode/session-scanner-zcode.test.ts src/main/ai-vault/scanners/opencode/reader-transport/opencode-wsl-runtime-preparation.test.ts src/shared/native-chat/transcript/native-chat-transcript-projection.test.ts src/renderer/src/components/native-chat/conversation/projection/structured-agent-session-transcript-order.test.ts
+58 -1
View File
@@ -114,6 +114,22 @@
"unicorn/throw-new-error": "error"
},
"overrides": [
{
"files": ["src/main/backend/**/*.ts"],
"rules": {
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": ["electron", "electron/*"],
"message": "Backend services use the typed desktop capability bridge; only the shell imports Electron."
}
]
}
]
}
},
{
"files": ["**/*.{js,cjs,mjs,jsx,ts,tsx,mts,cts}"],
"excludeFiles": [
@@ -151,6 +167,47 @@
]
}
},
{
"files": ["src/main/backend/**/*.ts"],
"excludeFiles": [
"**/*.{test,spec}.{js,cjs,mjs,jsx,ts,tsx,mts,cts}",
"**/__tests__/**",
"src/renderer/src/runtime/web-runtime-browser-creation-placement-test-rig.ts"
],
"rules": {
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@linear/sdk",
"allowTypeImports": true,
"message": "Value-importing @linear/sdk hoists its ~2.6MB CJS bundle into the eager top-level require block and defeats the lazy loader. Use `import type` plus loadLinearSdk() from src/main/linear/linear-sdk.ts."
},
{
"name": "ssh2",
"allowTypeImports": true,
"message": "Value-importing ssh2 hoists it into the eager main-process require block and blocks startup (#35731). Use `import type` plus loadSsh2() from src/main/ssh/ssh2-module-load.ts."
}
],
"patterns": [
{
"regex": "(?i)(?:^|[/\\\\])(?:web-session-tabs-sync-test-harness|web-runtime-browser-creation-placement-test-rig)(?:\\.[cm]?[jt]sx?)?(?:[?#].*)?$",
"message": "This module is unit-test support. Import the production web-session owner instead."
},
{
"regex": "\\.fixture(?:\\.tsx?)?(?:[?#].*)?$",
"message": "Component fixtures are dev-only and load through the fixture explorer's import.meta.glob; importing one would ship it in a production bundle."
},
{
"group": ["electron", "electron/*"],
"message": "Backend services use the typed desktop capability bridge; only the shell imports Electron."
}
]
}
]
}
},
{
"files": ["src/renderer/src/**/*.{ts,tsx}"],
"rules": {
@@ -251,7 +308,7 @@
"ignorePatterns": [
"src/main/acp/generated/acp-protocol.generated.ts",
"src/shared/rpc-contract/rpc-params-catalog.generated.ts",
"src/cli/bundled-skill-guides.ts",
"src/cli/skills/bundled-skill-guides.ts",
"resources/plugins/declarative/*.mjs",
"**/node_modules",
"**/dist",
+1 -1
View File
@@ -8,7 +8,7 @@ cask "orca" do
url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg"
name "Orca"
desc "IDE for orchestrating AI coding agents across terminals and worktrees"
homepage "https://onorca.dev/"
homepage "https://orca.dev/"
livecheck do
url :url
+1 -1
View File
@@ -8,7 +8,7 @@ cask "orca@rc" do
url "https://github.com/stablyai/orca/releases/download/v#{version}/orca-macos-#{arch}.dmg"
name "Orca RC"
desc "IDE for orchestrating AI coding agents across terminals and worktrees"
homepage "https://onorca.dev/"
homepage "https://orca.dev/"
livecheck do
url "https://github.com/stablyai/orca"
+37 -37
View File
@@ -1,5 +1,5 @@
<h1 align="center">
<a href="https://onOrca.dev"><img src="resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
<a href="https://orca.dev"><img src="resources/build/icon.png" alt="Orca" width="64" valign="middle" /></a> Orca
</h1>
<p align="center">
@@ -20,7 +20,7 @@
Run Claude Code, Codex, and any other coding agent in parallel, each in its own worktree. Chat or terminal, on your laptop or in the cloud, with diffs, a browser, and a CLI in one app.
</p>
<h3 align="center"><a href="https://onorca.dev/download"><ins>Download Orca</ins></a></h3>
<h3 align="center"><a href="https://orca.dev/download"><ins>Download Orca</ins></a></h3>
<p align="center">
<img src="docs/assets/readme-hero.jpg" alt="Orca desktop app with a coordinator agent running worker agents in parallel workspaces, and the Orca mobile app in the corner" width="960" />
@@ -36,11 +36,11 @@
Run any agent in Chat UI or as the raw CLI, and move a conversation between the two without losing context.
[Docs →](https://www.onorca.dev/docs/agents/native-chat)
[Docs →](https://orca.dev/docs/agents/native-chat)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/agents/native-chat"><picture><source srcset="docs/assets/readme-features/chat-or-terminal.webp" type="image/webp"><img src="docs/assets/readme-features/chat-or-terminal.poster.jpg" alt="The same agent conversation in Chat UI and in the terminal, side by side" width="100%" /></picture></a>
<a href="https://orca.dev/docs/agents/native-chat"><picture><source srcset="docs/assets/readme-features/chat-or-terminal.webp" type="image/webp"><img src="docs/assets/readme-features/chat-or-terminal.poster.jpg" alt="The same agent conversation in Chat UI and in the terminal, side by side" width="100%" /></picture></a>
</td>
</tr>
<tr>
@@ -50,11 +50,11 @@ Run any agent in Chat UI or as the raw CLI, and move a conversation between the
Hand a big task to one agent. It splits the work, starts worker agents in their own workspaces, and brings their results back to you.
[Docs →](https://www.onorca.dev/docs/cli/orchestration)
[Docs →](https://orca.dev/docs/cli/orchestration)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/orchestration"><picture><source srcset="docs/assets/readme-features/orchestration.webp" type="image/webp"><img src="docs/assets/readme-features/orchestration.poster.jpg" alt="A coordinator agent splitting a task between two worker agents and reporting their results" width="100%" /></picture></a>
<a href="https://orca.dev/docs/cli/orchestration"><picture><source srcset="docs/assets/readme-features/orchestration.webp" type="image/webp"><img src="docs/assets/readme-features/orchestration.poster.jpg" alt="A coordinator agent splitting a task between two worker agents and reporting their results" width="100%" /></picture></a>
</td>
</tr>
<tr>
@@ -64,11 +64,11 @@ Hand a big task to one agent. It splits the work, starts worker agents in their
Pair a Remote Orca Server, work on any SSH host, or give a worktree its own cloud VM. Your agents keep running when your laptop sleeps.
[Docs →](https://www.onorca.dev/docs/ways-to-run)
[Docs →](https://orca.dev/docs/ways-to-run)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/ways-to-run"><picture><source srcset="docs/assets/readme-features/remote.webp" type="image/webp"><img src="docs/assets/readme-features/remote.poster.jpg" alt="Choosing which machine a new worktree runs on, from this computer to an SSH host or a per-workspace environment" width="100%" /></picture></a>
<a href="https://orca.dev/docs/ways-to-run"><picture><source srcset="docs/assets/readme-features/remote.webp" type="image/webp"><img src="docs/assets/readme-features/remote.poster.jpg" alt="Choosing which machine a new worktree runs on, from this computer to an SSH host or a per-workspace environment" width="100%" /></picture></a>
</td>
</tr>
<tr>
@@ -78,11 +78,11 @@ Pair a Remote Orca Server, work on any SSH host, or give a worktree its own clou
Pair Orca with the mobile app to get pinged when an agent needs you, reply in chat, and keep work moving on any of your machines when you are away from your desk.
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://www.onorca.dev/download/android) · [Docs →](https://www.onorca.dev/docs/mobile)
[iOS App Store](https://apps.apple.com/us/app/orca-ide/id6766130217) · [Android APK](https://orca.dev/download/android) · [Docs →](https://orca.dev/docs/mobile)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/mobile"><picture><source srcset="docs/assets/readme-features/mobile-companion.webp" type="image/webp"><img src="docs/assets/readme-features/mobile-companion.poster.jpg" alt="The Orca mobile app getting a notification that Claude needs input, opening the session, and replying so the agent opens the PR" width="100%" /></picture></a>
<a href="https://orca.dev/docs/mobile"><picture><source srcset="docs/assets/readme-features/mobile-companion.webp" type="image/webp"><img src="docs/assets/readme-features/mobile-companion.poster.jpg" alt="The Orca mobile app getting a notification that Claude needs input, opening the session, and replying so the agent opens the PR" width="100%" /></picture></a>
</td>
</tr>
<tr>
@@ -92,11 +92,11 @@ Pair Orca with the mobile app to get pinged when an agent needs you, reply in ch
Review diffs, leave notes on exact changed lines, and send focused feedback back to an agent.
[Docs →](https://www.onorca.dev/docs/review/annotate-ai-diff)
[Docs →](https://orca.dev/docs/review/annotate-ai-diff)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/annotate-ai-diff"><picture><source srcset="docs/assets/readme-features/annotate-ai-diffs.webp" type="image/webp"><img src="docs/assets/readme-features/annotate-ai-diffs.poster.jpg" alt="A note on a changed diff line sent back to the agent" width="100%" /></picture></a>
<a href="https://orca.dev/docs/review/annotate-ai-diff"><picture><source srcset="docs/assets/readme-features/annotate-ai-diffs.webp" type="image/webp"><img src="docs/assets/readme-features/annotate-ai-diffs.poster.jpg" alt="A note on a changed diff line sent back to the agent" width="100%" /></picture></a>
</td>
</tr>
<tr>
@@ -106,11 +106,11 @@ Review diffs, leave notes on exact changed lines, and send focused feedback back
Draft commit messages and pull requests from the diff, then send failing checks or PR review comments back to an agent to fix.
[Docs →](https://www.onorca.dev/docs/review/commit-push)
[Docs →](https://orca.dev/docs/review/commit-push)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/commit-push"><picture><source srcset="docs/assets/readme-features/git-integration.webp" type="image/webp"><img src="docs/assets/readme-features/git-integration.poster.jpg" alt="Drafting a commit and pull request, then sending a review comment back to the agent" width="100%" /></picture></a>
<a href="https://orca.dev/docs/review/commit-push"><picture><source srcset="docs/assets/readme-features/git-integration.webp" type="image/webp"><img src="docs/assets/readme-features/git-integration.poster.jpg" alt="Drafting a commit and pull request, then sending a review comment back to the agent" width="100%" /></picture></a>
</td>
</tr>
<tr>
@@ -120,11 +120,11 @@ Draft commit messages and pull requests from the diff, then send failing checks
Quickly split tasks into isolated environments, so multiple agents can work side by side without interfering with each other.
[Docs →](https://www.onorca.dev/docs/model/worktrees)
[Docs →](https://orca.dev/docs/model/worktrees)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/worktrees"><picture><source srcset="docs/assets/readme-features/parallel-worktrees.webp" type="image/webp"><img src="docs/assets/readme-features/parallel-worktrees.poster.jpg" alt="Creating a new worktree with its own agent next to other running worktrees" width="100%" /></picture></a>
<a href="https://orca.dev/docs/model/worktrees"><picture><source srcset="docs/assets/readme-features/parallel-worktrees.webp" type="image/webp"><img src="docs/assets/readme-features/parallel-worktrees.poster.jpg" alt="Creating a new worktree with its own agent next to other running worktrees" width="100%" /></picture></a>
</td>
</tr>
<tr>
@@ -134,11 +134,11 @@ Quickly split tasks into isolated environments, so multiple agents can work side
Click any element in Orca's browser to copy its HTML, styles, and selector, then paste it into your agent's chat.
[Docs →](https://www.onorca.dev/docs/browser/design-mode)
[Docs →](https://orca.dev/docs/browser/design-mode)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/browser/design-mode"><picture><source srcset="docs/assets/readme-features/design-mode.webp" type="image/webp"><img src="docs/assets/readme-features/design-mode.poster.jpg" alt="Picking a button in Orca's browser and sending it to the agent's chat" width="100%" /></picture></a>
<a href="https://orca.dev/docs/browser/design-mode"><picture><source srcset="docs/assets/readme-features/design-mode.webp" type="image/webp"><img src="docs/assets/readme-features/design-mode.poster.jpg" alt="Picking a button in Orca's browser and sending it to the agent's chat" width="100%" /></picture></a>
</td>
</tr>
<tr>
@@ -148,11 +148,11 @@ Click any element in Orca's browser to copy its HTML, styles, and selector, then
See your GitHub and Linear tasks in one place, and start a workspace for each when you're ready to build.
[Docs →](https://www.onorca.dev/docs/review/linear)
[Docs →](https://orca.dev/docs/review/linear)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/review/linear"><picture><source srcset="docs/assets/readme-features/github-linear-tasks.webp" type="image/webp"><img src="docs/assets/readme-features/github-linear-tasks.poster.jpg" alt="GitHub and Linear issues in Orca, with a workspace started from a Linear issue" width="100%" /></picture></a>
<a href="https://orca.dev/docs/review/linear"><picture><source srcset="docs/assets/readme-features/github-linear-tasks.webp" type="image/webp"><img src="docs/assets/readme-features/github-linear-tasks.poster.jpg" alt="GitHub and Linear issues in Orca, with a workspace started from a Linear issue" width="100%" /></picture></a>
</td>
</tr>
<tr>
@@ -162,11 +162,11 @@ See your GitHub and Linear tasks in one place, and start a workspace for each wh
Anything you can do in Orca, your agents can do too: create worktrees, start workers, drive the browser and desktop apps, and schedule automations, all from the command line.
[Docs →](https://www.onorca.dev/docs/cli/overview)
[Docs →](https://orca.dev/docs/cli/overview)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/cli/overview"><picture><source srcset="docs/assets/readme-features/orca-cli.webp" type="image/webp"><img src="docs/assets/readme-features/orca-cli.poster.jpg" alt="An agent running orca worktree create to start a worker in a new worktree" width="100%" /></picture></a>
<a href="https://orca.dev/docs/cli/overview"><picture><source srcset="docs/assets/readme-features/orca-cli.webp" type="image/webp"><img src="docs/assets/readme-features/orca-cli.poster.jpg" alt="An agent running orca worktree create to start a worker in a new worktree" width="100%" /></picture></a>
</td>
</tr>
<tr>
@@ -176,11 +176,11 @@ Anything you can do in Orca, your agents can do too: create worktrees, start wor
Themes, editors, languages, and panels from the community, installed in a click.
[Browse plugins →](https://www.onorca.dev/plugins)
[Browse plugins →](https://orca.dev/plugins)
</td>
<td width="50%">
<a href="https://www.onorca.dev/plugins"><picture><source srcset="docs/assets/readme-features/plugins.webp" type="image/webp"><img src="docs/assets/readme-features/plugins.poster.jpg" alt="Installing the Orca Midnight theme from the plugin catalog" width="100%" /></picture></a>
<a href="https://orca.dev/plugins"><picture><source srcset="docs/assets/readme-features/plugins.webp" type="image/webp"><img src="docs/assets/readme-features/plugins.poster.jpg" alt="Installing the Orca Midnight theme from the plugin catalog" width="100%" /></picture></a>
</td>
</tr>
<tr>
@@ -190,25 +190,25 @@ Themes, editors, languages, and panels from the community, installed in a click.
Put agents, terminals, browsers, and diffs side by side in split panes shaped to the task.
[Docs →](https://www.onorca.dev/docs/model/tabs-panes-splits)
[Docs →](https://orca.dev/docs/model/tabs-panes-splits)
</td>
<td width="50%">
<a href="https://www.onorca.dev/docs/model/tabs-panes-splits"><picture><source srcset="docs/assets/readme-features/split-anything.webp" type="image/webp"><img src="docs/assets/readme-features/split-anything.poster.jpg" alt="Dragging tabs into split panes with an agent, browser, terminal, and diff" width="100%" /></picture></a>
<a href="https://orca.dev/docs/model/tabs-panes-splits"><picture><source srcset="docs/assets/readme-features/split-anything.webp" type="image/webp"><img src="docs/assets/readme-features/split-anything.poster.jpg" alt="Dragging tabs into split panes with an agent, browser, terminal, and diff" width="100%" /></picture></a>
</td>
</tr>
</table>
**Also in the box:**
- **[Drag Files to Agents](https://www.onorca.dev/docs/editing/file-explorer)**: drag files from the explorer, or images from your desktop, straight into an agent's prompt.
- **[Jump Anywhere](https://www.onorca.dev/docs/model/quick-open)**: one shortcut jumps to any workspace or tab across every repo and host.
- **[Rich Previews](https://www.onorca.dev/docs/editing/viewers)**: open images, PDFs, and Markdown right in the workspace, next to the agent that made them.
- **[Mobile Emulator](https://www.onorca.dev/docs/cli/overview#mobile-emulator)**: run an iOS Simulator or Android emulator beside your agent, and let it tap, type, and check its own work.
- **[Dictation](https://www.onorca.dev/docs/settings#voice)**: press a shortcut and talk. Orca types what you say into the agent's prompt or terminal.
- **[Usage &amp; Accounts](https://www.onorca.dev/docs/agents/usage-tracking)**: see how much of your agent limits you've used and when they reset, and switch accounts without logging out.
- **[Automations](https://www.onorca.dev/docs/cli/automations)**: schedule prompts to run on any repo: nightly dependency bumps, weekly triage, flaky-test sweeps.
- **[Computer Use](https://www.onorca.dev/docs/cli/computer-use)**: when there's no CLI or API, agents can see and drive desktop apps and external browsers.
- **[Drag Files to Agents](https://orca.dev/docs/editing/file-explorer)**: drag files from the explorer, or images from your desktop, straight into an agent's prompt.
- **[Jump Anywhere](https://orca.dev/docs/model/quick-open)**: one shortcut jumps to any workspace or tab across every repo and host.
- **[Rich Previews](https://orca.dev/docs/editing/viewers)**: open images, PDFs, and Markdown right in the workspace, next to the agent that made them.
- **[Mobile Emulator](https://orca.dev/docs/cli/overview#mobile-emulator)**: run an iOS Simulator or Android emulator beside your agent, and let it tap, type, and check its own work.
- **[Dictation](https://orca.dev/docs/settings#voice)**: press a shortcut and talk. Orca types what you say into the agent's prompt or terminal.
- **[Usage &amp; Accounts](https://orca.dev/docs/agents/usage-tracking)**: see how much of your agent limits you've used and when they reset, and switch accounts without logging out.
- **[Automations](https://orca.dev/docs/cli/automations)**: schedule prompts to run on any repo: nightly dependency bumps, weekly triage, flaky-test sweeps.
- **[Computer Use](https://orca.dev/docs/cli/computer-use)**: when there's no CLI or API, agents can see and drive desktop apps and external browsers.
- **And many, many more**: we ship daily, so this list is perpetually behind. The [changelog](https://github.com/stablyai/orca/releases) is the real feature list.
---
@@ -261,7 +261,7 @@ Works with **any CLI agent** — if it runs in a terminal, it runs in Orca.
### Desktop — macOS, Windows, Linux
- **[Download from onOrca.dev](https://onorca.dev/download)**
- **[Download from orca.dev](https://orca.dev/download)**
- Or grab a build directly: [macOS Apple Silicon](https://github.com/stablyai/orca/releases/latest/download/orca-macos-arm64.dmg) · [macOS Intel](https://github.com/stablyai/orca/releases/latest/download/orca-macos-x64.dmg) · [Windows (.exe)](https://github.com/stablyai/orca/releases/latest/download/orca-windows-setup.exe) · [Linux AppImage](https://github.com/stablyai/orca/releases/latest/download/orca-linux.AppImage) · [All builds](https://github.com/stablyai/orca/releases/latest)
_Or via a package manager:_
@@ -284,7 +284,7 @@ yay -S stably-orca-bin
Pair with your desktop app to monitor and steer your agents from your phone.
- **iOS:** [Download on the App Store](https://apps.apple.com/us/app/orca-ide/id6766130217)
- **Android:** [Download APK](https://www.onorca.dev/download/android) · [Install guide](https://www.onorca.dev/docs/android-apk)
- **Android:** [Download APK](https://orca.dev/download/android) · [Install guide](https://orca.dev/docs/android-apk)
---
@@ -297,7 +297,7 @@ Pair with your desktop app to monitor and steer your agents from your phone.
<img src="docs/assets/wechat-qr.jpg" alt="WeChat QR code for the Orca community" width="160" />
- **Feedback &amp; Ideas:** We ship fast. Missing something? [Request a new feature](https://github.com/stablyai/orca/issues).
- **Privacy:** See the [privacy &amp; telemetry docs](https://www.onorca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
- **Privacy:** See the [privacy &amp; telemetry docs](https://orca.dev/docs/telemetry) for what anonymous usage data Orca collects and how to opt out.
- **Show Support:** [Star](https://github.com/stablyai/orca) this repo to follow along with our daily ships.
---
+2 -2
View File
@@ -15,11 +15,11 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@hono/node-server": "^2.1.5",
"@hono/node-server": "^2.1.6",
"@orca-cloud/cloud-database": "workspace:*",
"@orca-cloud/push-contract": "workspace:*",
"google-auth-library": "^11.3.1",
"hono": "^4.13.13",
"hono": "^4.13.17",
"tweetnacl": "^1.0.3",
"zod": "^4.6.7"
},
-100
View File
@@ -1,100 +0,0 @@
import { generateKeyPairSync } from 'node:crypto'
import { expect, it } from 'vitest'
import { ApnsClient } from './apns-client.js'
import type { ApnsRequest, ApnsResponse } from './apns-http2-transport.js'
import type { ApnsCredentials } from './config.js'
import { buildPushDelivery } from './push-delivery-message.js'
const HOST = 'abcdefghijklmnop'
function credentials(): ApnsCredentials {
const { privateKey } = generateKeyPairSync('ec', {
namedCurve: 'P-256',
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
})
return { keyPem: privateKey, keyId: 'ABCDE12345', teamId: 'TEAM123456' }
}
function delivery(now = Date.now()) {
return buildPushDelivery({
expiresAt: now + 300_000,
registrationId: 'reg-1',
hostFingerprint: HOST,
notification: {
notificationId: 'note-1',
notificationSeq: 7,
notificationEpoch: 'epoch-1',
source: 'agent-task-complete',
agentState: 'needs-input',
title: 'Agent needs input',
body: 'Waiting on your answer',
worktreeId: 'wt-1'
}
})
}
function fakeTransport(response: ApnsResponse) {
const requests: ApnsRequest[] = []
return {
requests,
transport: async (request: ApnsRequest): Promise<ApnsResponse> => {
requests.push(request)
return response
}
}
}
it('does not collapse background dismissals with visible alerts', async () => {
const fake = fakeTransport({ status: 200, body: '' })
const apns = new ApnsClient({
topic: 'test',
credentials: credentials(),
transport: fake.transport
})
const alert = delivery()
await apns.send(
{ ...alert, orca: { ...alert.orca, kind: 'dismiss' } },
{
token: 'test',
apnsEnvironment: 'sandbox'
}
)
expect(fake.requests[0]?.headers).not.toHaveProperty('apns-collapse-id')
expect(fake.requests[0]?.headers).toMatchObject({
'apns-push-type': 'background',
'apns-priority': '5'
})
expect(JSON.parse(fake.requests[0]!.body).aps).toEqual({ 'content-available': 1 })
})
it('keeps the absolute deadline across retries and refuses expired delivery', async () => {
let now = 1_700_000_000_000
const fake = fakeTransport({ status: 503, body: '{}' })
const client = new ApnsClient({
topic: 'test',
credentials: credentials(),
now: () => now,
transport: fake.transport
})
const pending = delivery(now)
const device = { token: 'test', apnsEnvironment: 'sandbox' as const }
await client.send(pending, device)
now += 60_000
await client.send(pending, device)
expect(fake.requests.map((request) => request.headers['apns-expiration'])).toEqual([
String(pending.expiresAt / 1000),
String(pending.expiresAt / 1000)
])
now = pending.expiresAt
await expect(client.send(pending, device)).resolves.toEqual({
status: 'error',
reason: 'expired'
})
expect(fake.requests).toHaveLength(2)
})
@@ -1,45 +0,0 @@
import { EventEmitter } from 'node:events'
import { expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
connect: vi.fn(),
read: vi.fn(async () => ({ status: 200, body: '' }))
}))
vi.mock('node:http2', async (original) => ({
...(await original<typeof import('node:http2')>()),
connect: mocks.connect
}))
vi.mock('./apns-stream-response.js', () => ({ readApnsStreamResponse: mocks.read }))
import { createApnsHttp2Transport } from './apns-http2-transport.js'
it('keeps the replacement cached when the draining session closes later', async () => {
const sessions: Array<
EventEmitter & {
closed: boolean
destroyed: boolean
request: ReturnType<typeof vi.fn>
close: ReturnType<typeof vi.fn>
}
> = []
mocks.connect.mockImplementation(() => {
const session = Object.assign(new EventEmitter(), {
closed: false,
destroyed: false,
request: vi.fn(() => ({})),
close: vi.fn()
})
sessions.push(session)
return session
})
const transport = createApnsHttp2Transport()
const request = { host: 'api.push.apple.com', path: '/synthetic', headers: {}, body: '{}' }
await transport(request)
sessions[0]!.closed = true
await transport(request)
sessions[0]!.emit('close')
sessions[0]!.emit('error', new Error('old-session'))
await transport(request)
expect(sessions).toHaveLength(2)
expect(sessions[1]!.request).toHaveBeenCalledTimes(2)
transport.close()
expect(sessions[1]!.close).toHaveBeenCalledOnce()
})
@@ -1,93 +0,0 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { Hono } from 'hono'
import { describe, expect, it } from 'vitest'
import { ClientIpRateLimiter, clientIpRateLimit } from './client-ip-rate-limit.js'
const CAPACITY = PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp
function limiterApp(limiter: ClientIpRateLimiter, trustedProxyHops = 0): Hono {
const app = new Hono()
app.post('/probe', clientIpRateLimit(limiter, { trustedProxyHops }), (context) =>
context.json({ ok: true })
)
return app
}
describe('client ip rate limiter', () => {
it('buckets on the last forwarded hop, the only one the platform appended', async () => {
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000 }))
for (let index = 0; index < CAPACITY; index++) {
await app.request('/probe', {
method: 'POST',
headers: { 'x-forwarded-for': `10.0.0.${index}, 203.0.113.7` }
})
}
const sameClient = await app.request('/probe', {
method: 'POST',
headers: { 'x-forwarded-for': '10.9.9.9, 203.0.113.7' }
})
expect(sameClient.status).toBe(429)
const otherClient = await app.request('/probe', {
method: 'POST',
headers: { 'x-forwarded-for': '10.0.0.1, 198.51.100.9' }
})
expect(otherClient.status).toBe(200)
})
it('skips the configured trusted proxies when counting from the right', async () => {
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }), 1)
// <client>, <cloud run>, <load balancer>: one trusted hop after the client.
const headers = { 'x-forwarded-for': '203.0.113.7, 10.0.0.1' }
expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200)
expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(429)
expect(
(
await app.request('/probe', {
method: 'POST',
headers: { 'x-forwarded-for': '198.51.100.9, 10.0.0.1' }
})
).status
).toBe(200)
})
it('trusts nothing when the header is shorter than the configured depth', async () => {
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }), 1)
// Only one hop, so the client value the depth points at does not exist.
const headers = { 'x-forwarded-for': '203.0.113.7' }
expect((await app.request('/probe', { method: 'POST', headers })).status).toBe(200)
expect(
(
await app.request('/probe', {
method: 'POST',
headers: { 'x-forwarded-for': '198.51.100.9' }
})
).status
).toBe(429)
})
it('ignores spoofable x-real-ip and uses a single shared bucket', async () => {
const app = limiterApp(new ClientIpRateLimiter({ now: () => 1_000, capacity: 1 }))
expect(
(await app.request('/probe', { method: 'POST', headers: { 'x-real-ip': '198.51.100.9' } }))
.status
).toBe(200)
expect(
(await app.request('/probe', { method: 'POST', headers: { 'x-real-ip': '203.0.113.7' } }))
.status
).toBe(429)
expect((await app.request('/probe', { method: 'POST' })).status).toBe(429)
expect((await app.request('/probe', { method: 'POST' })).status).toBe(429)
})
})
+1 -1
View File
@@ -1,4 +1,4 @@
import { PUSH_DEFAULTS } from '@orca-cloud/push-contract'
import { PUSH_DEFAULTS } from '@orca-cloud/push-contract/push-limits'
import { z } from 'zod'
export const PUSH_DATABASE_POOL_MAX = 10
@@ -1,4 +1,4 @@
import type { PushDatabase } from './push-database.js'
import type { CloudDatabase as PushDatabase } from '@orca-cloud/cloud-database/cloud-database'
// Caps worker and prune traffic at poolMax - 1 connections so request-path lookups always find one.
export function reserveRequestConnection(database: PushDatabase, poolMax: number): PushDatabase {
@@ -0,0 +1,21 @@
import {
openCloudDatabase,
openInMemoryCloudDatabase,
type CloudDatabase,
type OpenCloudDatabaseInput
} from '@orca-cloud/cloud-database/cloud-database'
import { pushSchemaStatements } from './push-schema.js'
export async function openPushDatabase(
input: Omit<OpenCloudDatabaseInput, 'service' | 'schemaStatements'>
): Promise<CloudDatabase> {
return await openCloudDatabase({
...input,
service: 'push',
schemaStatements: pushSchemaStatements()
})
}
export async function openInMemoryPushDatabase(): Promise<CloudDatabase> {
return await openInMemoryCloudDatabase(pushSchemaStatements())
}
@@ -0,0 +1,158 @@
import { afterEach, expect, it } from 'vitest'
import type { CloudDatabase as PushDatabase } from '@orca-cloud/cloud-database/cloud-database'
import { DELIVERY_LEASE_MS, DurablePushStore } from './durable-push-store.js'
import {
cleanupDurablePushFixtures,
DEVICE_HEAD_SQL,
durablePushTestDatabaseUrl,
fixture,
notification,
pauseAfter,
within
} from './durable-push-store.test-fixture.js'
afterEach(cleanupDurablePushFixtures)
it.skipIf(!durablePushTestDatabaseUrl)(
'rolls back a failed claim and admits a fourth waiter while the other claims stay held',
async () => {
const { db, store, clock, advance } = await fixture({ poolMax: 6 })
await store.accept('host', 'phone-a', notification(1))
advance(1)
await store.accept('host', 'phone-b', notification(2))
advance(1)
await store.accept('host', 'phone-c', notification(3))
const held = [pauseAfter(db), pauseAfter(db), pauseAfter(db)] as const
let admitted = 0
const failing: PushDatabase = {
...held[0].wrapped,
transaction: (run) => {
const position = admitted++
return (held[position]?.wrapped ?? db).transaction(async (tx) => {
const result = await run(tx)
if (position === 0) throw new Error('claim_commit_failed')
return result
})
}
}
const limited = new DurablePushStore(db, clock, failing)
const first = limited.claim()
const rejected = expect(first).rejects.toThrow('claim_commit_failed')
await held[0].atCandidate
const second = limited.claim()
await held[1].atCandidate
const third = limited.claim()
await held[2].atCandidate
const next = limited.claim()
try {
held[0].release()
await rejected
const delivery = await within(next, 2_000)
expect(delivery?.notification.notificationSeq).toBe(1)
expect(delivery?.attempts).toBe(1)
if (!delivery) throw new Error('queued_claim_missing')
await limited.finish(delivery)
expect(await limited.pendingCount('phone-a')).toBe(0)
} finally {
for (const pause of held) pause.release()
await Promise.allSettled([first, second, third, next])
}
expect((await second)?.registrationId).toBe('phone-b')
expect((await third)?.registrationId).toBe('phone-c')
}
)
it('leases exactly one delivery per device across concurrent claimers', async () => {
const { db, store, clock, advance } = await fixture()
for (let seq = 1; seq <= 5; seq++) {
await store.accept('host', 'phone', notification(seq))
advance(1)
}
const claims = await Promise.all(
Array.from({ length: 8 }, () => new DurablePushStore(db, clock).claim())
)
const leased = claims.filter((claim) => claim !== null)
expect(leased).toHaveLength(1)
expect(leased[0]?.notification.notificationSeq).toBe(1)
})
it.skipIf(!durablePushTestDatabaseUrl)(
'does not lease the row behind a head another claimer holds',
async () => {
const { db, store, clock, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
advance(1)
await store.accept('host', 'phone', notification(2))
const paused = pauseAfter(db)
const first = new DurablePushStore(paused.wrapped, clock).claim()
await paused.atCandidate
try {
expect(await within(store.claim(), 2_000)).toBeNull()
} finally {
paused.release()
}
expect((await first)?.notification.notificationSeq).toBe(1)
expect(await store.claim()).toBeNull()
}
)
it.skipIf(!durablePushTestDatabaseUrl)(
'claims another device while one claim transaction is still open',
async () => {
const { db, store, clock, advance } = await fixture()
await store.accept('host', 'phone-a', notification(1))
advance(1)
await store.accept('host', 'phone-b', notification(2))
const paused = pauseAfter(db)
const first = new DurablePushStore(paused.wrapped, clock).claim()
await paused.atCandidate
try {
expect((await within(store.claim(), 2_000))?.registrationId).toBe('phone-b')
} finally {
paused.release()
}
expect((await first)?.registrationId).toBe('phone-a')
}
)
it.skipIf(!durablePushTestDatabaseUrl)(
'keeps a device exclusive when an earlier-sorting row lands mid-claim',
async () => {
const { db, store, clock } = await fixture()
await store.accept('host', 'phone', notification(1))
const paused = pauseAfter(db, DEVICE_HEAD_SQL)
const first = new DurablePushStore(paused.wrapped, clock).claim()
await paused.atCandidate
try {
// Another instance with a slower clock queues a row that now sorts first.
await db.query(
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
VALUES ('skewed', 'host', 'phone', 'alert', ?, 'pending', ?, ?, 0, 0, ?)`,
[JSON.stringify(notification(2)), clock() - 10, clock() + 60_000, clock() - 10]
)
expect(await within(store.claim(), 2_000)).toBeNull()
} finally {
paused.release()
}
expect((await first)?.notification.notificationSeq).toBe(1)
}
)
// Only the TTL term keeps the candidate scan off an unpruned backlog; expires_at is not indexed first.
it('frees a device held by a crashed worker once its lease lapses', async () => {
const { db, store, clock, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
advance(1)
await store.accept('host', 'phone', notification(2))
const crashed = (await new DurablePushStore(db, clock).claim())!
advance(DELIVERY_LEASE_MS - 1)
expect(await store.claim()).toBeNull()
advance(1)
const reclaimed = (await store.claim())!
expect(reclaimed.id).toBe(crashed.id)
expect(reclaimed.attempts).toBe(2)
await store.finish(reclaimed)
expect((await store.claim())?.notification.notificationSeq).toBe(2)
})
@@ -0,0 +1,60 @@
import { afterEach, expect, it } from 'vitest'
import {
cleanupDurablePushFixtures,
durablePushTestDatabaseUrl,
fixture,
within
} from './durable-push-store.test-fixture.js'
afterEach(cleanupDurablePushFixtures)
const retentionTables = [
{
table: 'push_dismissed_events',
columns: 'host_fingerprint, notification_epoch, notification_id, notification_seq, created_at',
values: "'host', 'epoch-' || n, 'notification', n, created_at"
},
{
table: 'push_event_recipients',
columns: 'event_id, registration_id, created_at',
values: "'event', 'phone-' || n, created_at"
},
{
table: 'push_events',
columns: 'event_id, host_fingerprint, kind, fingerprint, created_at, expires_at',
values: "'event-' || n, 'host', 'alert', 'fingerprint', created_at, created_at"
}
] as const
it.skipIf(!durablePushTestDatabaseUrl).each(retentionTables)(
'skips a locked oldest $table row until its transaction releases it',
async ({ table, columns, values }) => {
const { db, store, clock } = await fixture()
const cutoff = clock() - 24 * 60 * 60_000
await db.query(
`INSERT INTO ${table} (${columns})
WITH rows(n, created_at) AS (VALUES (1, ?::bigint), (2, ?::bigint), (3, ?::bigint))
SELECT ${values} FROM rows`,
[cutoff - 2, cutoff - 1, cutoff]
)
const locked = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const holding = db.transaction(async (tx) => {
await tx.query(`SELECT * FROM ${table} WHERE created_at = ? FOR UPDATE`, [cutoff - 2])
locked.resolve()
await release.promise
})
try {
await locked.promise
expect(await within(store.prune(), 1000)).toEqual({ deleted: 1, saturated: false })
const preserved = await db.query(`SELECT created_at FROM ${table} ORDER BY created_at`)
expect(preserved.map((row) => Number(row.created_at))).toEqual([cutoff - 2, cutoff])
} finally {
release.resolve()
await holding
}
expect(await store.prune()).toEqual({ deleted: 1, saturated: false })
const preserved = await db.query(`SELECT created_at FROM ${table}`)
expect(preserved.map((row) => Number(row.created_at))).toEqual([cutoff])
}
)
@@ -1,7 +1,8 @@
import { randomUUID } from 'node:crypto'
import pg from 'pg'
import type { PushNotification } from '@orca-cloud/push-contract'
import { openInMemoryPushDatabase, openPushDatabase, type PushDatabase } from './push-database.js'
import type { PushNotification } from '@orca-cloud/push-contract/send-messages'
import { openInMemoryPushDatabase, openPushDatabase } from '../database/push-database.js'
import type { CloudDatabase as PushDatabase } from '@orca-cloud/cloud-database/cloud-database'
import { DurablePushStore } from './durable-push-store.js'
export const durablePushTestDatabaseUrl =
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from 'vitest'
import { openPushDatabase } from './push-database.js'
import { openPushDatabase } from '../database/push-database.js'
import { DurablePushStore, DELIVERY_LEASE_MS } from './durable-push-store.js'
import {
cleanupDurablePushFixtures,
@@ -89,8 +89,6 @@ describe('durable push acceptance', () => {
await store.finish(fresh!)
})
it('rolls quota and payload back together if persistence fails', async () => {
const { db, store } = await fixture()
await db.query('ALTER TABLE push_delivery_batches RENAME TO push_delivery_batches_unavailable')
@@ -1,10 +1,14 @@
import { isDismissedAlert, reconcileQueuedDismissal } from './push-queued-dismissal.js'
import { parsePushDeliveryPayload } from './push-delivery-payload.js'
import { createHash, randomUUID } from 'node:crypto'
import { PUSH_LIMITS, type PushNotification } from '@orca-cloud/push-contract'
import { PUSH_LIMITS } from '@orca-cloud/push-contract/push-limits'
import type { PushNotification } from '@orca-cloud/push-contract/send-messages'
import { WORKER_DRAINS } from './push-worker-concurrency.js'
import type { PushDatabase, SqlRow } from './push-database.js'
import { limitPushDatabaseConcurrency } from './push-background-database.js'
import type {
CloudDatabase as PushDatabase,
SqlRow
} from '@orca-cloud/cloud-database/cloud-database'
import { limitPushDatabaseConcurrency } from '../database/push-background-database.js'
const RETENTION_MS = 24 * 60 * 60_000
// accept() caps expires_at at due_at + TTL, so older due_at is expired: scans skip an unpruned backlog.
@@ -0,0 +1,164 @@
import { afterEach, expect, it, vi } from 'vitest'
import type { PushNotification } from '@orca-cloud/push-contract/send-messages'
import { DurablePushStore } from './durable-push-store.js'
import { DurablePushWorker } from './durable-push-worker.js'
import { PushDispatcher } from './push-dispatcher.js'
import { PushDeviceRegistryStore } from '../devices/device-registry-store.js'
import { openInMemoryPushDatabase } from '../database/push-database.js'
import type { PushDelivery } from './push-delivery-message.js'
import type { PushProviderOutcome } from '../providers/push-provider-outcome.js'
import { pauseAfter, within } from './durable-push-store.test-fixture.js'
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) await cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
const note = (seq: number, overrides: Partial<PushNotification> = {}): PushNotification => ({
notificationId: `note-${seq}`,
notificationSeq: seq,
notificationEpoch: 'epoch',
source: 'agent-task-complete',
agentState: 'finished',
title: 'Done',
body: 'Finished task',
...overrides
})
async function fixture() {
const db = await openInMemoryPushDatabase()
let time = 1_000_000
const now = () => time
const store = new DurablePushStore(db, now)
const devices = new PushDeviceRegistryStore(db, now)
const device = await devices.upsert({
hostFingerprint: 'host',
deviceId: 'phone',
platform: 'android',
token: 'test-token'
})
if (!device.ok) throw new Error('registration failed')
const send = vi.fn(async (_delivery: PushDelivery): Promise<PushProviderOutcome> => ({
status: 'sent'
}))
const onRetry = vi.fn()
const dispatcher = new PushDispatcher({ devices, fcm: { send } as never })
const worker = new DurablePushWorker(store, dispatcher, { now, onRetry })
cleanups.push(async () => {
await worker.stop()
await db.close()
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
return {
db,
store,
devices,
worker,
dispatcher,
send,
onRetry,
now,
registrationId: device.registrationId,
accept: (notification: PushNotification) =>
store.accept('host', device.registrationId, notification),
advance: (ms: number) => {
time += ms
}
}
}
it('rechecks the device before a persisted retry and does not send after unregistration', async () => {
const h = await fixture()
h.send.mockResolvedValue({ status: 'error', reason: 'timeout', retryable: true })
await h.accept(note(1))
await h.worker.runDue()
await h.devices.deleteOwned('host', h.registrationId)
h.advance(3000)
await h.worker.runDue()
expect(h.send).toHaveBeenCalledOnce()
expect(await h.store.pendingCount(h.registrationId)).toBe(0)
})
it('stops queued claim admissions without leasing the remaining device', async () => {
const h = await fixture()
const other = await h.devices.upsert({
hostFingerprint: 'host',
deviceId: 'phone-queued',
platform: 'android',
token: 'queued-token'
})
if (!other.ok) throw new Error('registration failed')
await h.accept(note(1))
h.advance(1)
await h.store.accept('host', other.registrationId, note(2))
const paused = pauseAfter(h.db)
const store = new DurablePushStore(h.db, h.now, paused.wrapped)
const worker = new DurablePushWorker(store, h.dispatcher, { now: h.now })
const pending = worker.runDue()
try {
await paused.atCandidate
const stopping = worker.stop()
paused.release()
await within(Promise.all([pending, stopping]), 2_000)
expect(h.send).toHaveBeenCalledOnce()
expect(h.send.mock.calls[0]?.[0].orca.notificationSeq).toBe(1)
const [remaining] = await h.db.query(
'SELECT attempts, lease_until FROM push_delivery_batches WHERE registration_id = ?',
[other.registrationId]
)
expect(Number(remaining?.attempts)).toBe(0)
expect(Number(remaining?.lease_until)).toBe(0)
} finally {
paused.release()
await worker.stop()
await pending
}
})
it('claims new devices while an earlier provider response is still pending', async () => {
const h = await fixture()
const second = await h.devices.upsert({
hostFingerprint: 'host',
deviceId: 'phone-second',
platform: 'android',
token: 'second-token'
})
const third = await h.devices.upsert({
hostFingerprint: 'host',
deviceId: 'phone-third',
platform: 'android',
token: 'third-token'
})
if (!second.ok || !third.ok) throw new Error('registration failed')
const held = Promise.withResolvers<PushProviderOutcome>()
const entered = Promise.withResolvers<void>()
const secondSent = Promise.withResolvers<void>()
const thirdSent = Promise.withResolvers<void>()
h.send.mockImplementation(async (delivery) => {
if (delivery.orca.notificationSeq === 1) {
entered.resolve()
return await held.promise
}
if (delivery.orca.notificationSeq === 2) secondSent.resolve()
if (delivery.orca.notificationSeq === 3) thirdSent.resolve()
return { status: 'sent' }
})
await h.accept(note(1))
h.worker.start()
h.worker.wake()
try {
await within(entered.promise, 2_000)
await h.store.accept('host', second.registrationId, note(2))
h.worker.wake()
await within(secondSent.promise, 2_000)
await h.store.accept('host', third.registrationId, note(3))
h.worker.wake()
await within(thirdSent.promise, 2_000)
expect(await h.store.pendingCount(h.registrationId)).toBe(1)
expect(h.send).toHaveBeenCalledTimes(3)
} finally {
held.resolve({ status: 'sent' })
await h.worker.stop()
}
})
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import { type PushNotification } from '@orca-cloud/push-contract'
import type { PushNotification } from '@orca-cloud/push-contract/send-messages'
export type PushOrcaData = {
kind?: 'alert' | 'dismiss'
@@ -1,4 +1,4 @@
import type { PushNotification } from '@orca-cloud/push-contract'
import type { PushNotification } from '@orca-cloud/push-contract/send-messages'
export function parsePushDeliveryPayload(payload: string): PushNotification {
const value: unknown = JSON.parse(payload)
@@ -1,9 +1,9 @@
import type { ApnsClient } from './apns-client.js'
import type { PushDeviceRegistryStore } from './device-registry-store.js'
import type { FcmClient } from './fcm-client.js'
import { fingerprintLogPrefix } from './host-fingerprint.js'
import type { ApnsClient } from '../providers/apns/apns-client.js'
import type { PushDeviceRegistryStore } from '../devices/device-registry-store.js'
import type { FcmClient } from '../providers/fcm/fcm-client.js'
import { fingerprintLogPrefix } from '../host-auth/host-fingerprint.js'
import type { PushDelivery } from './push-delivery-message.js'
import type { PushProviderOutcome } from './push-provider-outcome.js'
import type { PushProviderOutcome } from '../providers/push-provider-outcome.js'
export type PushDispatcherOptions = {
devices: PushDeviceRegistryStore
@@ -1,5 +1,5 @@
import type { PushNotification } from '@orca-cloud/push-contract'
import type { PushDatabase } from './push-database.js'
import type { PushNotification } from '@orca-cloud/push-contract/send-messages'
import type { CloudDatabase as PushDatabase } from '@orca-cloud/cloud-database/cloud-database'
import { parsePushDeliveryPayload } from './push-delivery-payload.js'
export async function reconcileQueuedDismissal(
@@ -1,92 +0,0 @@
import { expect, it, vi } from 'vitest'
import { openPushDatabase, type PushDatabase } from './push-database.js'
import { PushDeviceRegistryStore } from './device-registry-store.js'
const databaseUrl = process.env.ORCA_PUSH_TEST_DATABASE_URL
it.skipIf(!databaseUrl)(
'serializes deletion with a registration that has already read its row',
async () => {
if (!process.env.CI && new URL(databaseUrl!).port !== '55440')
throw new Error('isolated_postgres_port_required')
const database = await openPushDatabase({
databaseUrl,
dataDir: '',
poolMax: 4,
applicationName: 'push-delete-race'
})
let release!: () => void
const paused = new Promise<void>((resolve) => {
release = resolve
})
let read = false
let pause = false
const wrapped: PushDatabase = {
dialect: database.dialect,
query: database.query.bind(database),
close: database.close.bind(database),
lockQuotaScope: database.lockQuotaScope.bind(database),
tryLockScope: database.tryLockScope.bind(database),
tryLockSharedScope: database.tryLockSharedScope.bind(database),
transaction: (run) =>
database.transaction((tx) =>
run({
dialect: tx.dialect,
close: tx.close.bind(tx),
transaction: tx.transaction.bind(tx),
lockQuotaScope: tx.lockQuotaScope.bind(tx),
tryLockScope: tx.tryLockScope.bind(tx),
tryLockSharedScope: tx.tryLockSharedScope.bind(tx),
query: async (sql, params) => {
const rows = await tx.query(sql, params)
if (pause && sql.startsWith('SELECT registration_id FROM push_devices')) {
read = true
await paused
}
return rows
}
})
)
}
const devices = new PushDeviceRegistryStore(wrapped)
const input = {
hostFingerprint: 'delete-race-host',
deviceId: 'phone',
platform: 'android' as const,
token: 'synthetic'
}
let registration: Promise<unknown> | undefined
let deletion: Promise<boolean> | undefined
try {
await database.query('DELETE FROM push_devices WHERE host_fingerprint = ?', [
input.hostFingerprint
])
const first = await devices.upsert(input)
if (!first.ok) throw new Error('registration refused')
pause = true
registration = devices.upsert(input)
await vi.waitFor(() => expect(read).toBe(true))
let deleted = false
deletion = devices.deleteOwned(input.hostFingerprint, first.registrationId).then((value) => {
deleted = true
return value
})
await vi.waitFor(async () => {
const rows = await database.query(
"SELECT 1 FROM pg_stat_activity WHERE application_name = 'push-delete-race' AND wait_event_type = 'Lock'"
)
expect(deleted || rows.length > 0).toBe(true)
})
expect(deleted).toBe(false)
release()
expect(await registration).toEqual(first)
expect(await deletion).toBe(true)
} finally {
release()
await Promise.allSettled([registration, deletion])
await database.query('DELETE FROM push_devices WHERE host_fingerprint = ?', [
input.hostFingerprint
])
await database.close()
}
}
)
@@ -1,151 +0,0 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { PushDeviceRegistryStore, type PushDeviceUpsert } from './device-registry-store.js'
import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js'
const OWNER = 'abcdefghijklmnop'
const OTHER = 'ponmlkjihgfedcba'
describe('push device registry store', () => {
let database: PushDatabase
let clock = 1_700_000_000_000
let devices: PushDeviceRegistryStore
beforeEach(async () => {
database = await openInMemoryPushDatabase()
clock = 1_700_000_000_000
devices = new PushDeviceRegistryStore(database, () => clock)
})
afterEach(async () => {
await database.close()
})
async function upsertOk(input: PushDeviceUpsert): Promise<string> {
const result = await devices.upsert(input)
if (!result.ok) throw new Error(`unexpected upsert refusal: ${result.reason}`)
return result.registrationId
}
function androidDevice(deviceId: string): PushDeviceUpsert {
return {
hostFingerprint: OWNER,
deviceId,
platform: 'android',
token: `token-${deviceId}`
}
}
it('keeps one registration per host and device while replacing the token', async () => {
const first = await upsertOk({
hostFingerprint: OWNER,
deviceId: 'device-1',
platform: 'ios',
token: 'a'.repeat(64),
apnsEnvironment: 'sandbox'
})
clock += 1_000
const second = await upsertOk({
hostFingerprint: OWNER,
deviceId: 'device-1',
platform: 'ios',
token: 'b'.repeat(64),
apnsEnvironment: 'production'
})
expect(second).toBe(first)
const registration = await devices.findById(first)
expect(registration).toMatchObject({
token: 'b'.repeat(64),
apnsEnvironment: 'production',
dead: false
})
expect(await devices.list(OWNER)).toHaveLength(1)
})
it('revives a registration that a re-registered token replaces', async () => {
const registrationId = await upsertOk({
hostFingerprint: OWNER,
deviceId: 'device-1',
platform: 'android',
token: 'token-one'
})
await devices.markDead((await devices.findById(registrationId))!)
expect((await devices.findById(registrationId))?.dead).toBe(true)
await upsertOk({
hostFingerprint: OWNER,
deviceId: 'device-1',
platform: 'android',
token: 'token-two'
})
expect(await devices.findById(registrationId)).toMatchObject({
token: 'token-two',
dead: false
})
})
it('lets only the owning host delete a registration', async () => {
const registrationId = await upsertOk({
hostFingerprint: OWNER,
deviceId: 'device-1',
platform: 'android',
token: 'token-one'
})
expect(await devices.deleteOwned(OTHER, registrationId)).toBe(false)
expect(await devices.findById(registrationId)).not.toBeNull()
expect(await devices.deleteOwned(OWNER, registrationId)).toBe(true)
expect(await devices.findById(registrationId)).toBeNull()
})
it('scopes lookups and listings to the owning host', async () => {
const owned = await upsertOk({
hostFingerprint: OWNER,
deviceId: 'device-1',
platform: 'android',
token: 'token-one'
})
const foreign = await upsertOk({
hostFingerprint: OTHER,
deviceId: 'device-2',
platform: 'android',
token: 'token-two'
})
const found = await devices.findOwned(OWNER, [owned, foreign])
expect([...found.keys()]).toEqual([owned])
expect(await devices.list(OTHER)).toEqual([
{ registrationId: foreign, deviceId: 'device-2', platform: 'android', dead: false }
])
expect(await devices.findOwned(OWNER, [])).toEqual(new Map())
})
it('refuses a new device once the host reaches its registration cap', async () => {
for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) {
await upsertOk(androidDevice(`device-${index}`))
}
expect(await devices.upsert(androidDevice('one-too-many'))).toEqual({
ok: false,
reason: 'too_many_devices'
})
expect(await devices.list(OWNER)).toHaveLength(PUSH_LIMITS.maxDevicesPerHost)
})
it('still lets a capped host re-register a device it already owns', async () => {
for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) {
await upsertOk(androidDevice(`device-${index}`))
}
const rotated = await devices.upsert({ ...androidDevice('device-0'), token: 'rotated-token' })
expect(rotated.ok).toBe(true)
expect(await devices.list(OWNER)).toHaveLength(PUSH_LIMITS.maxDevicesPerHost)
})
it('counts the cap per host, not across the whole table', async () => {
for (let index = 0; index < PUSH_LIMITS.maxDevicesPerHost; index++) {
await upsertOk(androidDevice(`device-${index}`))
}
expect((await devices.upsert(androidDevice('extra'))).ok).toBe(false)
expect(
(await devices.upsert({ ...androidDevice('device-0'), hostFingerprint: OTHER })).ok
).toBe(true)
})
})
@@ -1,11 +1,14 @@
import { randomUUID } from 'node:crypto'
import {
PUSH_LIMITS,
type ApnsEnvironment,
type PushDeviceSummary,
type PushPlatform
} from '@orca-cloud/push-contract'
import type { PushDatabase, SqlRow } from './push-database.js'
import { PUSH_LIMITS } from '@orca-cloud/push-contract/push-limits'
import type {
ApnsEnvironment,
PushDeviceSummary,
PushPlatform
} from '@orca-cloud/push-contract/device-registration-messages'
import type {
CloudDatabase as PushDatabase,
SqlRow
} from '@orca-cloud/cloud-database/cloud-database'
const DEVICE_CAP_LOCK_PREFIX = 'orca-push-device-cap:'
@@ -1,103 +0,0 @@
import { afterEach, expect, it, vi } from 'vitest'
import type { PushDatabase } from './push-database.js'
import { DELIVERY_LEASE_MS, DurablePushStore } from './durable-push-store.js'
import {
cleanupDurablePushFixtures,
durablePushTestDatabaseUrl,
fixture,
notification,
pauseAfter,
within
} from './durable-push-store.test-fixture.js'
afterEach(cleanupDurablePushFixtures)
const LEASE_SQL = 'UPDATE push_delivery_batches SET lease_token'
// The previous revision's claim statements, in order, with a hook between its candidate scan and re-read.
function previousRevisionClaim(
db: PushDatabase,
clock: () => number,
afterCandidate: () => Promise<void>
): Promise<string | null> {
return db.transaction(async (tx) => {
await tx.lockQuotaScope('push-worker-claim')
const now = clock()
const [candidate] = await tx.query(
`SELECT * FROM push_delivery_batches WHERE state = 'pending' AND lease_until <= ? AND expires_at > ? AND due_at <= ? AND NOT EXISTS (SELECT 1 FROM push_delivery_batches busy WHERE busy.registration_id = push_delivery_batches.registration_id AND busy.lease_until > ?)
ORDER BY due_at, created_at, batch_id LIMIT 1`,
[now, now, now, now]
)
await afterCandidate()
if (!candidate) return null
await tx.lockQuotaScope(`push-events:${String(candidate.host_fingerprint)}`)
const [row] = await tx.query('SELECT * FROM push_delivery_batches WHERE batch_id = ?', [
candidate.batch_id
])
if (!row || row.state !== 'pending' || Number(row.expires_at) <= now) return null
await tx.query(
'UPDATE push_delivery_batches SET lease_token = ?, lease_until = ?, attempts = attempts + 1 WHERE batch_id = ?',
['previous', now + DELIVERY_LEASE_MS, row.batch_id]
)
return 'previous'
})
}
it.skipIf(!durablePushTestDatabaseUrl)(
'keeps a previous-revision claim from re-leasing a delivery this revision leased',
async () => {
const { db, store, clock } = await fixture({ ownDatabase: true })
await store.accept('host', 'phone', notification(1))
const paused = pauseAfter(db, LEASE_SQL)
const current = new DurablePushStore(paused.wrapped, clock).claim()
await paused.atCandidate
let committed!: () => void
const currentCommitted = new Promise<void>((resolve) => (committed = resolve))
const previous = previousRevisionClaim(db, clock, () => currentCommitted)
try {
await vi.waitFor(async () => {
const waiting = await db.query(
"SELECT 1 FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock' AND wait_event = 'advisory'"
)
expect(waiting).toHaveLength(1)
})
} finally {
paused.release()
try {
await current
} finally {
committed()
await previous
}
}
const leased = await current
expect(await previous).toBeNull()
const [row] = await db.query('SELECT lease_token FROM push_delivery_batches')
expect(leased?.lease).toBeTruthy()
expect(row?.lease_token).toBe(leased?.lease)
}
)
it.skipIf(!durablePushTestDatabaseUrl)(
'yields to a previous-revision claim that already holds the claim lock',
async () => {
const { db, store, clock } = await fixture({ ownDatabase: true })
await store.accept('host', 'phone', notification(1))
let scanned!: () => void
const atCandidate = new Promise<void>((resolve) => (scanned = resolve))
let release!: () => void
const released = new Promise<void>((resolve) => (release = resolve))
const previous = previousRevisionClaim(db, clock, async () => {
scanned()
await released
})
await atCandidate
try {
expect(await within(store.claim(), 2_000)).toBeNull()
} finally {
release()
}
expect(await previous).toBe('previous')
expect(await store.claim()).toBeNull()
}
)
@@ -1,219 +0,0 @@
import { afterEach, expect, it } from 'vitest'
import type { PushDatabase } from './push-database.js'
import { DELIVERY_LEASE_MS, DurablePushStore } from './durable-push-store.js'
import { reserveRequestConnection } from './push-background-database.js'
import { cleanupDurablePushFixtures, DEVICE_HEAD_SQL, durablePushTestDatabaseUrl, fixture, notification, pauseAfter, within } from './durable-push-store.test-fixture.js'
afterEach(cleanupDurablePushFixtures)
it.skipIf(!durablePushTestDatabaseUrl)(
'leaves completion, renewal, requests and another store independent of queued claims',
async () => {
const { db, store, clock, advance } = await fixture({ poolMax: 6 })
await store.accept('host', 'finished', notification(1))
const finished = await store.claim()
await store.accept('host', 'renewed', notification(2))
const renewed = await store.claim()
if (!finished || !renewed) throw new Error('setup_claim_missing')
await store.accept('host', 'phone-a', notification(3))
advance(1)
await store.accept('host', 'phone-b', notification(4))
advance(1)
await store.accept('host', 'phone-c', notification(5))
advance(1)
await store.accept('host', 'phone-d', notification(6))
const background = reserveRequestConnection(db, 6)
const held = [pauseAfter(background), pauseAfter(background), pauseAfter(background)] as const
let admitted = 0
const observed: PushDatabase = {
...background,
transaction: (run) => (held[admitted++]?.wrapped ?? background).transaction(run)
}
const limited = new DurablePushStore(db, clock, observed)
const first = limited.claim()
await held[0].atCandidate
const second = limited.claim()
await held[1].atCandidate
const third = limited.claim()
await held[2].atCandidate
const waiting = Array.from({ length: 3 }, () => limited.claim())
try {
advance(10)
const [, , rows, other] = await within(
Promise.all([
limited.finish(finished),
limited.renew(renewed),
db.query('SELECT COUNT(*) AS total FROM push_devices'),
store.claim()
]),
2_000
)
expect(Number(rows[0]?.total)).toBe(0)
expect(other?.registrationId).toBe('phone-d')
const [lease] = await db.query(
'SELECT lease_until FROM push_delivery_batches WHERE batch_id = ?',
[renewed.id]
)
expect(Number(lease?.lease_until)).toBe(clock() + DELIVERY_LEASE_MS)
expect(await store.pendingCount('finished')).toBe(0)
} finally {
for (const pause of held) pause.release()
await Promise.allSettled([first, second, third, ...waiting])
}
expect((await first)?.registrationId).toBe('phone-a')
expect((await second)?.registrationId).toBe('phone-b')
expect((await third)?.registrationId).toBe('phone-c')
expect(await Promise.all(waiting)).toEqual([null, null, null])
}
)
it.skipIf(!durablePushTestDatabaseUrl)(
'rolls back a failed claim and admits a fourth waiter while the other claims stay held',
async () => {
const { db, store, clock, advance } = await fixture({ poolMax: 6 })
await store.accept('host', 'phone-a', notification(1))
advance(1)
await store.accept('host', 'phone-b', notification(2))
advance(1)
await store.accept('host', 'phone-c', notification(3))
const held = [pauseAfter(db), pauseAfter(db), pauseAfter(db)] as const
let admitted = 0
const failing: PushDatabase = {
...held[0].wrapped,
transaction: (run) => {
const position = admitted++
return (held[position]?.wrapped ?? db).transaction(async (tx) => {
const result = await run(tx)
if (position === 0) throw new Error('claim_commit_failed')
return result
})
}
}
const limited = new DurablePushStore(db, clock, failing)
const first = limited.claim()
const rejected = expect(first).rejects.toThrow('claim_commit_failed')
await held[0].atCandidate
const second = limited.claim()
await held[1].atCandidate
const third = limited.claim()
await held[2].atCandidate
const next = limited.claim()
try {
held[0].release()
await rejected
const delivery = await within(next, 2_000)
expect(delivery?.notification.notificationSeq).toBe(1)
expect(delivery?.attempts).toBe(1)
if (!delivery) throw new Error('queued_claim_missing')
await limited.finish(delivery)
expect(await limited.pendingCount('phone-a')).toBe(0)
} finally {
for (const pause of held) pause.release()
await Promise.allSettled([first, second, third, next])
}
expect((await second)?.registrationId).toBe('phone-b')
expect((await third)?.registrationId).toBe('phone-c')
}
)
it('leases exactly one delivery per device across concurrent claimers', async () => {
const { db, store, clock, advance } = await fixture()
for (let seq = 1; seq <= 5; seq++) {
await store.accept('host', 'phone', notification(seq))
advance(1)
}
const claims = await Promise.all(
Array.from({ length: 8 }, () => new DurablePushStore(db, clock).claim())
)
const leased = claims.filter((claim) => claim !== null)
expect(leased).toHaveLength(1)
expect(leased[0]?.notification.notificationSeq).toBe(1)
})
it.skipIf(!durablePushTestDatabaseUrl)(
'does not lease the row behind a head another claimer holds',
async () => {
const { db, store, clock, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
advance(1)
await store.accept('host', 'phone', notification(2))
const paused = pauseAfter(db)
const first = new DurablePushStore(paused.wrapped, clock).claim()
await paused.atCandidate
try {
expect(await within(store.claim(), 2_000)).toBeNull()
} finally {
paused.release()
}
expect((await first)?.notification.notificationSeq).toBe(1)
expect(await store.claim()).toBeNull()
}
)
it.skipIf(!durablePushTestDatabaseUrl)(
'claims another device while one claim transaction is still open',
async () => {
const { db, store, clock, advance } = await fixture()
await store.accept('host', 'phone-a', notification(1))
advance(1)
await store.accept('host', 'phone-b', notification(2))
const paused = pauseAfter(db)
const first = new DurablePushStore(paused.wrapped, clock).claim()
await paused.atCandidate
try {
expect((await within(store.claim(), 2_000))?.registrationId).toBe('phone-b')
} finally {
paused.release()
}
expect((await first)?.registrationId).toBe('phone-a')
}
)
it.skipIf(!durablePushTestDatabaseUrl)(
'keeps a device exclusive when an earlier-sorting row lands mid-claim',
async () => {
const { db, store, clock } = await fixture()
await store.accept('host', 'phone', notification(1))
const paused = pauseAfter(db, DEVICE_HEAD_SQL)
const first = new DurablePushStore(paused.wrapped, clock).claim()
await paused.atCandidate
try {
// Another instance with a slower clock queues a row that now sorts first.
await db.query(
`INSERT INTO push_delivery_batches(batch_id, host_fingerprint, registration_id, kind, payload_json, state, due_at, expires_at, lease_until, attempts, created_at)
VALUES ('skewed', 'host', 'phone', 'alert', ?, 'pending', ?, ?, 0, 0, ?)`,
[JSON.stringify(notification(2)), clock() - 10, clock() + 60_000, clock() - 10]
)
expect(await within(store.claim(), 2_000)).toBeNull()
} finally {
paused.release()
}
expect((await first)?.notification.notificationSeq).toBe(1)
}
)
// Only the TTL term keeps the candidate scan off an unpruned backlog; expires_at is not indexed first.
it('frees a device held by a crashed worker once its lease lapses', async () => {
const { db, store, clock, advance } = await fixture()
await store.accept('host', 'phone', notification(1))
advance(1)
await store.accept('host', 'phone', notification(2))
const crashed = (await new DurablePushStore(db, clock).claim())!
advance(DELIVERY_LEASE_MS - 1)
expect(await store.claim()).toBeNull()
advance(1)
const reclaimed = (await store.claim())!
expect(reclaimed.id).toBe(crashed.id)
expect(reclaimed.attempts).toBe(2)
await store.finish(reclaimed)
expect((await store.claim())?.notification.notificationSeq).toBe(2)
})
@@ -1,81 +0,0 @@
import { afterEach, expect, it } from 'vitest'
import {
cleanupDurablePushFixtures,
durablePushTestDatabaseUrl,
fixture,
within
} from './durable-push-store.test-fixture.js'
import { PRUNE_BATCH_ROWS } from './durable-push-store.js'
afterEach(cleanupDurablePushFixtures)
const retentionTables = [
{
table: 'push_dismissed_events',
columns: 'host_fingerprint, notification_epoch, notification_id, notification_seq, created_at',
values: "'host', 'epoch-' || n, 'notification', n, created_at"
},
{
table: 'push_event_recipients',
columns: 'event_id, registration_id, created_at',
values: "'event', 'phone-' || n, created_at"
},
{
table: 'push_events',
columns: 'event_id, host_fingerprint, kind, fingerprint, created_at, expires_at',
values: "'event-' || n, 'host', 'alert', 'fingerprint', created_at, created_at"
}
] as const
it.each(retentionTables)(
'prunes expired $table rows across batches and preserves the cutoff',
async ({ table, columns, values }) => {
const { db, store, clock } = await fixture()
const cutoff = clock() - 24 * 60 * 60_000
const expired = PRUNE_BATCH_ROWS + 3
await db.query(
`INSERT INTO ${table} (${columns})
WITH RECURSIVE seq(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < ?),
rows AS (SELECT n, CASE WHEN n <= ? THEN ? ELSE ? + n - ? - 1 END AS created_at FROM seq)
SELECT ${values} FROM rows`,
[expired + 2, expired, cutoff - 1, cutoff, expired]
)
expect(await store.prune()).toEqual({ deleted: expired, saturated: false })
const preserved = await db.query(`SELECT created_at FROM ${table} ORDER BY created_at`)
expect(preserved.map((row) => Number(row.created_at))).toEqual([cutoff, cutoff + 1])
expect(await store.prune()).toEqual({ deleted: 0, saturated: false })
}
)
it.skipIf(!durablePushTestDatabaseUrl).each(retentionTables)(
'skips a locked oldest $table row until its transaction releases it',
async ({ table, columns, values }) => {
const { db, store, clock } = await fixture()
const cutoff = clock() - 24 * 60 * 60_000
await db.query(
`INSERT INTO ${table} (${columns})
WITH rows(n, created_at) AS (VALUES (1, ?::bigint), (2, ?::bigint), (3, ?::bigint))
SELECT ${values} FROM rows`,
[cutoff - 2, cutoff - 1, cutoff]
)
const locked = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
const holding = db.transaction(async (tx) => {
await tx.query(`SELECT * FROM ${table} WHERE created_at = ? FOR UPDATE`, [cutoff - 2])
locked.resolve()
await release.promise
})
try {
await locked.promise
expect(await within(store.prune(), 1000)).toEqual({ deleted: 1, saturated: false })
const preserved = await db.query(`SELECT created_at FROM ${table} ORDER BY created_at`)
expect(preserved.map((row) => Number(row.created_at))).toEqual([cutoff - 2, cutoff])
} finally {
release.resolve()
await holding
}
expect(await store.prune()).toEqual({ deleted: 1, saturated: false })
const preserved = await db.query(`SELECT created_at FROM ${table}`)
expect(preserved.map((row) => Number(row.created_at))).toEqual([cutoff])
}
)
@@ -1,251 +0,0 @@
import { afterEach, expect, it, vi } from 'vitest'
import type { PushNotification } from '@orca-cloud/push-contract'
import { DurablePushStore } from './durable-push-store.js'
import { DurablePushWorker } from './durable-push-worker.js'
import { PushDispatcher } from './push-dispatcher.js'
import { PushDeviceRegistryStore } from './device-registry-store.js'
import { openInMemoryPushDatabase } from './push-database.js'
import type { PushDelivery } from './push-delivery-message.js'
import type { PushProviderOutcome } from './push-provider-outcome.js'
import { pauseAfter, within } from './durable-push-store.test-fixture.js'
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0)) await cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
const note = (seq: number, overrides: Partial<PushNotification> = {}): PushNotification => ({
notificationId: `note-${seq}`,
notificationSeq: seq,
notificationEpoch: 'epoch',
source: 'agent-task-complete',
agentState: 'finished',
title: 'Done',
body: 'Finished task',
...overrides
})
async function fixture() {
const db = await openInMemoryPushDatabase()
let time = 1_000_000
const now = () => time
const store = new DurablePushStore(db, now)
const devices = new PushDeviceRegistryStore(db, now)
const device = await devices.upsert({
hostFingerprint: 'host',
deviceId: 'phone',
platform: 'android',
token: 'test-token'
})
if (!device.ok) throw new Error('registration failed')
const send = vi.fn(async (_delivery: PushDelivery): Promise<PushProviderOutcome> => ({
status: 'sent'
}))
const onRetry = vi.fn()
const dispatcher = new PushDispatcher({ devices, fcm: { send } as never })
const worker = new DurablePushWorker(store, dispatcher, { now, onRetry })
cleanups.push(async () => {
await worker.stop()
await db.close()
})
vi.spyOn(console, 'warn').mockImplementation(() => {})
return {
db,
store,
devices,
worker,
dispatcher,
send,
onRetry,
now,
registrationId: device.registrationId,
accept: (notification: PushNotification) =>
store.accept('host', device.registrationId, notification),
advance: (ms: number) => {
time += ms
}
}
}
it('preserves provider retry delay when waking a restarted worker', async () => {
const h = await fixture()
vi.useFakeTimers()
h.send.mockResolvedValueOnce({
status: 'error',
reason: 'busy',
retryable: true,
retryAfterMs: 10000
})
await h.accept(note(1))
await h.worker.runDue()
expect(h.send).toHaveBeenCalledOnce()
await h.worker.stop()
const restarted = new DurablePushWorker(h.store, h.dispatcher, { now: h.now, onRetry: h.onRetry })
restarted.start()
h.advance(9999)
restarted.wake()
await restarted.runDue()
expect(h.send).toHaveBeenCalledOnce()
h.advance(1)
restarted.wake()
await restarted.runDue()
expect(h.send).toHaveBeenCalledTimes(2)
expect(h.send.mock.calls.map(([delivery]) => delivery.expiresAt)).toEqual([1_300_000, 1_300_000])
expect(h.onRetry).toHaveBeenCalledOnce()
expect(await h.store.pendingCount(h.registrationId)).toBe(0)
await restarted.stop()
})
it('expires instead of shortening a provider delay beyond the delivery lifetime', async () => {
const h = await fixture()
h.send.mockResolvedValue({
status: 'error',
reason: 'busy',
retryable: true,
retryAfterMs: 600000
})
await h.accept(note(1))
await h.worker.runDue()
h.advance(600000)
await h.worker.runDue()
expect(h.send).toHaveBeenCalledOnce()
expect(await h.store.pendingCount(h.registrationId)).toBe(0)
expect(h.onRetry).not.toHaveBeenCalled()
})
it('rechecks the device before a persisted retry and does not send after unregistration', async () => {
const h = await fixture()
h.send.mockResolvedValue({ status: 'error', reason: 'timeout', retryable: true })
await h.accept(note(1))
await h.worker.runDue()
await h.devices.deleteOwned('host', h.registrationId)
h.advance(3000)
await h.worker.runDue()
expect(h.send).toHaveBeenCalledOnce()
expect(await h.store.pendingCount(h.registrationId)).toBe(0)
})
it('joins active work on shutdown and leaves unclaimed work for the next instance', async () => {
const h = await fixture()
vi.useFakeTimers()
let finish!: (outcome: PushProviderOutcome) => void
let started!: () => void
const entered = new Promise<void>((resolve) => {
started = resolve
})
h.send.mockImplementationOnce(() => {
started()
return new Promise((resolve) => {
finish = resolve
})
})
await h.accept(note(1))
h.worker.start()
h.worker.wake()
const pending = h.worker.runDue()
await entered
await h.accept(note(2))
h.worker.wake()
let stopped = false
const stopping = h.worker.stop().then(() => {
stopped = true
})
await Promise.resolve()
expect(stopped).toBe(false)
finish({ status: 'sent' })
await Promise.all([pending, stopping])
expect(stopped).toBe(true)
expect(h.send).toHaveBeenCalledOnce()
expect(vi.getTimerCount()).toBe(0)
const resumed = new DurablePushWorker(h.store, h.dispatcher, { now: h.now })
resumed.start()
await vi.advanceTimersByTimeAsync(1000)
await resumed.runDue()
expect(h.send).toHaveBeenCalledTimes(2)
await resumed.stop()
})
it('stops queued claim admissions without leasing the remaining device', async () => {
const h = await fixture()
const other = await h.devices.upsert({
hostFingerprint: 'host',
deviceId: 'phone-queued',
platform: 'android',
token: 'queued-token'
})
if (!other.ok) throw new Error('registration failed')
await h.accept(note(1))
h.advance(1)
await h.store.accept('host', other.registrationId, note(2))
const paused = pauseAfter(h.db)
const store = new DurablePushStore(h.db, h.now, paused.wrapped)
const worker = new DurablePushWorker(store, h.dispatcher, { now: h.now })
const pending = worker.runDue()
try {
await paused.atCandidate
const stopping = worker.stop()
paused.release()
await within(Promise.all([pending, stopping]), 2_000)
expect(h.send).toHaveBeenCalledOnce()
expect(h.send.mock.calls[0]?.[0].orca.notificationSeq).toBe(1)
const [remaining] = await h.db.query(
'SELECT attempts, lease_until FROM push_delivery_batches WHERE registration_id = ?',
[other.registrationId]
)
expect(Number(remaining?.attempts)).toBe(0)
expect(Number(remaining?.lease_until)).toBe(0)
} finally {
paused.release()
await worker.stop()
await pending
}
})
it('claims new devices while an earlier provider response is still pending', async () => {
const h = await fixture()
const second = await h.devices.upsert({
hostFingerprint: 'host',
deviceId: 'phone-second',
platform: 'android',
token: 'second-token'
})
const third = await h.devices.upsert({
hostFingerprint: 'host',
deviceId: 'phone-third',
platform: 'android',
token: 'third-token'
})
if (!second.ok || !third.ok) throw new Error('registration failed')
const held = Promise.withResolvers<PushProviderOutcome>()
const entered = Promise.withResolvers<void>()
const secondSent = Promise.withResolvers<void>()
const thirdSent = Promise.withResolvers<void>()
h.send.mockImplementation(async (delivery) => {
if (delivery.orca.notificationSeq === 1) {
entered.resolve()
return await held.promise
}
if (delivery.orca.notificationSeq === 2) secondSent.resolve()
if (delivery.orca.notificationSeq === 3) thirdSent.resolve()
return { status: 'sent' }
})
await h.accept(note(1))
h.worker.start()
h.worker.wake()
try {
await within(entered.promise, 2_000)
await h.store.accept('host', second.registrationId, note(2))
h.worker.wake()
await within(secondSent.promise, 2_000)
await h.store.accept('host', third.registrationId, note(3))
h.worker.wake()
await within(thirdSent.promise, 2_000)
expect(await h.store.pendingCount(h.registrationId)).toBe(1)
expect(h.send).toHaveBeenCalledTimes(3)
} finally {
held.resolve({ status: 'sent' })
await h.worker.stop()
}
})
-132
View File
@@ -1,132 +0,0 @@
import { describe, expect, it } from 'vitest'
import { FcmClient, type FcmRequest, type FcmResponse } from './fcm-client.js'
import { buildPushDelivery } from './push-delivery-message.js'
const NOW = 1_700_000_000_000
const HOST = 'abcdefghijklmnop'
const TOKEN = 'cQ1abcDEF_gh:APA91bZZ-zz0123456789abcdefghijklmnopqrstuvwxyz'
function delivery(agentState: 'needs-input' | null = 'needs-input') {
return buildPushDelivery({
expiresAt: NOW + 300_000,
registrationId: 'reg-1',
hostFingerprint: HOST,
notification: {
notificationId: 'note-1',
notificationSeq: 7,
notificationEpoch: 'epoch-1',
source: 'agent-task-complete',
agentState,
title: 'Agent needs input',
body: 'Waiting on your answer',
paneKey: 'tab-b:pane-1',
worktreeId: 'wt-1'
}
})
}
function fakeTransport(response: FcmResponse) {
const requests: FcmRequest[] = []
return {
requests,
transport: async (request: FcmRequest): Promise<FcmResponse> => {
requests.push(request)
return response
}
}
}
function client(response: FcmResponse) {
const fake = fakeTransport(response)
return {
fake,
client: new FcmClient({
projectId: 'onorca-cloud',
now: () => NOW,
accessToken: async () => 'access-token',
transport: fake.transport
})
}
}
describe('fcm client', () => {
it('marks an invalid-argument that names the token dead, and others an error', async () => {
const named = client({
status: 400,
body: JSON.stringify({
error: { status: 'INVALID_ARGUMENT', message: 'The registration token is not valid.' }
})
})
await expect(named.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
status: 'dead',
reason: 'INVALID_ARGUMENT'
})
const unnamed = client({
status: 400,
body: JSON.stringify({
error: { status: 'INVALID_ARGUMENT', message: 'Invalid value at message.android.ttl' }
})
})
await expect(unnamed.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
status: 'error',
reason: 'INVALID_ARGUMENT',
retryable: false,
retryAfterMs: 10000
})
})
})
it('does not send when credential refresh crosses the absolute expiry', async () => {
let now = 1000
const fake = fakeTransport({ status: 200, body: '{}' })
const fcm = new FcmClient({
projectId: 'test',
now: () => now,
accessToken: async () => {
now = 3000
return 'test-token'
},
transport: fake.transport
})
await expect(fcm.send({ ...delivery(), expiresAt: 2000 }, { token: TOKEN })).resolves.toEqual({
status: 'error',
reason: 'expired'
})
expect(fake.requests).toHaveLength(0)
})
it('decreases retry TTL and refuses expired delivery before refreshing credentials', async () => {
let now = NOW
let refreshes = 0
const fake = fakeTransport({ status: 503, body: '{}' })
const fcm = new FcmClient({
projectId: 'test',
now: () => now,
accessToken: async () => {
refreshes++
return 'test-token'
},
transport: fake.transport
})
const pending = delivery()
await fcm.send(pending, { token: TOKEN })
now += 60_000
await fcm.send(pending, { token: TOKEN })
expect(fake.requests.map((request) => JSON.parse(request.body).message.android.ttl)).toEqual([
'300s',
'240s'
])
now = pending.expiresAt
await expect(fcm.send(pending, { token: TOKEN })).resolves.toEqual({
status: 'error',
reason: 'expired'
})
expect(fake.requests).toHaveLength(2)
expect(refreshes).toBe(2)
})
@@ -2,9 +2,9 @@ import { createHmac, timingSafeEqual } from 'node:crypto'
import {
PUSH_HOST_CHALLENGE_PLAINTEXT_DOMAIN,
PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN,
PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT,
PUSH_LIMITS
} from '@orca-cloud/push-contract'
PUSH_HOST_PROOF_TRANSCRIPT_FIELD_COUNT
} from '@orca-cloud/push-contract/push-host-proof-transcript'
import { PUSH_LIMITS } from '@orca-cloud/push-contract/push-limits'
import nacl from 'tweetnacl'
import { decodeCanonicalBase64 } from './canonical-base64.js'
import { deriveHostFingerprint } from './host-fingerprint.js'
@@ -63,7 +63,10 @@ function parseTranscript(transcript: Uint8Array): Map<string, Uint8Array> | null
function readUint64(value: Uint8Array | undefined): number | null {
if (!value || value.byteLength !== 8) return null
const parsed = new DataView(value.buffer, value.byteOffset, value.byteLength).getBigUint64(0, false)
const parsed = new DataView(value.buffer, value.byteOffset, value.byteLength).getBigUint64(
0,
false
)
return parsed <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(parsed) : null
}
@@ -98,14 +101,17 @@ function validateTranscript(
],
['not-expired', now - PUSH_LIMITS.clockSkewToleranceMs <= challenge.expiresAt],
['issuedAt-before-expiry', issuedAt === null || issuedAt <= challenge.expiresAt],
[
'window',
issuedAt === null || challenge.expiresAt - issuedAt <= PUSH_LIMITS.challengeTtlMs
],
['window', issuedAt === null || challenge.expiresAt - issuedAt <= PUSH_LIMITS.challengeTtlMs],
['expiry-consistent', expiresAt === challenge.expiresAt],
['protocol', equal(fields.get('protocol'), textEncoder.encode(PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN))],
[
'protocol',
equal(fields.get('protocol'), textEncoder.encode(PUSH_HOST_PROOF_TRANSCRIPT_DOMAIN))
],
['version', equal(fields.get('version'), new Uint8Array([1]))],
['gatewayOrigin', equal(fields.get('gatewayOrigin'), textEncoder.encode(context.gatewayOrigin))],
[
'gatewayOrigin',
equal(fields.get('gatewayOrigin'), textEncoder.encode(context.gatewayOrigin))
],
['gatewayEphemeralPublicKey', equal(fields.get('gatewayEphemeralPublicKey'), gatewayKey)],
['challengeNonce', equal(fields.get('challengeNonce'), nonce)],
['challengeId', equal(fields.get('challengeId'), textEncoder.encode(challenge.challengeId))],
@@ -125,7 +131,9 @@ export function answerPushHostChallenge(
const gatewayKey = decodeCanonicalBase64(challenge.gatewayEphemeralPublicKeyB64, 32)
const nonce = decodeCanonicalBase64(challenge.nonceB64, 24)
const ciphertext = Buffer.from(challenge.ciphertextB64, 'base64')
if (!gatewayKey || !nonce || ciphertext.toString('base64') !== challenge.ciphertextB64) return null
if (!gatewayKey || !nonce || ciphertext.toString('base64') !== challenge.ciphertextB64) {
return null
}
const plaintext = nacl.box.open(ciphertext, nonce, gatewayKey, context.keypair.secretKey)
if (!plaintext) {
context.onInvalid?.('challenge-box-open')
@@ -0,0 +1,52 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
answerPushHostChallenge,
createPushHostKeypair,
hostPublicKeyB64
} from './host-challenge-answering.test-fixture.js'
import { PushHostChallengeStore } from './host-challenge-store.js'
import { openInMemoryPushDatabase } from '../database/push-database.js'
import type { CloudDatabase as PushDatabase } from '@orca-cloud/cloud-database/cloud-database'
const GATEWAY_ORIGIN = 'https://push.onorca.dev'
describe('push host challenge store', () => {
let database: PushDatabase
let clock = 1_700_000_000_000
let store: PushHostChallengeStore
beforeEach(async () => {
database = await openInMemoryPushDatabase()
clock = 1_700_000_000_000
store = new PushHostChallengeStore(database, GATEWAY_ORIGIN, () => clock)
})
afterEach(async () => {
await database.close()
})
it('refuses a wrong host: the box will not open and a foreign proof will not match', async () => {
const owner = createPushHostKeypair(6)
const intruder = createPushHostKeypair(7)
const ownerChallenge = await store.issue(hostPublicKeyB64(owner))
expect(
answerPushHostChallenge(ownerChallenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: intruder,
now: () => clock
})
).toBeNull()
const intruderChallenge = await store.issue(hostPublicKeyB64(intruder))
const intruderProof = answerPushHostChallenge(intruderChallenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: intruder,
now: () => clock
})!
await expect(store.verify(ownerChallenge!.challengeId, intruderProof)).resolves.toEqual({
ok: false,
reason: 'proof_mismatch'
})
})
})
@@ -2,13 +2,13 @@ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from
import {
buildPushHostChallengePlaintext,
buildPushHostProofMacInput,
buildPushHostProofTranscript,
PUSH_LIMITS
} from '@orca-cloud/push-contract'
buildPushHostProofTranscript
} from '@orca-cloud/push-contract/push-host-proof-transcript'
import { PUSH_LIMITS } from '@orca-cloud/push-contract/push-limits'
import nacl from 'tweetnacl'
import { decodeCanonicalBase64 } from './canonical-base64.js'
import { deriveHostFingerprint } from './host-fingerprint.js'
import type { PushDatabase } from './push-database.js'
import type { CloudDatabase as PushDatabase } from '@orca-cloud/cloud-database/cloud-database'
export type IssuedPushChallenge = {
challengeId: string
@@ -1,5 +1,5 @@
import { createHash } from 'node:crypto'
import { PUSH_HOST_FINGERPRINT_LENGTH } from '@orca-cloud/push-contract'
import { PUSH_HOST_FINGERPRINT_LENGTH } from '@orca-cloud/push-contract/push-limits'
// Identical derivation to deriveRelayHostId on the desktop, so a host and a
// phone reach the same fingerprint from the same X25519 public key.
@@ -1,5 +1,5 @@
import { expect, it } from 'vitest'
import { openInMemoryPushDatabase, openPushDatabase } from './push-database.js'
import { openInMemoryPushDatabase, openPushDatabase } from '../database/push-database.js'
import { PushHostChallengeStore } from './host-challenge-store.js'
import {
answerPushHostChallenge,
@@ -1,6 +1,6 @@
import { createHash, randomBytes } from 'node:crypto'
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import type { PushDatabase } from './push-database.js'
import { PUSH_LIMITS } from '@orca-cloud/push-contract/push-limits'
import type { CloudDatabase as PushDatabase } from '@orca-cloud/cloud-database/cloud-database'
export type IssuedPushSession = {
sessionToken: string
@@ -0,0 +1,44 @@
import { randomUUID } from 'node:crypto'
import { tmpdir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
import { openInMemoryPushDatabase, openPushDatabase } from '../database/push-database.js'
import type { CloudDatabase as PushDatabase } from '@orca-cloud/cloud-database/cloud-database'
import { PushHostSessionStore } from './host-session-store.js'
const databases: PushDatabase[] = []
afterEach(async () => {
await Promise.all(databases.splice(0).map((db) => db.close()))
})
async function concurrentSessions(db: PushDatabase) {
databases.push(db)
const host = randomUUID()
const store = new PushHostSessionStore(db)
try {
const sessions = await Promise.all(Array.from({ length: 20 }, () => store.create(host)))
const decisions = await Promise.all(
sessions.map((session) => store.resolve(session.sessionToken))
)
expect(decisions.filter((decision) => decision.ok)).toHaveLength(1)
const [row] = await db.query(
'SELECT COUNT(*) AS count FROM push_sessions WHERE host_fingerprint = ?',
[host]
)
expect(Number(row?.count)).toBe(1)
} finally {
await db.query('DELETE FROM push_sessions WHERE host_fingerprint = ?', [host])
}
}
it('serializes sessions on SQLite', async () => {
await concurrentSessions(await openInMemoryPushDatabase())
})
describe.skipIf(!process.env.ORCA_PUSH_TEST_DATABASE_URL)('PostgreSQL push sessions', () => {
it('leaves exactly one live token after concurrent creates', async () => {
await concurrentSessions(
await openPushDatabase({
databaseUrl: process.env.ORCA_PUSH_TEST_DATABASE_URL!,
dataDir: tmpdir()
})
)
})
})
@@ -1,79 +0,0 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
answerPushHostChallenge,
createPushHostKeypair,
hostPublicKeyB64
} from './host-challenge-answering.test-fixture.js'
import { PushHostChallengeStore } from './host-challenge-store.js'
import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js'
const GATEWAY_ORIGIN = 'https://push.onorca.dev'
describe('push host challenge store', () => {
let database: PushDatabase
let clock = 1_700_000_000_000
let store: PushHostChallengeStore
beforeEach(async () => {
database = await openInMemoryPushDatabase()
clock = 1_700_000_000_000
store = new PushHostChallengeStore(database, GATEWAY_ORIGIN, () => clock)
})
afterEach(async () => {
await database.close()
})
it('rejects a challenge the moment its own ttl elapses', async () => {
const host = createPushHostKeypair(4)
const challenge = await store.issue(hostPublicKeyB64(host))
const proof = answerPushHostChallenge(challenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: host,
now: () => clock
})!
clock += PUSH_LIMITS.challengeTtlMs + 1
await expect(store.verify(challenge!.challengeId, proof)).resolves.toEqual({
ok: false,
reason: 'expired'
})
})
it('refuses a wrong host: the box will not open and a foreign proof will not match', async () => {
const owner = createPushHostKeypair(6)
const intruder = createPushHostKeypair(7)
const ownerChallenge = await store.issue(hostPublicKeyB64(owner))
expect(
answerPushHostChallenge(ownerChallenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: intruder,
now: () => clock
})
).toBeNull()
const intruderChallenge = await store.issue(hostPublicKeyB64(intruder))
const intruderProof = answerPushHostChallenge(intruderChallenge!, {
gatewayOrigin: GATEWAY_ORIGIN,
keypair: intruder,
now: () => clock
})!
await expect(store.verify(ownerChallenge!.challengeId, intruderProof)).resolves.toEqual({
ok: false,
reason: 'proof_mismatch'
})
})
})
@@ -1,66 +0,0 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { PushHostSessionStore } from './host-session-store.js'
import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js'
const HOST = 'abcdefghijklmnop'
describe('push host session store', () => {
let database: PushDatabase
let clock = 1_700_000_000_000
let sessions: PushHostSessionStore
beforeEach(async () => {
database = await openInMemoryPushDatabase()
clock = 1_700_000_000_000
sessions = new PushHostSessionStore(database, () => clock)
})
afterEach(async () => {
await database.close()
})
it('mints a 24 hour session and stores only its hash', async () => {
const session = await sessions.create(HOST)
expect(session.expiresAt).toBe(clock + PUSH_LIMITS.sessionTtlMs)
expect(Buffer.from(session.sessionToken, 'base64url').byteLength).toBe(32)
const [row] = await database.query('SELECT token_hash FROM push_sessions')
expect(String(row?.token_hash)).not.toBe(session.sessionToken)
await expect(sessions.resolve(session.sessionToken)).resolves.toMatchObject({
ok: true,
hostFingerprint: HOST
})
})
it('reports expiry separately from an unknown token', async () => {
const session = await sessions.create(HOST)
clock += PUSH_LIMITS.sessionTtlMs + 1
await expect(sessions.resolve(session.sessionToken)).resolves.toEqual({
ok: false,
reason: 'session_expired'
})
await expect(sessions.resolve('not-a-session')).resolves.toEqual({
ok: false,
reason: 'unknown_session'
})
})
it('keeps one live session per host and prunes it once expired', async () => {
const first = await sessions.create(HOST)
const second = await sessions.create(HOST)
// The earlier session is gone the moment its host proves again, so a flood
// of proofs leaves one row per host rather than one per proof.
await expect(sessions.resolve(first.sessionToken)).resolves.toEqual({
ok: false,
reason: 'unknown_session'
})
await expect(sessions.resolve(second.sessionToken)).resolves.toMatchObject({ ok: true })
const other = await sessions.create('ponmlkjihgfedcba')
await expect(sessions.resolve(second.sessionToken)).resolves.toMatchObject({ ok: true })
clock += PUSH_LIMITS.sessionTtlMs + 1
expect(await sessions.pruneExpired()).toBe(2)
await expect(sessions.resolve(other.sessionToken)).resolves.toMatchObject({ ok: false })
})
})
@@ -1,4 +1,4 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { PUSH_LIMITS } from '@orca-cloud/push-contract/push-limits'
import type { Context, MiddlewareHandler } from 'hono'
const REFILL_WINDOW_MS = 60_000
@@ -1,6 +1,6 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract'
import { PUSH_LIMITS } from '@orca-cloud/push-contract/push-limits'
import { expect, it } from 'vitest'
import { createPushHostKeypair } from './host-challenge-answering.test-fixture.js'
import { createPushHostKeypair } from '../host-auth/host-challenge-answering.test-fixture.js'
import { createPushServerHarness, FCM_TOKEN } from './push-server-harness.test-fixture.js'
it('bounds valid hosts together across routes without letting key rotation reset the IP budget', async () => {
@@ -1,4 +1,4 @@
import type { PushDatabase } from './push-database.js'
import type { CloudDatabase as PushDatabase } from '@orca-cloud/cloud-database/cloud-database'
export type PushReadinessOptions = {
cacheMs?: number
@@ -0,0 +1,36 @@
import { afterEach, expect, it } from 'vitest'
import { createPushServerHarness, notification } from './push-server-harness.test-fixture.js'
import { createPushHostKeypair } from '../host-auth/host-challenge-answering.test-fixture.js'
const harnesses: Awaited<ReturnType<typeof createPushServerHarness>>[] = []
afterEach(async () => {
await Promise.all(harnesses.splice(0).map((h) => h.close()))
})
it('returns queued for concurrent retries without double quota or delivery', async () => {
const h = await createPushServerHarness()
harnesses.push(h)
const token = await h.signIn(createPushHostKeypair(2))
const registrationId = await h.registerAndroid(token)
const body = { v: 1, registrationIds: [registrationId], notification: notification() }
const responses = await Promise.all(
Array.from({ length: 10 }, () => h.post('/v1/send', body, token))
)
for (const response of responses)
expect(await response.json()).toEqual({ results: [{ registrationId, status: 'queued' }] })
expect(await h.server.deliveryStore.pendingCount(registrationId)).toBe(1)
await h.flushDeliveries()
await h.post('/v1/send', body, token)
await h.flushDeliveries()
expect(h.fcmRequests).toHaveLength(1)
expect(JSON.parse(h.fcmRequests[0]!.body).message.data.coalescedCount).toBeUndefined()
expect(
Number((await h.database.query('SELECT COUNT(*) AS count FROM push_events'))[0]?.count)
).toBe(1)
await h.post(
'/v1/send',
{ ...body, notification: notification({ notificationEpoch: 'new-epoch' }) },
token
)
await h.flushDeliveries()
expect(h.fcmRequests).toHaveLength(2)
})
@@ -0,0 +1,30 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPushHostKeypair } from '../host-auth/host-challenge-answering.test-fixture.js'
import { createPushServerHarness } from './push-server-harness.test-fixture.js'
describe('push gateway authentication and device routes', () => {
let harness: Awaited<ReturnType<typeof createPushServerHarness>>
beforeEach(async () => {
harness = await createPushServerHarness()
})
afterEach(async () => {
await harness.close()
})
it('lets only the owning host delete a registration', async () => {
const ownerToken = await harness.signIn(createPushHostKeypair(14))
const intruderToken = await harness.signIn(createPushHostKeypair(15))
const registrationId = await harness.registerAndroid(ownerToken)
const forbidden = await harness.authorized(
`/v1/devices/${registrationId}`,
{ method: 'DELETE' },
intruderToken
)
expect(forbidden.status).toBe(404)
expect(await forbidden.json()).toEqual({ error: 'not_found' })
expect(await harness.server.devices.findById(registrationId)).not.toBeNull()
})
})
@@ -1,15 +1,17 @@
import { generateKeyPairSync } from 'node:crypto'
import { expect } from 'vitest'
import type { ApnsRequest, ApnsResponse } from './apns-http2-transport.js'
import type { PushConfig } from './config.js'
import type { FcmRequest, FcmResponse } from './fcm-client.js'
import type { ApnsRequest } from '../providers/apns/apns-http2-transport.js'
import type { ApnsResponse } from '../providers/apns/apns-stream-response.js'
import type { PushConfig } from '../config.js'
import type { FcmRequest, FcmResponse } from '../providers/fcm/fcm-client.js'
import {
answerPushHostChallenge,
hostPublicKeyB64,
type PushHostKeypair
} from './host-challenge-answering.test-fixture.js'
import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js'
import { createPushServer } from './push-server.js'
} from '../host-auth/host-challenge-answering.test-fixture.js'
import { openInMemoryPushDatabase } from '../database/push-database.js'
import type { CloudDatabase as PushDatabase } from '@orca-cloud/cloud-database/cloud-database'
import { createPushServer } from '../push-server.js'
export const GATEWAY_ORIGIN = 'https://push.onorca.dev'
export const APNS_TOKEN = 'a'.repeat(64)
@@ -0,0 +1,121 @@
import { PUSH_LIMITS } from '@orca-cloud/push-contract/push-limits'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
createPushHostKeypair,
hostPublicKeyB64
} from '../host-auth/host-challenge-answering.test-fixture.js'
import { createPushServerHarness } from './push-server-harness.test-fixture.js'
const CLIENT_IP = '203.0.113.7'
const OTHER_CLIENT_IP = '198.51.100.9'
function oversizedChallengeBody(): string {
return JSON.stringify({ v: 1, filler: 'x'.repeat(PUSH_LIMITS.maxHttpBodyBytes) })
}
function chunkedRequest(path: string, body: string): Request {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(body))
controller.close()
}
})
return new Request(`http://push.test${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: stream,
duplex: 'half'
} as RequestInit)
}
describe('push gateway request limits', () => {
let harness: Awaited<ReturnType<typeof createPushServerHarness>>
beforeEach(async () => {
harness = await createPushServerHarness()
})
afterEach(async () => {
await harness.close()
})
it('refuses an oversized chunked body that declares no content length', async () => {
const request = chunkedRequest('/v1/host/challenge', oversizedChallengeBody())
expect(request.headers.get('content-length')).toBeNull()
const response = await harness.server.app.request(request)
expect(response.status).toBe(413)
expect(await response.json()).toEqual({ error: 'request_too_large' })
})
it('rate limits one client ip across both unauthenticated routes', async () => {
const body = JSON.stringify({
v: 1,
hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(62))
})
// Cloud Run appends the peer, so the caller's own IP is the last value.
const headers = {
'content-type': 'application/json',
'x-forwarded-for': `10.0.0.1, ${CLIENT_IP}`
}
for (let index = 0; index < PUSH_LIMITS.unauthenticatedRequestsPerMinutePerIp; index++) {
const allowed = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers,
body
})
expect(allowed.status).toBe(200)
}
const limited = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers,
body
})
expect(limited.status).toBe(429)
expect(await limited.json()).toEqual({ error: 'rate_limited' })
// The session route draws on the same bucket, so a flood cannot simply move.
const session = await harness.server.app.request('/v1/host/session', {
method: 'POST',
headers,
body: JSON.stringify({ v: 1, challengeId: 'anything', proofB64: 'x'.repeat(44) })
})
expect(session.status).toBe(429)
const other = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers: { ...headers, 'x-forwarded-for': `10.0.0.1, ${OTHER_CLIENT_IP}` },
body
})
expect(other.status).toBe(200)
// A caller rewriting the left of the chain lands in its own bucket anyway.
const spoofed = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers: { ...headers, 'x-forwarded-for': `198.51.100.250, ${CLIENT_IP}` },
body
})
expect(spoofed.status).toBe(429)
})
it('limits authenticated hosts independently behind the same IP', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(64))
const headers = { 'x-forwarded-for': CLIENT_IP }
for (let index = 0; index < 600; index++) {
const listed = await harness.authorized('/v1/devices', { headers }, sessionToken)
expect(listed.status).toBe(200)
}
const limited = await harness.authorized('/v1/devices', { headers }, sessionToken)
expect(limited.status).toBe(429)
const otherToken = await harness.signIn(createPushHostKeypair(68))
expect((await harness.authorized('/v1/devices', { headers }, otherToken)).status).toBe(200)
// The handshake bucket is untouched by any of that.
const challenge = await harness.server.app.request('/v1/host/challenge', {
method: 'POST',
headers: { ...headers, 'content-type': 'application/json' },
body: JSON.stringify({ v: 1, hostPublicKeyB64: hostPublicKeyB64(createPushHostKeypair(67)) })
})
expect(challenge.status).toBe(200)
})
})
@@ -0,0 +1,75 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { createPushHostKeypair } from '../host-auth/host-challenge-answering.test-fixture.js'
import {
createPushServerHarness,
FCM_TOKEN,
notification
} from './push-server-harness.test-fixture.js'
describe('push gateway send route', () => {
let harness: Awaited<ReturnType<typeof createPushServerHarness>>
beforeEach(async () => {
harness = await createPushServerHarness()
})
afterEach(async () => {
await harness.close()
})
it('queues a send, delivers it to fcm, and reports a dead token on the next send', async () => {
const sessionToken = await harness.signIn(createPushHostKeypair(17))
const registrationId = await harness.registerAndroid(sessionToken)
const queued = await harness.post(
'/v1/send',
{ v: 1, registrationIds: [registrationId], notification: notification() },
sessionToken
)
expect(await queued.json()).toEqual({ results: [{ registrationId, status: 'queued' }] })
harness.setFcmResponse({
status: 404,
body: JSON.stringify({ error: { status: 'UNREGISTERED', message: 'gone' } })
})
await harness.flushDeliveries()
expect(harness.fcmRequests).toHaveLength(1)
expect(JSON.parse(harness.fcmRequests[0]!.body)).toMatchObject({
message: {
token: FCM_TOKEN,
data: { title: 'Agent needs input', tag: expect.stringMatching(/^[a-f0-9]{64}$/) }
}
})
const afterDeath = await harness.post(
'/v1/send',
{ v: 1, registrationIds: [registrationId], notification: notification() },
sessionToken
)
expect(await afterDeath.json()).toEqual({ results: [{ registrationId, status: 'dead' }] })
const listed = await harness.authorized('/v1/devices', {}, sessionToken)
expect(await listed.json()).toEqual({
devices: [{ registrationId, deviceId: 'device-1', platform: 'android', dead: true }]
})
})
it('reports an error for a registration the host does not own', async () => {
const ownerToken = await harness.signIn(createPushHostKeypair(19))
const intruderToken = await harness.signIn(createPushHostKeypair(20))
const registrationId = await harness.registerAndroid(ownerToken)
const foreign = await harness.post(
'/v1/send',
{ v: 1, registrationIds: [registrationId, 'made-up'], notification: notification() },
intruderToken
)
expect(await foreign.json()).toEqual({
results: [
{ registrationId, status: 'error' },
{ registrationId: 'made-up', status: 'error' }
]
})
expect(await harness.server.deliveryStore.pendingCount(registrationId)).toBe(0)
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
import { startPushBackground } from './push-background.js'
import { loadPushConfig } from './config.js'
import { openPushDatabase } from './push-database.js'
import { openPushDatabase } from './database/push-database.js'
import { createPushServer } from './push-server.js'
const config = loadPushConfig()
@@ -1,5 +1,5 @@
import { createPrivateKey, type KeyObject, sign } from 'node:crypto'
import type { ApnsCredentials } from './config.js'
import type { ApnsCredentials } from '../../config.js'
// Apple rejects a provider token older than an hour and throttles reissue
// under about 20 minutes, so 50 minutes is the safe rotation point.
@@ -1,9 +1,9 @@
import type { ApnsEnvironment } from '@orca-cloud/push-contract'
import type { ApnsEnvironment } from '@orca-cloud/push-contract/device-registration-messages'
import { ApnsAuthenticationToken } from './apns-authentication-token.js'
import type { ApnsTransport } from './apns-http2-transport.js'
import type { ApnsCredentials } from './config.js'
import type { PushDelivery } from './push-delivery-message.js'
import type { PushProviderOutcome } from './push-provider-outcome.js'
import type { ApnsCredentials } from '../../config.js'
import type { PushDelivery } from '../../delivery/push-delivery-message.js'
import type { PushProviderOutcome } from '../push-provider-outcome.js'
const APNS_HOSTS: Record<ApnsEnvironment, string> = {
production: 'api.push.apple.com',
@@ -8,7 +8,6 @@ export type ApnsRequest = {
body: string
}
export type { ApnsResponse }
export type ApnsTransport = (request: ApnsRequest) => Promise<ApnsResponse>
// Cache APNs HTTP/2 sessions per host until socket loss; long-lived connections avoid reconnect overhead.
@@ -28,8 +28,6 @@ function fakeApnsStream(): FakeStream {
}
describe('apns stream response', () => {
it('rejects when the peer resets the stream without an end or an error', async () => {
const stream = fakeApnsStream()
const pending = readApnsStreamResponse(stream, 'body')
@@ -38,12 +36,4 @@ describe('apns stream response', () => {
stream.emit('close')
await expect(pending).rejects.toThrow('apns_stream_closed')
})
})
@@ -1,5 +1,5 @@
import type { EventEmitter } from 'node:events'
import { providerRetryAfter } from './provider-retry-delay.js'
import { providerRetryAfter } from '../provider-retry-delay.js'
import { constants } from 'node:http2'
export type ApnsResponse = { status: number; body: string; retryAfterMs?: number }
@@ -1,10 +1,10 @@
import { expect, it } from 'vitest'
import { createPushHostKeypair } from './host-challenge-answering.test-fixture.js'
import { createPushHostKeypair } from '../../host-auth/host-challenge-answering.test-fixture.js'
import {
APNS_TOKEN,
createPushServerHarness,
notification
} from './push-server-harness.test-fixture.js'
} from '../../http/push-server-harness.test-fixture.js'
it('delivers again after a topic error without re-registering the phone', async () => {
const h = await createPushServerHarness()
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import { FcmClient, type FcmRequest, type FcmResponse } from './fcm-client.js'
import { buildPushDelivery } from '../../delivery/push-delivery-message.js'
const NOW = 1_700_000_000_000
const HOST = 'abcdefghijklmnop'
const TOKEN = 'cQ1abcDEF_gh:APA91bZZ-zz0123456789abcdefghijklmnopqrstuvwxyz'
function delivery(agentState: 'needs-input' | null = 'needs-input') {
return buildPushDelivery({
expiresAt: NOW + 300_000,
registrationId: 'reg-1',
hostFingerprint: HOST,
notification: {
notificationId: 'note-1',
notificationSeq: 7,
notificationEpoch: 'epoch-1',
source: 'agent-task-complete',
agentState,
title: 'Agent needs input',
body: 'Waiting on your answer',
paneKey: 'tab-b:pane-1',
worktreeId: 'wt-1'
}
})
}
function fakeTransport(response: FcmResponse) {
const requests: FcmRequest[] = []
return {
requests,
transport: async (request: FcmRequest): Promise<FcmResponse> => {
requests.push(request)
return response
}
}
}
function client(response: FcmResponse) {
const fake = fakeTransport(response)
return {
fake,
client: new FcmClient({
projectId: 'onorca-cloud',
now: () => NOW,
accessToken: async () => 'access-token',
transport: fake.transport
})
}
}
describe('fcm client', () => {
it('marks an invalid-argument that names the token dead, and others an error', async () => {
const named = client({
status: 400,
body: JSON.stringify({
error: { status: 'INVALID_ARGUMENT', message: 'The registration token is not valid.' }
})
})
await expect(named.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
status: 'dead',
reason: 'INVALID_ARGUMENT'
})
const unnamed = client({
status: 400,
body: JSON.stringify({
error: { status: 'INVALID_ARGUMENT', message: 'Invalid value at message.android.ttl' }
})
})
await expect(unnamed.client.send(delivery(), { token: TOKEN })).resolves.toEqual({
status: 'error',
reason: 'INVALID_ARGUMENT',
retryable: false,
retryAfterMs: 10000
})
})
})
it('does not send when credential refresh crosses the absolute expiry', async () => {
let now = 1000
const fake = fakeTransport({ status: 200, body: '{}' })
const fcm = new FcmClient({
projectId: 'test',
now: () => now,
accessToken: async () => {
now = 3000
return 'test-token'
},
transport: fake.transport
})
await expect(fcm.send({ ...delivery(), expiresAt: 2000 }, { token: TOKEN })).resolves.toEqual({
status: 'error',
reason: 'expired'
})
expect(fake.requests).toHaveLength(0)
})
@@ -1,7 +1,7 @@
import { providerRetryAfter } from './provider-retry-delay.js'
import { PUSH_DEFAULTS } from '@orca-cloud/push-contract'
import { orcaDataStrings, type PushDelivery } from './push-delivery-message.js'
import type { PushProviderOutcome } from './push-provider-outcome.js'
import { providerRetryAfter } from '../provider-retry-delay.js'
import { PUSH_DEFAULTS } from '@orca-cloud/push-contract/push-limits'
import { orcaDataStrings, type PushDelivery } from '../../delivery/push-delivery-message.js'
import type { PushProviderOutcome } from '../push-provider-outcome.js'
export const FCM_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
+5 -4
View File
@@ -1,5 +1,5 @@
import type { PushConfig } from './config.js'
import type { PushPruneSweep } from './durable-push-store.js'
import type { PushPruneSweep } from './delivery/durable-push-store.js'
import type { createPushServer } from './push-server.js'
const CHALLENGE_PRUNE_INTERVAL_MS = 60_000
@@ -45,9 +45,10 @@ function prune(label: string, run: () => Promise<PushPruneSweep>, intervalMs: nu
}
// One DELETE under a statement timeout: there is no batch budget for it to exhaust.
const unbatchedSweep =
(run: () => Promise<number>) =>
async (): Promise<PushPruneSweep> => ({ deleted: await run(), saturated: false })
const unbatchedSweep = (run: () => Promise<number>) => async (): Promise<PushPruneSweep> => ({
deleted: await run(),
saturated: false
})
export function startPushBackground(
config: Pick<PushConfig, 'mode'>,
@@ -1,101 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { randomUUID } from 'node:crypto'
const fakes = vi.hoisted(() => ({
configs: [] as Array<Record<string, unknown>>,
lifecycle: [] as string[],
query: vi.fn(async (_sql: string) => ({ rows: [], rowCount: 0 })),
release: vi.fn()
}))
vi.mock('pg', () => ({
default: {
Pool: class {
on = vi.fn()
connect = vi.fn(async () => ({ query: fakes.query, release: fakes.release }))
private readonly label: string
constructor(config: Record<string, unknown>) {
fakes.configs.push(config)
this.label = `max=${String(config.max)} statement_timeout=${String(config.statement_timeout)}`
fakes.lifecycle.push(`open ${this.label}`)
}
async end(): Promise<void> {
fakes.lifecycle.push(`end ${this.label}`)
}
}
}
}))
import { openPushDatabase } from './push-database.js'
import { pushSchemaStatements } from './push-schema.js'
describe('PostgreSQL push gateway startup', () => {
beforeEach(() => {
fakes.configs.length = 0
fakes.lifecycle.length = 0
fakes.query.mockClear()
})
afterEach(() => {
vi.restoreAllMocks()
})
const socketPassword = `${randomUUID()}@/`
const socketUrl = `postgresql://push:${encodeURIComponent(socketPassword)}@/orca_push?host=/cloudsql/test:region:instance`
it('passes the Terraform socket URL unchanged to both active pools', async () => {
const database = await openPushDatabase({ databaseUrl: socketUrl, dataDir: '/unused' })
expect(fakes.configs.map((config) => config.connectionString)).toEqual([socketUrl, socketUrl])
await database.close()
})
it('parses socket credentials and query options before enforcing validation read-only', async () => {
const options = '-c search_path=validation -c default_transaction_read_only=off'
const database = await openPushDatabase({
databaseUrl: `${socketUrl}&port=5433&sslmode=disable&options=${encodeURIComponent(options)}`,
dataDir: '/unused',
readOnly: true
})
expect(fakes.configs).toHaveLength(1)
expect(fakes.configs[0]).toMatchObject({
host: '/cloudsql/test:region:instance',
user: 'push',
password: socketPassword,
database: 'orca_push',
port: 5433,
ssl: false,
options: `${options} -c default_transaction_read_only=on`
})
expect(fakes.configs[0]).not.toHaveProperty('connectionString')
expect(fakes.query).not.toHaveBeenCalled()
await database.close()
})
// Growing-table CREATE INDEX can exceed request deadlines; inheriting them makes startup fail forever at the same DDL.
it('applies the schema on an untimed pool that is gone before the serving pool opens', async () => {
const database = await openPushDatabase({
databaseUrl: 'postgresql://push@localhost:55440/orca_push',
dataDir: '/unused',
poolMax: 2,
applicationName: 'orca-push'
})
expect(fakes.lifecycle).toEqual([
'open max=1 statement_timeout=0',
'end max=1 statement_timeout=0',
'open max=2 statement_timeout=5000'
])
expect(fakes.configs[0]).toMatchObject({
application_name: 'orca-push/schema',
lock_timeout: 1_000,
idle_in_transaction_session_timeout: 5_000
})
expect(
fakes.query.mock.calls.map(([sql]) => sql).slice(0, pushSchemaStatements().length)
).toEqual(pushSchemaStatements())
await database.close()
})
})
-19
View File
@@ -1,19 +0,0 @@
import {
openCloudDatabase,
openInMemoryCloudDatabase,
type CloudDatabase,
type OpenCloudDatabaseInput
} from '@orca-cloud/cloud-database'
import { pushSchemaStatements } from './push-schema.js'
export type { CloudDatabase as PushDatabase, SqlRow } from '@orca-cloud/cloud-database'
export async function openPushDatabase(
input: Omit<OpenCloudDatabaseInput, 'service' | 'schemaStatements'>
): Promise<CloudDatabase> {
return await openCloudDatabase({ ...input, service: 'push', schemaStatements: pushSchemaStatements() })
}
export async function openInMemoryPushDatabase(): Promise<CloudDatabase> {
return await openInMemoryCloudDatabase(pushSchemaStatements())
}
@@ -1,12 +1,13 @@
import { afterEach, expect, it, vi } from 'vitest'
import { Hono } from 'hono'
import { PushRequestDrain } from './push-request-drain.js'
import { PushDispatcher } from './push-dispatcher.js'
import { PushDeviceRegistryStore } from './device-registry-store.js'
import { openInMemoryPushDatabase, type PushDatabase } from './push-database.js'
import { buildPushDelivery } from './push-delivery-message.js'
import { PushNotificationSchema } from '@orca-cloud/push-contract'
import { notification } from './push-server-harness.test-fixture.js'
import { PushRequestDrain } from './http/push-request-drain.js'
import { PushDispatcher } from './delivery/push-dispatcher.js'
import { PushDeviceRegistryStore } from './devices/device-registry-store.js'
import { openInMemoryPushDatabase } from './database/push-database.js'
import type { CloudDatabase as PushDatabase } from '@orca-cloud/cloud-database/cloud-database'
import { buildPushDelivery } from './delivery/push-delivery-message.js'
import { PushNotificationSchema } from '@orca-cloud/push-contract/send-messages'
import { notification } from './http/push-server-harness.test-fixture.js'
const databases: PushDatabase[] = []
afterEach(async () => {
@@ -1,31 +0,0 @@
import { expect, it } from 'vitest'
import { apnsBody } from './apns-client.js'
import { fcmMessageBody } from './fcm-client.js'
import { buildPushDelivery } from './push-delivery-message.js'
it('dismissal provider payloads cannot display a new alert or play a sound', () => {
const delivery = buildPushDelivery({
expiresAt: Date.now() + 300_000,
registrationId: 'reg',
hostFingerprint: 'host',
notification: {
kind: 'dismiss',
notificationId: 'note',
notificationSeq: 2,
notificationEpoch: 'epoch',
source: 'agent-task-complete',
agentState: null,
title: 'Orca',
body: ''
}
})
expect(JSON.parse(apnsBody(delivery)).aps).toEqual({ 'content-available': 1 })
const android = JSON.parse(fcmMessageBody({ delivery, token: 'test', channelId: 'test' })).message
expect(android).not.toHaveProperty('notification')
expect(android.android).not.toHaveProperty('notification')
expect(android.android).not.toHaveProperty('collapse_key')
expect(android.data).not.toHaveProperty('title')
expect(android.data).not.toHaveProperty('message')
expect(android.data).not.toHaveProperty('sound')
expect(android.data.kind).toBe('dismiss')
})
@@ -1,33 +0,0 @@
import { expect, it } from 'vitest'
import { apnsBody } from './apns-client.js'
import { fcmMessageBody } from './fcm-client.js'
import { buildPushDelivery } from './push-delivery-message.js'
import { PushNotificationSchema } from '@orca-cloud/push-contract'
it('carries a silent preference through validation to APNs and Android payloads', () => {
const notification = PushNotificationSchema.parse({
notificationSeq: 1,
notificationEpoch: 'epoch',
source: 'terminal-bell',
agentState: null,
title: 'Bell',
body: '',
sound: false
})
const delivery = buildPushDelivery({
expiresAt: Date.now() + 300_000,
registrationId: 'reg',
hostFingerprint: 'host',
notification
})
expect(JSON.parse(apnsBody(delivery)).aps).not.toHaveProperty('sound')
expect(
JSON.parse(fcmMessageBody({ delivery, token: 'test-token', channelId: 'orca-desktop' })).message
.data.channelId
).toBe('orca-desktop-silent')
expect(
JSON.parse(fcmMessageBody({ delivery, token: 'test-token', channelId: 'orca-desktop' })).message
.data.sound
).toBe('')
expect(JSON.parse(apnsBody({ ...delivery, sound: undefined })).aps.sound).toBe('default')
})
@@ -1,56 +0,0 @@
import { randomUUID } from 'node:crypto'
import pg from 'pg'
import { afterEach, expect, it, vi } from 'vitest'
import { openPushDatabase } from './push-database.js'
import { durablePushTestDatabaseUrl } from './durable-push-store.test-fixture.js'
const QUEUE_INDEXES = ['push_batches_pending_device']
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
vi.restoreAllMocks()
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
})
// Push applies its schema with retryLockTimeout, so a lock timeout is retried, never deferred:
// the new revision either boots with every index or does not boot at all.
it.skipIf(!durablePushTestDatabaseUrl)(
'creates the queue index after waiting out a writer that holds the table',
async () => {
const admin = new pg.Client({ connectionString: durablePushTestDatabaseUrl })
await admin.connect()
const schema = `boot_${randomUUID().replaceAll('-', '')}`
cleanups.push(async () => {
await admin.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
await admin.end()
})
await admin.query(`CREATE SCHEMA ${schema}`)
const url = new URL(durablePushTestDatabaseUrl!)
url.searchParams.set('options', `-c search_path=${schema}`)
await (await openPushDatabase({ databaseUrl: url.toString(), dataDir: '' })).close()
await admin.query(
`DROP INDEX ${QUEUE_INDEXES.map((name) => `${schema}.${name}`).join(', ')}`
)
const writer = new pg.Client({ connectionString: url.toString() })
await writer.connect()
cleanups.push(() => writer.end())
await writer.query('BEGIN')
await writer.query('LOCK TABLE push_delivery_batches IN ROW EXCLUSIVE MODE')
const warnings: string[] = []
vi.spyOn(console, 'warn').mockImplementation((line: string) => warnings.push(line))
vi.spyOn(console, 'log').mockImplementation(() => undefined)
const held = setTimeout(() => void writer.query('COMMIT'), 2_500)
cleanups.push(async () => clearTimeout(held))
const booted = await openPushDatabase({ databaseUrl: url.toString(), dataDir: '' })
await booted.close()
const { rows } = await admin.query(
'SELECT indexname FROM pg_indexes WHERE schemaname = $1 AND indexname = ANY($2) ORDER BY 1',
[schema, QUEUE_INDEXES]
)
expect(rows.map((row) => row.indexname)).toEqual(QUEUE_INDEXES)
const events = warnings.map((line) => JSON.parse(line).event)
expect(events).toContain('orca_push_postgres_schema_retry')
expect(events).not.toContain('orca_push_postgres_schema_object_deferred')
}
)
@@ -1,64 +0,0 @@
import { afterEach, expect, it } from 'vitest'
import { createPushServerHarness, notification } from './push-server-harness.test-fixture.js'
import { createPushHostKeypair } from './host-challenge-answering.test-fixture.js'
const harnesses: Awaited<ReturnType<typeof createPushServerHarness>>[] = []
afterEach(async () => {
await Promise.all(harnesses.splice(0).map((h) => h.close()))
})
it('returns queued for concurrent retries without double quota or delivery', async () => {
const h = await createPushServerHarness()
harnesses.push(h)
const token = await h.signIn(createPushHostKeypair(2))
const registrationId = await h.registerAndroid(token)
const body = { v: 1, registrationIds: [registrationId], notification: notification() }
const responses = await Promise.all(
Array.from({ length: 10 }, () => h.post('/v1/send', body, token))
)
for (const response of responses)
expect(await response.json()).toEqual({ results: [{ registrationId, status: 'queued' }] })
expect(await h.server.deliveryStore.pendingCount(registrationId)).toBe(1)
await h.flushDeliveries()
await h.post('/v1/send', body, token)
await h.flushDeliveries()
expect(h.fcmRequests).toHaveLength(1)
expect(JSON.parse(h.fcmRequests[0]!.body).message.data.coalescedCount).toBeUndefined()
expect(
Number((await h.database.query('SELECT COUNT(*) AS count FROM push_events'))[0]?.count)
).toBe(1)
await h.post(
'/v1/send',
{ ...body, notification: notification({ notificationEpoch: 'new-epoch' }) },
token
)
await h.flushDeliveries()
expect(h.fcmRequests).toHaveLength(2)
})
it.each([false, true])(
'accepts default alert kind equivalently through the API (explicit first: %s)',
async (explicitFirst) => {
const h = await createPushServerHarness()
harnesses.push(h)
const token = await h.signIn(createPushHostKeypair(3))
const registrationId = await h.registerAndroid(token)
const implicit = notification()
const explicit = { kind: 'alert', ...implicit }
for (const event of explicitFirst ? [explicit, implicit] : [implicit, explicit]) {
const response = await h.post(
'/v1/send',
{ v: 1, registrationIds: [registrationId], notification: event },
token
)
expect(await response.json()).toEqual({ results: [{ registrationId, status: 'queued' }] })
}
await h.flushDeliveries()
expect(h.fcmRequests).toHaveLength(1)
const changed = await h.post(
'/v1/send',
{ v: 1, registrationIds: [registrationId], notification: { ...explicit, body: 'changed' } },
token
)
expect(await changed.json()).toEqual({ results: [{ registrationId, status: 'error' }] })
}
)

Some files were not shown because too many files have changed in this diff Show More