mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/agent-status-c5-run-identity
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
## ELI5
|
||||
|
||||
<!-- Simple high-level explanation -->
|
||||
<!-- Simple high-level explanation, in plain language. No jargon. -->
|
||||
|
||||
## What Changed
|
||||
|
||||
<!-- Describe the change clearly and keep scope tight. -->
|
||||
<!-- Describe the change clearly and keep scope tight. Cover the before and after as the user experiences it, and the mechanism you changed — not just the symptom. -->
|
||||
|
||||
## Why
|
||||
|
||||
<!-- What problem does this solve, and why is this approach right? -->
|
||||
<!-- What problem does this solve, and why is this approach better than the alternatives you considered? -->
|
||||
|
||||
## Linked Issue
|
||||
|
||||
@@ -47,7 +47,7 @@ Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Re
|
||||
## Checklist
|
||||
|
||||
- [ ] This PR is small and focused
|
||||
- [ ] I explained what changed and why (including ELI5)
|
||||
- [ ] I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
|
||||
- [ ] Before/after screenshots or videos attached for UI changes, or `N/A` with reason
|
||||
- [ ] Self-reviewed for correctness, security, and performance
|
||||
- [ ] Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
|
||||
|
||||
@@ -213,10 +213,12 @@ jobs:
|
||||
c7|c8|c9|c10|c13|c14|c15|c16|c19|c20|c21|c22|c23|c24|c25|c26)
|
||||
EXPECTED_HARD_CAP=1000
|
||||
EXPECTED_REGION=us-central1
|
||||
EXPECTED_DATABASE_POOL_MAX=
|
||||
;;
|
||||
c27|c28|c29)
|
||||
EXPECTED_HARD_CAP=3000
|
||||
EXPECTED_REGION=asia-east2
|
||||
EXPECTED_DATABASE_POOL_MAX=16
|
||||
;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
@@ -236,6 +238,10 @@ jobs:
|
||||
test "$(jq -r '.connection_hard_cap' <<< "${CURRENT_SHAPE}")" = "${EXPECTED_HARD_CAP}"
|
||||
test "$(jq -r '.connection_unobserved_bound' <<< "${CURRENT_SHAPE}")" = \
|
||||
"${EXPECTED_UNOBSERVED_BOUND}"
|
||||
# The startup script emits a pool line only off the root default, so an unpinned cell
|
||||
# must still be on that default or its plan would carry a line nothing reviews.
|
||||
test "$(jq -r '.database_pool_max' <<< "${CURRENT_SHAPE}")" = \
|
||||
"${EXPECTED_DATABASE_POOL_MAX:-10}"
|
||||
TARGET_ZONE="$(jq -r '.zone' <<< "${CURRENT_SHAPE}")"
|
||||
MIG_NAME="orca-cloud-relay-gce-${TARGET_HOSTNAME}"
|
||||
if test "${DEPLOY_MODE}" = rollback; then
|
||||
@@ -264,6 +270,7 @@ jobs:
|
||||
echo "MIG_NAME=${MIG_NAME}"
|
||||
echo "EXPECTED_HARD_CAP=${EXPECTED_HARD_CAP}"
|
||||
echo "EXPECTED_UNOBSERVED_BOUND=${EXPECTED_UNOBSERVED_BOUND}"
|
||||
echo "EXPECTED_DATABASE_POOL_MAX=${EXPECTED_DATABASE_POOL_MAX}"
|
||||
echo "EXPECTED_REGION=${EXPECTED_REGION}"
|
||||
echo "DESIRED_IMAGE=${DESIRED_IMAGE}"
|
||||
echo "DESIRED_IMAGE_DIGEST=${DESIRED_IMAGE_DIGEST}"
|
||||
@@ -461,6 +468,11 @@ jobs:
|
||||
env:
|
||||
DIRECTOR_RUNTIME_SERVICE_ACCOUNT: ${{ vars.PRODUCTION_GCP_RELAY_DIRECTOR_RUNTIME_SERVICE_ACCOUNT }}
|
||||
run: |
|
||||
# A cell on the root pool default emits no pool line, so pin one only where it exists.
|
||||
POOL_ARGUMENTS=()
|
||||
if test -n "${EXPECTED_DATABASE_POOL_MAX}"; then
|
||||
POOL_ARGUMENTS=(--database-pool-max "${EXPECTED_DATABASE_POOL_MAX}")
|
||||
fi
|
||||
# Zero resource changes prove the prior run's apply completed and no
|
||||
# restart will follow, keeping the incarnation check honest. Root
|
||||
# outputs may lag a targeted apply, so judge resource_changes only.
|
||||
@@ -502,6 +514,7 @@ jobs:
|
||||
--rehome-director-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \
|
||||
--rehome-audience https://relay.onorca.dev/v1/admin/host-drain \
|
||||
--regional-rehome-protocol "${DESIRED_REHOME_PROTOCOL}" \
|
||||
"${POOL_ARGUMENTS[@]}" \
|
||||
| jq -e '.changes == 2' >/dev/null
|
||||
fi
|
||||
gcloud compute instance-groups managed wait-until "${MIG_NAME}" --stable \
|
||||
@@ -514,6 +527,11 @@ jobs:
|
||||
CAPACITY_SERVICE_ACCOUNT: ${{ vars.PRODUCTION_GCP_RELAY_CAPACITY_SERVICE_ACCOUNT }}
|
||||
DIRECTOR_RUNTIME_SERVICE_ACCOUNT: ${{ vars.PRODUCTION_GCP_RELAY_DIRECTOR_RUNTIME_SERVICE_ACCOUNT }}
|
||||
run: |
|
||||
# A cell on the root pool default emits no pool line, so pin one only where it exists.
|
||||
POOL_ARGUMENTS=()
|
||||
if test -n "${EXPECTED_DATABASE_POOL_MAX}"; then
|
||||
POOL_ARGUMENTS=(--database-pool-max "${EXPECTED_DATABASE_POOL_MAX}")
|
||||
fi
|
||||
terraform -chdir=infra/terraform plan \
|
||||
-var-file=environments/production.tfvars \
|
||||
-var-file="${RUNNER_TEMP}/relay-same-cap.tfvars.json" \
|
||||
@@ -528,7 +546,8 @@ jobs:
|
||||
--rollback-image "${IMAGE_REPOSITORY}@${CURRENT_IMAGE_DIGEST}" \
|
||||
--rehome-director-service-account "${DIRECTOR_RUNTIME_SERVICE_ACCOUNT}" \
|
||||
--rehome-audience https://relay.onorca.dev/v1/admin/host-drain \
|
||||
--regional-rehome-protocol "${DESIRED_REHOME_PROTOCOL}"
|
||||
--regional-rehome-protocol "${DESIRED_REHOME_PROTOCOL}" \
|
||||
"${POOL_ARGUMENTS[@]}"
|
||||
terraform -chdir=infra/terraform apply -auto-approve \
|
||||
"${RUNNER_TEMP}/relay-same-cap.tfplan"
|
||||
gcloud compute instance-groups managed wait-until "${MIG_NAME}" --stable \
|
||||
|
||||
@@ -12,18 +12,45 @@ on:
|
||||
# Why: the mobile terminal link parsers are conformance-tested against
|
||||
# these shared fixtures; desktop-side fixture edits must re-run this suite.
|
||||
- 'src/shared/terminal-file-link-conformance.ts'
|
||||
# Why: mobile imports the negotiated capability names directly and records
|
||||
# the whole capability read verbatim in its goldens, so a capability added
|
||||
# desktop-side rewrites a mobile fixture and must re-run this suite.
|
||||
- 'src/shared/protocol-version.ts'
|
||||
# Why: mobile's rpc-params-contract.ts is a type-only re-export of the
|
||||
# generated params catalog, and mobile/tsconfig.json includes **/*.ts. A
|
||||
# schema edit anywhere under here changes mobile's types, so a desktop-only
|
||||
# change can break mobile's typecheck with no other mobile signal.
|
||||
- 'src/shared/rpc-contract/**'
|
||||
# Why: the catalog above holds params only. This file is the sole holder of
|
||||
# the agent.launch RESULT shape, and mobile imports it as a value, not just
|
||||
# a type. CROSS_VERSION_WIRE_PREFIXES already treats it as wire-critical, so
|
||||
# without this one gate classes it that way while this one cannot see it.
|
||||
- 'src/shared/agent-launch-intent.ts'
|
||||
# Why: this job holds the only checks that load the Fastfile, so edits to
|
||||
# it or to the release workflow it guards must re-run them.
|
||||
- '.github/workflows/mobile.yml'
|
||||
- '.github/actions/install-node-dependencies/**'
|
||||
- '.github/workflows/mobile-ios-release.yml'
|
||||
# Why main too: a behaviour-change branch legitimately pins its own last fenced commit, and that
|
||||
# commit only stops being reachable when the branch squash-merges. The pull_request run cannot
|
||||
# see that; this one is where the pin guard finds it.
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'mobile/**'
|
||||
- '.github/workflows/mobile.yml'
|
||||
|
||||
concurrency:
|
||||
group: mobile-${{ github.event.pull_request.number || github.ref }}
|
||||
# Per commit on main, not per branch. GitHub cancels any PENDING run in a group when a new one
|
||||
# queues, whatever `cancel-in-progress` says, so one shared main group drops the middle merge of
|
||||
# three -- and a pin that breaks there is exactly what this workflow now checks for.
|
||||
group: mobile-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
@@ -87,3 +114,56 @@ jobs:
|
||||
|
||||
- name: Check formatting
|
||||
run: pnpm format:check
|
||||
|
||||
recording-pin:
|
||||
name: RPC recording pin
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: mobile
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# The ancestry verdict is read straight off history. On a shallow checkout
|
||||
# `git merge-base --is-ancestor` answers from grafted parents, so the guard refuses to
|
||||
# answer at all rather than reporting a pass it has no evidence for -- and the pinned tree
|
||||
# below has to be checkable out.
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: ./.github/actions/install-node-dependencies
|
||||
with:
|
||||
cache-dependency-path: |
|
||||
pnpm-lock.yaml
|
||||
mobile/pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# Seconds. No `--ref`, so the pin is judged against the same tree it was read out of. On a
|
||||
# pull request that is the merge preview, which already carries main's repins; judging the
|
||||
# branch head instead fails every branch cut before the day's repin, and its instruction would
|
||||
# tell the author to pin their own head -- creating the break this guard exists to catch. A
|
||||
# branch that pins its own commit passes here and fails on the push after the squash, which is
|
||||
# where the pin actually leaves the history.
|
||||
- name: Check the recording pin is reachable
|
||||
shell: bash
|
||||
run: pnpm exec tsx scripts/rpc-recording-pin-guard.mts ancestry
|
||||
|
||||
# ~2 min locally for the record itself, so it is gated rather than run twice over. A pull
|
||||
# request that moves none of the corpus, the manifest or the recorder cannot move this
|
||||
# verdict away from the one the base commit already published, and `verify` replays the
|
||||
# corpus against the branch tree in the meantime. A push to main has no `verify` job and is
|
||||
# where a squash lands a spliced corpus, so there it always runs.
|
||||
- name: Reproduce the corpus from the pinned tree
|
||||
shell: bash
|
||||
env:
|
||||
PIN_GUARD_BASE: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ -n "$PIN_GUARD_BASE" ]; then
|
||||
pnpm exec tsx scripts/rpc-recording-pin-guard.mts reproduce --if-changed-since "$PIN_GUARD_BASE"
|
||||
else
|
||||
pnpm exec tsx scripts/rpc-recording-pin-guard.mts reproduce
|
||||
fi
|
||||
|
||||
@@ -871,6 +871,11 @@ jobs:
|
||||
pnpm exec vitest run --config config/vitest.config.ts
|
||||
config/scripts/rebuild-native-deps.test.mjs
|
||||
config/scripts/rebuild-native-deps-windows-process-tree.test.mjs
|
||||
config/scripts/rebuild-native-deps-node-pty.test.mjs
|
||||
config/scripts/ensure-native-runtime-job-ownership.test.mjs
|
||||
config/scripts/verify-packaged-node-pty-job-ownership.test.mjs
|
||||
config/scripts/windows-pe-machine.test.mjs
|
||||
config/scripts/script-module-dependencies.test.mjs
|
||||
src/main/windows-registry-addon.test.ts
|
||||
config/scripts/windows-process-tree-gyp-path.test.mjs
|
||||
config/scripts/windows-process-tree-gyp-rebuild.test.mjs
|
||||
|
||||
@@ -122,6 +122,7 @@ docs/**
|
||||
!docs/reference/windows-cmd-shim-resolution.md
|
||||
!docs/reference/windows-daemon-host-relocation.md
|
||||
!docs/reference/windows-edr-posture.md
|
||||
!docs/reference/windows-msys-job-breakaway.md
|
||||
!docs/reference/windows-process-enumeration.md
|
||||
!docs/reference/wsl-runner-verification.md
|
||||
!docs/reference/remote-wire-compatibility.md
|
||||
|
||||
@@ -48,6 +48,17 @@ Avoid type assertions except `as const`. Unavoidable casts need a line-specific
|
||||
- **Lint**: `oxlint`, or `pnpm run check:code-quality:changed` for changed files (full `pnpm lint` is slow); format with `pnpm format`
|
||||
- **Design system**: `pnpm run lint:design-system` for the full renderer report (not a gate); the changed-lines gate above is what CI enforces
|
||||
|
||||
# Writing Pull Requests
|
||||
|
||||
Fill in [`.github/pull_request_template.md`](./.github/pull_request_template.md), written for a reviewer who has never seen this code:
|
||||
|
||||
- No jargon — plain language, no internal shorthand.
|
||||
- The before and after as the user experiences it.
|
||||
- The mechanism you changed, not just the symptom.
|
||||
- Why this approach over the alternatives you considered.
|
||||
|
||||
Cover all four concisely. Don't pad or walk the diff.
|
||||
|
||||
# Considerations
|
||||
|
||||
## Worktree Safety
|
||||
@@ -65,6 +76,7 @@ Orca targets macOS, Linux, and Windows. Keep all platform-dependent behavior beh
|
||||
- **Windows setup scripts**: the setup/issue-command runner is a `.cmd` batch file unless the script starts with a `#!` line — never derive that from the user's terminal-shell preference, and never launch a `.cmd` runner with a bare `cmd.exe /c` from a Git Bash pane (MSYS rewrites the `/c`). See [`docs/reference/windows-setup-shell.md`](./docs/reference/windows-setup-shell.md).
|
||||
- **Windows child processes**: start them through `runProcess`/`spawnProcess` in `src/shared/child-process/` — never `child_process` directly. It pins `windowsHide`, refuses `shell: true`, and encodes `.cmd`/`.bat` arguments so neither `CommandLineToArgvW` nor `cmd.exe` mangles them. A ratchet test fails on any new direct import. Recognised npm/pnpm `.cmd` shims are resolved to their real target so the spawn skips `cmd.exe` entirely; see [`docs/reference/windows-cmd-shim-resolution.md`](./docs/reference/windows-cmd-shim-resolution.md) before adding a shim shape or debugging one.
|
||||
- **Windows process enumeration**: read the table through `src/main/windows/windows-process-table.ts`, never by forking `powershell.exe`. See [`docs/reference/windows-process-enumeration.md`](./docs/reference/windows-process-enumeration.md).
|
||||
- **Windows MSYS/Git Bash panes**: their children break away from the per-PTY job unless it is created without `JOB_OBJECT_LIMIT_BREAKAWAY_OK`, and a `conpty.node` built before that fix passes every existing gate. Before changing the per-PTY job or debugging `windows-msys-job.win32.test.ts`, read [`docs/reference/windows-msys-job-breakaway.md`](./docs/reference/windows-msys-job-breakaway.md).
|
||||
- **Windows daemon-host relocation**: the terminal daemon runs from a copy of the app runtime under `%LOCALAPPDATA%`, which is what survives an auto-update. Before touching that copy, its exe name, or the NSIS uninstall macro, read [`docs/reference/windows-daemon-host-relocation.md`](./docs/reference/windows-daemon-host-relocation.md).
|
||||
- **Windows EDR signal**: don't add `-ExecutionPolicy Bypass`, `-EncodedCommand`, `cmd.exe /c` with escaped free text, per-operation interpreter spawning, or runtime `Add-Type` compilation without reading [`docs/reference/windows-edr-posture.md`](./docs/reference/windows-edr-posture.md) first — behavioural EDR scores each of those, and being signed does not clear them.
|
||||
- **WSL commands**: build argv with `buildWslExecArgs` (always `--exec` — under `--`, `wsl.exe` expands `$name` in every argument and silently rewrites the script), and fence anything whose stdout you parse with `buildWslCapturedLoginShellCommand`, because the interactive login shell prints the distro banner to stdout. See [`docs/reference/wsl-command-execution.md`](./docs/reference/wsl-command-execution.md).
|
||||
|
||||
@@ -30,6 +30,6 @@
|
||||
"@types/pg": "^8.20.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.8"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +213,10 @@ async function applySchemaOnUntimedPool(
|
||||
const database = new PostgresDatabase(pool)
|
||||
try {
|
||||
await applyPostgresSchema(pushSchemaStatements(), (statement) => database.query(statement), {
|
||||
eventPrefix: 'orca_push_postgres_schema'
|
||||
eventPrefix: 'orca_push_postgres_schema',
|
||||
// Push has no catalog pre-check, so a lock timeout here says nothing about whether the
|
||||
// object already exists and the old bounded retry is still the right answer.
|
||||
retryLockTimeout: true
|
||||
})
|
||||
} finally {
|
||||
await database.close().catch(() => undefined)
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
"@types/node": "^24.10.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.8"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,6 @@
|
||||
"@types/node": "^24.10.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.8"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,6 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.8"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type AssignmentAdmissionRejection
|
||||
} from './public-assignment-admission.js'
|
||||
import { relayHostLogDigest } from './relay-host-log-digest.js'
|
||||
import type { RelayReadinessDependency } from './relay-readiness.js'
|
||||
import type { RegionalRehomeSafetySnapshot, RelayRuntimeCounts } from './relay-observability.js'
|
||||
import {
|
||||
isRegionalRehomeTrustProbe,
|
||||
@@ -96,6 +97,7 @@ export function createRelayApp(
|
||||
regionalRehomeSafetySnapshot?: () => RegionalRehomeSafetySnapshot
|
||||
runtimeCounts?: () => RelayRuntimeCounts
|
||||
ready: () => Promise<boolean>
|
||||
readinessDegradation?: () => RelayReadinessDependency[]
|
||||
recordAssignmentAdmission?: (
|
||||
outcome: 'sticky' | 'sticky-rejected' | 'placement' | 'placement-rejected'
|
||||
) => void
|
||||
@@ -212,11 +214,13 @@ export function createRelayApp(
|
||||
app.get('/health', (context) =>
|
||||
context.json({ ok: true, connectionCapacityProtocol: 2 })
|
||||
)
|
||||
app.get('/ready', async (context) =>
|
||||
(await operations.ready())
|
||||
? context.json({ ok: true })
|
||||
: context.json({ error: 'dependency_unavailable' }, 503)
|
||||
)
|
||||
app.get('/ready', async (context) => {
|
||||
if (!(await operations.ready())) return context.json({ error: 'dependency_unavailable' }, 503)
|
||||
const dependency = operations.readinessDegradation?.() ?? []
|
||||
// Still the 200 the load balancer needs, with the marker that says the answer is remembered.
|
||||
if (dependency.length === 0) return context.json({ ok: true })
|
||||
return context.json({ ok: true, degraded: true, dependency })
|
||||
})
|
||||
app.get('/v1/regions', async (context) => {
|
||||
if (config.role === 'cell') return context.json({ error: 'director_only' }, 404)
|
||||
return context.json({ v: 1, regions: await regionCatalog() })
|
||||
|
||||
@@ -67,4 +67,46 @@ describe('cell inventory hold samples', () => {
|
||||
expect(samples.consumeCounts().cellInventoryHolds).toBe(2)
|
||||
expect(samples.consumeCounts()).toEqual(emptyCellInventoryHoldCounts())
|
||||
})
|
||||
|
||||
// Why: this is the case the hold metrics alone cannot see. A NOWAIT grab that
|
||||
// fails has no duration, so a retry storm used to leave every hold field at
|
||||
// zero while the lock was saturated.
|
||||
it('counts failed acquisitions in a window that recorded no holds', () => {
|
||||
const samples = new CellInventoryHoldSamples()
|
||||
for (let attempt = 0; attempt < 65; attempt++) samples.recordUnavailable()
|
||||
|
||||
const counts = samples.readCounts()
|
||||
|
||||
expect(counts.cellInventoryLockUnavailable).toBe(65)
|
||||
expect(counts.cellInventoryHolds).toBe(0)
|
||||
expect(counts.cellInventoryHoldMsMax).toBe(0)
|
||||
})
|
||||
|
||||
it('reports failed acquisitions alongside the holds that did succeed', () => {
|
||||
const samples = samplesOf([12, 34])
|
||||
samples.recordUnavailable(3)
|
||||
|
||||
expect(samples.readCounts()).toMatchObject({
|
||||
cellInventoryHolds: 2,
|
||||
cellInventoryHoldMsMax: 34,
|
||||
cellInventoryLockUnavailable: 3
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a failure count that is not a positive number', () => {
|
||||
const samples = new CellInventoryHoldSamples()
|
||||
samples.recordUnavailable(0)
|
||||
samples.recordUnavailable(-2)
|
||||
samples.recordUnavailable(Number.NaN)
|
||||
|
||||
expect(samples.readCounts()).toEqual(emptyCellInventoryHoldCounts())
|
||||
})
|
||||
|
||||
it('resets failed acquisitions on consume', () => {
|
||||
const samples = new CellInventoryHoldSamples()
|
||||
samples.recordUnavailable(4)
|
||||
|
||||
expect(samples.consumeCounts().cellInventoryLockUnavailable).toBe(4)
|
||||
expect(samples.consumeCounts()).toEqual(emptyCellInventoryHoldCounts())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,14 @@ export type CellInventoryHoldCounts = {
|
||||
cellInventoryHoldMsMax: number
|
||||
cellInventoryHoldMsP95: number
|
||||
cellInventoryHolds: number
|
||||
// Why: a failed acquisition produces no hold sample, so the hold fields alone
|
||||
// read healthy while the lock is saturated. Split by wait policy, not by
|
||||
// caller: fail-fast covers background sweeps that step aside by design AND
|
||||
// request-path first attempts that retry, so it reads as contention pressure,
|
||||
// not user-visible failure. An expired bounded wait has already spent its
|
||||
// budget, so that lane is the one that tracks stalls.
|
||||
cellInventoryLockUnavailable: number
|
||||
cellInventoryLockTimeouts: number
|
||||
}
|
||||
|
||||
// Bounded so a flush interval with heavy assignment traffic cannot grow the array
|
||||
@@ -12,11 +20,19 @@ export type CellInventoryHoldCounts = {
|
||||
const MAX_SAMPLES = 2_048
|
||||
|
||||
export function emptyCellInventoryHoldCounts(): CellInventoryHoldCounts {
|
||||
return { cellInventoryHoldMsMax: 0, cellInventoryHoldMsP95: 0, cellInventoryHolds: 0 }
|
||||
return {
|
||||
cellInventoryHoldMsMax: 0,
|
||||
cellInventoryHoldMsP95: 0,
|
||||
cellInventoryHolds: 0,
|
||||
cellInventoryLockUnavailable: 0,
|
||||
cellInventoryLockTimeouts: 0
|
||||
}
|
||||
}
|
||||
|
||||
export class CellInventoryHoldSamples {
|
||||
private samples: number[] = []
|
||||
private unavailable = 0
|
||||
private timeouts = 0
|
||||
|
||||
record(holdMs: number): void {
|
||||
if (!Number.isFinite(holdMs) || holdMs < 0) return
|
||||
@@ -24,19 +40,37 @@ export class CellInventoryHoldSamples {
|
||||
this.samples.push(holdMs)
|
||||
}
|
||||
|
||||
// Counted, not sampled: a failed acquisition has no duration to record.
|
||||
recordUnavailable(count = 1): void {
|
||||
if (!Number.isFinite(count) || count <= 0) return
|
||||
this.unavailable += count
|
||||
}
|
||||
|
||||
recordLockTimeout(count = 1): void {
|
||||
if (!Number.isFinite(count) || count <= 0) return
|
||||
this.timeouts += count
|
||||
}
|
||||
|
||||
consumeCounts(): CellInventoryHoldCounts {
|
||||
const counts = this.readCounts()
|
||||
this.samples = []
|
||||
this.unavailable = 0
|
||||
this.timeouts = 0
|
||||
return counts
|
||||
}
|
||||
|
||||
readCounts(): CellInventoryHoldCounts {
|
||||
if (this.samples.length === 0) return emptyCellInventoryHoldCounts()
|
||||
const failures = {
|
||||
cellInventoryLockUnavailable: this.unavailable,
|
||||
cellInventoryLockTimeouts: this.timeouts
|
||||
}
|
||||
if (this.samples.length === 0) return { ...emptyCellInventoryHoldCounts(), ...failures }
|
||||
const sorted = [...this.samples].sort((left, right) => left - right)
|
||||
return {
|
||||
cellInventoryHoldMsMax: round(sorted[sorted.length - 1]!),
|
||||
cellInventoryHoldMsP95: round(sorted[Math.ceil(0.95 * sorted.length) - 1] ?? 0),
|
||||
cellInventoryHolds: sorted.length
|
||||
cellInventoryHolds: sorted.length,
|
||||
...failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,64 @@ describe('bounded cell-inventory lock wait', () => {
|
||||
await database.close()
|
||||
})
|
||||
|
||||
// Why: the 55P03 rolls the transaction back, so a drain on the commit path
|
||||
// alone would report zero for exactly the windows that were contended.
|
||||
it('reports a NOWAIT deferral that rolled its transaction back', async () => {
|
||||
const database = await openFakePostgres()
|
||||
fakes.query.mockImplementation(async (sql: string) => {
|
||||
if (sql.includes('FOR UPDATE NOWAIT')) {
|
||||
throw Object.assign(new Error('could not obtain lock'), { code: '55P03' })
|
||||
}
|
||||
return { rows: [], rowCount: 0 }
|
||||
})
|
||||
|
||||
await expect(
|
||||
database.transaction(async (transaction) => {
|
||||
await transaction.queryLocked(CELL_INVENTORY_SQL, [], {
|
||||
failIfUnavailable: true,
|
||||
measureHoldMs: true
|
||||
})
|
||||
})
|
||||
).rejects.toThrow('database_lock_unavailable')
|
||||
|
||||
const counts = consumeRelayCellInventoryHold(database)
|
||||
expect(counts.cellInventoryLockUnavailable).toBe(1)
|
||||
expect(counts.cellInventoryLockTimeouts).toBe(0)
|
||||
await database.close()
|
||||
})
|
||||
|
||||
// Why: a bounded request-path wait raises the same 55P03 without NOWAIT. Folding
|
||||
// it into the deferral counter would hide user-visible stalls among by-design
|
||||
// sweep skips, which outnumber them by roughly an order of magnitude.
|
||||
it('counts an expired bounded wait apart from a NOWAIT deferral', async () => {
|
||||
const database = await openFakePostgres()
|
||||
fakes.query.mockImplementation(async (sql: string) => {
|
||||
if (sql.includes('FOR UPDATE') && !sql.includes('NOWAIT')) {
|
||||
throw Object.assign(new Error('canceling statement due to lock timeout'), {
|
||||
code: '55P03'
|
||||
})
|
||||
}
|
||||
return { rows: [], rowCount: 0 }
|
||||
})
|
||||
|
||||
await expect(
|
||||
database.transaction(async (transaction) => {
|
||||
await transaction.queryLocked(CELL_INVENTORY_SQL, [], {
|
||||
lockTimeoutMs: 500,
|
||||
measureHoldMs: true
|
||||
})
|
||||
})
|
||||
).rejects.toThrow()
|
||||
|
||||
const counts = consumeRelayCellInventoryHold(database)
|
||||
// One per attempt, not per request: 55P03 is retryable, so an exhausted
|
||||
// request contributes POSTGRES_TRANSACTION_ATTEMPTS timeouts. Reading the
|
||||
// metric as affected-requests would overstate it threefold.
|
||||
expect(counts.cellInventoryLockTimeouts).toBe(3)
|
||||
expect(counts.cellInventoryLockUnavailable).toBe(0)
|
||||
await database.close()
|
||||
})
|
||||
|
||||
it('records no hold for a PostgreSQL transaction that took no measured lock', async () => {
|
||||
const database = await openFakePostgres()
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
RELAY_PUBLIC_RESOLVE_CONCURRENCY,
|
||||
RELAY_PUBLIC_RESOLVE_WAIT_MS
|
||||
} from './config.js'
|
||||
import {
|
||||
RELAY_MAX_READINESS_GRACE_MS,
|
||||
RELAY_READINESS_JWKS_GRACE_MS,
|
||||
RELAY_READINESS_SQL_GRACE_MS
|
||||
} from './relay-readiness.js'
|
||||
|
||||
function cellEnvironment(capacity: number): NodeJS.ProcessEnv {
|
||||
return {
|
||||
@@ -36,6 +41,35 @@ describe('GCE relay capacity configuration', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('defaults readiness grace to fifteen minutes for JWKS and three for SQL', () => {
|
||||
const env = cellEnvironment(4_000)
|
||||
expect(loadRelayConfig(env)).toMatchObject({
|
||||
readinessJwksGraceMs: RELAY_READINESS_JWKS_GRACE_MS,
|
||||
readinessSqlGraceMs: RELAY_READINESS_SQL_GRACE_MS
|
||||
})
|
||||
expect(RELAY_READINESS_SQL_GRACE_MS).toBeLessThan(RELAY_READINESS_JWKS_GRACE_MS)
|
||||
env.ORCA_RELAY_READINESS_JWKS_GRACE_MS = '0'
|
||||
env.ORCA_RELAY_READINESS_SQL_GRACE_MS = String(RELAY_MAX_READINESS_GRACE_MS)
|
||||
expect(loadRelayConfig(env)).toMatchObject({
|
||||
readinessJwksGraceMs: 0,
|
||||
readinessSqlGraceMs: RELAY_MAX_READINESS_GRACE_MS
|
||||
})
|
||||
for (const invalid of ['-1', String(RELAY_MAX_READINESS_GRACE_MS + 1), '1.5', 'soon']) {
|
||||
env.ORCA_RELAY_READINESS_JWKS_GRACE_MS = invalid
|
||||
expect(() => loadRelayConfig(env)).toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('reads an unset readiness grace variable as the default, never as zero', () => {
|
||||
const env = cellEnvironment(4_000)
|
||||
env.ORCA_RELAY_READINESS_JWKS_GRACE_MS = ''
|
||||
env.ORCA_RELAY_READINESS_SQL_GRACE_MS = ''
|
||||
expect(loadRelayConfig(env)).toMatchObject({
|
||||
readinessJwksGraceMs: RELAY_READINESS_JWKS_GRACE_MS,
|
||||
readinessSqlGraceMs: RELAY_READINESS_SQL_GRACE_MS
|
||||
})
|
||||
})
|
||||
|
||||
it('requires distinct dedicated admin identities and accepts omitted values', () => {
|
||||
const env = cellEnvironment(4_000)
|
||||
expect(loadRelayConfig(env)).toMatchObject({
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
type RelayCellConnectionHardCap,
|
||||
type RelayRegion
|
||||
} from '@orca-cloud/relay-contract'
|
||||
import {
|
||||
RELAY_MAX_READINESS_GRACE_MS,
|
||||
RELAY_READINESS_JWKS_GRACE_MS,
|
||||
RELAY_READINESS_SQL_GRACE_MS
|
||||
} from './relay-readiness.js'
|
||||
|
||||
export const RELAY_MAX_CELL_CAPACITY_REQUESTS = 100_000
|
||||
export const RELAY_DATABASE_POOL_MAX = 10
|
||||
@@ -31,6 +36,14 @@ const OptionalServiceAccountSchema = z.preprocess(
|
||||
z.string().email().optional()
|
||||
)
|
||||
|
||||
// 0 disables the window and restores the fail-on-first-error readiness answer. An unset variable
|
||||
// arrives as '' from Cloud Run, which z.coerce would read as 0 rather than as the default.
|
||||
const readinessGraceSchema = (defaultMs: number) =>
|
||||
z.preprocess(
|
||||
(value) => (value === '' ? undefined : value),
|
||||
z.coerce.number().int().min(0).max(RELAY_MAX_READINESS_GRACE_MS).default(defaultMs)
|
||||
)
|
||||
|
||||
const EnvSchema = z.object({
|
||||
PORT: z.coerce.number().int().positive().default(8080),
|
||||
ORCA_RELAY_PUBLIC_URL: z.string().url(),
|
||||
@@ -81,6 +94,8 @@ const EnvSchema = z.object({
|
||||
.optional(),
|
||||
ORCA_RELAY_ADMIN_JWKS_URL: z.string().url().default('https://www.googleapis.com/oauth2/v3/certs'),
|
||||
ORCA_RELAY_DATABASE_POOL_MAX: z.coerce.number().int().positive().max(100).optional(),
|
||||
ORCA_RELAY_READINESS_JWKS_GRACE_MS: readinessGraceSchema(RELAY_READINESS_JWKS_GRACE_MS),
|
||||
ORCA_RELAY_READINESS_SQL_GRACE_MS: readinessGraceSchema(RELAY_READINESS_SQL_GRACE_MS),
|
||||
ORCA_RELAY_PUBLIC_ASSIGNMENTS_ENABLED: EnvironmentBooleanSchema,
|
||||
ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED: EnvironmentBooleanSchema,
|
||||
ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT: z.coerce.number().int().min(0).max(100).default(0),
|
||||
@@ -187,6 +202,8 @@ export type RelayConfig = {
|
||||
connectionUnobservedBound?: number
|
||||
adminJwksUrl: string
|
||||
databasePoolMax: number
|
||||
readinessJwksGraceMs?: number
|
||||
readinessSqlGraceMs?: number
|
||||
publicAssignmentsEnabled: boolean
|
||||
regionalPlacementEnabled?: boolean
|
||||
regionCorrectionCohortPercent?: number
|
||||
@@ -335,6 +352,8 @@ export function loadRelayConfig(env: NodeJS.ProcessEnv = process.env): RelayConf
|
||||
connectionUnobservedBound: ownCell.connectionUnobservedBound,
|
||||
adminJwksUrl: parsed.ORCA_RELAY_ADMIN_JWKS_URL,
|
||||
databasePoolMax,
|
||||
readinessJwksGraceMs: parsed.ORCA_RELAY_READINESS_JWKS_GRACE_MS,
|
||||
readinessSqlGraceMs: parsed.ORCA_RELAY_READINESS_SQL_GRACE_MS,
|
||||
publicAssignmentsEnabled: parsed.ORCA_RELAY_PUBLIC_ASSIGNMENTS_ENABLED,
|
||||
regionalPlacementEnabled: parsed.ORCA_RELAY_REGIONAL_PLACEMENT_ENABLED,
|
||||
regionCorrectionCohortPercent: parsed.ORCA_RELAY_REGION_CORRECTION_COHORT_PERCENT,
|
||||
|
||||
@@ -119,15 +119,21 @@ describe('PostgreSQL relay deadlines', () => {
|
||||
dataDir: './unused'
|
||||
})
|
||||
|
||||
expect(ddl.length).toBeGreaterThan(0)
|
||||
expect(ddl).toContain(POSTGRES_STATEMENT_STATS_MIGRATION)
|
||||
// The catalog pre-check reads pg_catalog on the same untimed connection before each
|
||||
// lock-taking statement, so the schema pool now carries reads as well as DDL.
|
||||
const probes = ddl.filter((statement) => /^SELECT\b/i.test(statement))
|
||||
const statements = ddl.filter((statement) => !/^SELECT\b/i.test(statement))
|
||||
expect(probes.length).toBeGreaterThan(0)
|
||||
expect(probes.every((statement) => statement.includes('pg_catalog'))).toBe(true)
|
||||
expect(statements.length).toBeGreaterThan(0)
|
||||
expect(statements).toContain(POSTGRES_STATEMENT_STATS_MIGRATION.trim())
|
||||
// Statements can open with a leading `--` rationale comment.
|
||||
const body = (statement: string): string =>
|
||||
statement.replace(/^(?:\s*--[^\n]*\n)*\s*/, '')
|
||||
expect(
|
||||
ddl.every(
|
||||
statements.every(
|
||||
(statement) =>
|
||||
statement === POSTGRES_STATEMENT_STATS_MIGRATION ||
|
||||
statement === POSTGRES_STATEMENT_STATS_MIGRATION.trim() ||
|
||||
/^(?:CREATE|ALTER TABLE)\b/i.test(body(statement))
|
||||
)
|
||||
).toBe(true)
|
||||
@@ -201,11 +207,11 @@ describe('PostgreSQL relay deadlines', () => {
|
||||
})
|
||||
|
||||
describe('PostgreSQL schema startup', () => {
|
||||
it('retries lock and statement timeouts with bounded backoff', async () => {
|
||||
it('retries statement timeouts with bounded backoff', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const query = vi
|
||||
.fn<(statement: string) => Promise<unknown>>()
|
||||
.mockRejectedValueOnce(Object.assign(new Error('lock timeout'), { code: '55P03' }))
|
||||
.mockRejectedValueOnce(Object.assign(new Error('statement timeout'), { code: '57014' }))
|
||||
.mockRejectedValueOnce(Object.assign(new Error('statement timeout'), { code: '57014' }))
|
||||
.mockResolvedValue(undefined)
|
||||
const delays: number[] = []
|
||||
@@ -221,6 +227,31 @@ describe('PostgreSQL schema startup', () => {
|
||||
expect(delays).toEqual([125, 250])
|
||||
})
|
||||
|
||||
it('fails the boot on a lock timeout instead of re-entering the lock queue', async () => {
|
||||
// The catalog pre-check already answered that the object is missing, so a lock timeout means
|
||||
// this boot lost the queue. Relation locks are granted in queue order, so each retry parks
|
||||
// every writer behind it for another timeout.
|
||||
const errors: string[] = []
|
||||
vi.spyOn(console, 'error').mockImplementation((line: string) => {
|
||||
errors.push(line)
|
||||
})
|
||||
const error = Object.assign(new Error('lock timeout'), { code: '55P03' })
|
||||
const query = vi.fn<(statement: string) => Promise<unknown>>().mockRejectedValue(error)
|
||||
const pause = vi.fn(async () => undefined)
|
||||
|
||||
await expect(
|
||||
applyPostgresSchema(['CREATE INDEX IF NOT EXISTS i ON t(c)'], query, { wait: pause })
|
||||
).rejects.toBe(error)
|
||||
|
||||
expect(query).toHaveBeenCalledTimes(1)
|
||||
expect(pause).not.toHaveBeenCalled()
|
||||
expect(JSON.parse(errors[0] ?? '{}')).toMatchObject({
|
||||
event: 'orca_relay_postgres_schema_lock_timeout',
|
||||
code: '55P03',
|
||||
statement: 'CREATE INDEX IF NOT EXISTS i ON t(c)'
|
||||
})
|
||||
})
|
||||
|
||||
it('retries only the PostgreSQL concurrent type-creation collision', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const collision = Object.assign(new Error('duplicate type'), {
|
||||
@@ -386,7 +417,7 @@ describe('PostgreSQL schema startup', () => {
|
||||
|
||||
it('stops retrying at the shared startup deadline', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const error = Object.assign(new Error('lock timeout'), { code: '55P03' })
|
||||
const error = Object.assign(new Error('statement timeout'), { code: '57014' })
|
||||
const delays: number[] = []
|
||||
let now = 0
|
||||
const query = vi
|
||||
@@ -413,7 +444,7 @@ describe('PostgreSQL schema startup', () => {
|
||||
expect(console.warn).toHaveBeenLastCalledWith(
|
||||
JSON.stringify({
|
||||
event: 'orca_relay_postgres_schema_retry_exhausted',
|
||||
code: '55P03',
|
||||
code: '57014',
|
||||
attempts: 2
|
||||
})
|
||||
)
|
||||
|
||||
@@ -42,10 +42,12 @@ describePostgres('PostgreSQL statement deadline', () => {
|
||||
expect(result).toBe(2)
|
||||
}, 15_000)
|
||||
|
||||
// Why: DDL runs on its own untimed connection. relay_invites carries a
|
||||
// CREATE INDEX IF NOT EXISTS, which (unlike CREATE TABLE IF NOT EXISTS)
|
||||
// really does queue behind an ACCESS EXCLUSIVE lock on the table.
|
||||
it('applies the schema behind a held ACCESS EXCLUSIVE lock', async () => {
|
||||
// Why: relay_invites carries a CREATE INDEX IF NOT EXISTS, which (unlike CREATE TABLE IF NOT
|
||||
// EXISTS) really does queue behind an ACCESS EXCLUSIVE lock on the table. The catalog pre-check
|
||||
// asks pg_class whether that index is already there, and the read takes no lock on relay_invites,
|
||||
// so a boot on a migrated database no longer joins the queue at all. It used to, and every writer
|
||||
// queued behind it in lock order.
|
||||
it('boots without queueing behind a held ACCESS EXCLUSIVE lock', async () => {
|
||||
let releaseTable!: () => void
|
||||
const tableReleased = new Promise<void>((resolve) => {
|
||||
releaseTable = resolve
|
||||
@@ -61,7 +63,9 @@ describePostgres('PostgreSQL statement deadline', () => {
|
||||
})
|
||||
await tableHeldPromise
|
||||
|
||||
const opening = openRelayDatabase({
|
||||
// Resolving while the lock is still held is the whole proof: a statement that queued would hit
|
||||
// the schema connection's 1s lock_timeout and fail the boot, which is no longer retried.
|
||||
const database = await openRelayDatabase({
|
||||
databaseUrl,
|
||||
dataDir: '',
|
||||
applicationName,
|
||||
@@ -69,27 +73,18 @@ describePostgres('PostgreSQL statement deadline', () => {
|
||||
// connection must not.
|
||||
statementTimeoutMs: 200
|
||||
})
|
||||
const blockedOnSchemaConnection = async (): Promise<boolean> => {
|
||||
const deadline = Date.now() + 4_000
|
||||
while (Date.now() < deadline) {
|
||||
const rows = await databases[0]!.query(
|
||||
`SELECT count(*) AS waiting FROM pg_stat_activity
|
||||
WHERE datname = current_database() AND wait_event_type = 'Lock'
|
||||
AND application_name = ?`,
|
||||
[`${applicationName}/schema`]
|
||||
)
|
||||
if (Number(rows[0]!.waiting) > 0) return true
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
}
|
||||
return false
|
||||
}
|
||||
const blocked = await blockedOnSchemaConnection()
|
||||
databases.push(database)
|
||||
const waiting = await databases[0]!.query(
|
||||
`SELECT count(*) AS waiting FROM pg_stat_activity
|
||||
WHERE datname = current_database() AND wait_event_type = 'Lock'
|
||||
AND application_name = ?`,
|
||||
[`${applicationName}/schema`]
|
||||
)
|
||||
expect(Number(waiting[0]!.waiting)).toBe(0)
|
||||
|
||||
releaseTable()
|
||||
await holder
|
||||
|
||||
const database = await opening
|
||||
databases.push(database)
|
||||
expect(blocked).toBe(true)
|
||||
// The serving pool still carries the short deadline it was opened with.
|
||||
expect(await database.query(`SELECT current_setting('statement_timeout') AS statement_timeout`)).toEqual([
|
||||
{ statement_timeout: '200ms' }
|
||||
|
||||
@@ -68,6 +68,17 @@ export interface RelayDatabase {
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
// RULE - no new index and no new column on `relay_control_connection_reservations`,
|
||||
// `relay_confirm_results`, `relay_audit_events`, `relay_connection_bases`, or any other large
|
||||
// table may be added to SCHEMA or to POSTGRES_SCHEMA_MIGRATIONS. The catalog pre-check skips a
|
||||
// lock-taking statement only once the object exists, so a brand-new one reports missing on every
|
||||
// director at once and each runs a non-concurrent build over the whole table. POSTGRES_LOCK_TIMEOUT_MS
|
||||
// bounds how long that build waits for its lock, not how long it holds it. Build the index out of
|
||||
// band with CREATE INDEX CONCURRENTLY first, then add it here, where the pre-check skips it forever
|
||||
// after. relay-schema-lock-targets.test.ts pins the current list, so an addition fails CI.
|
||||
// Constraint swaps are matched by NAME in pg_constraint, never by body, because the CHECK list is
|
||||
// generated from REGION_LIST. Changing a constraint's definition under the same name therefore does
|
||||
// nothing on boot: an operator drops it, and the next boot adds the current definition back.
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS relay_invites (
|
||||
user_id TEXT NOT NULL,
|
||||
@@ -636,6 +647,14 @@ export const POSTGRES_SCHEMA_MIGRATIONS = [
|
||||
`ALTER TABLE relay_region_rehome_attempts ADD COLUMN IF NOT EXISTS source_generation BIGINT NOT NULL DEFAULT 0`
|
||||
]
|
||||
|
||||
// The exact statement list a Postgres boot applies, in order, so the lock-target census can read
|
||||
// what production runs rather than a copy of it. The SQLite path keeps SCHEMA on its own.
|
||||
export function relayPostgresSchemaStatements(): string[] {
|
||||
return [...SCHEMA.split(';'), ...POSTGRES_SCHEMA_MIGRATIONS]
|
||||
.map((statement) => statement.trim())
|
||||
.filter((statement) => statement.length > 0)
|
||||
}
|
||||
|
||||
function postgresSql(sql: string): string {
|
||||
let index = 0
|
||||
return sql.replace(/\?/g, () => `$${++index}`)
|
||||
@@ -784,6 +803,8 @@ class SqliteDatabase extends SqliteTransaction {
|
||||
class PostgresTransaction implements RelayDatabase {
|
||||
readonly dialect = 'postgres' as const
|
||||
private heldFromMs: number | undefined
|
||||
private lockUnavailable = 0
|
||||
private lockTimeouts = 0
|
||||
|
||||
constructor(protected readonly client: pg.PoolClient) {}
|
||||
|
||||
@@ -794,6 +815,20 @@ class PostgresTransaction implements RelayDatabase {
|
||||
return holdMs
|
||||
}
|
||||
|
||||
// Drained by the owning database on both the commit and the rollback path: a
|
||||
// 55P03 rolls the transaction back, so counting only on success would drop it.
|
||||
consumeLockUnavailable(): number {
|
||||
const count = this.lockUnavailable
|
||||
this.lockUnavailable = 0
|
||||
return count
|
||||
}
|
||||
|
||||
consumeLockTimeouts(): number {
|
||||
const count = this.lockTimeouts
|
||||
this.lockTimeouts = 0
|
||||
return count
|
||||
}
|
||||
|
||||
async query(sql: string, params: unknown[] = []): Promise<SqlRow[]> {
|
||||
try {
|
||||
const result = await this.client.query(postgresSql(sql), params)
|
||||
@@ -829,8 +864,14 @@ class PostgresTransaction implements RelayDatabase {
|
||||
options.failIfUnavailable &&
|
||||
String((error as { code?: unknown }).code) === '55P03'
|
||||
) {
|
||||
if (options.measureHoldMs) this.lockUnavailable += 1
|
||||
throw new Error('database_lock_unavailable')
|
||||
}
|
||||
// A bounded wait that expires raises the same 55P03 without NOWAIT. This is
|
||||
// the request path, so it is counted apart from by-design sweep deferrals.
|
||||
if (bounded && options.measureHoldMs && String((error as { code?: unknown }).code) === '55P03') {
|
||||
this.lockTimeouts += 1
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
// Restore on the error path too: the transaction may still be retried or
|
||||
@@ -950,6 +991,7 @@ class PostgresDatabase implements RelayDatabase {
|
||||
options.failIfUnavailable &&
|
||||
String((error as { code?: unknown }).code) === '55P03'
|
||||
) {
|
||||
if (options.measureHoldMs) this.holds.recordUnavailable()
|
||||
throw new Error('database_lock_unavailable')
|
||||
}
|
||||
throw error
|
||||
@@ -968,9 +1010,13 @@ class PostgresDatabase implements RelayDatabase {
|
||||
const result = await operation(transaction)
|
||||
await client.query('COMMIT')
|
||||
this.holds.record(measuredHoldMs(transaction) ?? Number.NaN)
|
||||
this.holds.recordUnavailable(transaction.consumeLockUnavailable())
|
||||
this.holds.recordLockTimeout(transaction.consumeLockTimeouts())
|
||||
return result
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK').catch(() => undefined)
|
||||
this.holds.recordUnavailable(transaction.consumeLockUnavailable())
|
||||
this.holds.recordLockTimeout(transaction.consumeLockTimeouts())
|
||||
if (!retryablePostgresTransactionError(error) || attempt === POSTGRES_TRANSACTION_ATTEMPTS) {
|
||||
if (retryablePostgresTransactionError(error) && options.reportRetries !== false) {
|
||||
console.warn(
|
||||
@@ -1087,11 +1133,14 @@ async function applySchemaOnUntimedPool(
|
||||
const database = new PostgresDatabase(pool)
|
||||
try {
|
||||
await applyPostgresSchema(
|
||||
[
|
||||
...SCHEMA.split(';').filter((statement) => statement.trim()),
|
||||
...POSTGRES_SCHEMA_MIGRATIONS
|
||||
],
|
||||
async (statement) => await database.query(statement)
|
||||
relayPostgresSchemaStatements(),
|
||||
async (statement) => await database.query(statement),
|
||||
// Asks the catalog whether each index or column is already there. CREATE INDEX IF NOT EXISTS
|
||||
// and ALTER TABLE ADD COLUMN IF NOT EXISTS take their relation lock before the server
|
||||
// evaluates the existence test, so on an already-migrated database the boot still joins the
|
||||
// lock queue - and relation locks are granted in queue order, so every writer queues behind
|
||||
// it. The catalog read takes no lock on the table.
|
||||
{ catalogQuery: async (sql, params) => await database.query(sql, params) }
|
||||
)
|
||||
} finally {
|
||||
await database.close().catch(() => undefined)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
REGIONAL_REHOME_POOL_WAIT_MS_MAX_LIMIT,
|
||||
REGIONAL_REHOME_POOL_WAITERS_MAX_LIMIT,
|
||||
REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT,
|
||||
REGIONAL_REHOME_SQL_FAILURES_LIMIT,
|
||||
regionalRehomeSafetyFailure
|
||||
@@ -41,14 +39,38 @@ describe('regionalRehomeSafetyFailure', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('still fails closed on each pool pressure bound', () => {
|
||||
for (const overrides of [
|
||||
{ databasePoolWaitersMax: REGIONAL_REHOME_POOL_WAITERS_MAX_LIMIT + 1 },
|
||||
{ databasePoolWaitMsMax: REGIONAL_REHOME_POOL_WAIT_MS_MAX_LIMIT + 1 }
|
||||
]) {
|
||||
expect(regionalRehomeSafetyFailure(safety(overrides), NOW, 19)).toBe(
|
||||
'database_pool_pressure'
|
||||
it('passes asia-scale pool pressure, which excludes a cell rather than the fleet', () => {
|
||||
// Measured 2026-09-16 on the asia-east2 cells: a client pool too narrow for
|
||||
// a 176ms round trip, with 0.2ms server-side execution. The fleet snapshot
|
||||
// is a Math.max, so gating on it here stops every region.
|
||||
expect(
|
||||
regionalRehomeSafetyFailure(
|
||||
safety({ databasePoolWaitersMax: 150, databasePoolWaitMsMax: 2_005 }),
|
||||
NOW,
|
||||
19
|
||||
)
|
||||
).toBeNull()
|
||||
// Every other bar still fails closed at that same pool pressure.
|
||||
for (const [overrides, reason] of [
|
||||
[{ sqlFailures: REGIONAL_REHOME_SQL_FAILURES_LIMIT + 1 }, 'sql_failures'],
|
||||
[{ controlActivityRecoveryFailures: 1 }, 'control_recovery_failures'],
|
||||
[
|
||||
{ reconnects: 19 * REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT + 1 },
|
||||
'elevated_reconnects'
|
||||
],
|
||||
[{ observedAt: 0 }, 'monitoring_stale']
|
||||
] as const) {
|
||||
expect(
|
||||
regionalRehomeSafetyFailure(
|
||||
safety({
|
||||
databasePoolWaitersMax: 150,
|
||||
databasePoolWaitMsMax: 2_005,
|
||||
...overrides
|
||||
}),
|
||||
NOW,
|
||||
19
|
||||
)
|
||||
).toBe(reason)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,12 +2,20 @@ import type { RegionalRehomeSafetySnapshot } from './relay-observability.js'
|
||||
|
||||
// Limits sit well above the healthy-fleet baseline measured in production on
|
||||
// 2026-08-28 (peak 2 waiters / 1ms pool waits on every cell; up to ~80
|
||||
// reconnects per published two-window row on the busiest cell). Sustained
|
||||
// pool saturation still trips: waiters-max 16 is 8x baseline yet far under a
|
||||
// backed-up pool, and 250ms peak wait is 1/10 of the incident-monitor alert.
|
||||
// Instantaneous databasePoolWaiting is not checked separately: it is bounded
|
||||
// by databasePoolWaitersMax within every published window.
|
||||
// reconnects per published two-window row on the busiest cell).
|
||||
export const REGIONAL_REHOME_RECONNECTS_PER_CELL_LIMIT = 250
|
||||
|
||||
// Pool pressure gates one cell, never the fleet. The combined snapshot is a
|
||||
// Math.max, so a single narrow client pool stops rehoming everywhere: measured
|
||||
// 2026-09-16, the asia-east2 cells sit at 94-156 waiters and ~2000ms waits
|
||||
// against 0.2ms server-side execution -- a pool too narrow for a 176ms round
|
||||
// trip, not database distress -- while us-central1 cells breach in bursts on
|
||||
// ~33% of polls, and a bar that flaps between the pre-check and the commit
|
||||
// re-check latches the worker off. regionalRehomeCellSafetyIsClean excludes a
|
||||
// breaching cell as both source and target; the bars below stay fleet-wide
|
||||
// because sql failures, control-recovery failures and reconnect storms mean
|
||||
// database-wide distress. Instantaneous databasePoolWaiting is not checked
|
||||
// separately: databasePoolWaitersMax bounds it within every published window.
|
||||
export const REGIONAL_REHOME_POOL_WAITERS_MAX_LIMIT = 16
|
||||
export const REGIONAL_REHOME_POOL_WAIT_MS_MAX_LIMIT = 250
|
||||
|
||||
@@ -19,7 +27,7 @@ export const REGIONAL_REHOME_POOL_WAIT_MS_MAX_LIMIT = 250
|
||||
// over four days; genuine database distress produced 395-457. The combined
|
||||
// snapshot spans up to two 30s windows per process (pathological ambient
|
||||
// alignment ~164), so 250 stays clear of noise while storms still trip.
|
||||
// Terminal outages also trip the pool bars and the worker's own
|
||||
// Terminal outages also trip the per-cell pool bars and the worker's own
|
||||
// dispatch-failure budget; the sql bar only needs to catch storms.
|
||||
export const REGIONAL_REHOME_SQL_FAILURES_LIMIT = 250
|
||||
// Per-cell candidate cleanliness is a soft skip, not a durable latch; the
|
||||
@@ -47,9 +55,6 @@ export function regionalRehomeSafetyFailure(
|
||||
return 'monitoring_stale'
|
||||
}
|
||||
if (safety.sqlFailures > REGIONAL_REHOME_SQL_FAILURES_LIMIT) return 'sql_failures'
|
||||
if (regionalRehomePoolPressure(safety)) {
|
||||
return 'database_pool_pressure'
|
||||
}
|
||||
if (safety.controlActivityRecoveryFailures > 0) {
|
||||
return 'control_recovery_failures'
|
||||
}
|
||||
|
||||
@@ -34,8 +34,12 @@ const target = {
|
||||
connectionHardCap: 1_000 as const,
|
||||
connectionUnobservedBound: 60
|
||||
}
|
||||
// A third general cell in the source region: never a source or target here,
|
||||
// but it is in the fleet whose safety the gate reads.
|
||||
const bystander = { ...source, id: 'us-c2', url: 'https://us-c2.relay.example.test' }
|
||||
const sourceIncarnation = '11111111-1111-4111-8111-111111111111'
|
||||
const targetIncarnation = '22222222-2222-4222-8222-222222222222'
|
||||
const bystanderIncarnation = '33333333-3333-4333-8333-333333333333'
|
||||
|
||||
describe('regional rehome assignment state', () => {
|
||||
it('advances past a full candidate page whose destination lacks capacity', async () => {
|
||||
@@ -424,7 +428,7 @@ describe('regional rehome assignment state', () => {
|
||||
await context.database.close()
|
||||
})
|
||||
|
||||
it('latches off on sustained pool pressure and logs the disable exactly once', async () => {
|
||||
it('defers on target pool pressure without disabling, and claims once it clears', async () => {
|
||||
const context = await setup()
|
||||
await activatePreferredSource(context, {
|
||||
userId: 'user-1',
|
||||
@@ -451,17 +455,59 @@ describe('regional rehome assignment state', () => {
|
||||
expect(await context.store.commitIdleRegionalRehome(candidate!, safety)).toEqual({
|
||||
outcome: 'deferred'
|
||||
})
|
||||
// Already disabled: the next tick returns before the gate and stays silent.
|
||||
expect(await context.store.tryIdleRehome()).toBeNull()
|
||||
} finally {
|
||||
warnings.restore()
|
||||
}
|
||||
// A pool bar crossed between the scan and the commit skips the cell; it must
|
||||
// not turn the durable switch off, or the worker never comes back.
|
||||
expect(await context.store.inspectRegionalRehomeControl()).toMatchObject({
|
||||
generation: 2,
|
||||
enabled: false
|
||||
generation: 1,
|
||||
enabled: true
|
||||
})
|
||||
expect(warnings.entries).toMatchObject([
|
||||
{ reason: 'database_pool_pressure', databasePoolWaitersMax: 17 }
|
||||
expect(warnings.entries).toEqual([])
|
||||
|
||||
await context.database.query(
|
||||
`UPDATE relay_cell_rehome_safety SET database_pool_waiters_max = 0 WHERE cell_id = ?`,
|
||||
[target.id]
|
||||
)
|
||||
expect(await context.store.tryIdleRehome()).toMatchObject({
|
||||
userId: 'user-1',
|
||||
sourceCellId: source.id,
|
||||
targetCellId: target.id
|
||||
})
|
||||
await context.database.close()
|
||||
})
|
||||
|
||||
it('keeps selecting candidates while an unrelated cell is over the pool bar', async () => {
|
||||
// Production case: the fleet snapshot is a Math.max, so one cell with a
|
||||
// narrow client pool used to empty every page.
|
||||
const context = await setup()
|
||||
await activatePreferredSource(context, {
|
||||
userId: 'user-1',
|
||||
relayHostId: 'abcdefghijklmnop'
|
||||
})
|
||||
await context.store.reconcileCells([source, target, bystander])
|
||||
await heartbeat(context.store, bystander, bystanderIncarnation, 3, 2, {
|
||||
observedAt: context.now(),
|
||||
sqlFailures: 0,
|
||||
reconnects: 0,
|
||||
controlActivityRecoveryFailures: 0,
|
||||
databasePoolWaiting: 150,
|
||||
databasePoolWaitersMax: 150,
|
||||
databasePoolWaitMsMax: 2_005
|
||||
})
|
||||
|
||||
const safety: RegionalRehomeSafetySnapshot = {
|
||||
observedAt: context.now(),
|
||||
sqlFailures: 0,
|
||||
reconnects: 0,
|
||||
controlActivityRecoveryFailures: 0,
|
||||
databasePoolWaiting: 0,
|
||||
databasePoolWaitersMax: 0,
|
||||
databasePoolWaitMsMax: 0
|
||||
}
|
||||
expect(await context.store.selectIdleRegionalRehomeCandidates(safety)).toMatchObject([
|
||||
{ sourceCellId: source.id, targetCellId: target.id }
|
||||
])
|
||||
await context.database.close()
|
||||
})
|
||||
|
||||
@@ -90,6 +90,87 @@ describe('relay observability', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('flags a readiness answer served from the last known good probe', () => {
|
||||
const entries: Array<Record<string, unknown>> = []
|
||||
const observability = new RelayObservability(
|
||||
{ role: 'cell', cellId: 'production-gce-c28', region: 'asia-east2' },
|
||||
(entry) => entries.push(entry)
|
||||
)
|
||||
|
||||
observability.recordReadiness({
|
||||
ready: true,
|
||||
degraded: true,
|
||||
degradedDependencies: ['jwks'],
|
||||
failure: 'jwks_timed_out',
|
||||
jwksLatencyMs: 2_001,
|
||||
sqlLatencyMs: 4,
|
||||
totalLatencyMs: 2_002
|
||||
})
|
||||
|
||||
expect(entries).toEqual([
|
||||
expect.objectContaining({
|
||||
severity: 'WARNING',
|
||||
event: 'orca_relay_readiness_check',
|
||||
ready: true,
|
||||
degraded: true,
|
||||
degradedDependencies: ['jwks'],
|
||||
failure: 'jwks_timed_out'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('separates entering the readiness grace window from leaving it', () => {
|
||||
const entries: Array<Record<string, unknown>> = []
|
||||
const observability = new RelayObservability(
|
||||
{ role: 'cell', cellId: 'production-gce-c28', region: 'asia-east2' },
|
||||
(entry) => entries.push(entry)
|
||||
)
|
||||
|
||||
observability.recordReadinessGrace({
|
||||
dependency: 'sql',
|
||||
grace: 'entered',
|
||||
failure: 'sql_failed',
|
||||
lastSuccessAgeMs: 12_000,
|
||||
graceMs: 180_000
|
||||
})
|
||||
observability.recordReadinessGrace({
|
||||
dependency: 'sql',
|
||||
grace: 'recovered',
|
||||
lastSuccessAgeMs: 0,
|
||||
graceMs: 180_000
|
||||
})
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
severity: 'WARNING',
|
||||
message: 'Orca Relay readiness entered last-known-good grace',
|
||||
event: 'orca_relay_readiness_grace_entered',
|
||||
metricVersion: 1,
|
||||
role: 'cell',
|
||||
cellId: 'production-gce-c28',
|
||||
region: 'asia-east2',
|
||||
dependency: 'sql',
|
||||
grace: 'entered',
|
||||
failure: 'sql_failed',
|
||||
lastSuccessAgeMs: 12_000,
|
||||
graceMs: 180_000
|
||||
},
|
||||
{
|
||||
severity: 'INFO',
|
||||
message: 'Orca Relay readiness left last-known-good grace',
|
||||
event: 'orca_relay_readiness_grace_left',
|
||||
metricVersion: 1,
|
||||
role: 'cell',
|
||||
cellId: 'production-gce-c28',
|
||||
region: 'asia-east2',
|
||||
dependency: 'sql',
|
||||
grace: 'recovered',
|
||||
lastSuccessAgeMs: 0,
|
||||
graceMs: 180_000
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('excludes sockets stuck in closing state from observed relay work', () => {
|
||||
expect(observedRelayRequests(counts)).toBe(7)
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RELAY_REGION_METRIC_SEGMENTS, type RelayRegion } from '@orca-cloud/rela
|
||||
import type { ControlRenewalOutcome } from './assignment-store.js'
|
||||
import type { CellInventoryHoldCounts } from './cell-inventory-hold-samples.js'
|
||||
import type { PostgresPoolPressureCounts } from './postgres-pool-pressure.js'
|
||||
import type { RelayReadinessObservation } from './relay-readiness.js'
|
||||
import type { RelayReadinessGraceEvent, RelayReadinessObservation } from './relay-readiness.js'
|
||||
|
||||
export type RelayRuntimeCounts = {
|
||||
totalConnections: number
|
||||
@@ -281,7 +281,7 @@ export class RelayObservability implements RelayRuntimeObserver {
|
||||
|
||||
recordReadiness(observation: RelayReadinessObservation): void {
|
||||
this.write({
|
||||
severity: observation.ready ? 'INFO' : 'WARNING',
|
||||
severity: observation.ready && !observation.degraded ? 'INFO' : 'WARNING',
|
||||
message: 'Orca Relay readiness check',
|
||||
event: 'orca_relay_readiness_check',
|
||||
metricVersion: 1,
|
||||
@@ -290,6 +290,20 @@ export class RelayObservability implements RelayRuntimeObserver {
|
||||
})
|
||||
}
|
||||
|
||||
recordReadinessGrace(event: RelayReadinessGraceEvent): void {
|
||||
const entered = event.grace === 'entered'
|
||||
this.write({
|
||||
severity: event.grace === 'recovered' ? 'INFO' : 'WARNING',
|
||||
message: entered
|
||||
? 'Orca Relay readiness entered last-known-good grace'
|
||||
: 'Orca Relay readiness left last-known-good grace',
|
||||
event: entered ? 'orca_relay_readiness_grace_entered' : 'orca_relay_readiness_grace_left',
|
||||
metricVersion: 1,
|
||||
...this.identity,
|
||||
...event
|
||||
})
|
||||
}
|
||||
|
||||
recordControlClose(code: number): void {
|
||||
const key = String(code)
|
||||
this.deltas.controlClosesByCode[key] = (this.deltas.controlClosesByCode[key] ?? 0) + 1
|
||||
|
||||
@@ -2,6 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RelayDatabase } from './database.js'
|
||||
import {
|
||||
createRelayReadiness,
|
||||
RELAY_READINESS_JWKS_GRACE_MS,
|
||||
RELAY_READINESS_SQL_GRACE_MS,
|
||||
type RelayReadinessGraceEvent,
|
||||
type RelayReadinessObservation
|
||||
} from './relay-readiness.js'
|
||||
|
||||
@@ -31,8 +34,8 @@ describe('relay readiness', () => {
|
||||
}
|
||||
)
|
||||
|
||||
expect(await jwksFailure()).toBe(false)
|
||||
expect(await sqlFailure()).toBe(false)
|
||||
expect(await jwksFailure.check()).toBe(false)
|
||||
expect(await sqlFailure.check()).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -68,35 +71,33 @@ describe('relay readiness', () => {
|
||||
}
|
||||
])('reports a safe reason for $name', async ({ fetch, query, failure }) => {
|
||||
const observations: RelayReadinessObservation[] = []
|
||||
const ready = createRelayReadiness(database(query), 'https://jwks', {
|
||||
const readiness = createRelayReadiness(database(query), 'https://jwks', {
|
||||
fetch,
|
||||
cacheMs: 0,
|
||||
observe: (observation) => observations.push(observation)
|
||||
})
|
||||
|
||||
expect(await ready()).toBe(false)
|
||||
expect(observations).toEqual([
|
||||
expect.objectContaining({ ready: false, failure })
|
||||
])
|
||||
expect(await readiness.check()).toBe(false)
|
||||
expect(observations).toEqual([expect.objectContaining({ ready: false, failure })])
|
||||
expect(JSON.stringify(observations)).not.toContain('redacted')
|
||||
if (failure.startsWith('jwks_')) expect(query).not.toHaveBeenCalled()
|
||||
expect(query).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports the initial success but not healthy repeats or cached reads', async () => {
|
||||
const observations: RelayReadinessObservation[] = []
|
||||
let now = 100
|
||||
const ready = createRelayReadiness(database(async () => [{ ready: 1 }]), 'https://jwks', {
|
||||
const readiness = createRelayReadiness(database(async () => [{ ready: 1 }]), 'https://jwks', {
|
||||
fetch: vi.fn(async () => new Response('{}', { status: 200 })) as typeof fetch,
|
||||
cacheMs: 10_000,
|
||||
now: () => now,
|
||||
observe: (observation) => observations.push(observation)
|
||||
})
|
||||
|
||||
expect(await ready()).toBe(true)
|
||||
expect(await readiness.check()).toBe(true)
|
||||
now += 1_000
|
||||
expect(await ready()).toBe(true)
|
||||
expect(await readiness.check()).toBe(true)
|
||||
now += 10_000
|
||||
expect(await ready()).toBe(true)
|
||||
expect(await readiness.check()).toBe(true)
|
||||
expect(observations).toEqual([
|
||||
{
|
||||
ready: true,
|
||||
@@ -107,3 +108,326 @@ describe('relay readiness', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('relay readiness last-known-good grace', () => {
|
||||
function graceProbe(input: {
|
||||
jwksGraceMs?: number
|
||||
sqlGraceMs?: number
|
||||
cacheMs?: number
|
||||
jwksOk: () => boolean
|
||||
sqlOk: () => boolean
|
||||
now: () => number
|
||||
}) {
|
||||
const observations: RelayReadinessObservation[] = []
|
||||
const graceEvents: RelayReadinessGraceEvent[] = []
|
||||
const query = vi.fn(async () => {
|
||||
if (!input.sqlOk()) throw new Error('redacted')
|
||||
return [{ ready: 1 }]
|
||||
})
|
||||
const fetchImpl = vi.fn(
|
||||
async () => new Response('{}', { status: input.jwksOk() ? 200 : 503 })
|
||||
) as typeof fetch
|
||||
const readiness = createRelayReadiness(database(query), 'https://jwks', {
|
||||
fetch: fetchImpl,
|
||||
cacheMs: input.cacheMs ?? 0,
|
||||
...(input.jwksGraceMs === undefined ? {} : { jwksGraceMs: input.jwksGraceMs }),
|
||||
...(input.sqlGraceMs === undefined ? {} : { sqlGraceMs: input.sqlGraceMs }),
|
||||
now: input.now,
|
||||
observe: (observation) => observations.push(observation),
|
||||
observeGrace: (event) => graceEvents.push(event)
|
||||
})
|
||||
return { readiness, observations, graceEvents, query, fetchImpl }
|
||||
}
|
||||
|
||||
it('stays ready while a JWKS failure sits inside the default fifteen minute window', async () => {
|
||||
let now = 1_000
|
||||
let jwksOk = true
|
||||
const { readiness, observations } = graceProbe({
|
||||
jwksOk: () => jwksOk,
|
||||
sqlOk: () => true,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = false
|
||||
now += RELAY_READINESS_JWKS_GRACE_MS - 1
|
||||
expect(await readiness.check()).toBe(true)
|
||||
expect(readiness.degradedDependencies()).toEqual(['jwks'])
|
||||
expect(observations.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
ready: true,
|
||||
degraded: true,
|
||||
degradedDependencies: ['jwks'],
|
||||
failure: 'jwks_http_failed'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('drops readiness once the JWKS window expires', async () => {
|
||||
let now = 1_000
|
||||
let jwksOk = true
|
||||
const { readiness, observations } = graceProbe({
|
||||
jwksOk: () => jwksOk,
|
||||
sqlOk: () => true,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = false
|
||||
now += RELAY_READINESS_JWKS_GRACE_MS
|
||||
expect(await readiness.check()).toBe(false)
|
||||
expect(readiness.degradedDependencies()).toEqual([])
|
||||
expect(observations.at(-1)).toEqual(
|
||||
expect.objectContaining({ ready: false, failure: 'jwks_http_failed' })
|
||||
)
|
||||
expect(observations.at(-1)).not.toHaveProperty('degraded')
|
||||
})
|
||||
|
||||
it('gives SQL a shorter window than JWKS by default', async () => {
|
||||
let now = 1_000
|
||||
let sqlOk = true
|
||||
const { readiness, observations } = graceProbe({
|
||||
jwksOk: () => true,
|
||||
sqlOk: () => sqlOk,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
expect(await readiness.check()).toBe(true)
|
||||
sqlOk = false
|
||||
now += RELAY_READINESS_SQL_GRACE_MS - 1
|
||||
expect(await readiness.check()).toBe(true)
|
||||
expect(observations.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
ready: true,
|
||||
degraded: true,
|
||||
degradedDependencies: ['sql'],
|
||||
failure: 'sql_failed'
|
||||
})
|
||||
)
|
||||
now += 1
|
||||
expect(await readiness.check()).toBe(false)
|
||||
expect(RELAY_READINESS_SQL_GRACE_MS).toBeLessThan(RELAY_READINESS_JWKS_GRACE_MS)
|
||||
})
|
||||
|
||||
it('keeps a process that never succeeded out of the grace window', async () => {
|
||||
let now = 1_000
|
||||
const { readiness, observations, graceEvents } = graceProbe({
|
||||
jwksOk: () => false,
|
||||
sqlOk: () => true,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
expect(await readiness.check()).toBe(false)
|
||||
now += 1_000
|
||||
expect(await readiness.check()).toBe(false)
|
||||
expect(graceEvents).toEqual([])
|
||||
expect(observations.every((observation) => observation.degraded === undefined)).toBe(true)
|
||||
})
|
||||
|
||||
it('measures the grace window on the injected clock, not wall time', async () => {
|
||||
const now = 1_000
|
||||
let jwksOk = true
|
||||
const { readiness } = graceProbe({ jwksOk: () => jwksOk, sqlOk: () => true, now: () => now })
|
||||
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = false
|
||||
for (let attempt = 0; attempt < 5; attempt++) expect(await readiness.check()).toBe(true)
|
||||
})
|
||||
|
||||
it('logs once on entering grace and once on leaving it', async () => {
|
||||
let now = 1_000
|
||||
let jwksOk = true
|
||||
const { readiness, graceEvents } = graceProbe({
|
||||
jwksOk: () => jwksOk,
|
||||
sqlOk: () => true,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = false
|
||||
now += 1_000
|
||||
expect(await readiness.check()).toBe(true)
|
||||
now += 1_000
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = true
|
||||
now += 1_000
|
||||
expect(await readiness.check()).toBe(true)
|
||||
expect(graceEvents).toEqual([
|
||||
{
|
||||
dependency: 'jwks',
|
||||
grace: 'entered',
|
||||
failure: 'jwks_http_failed',
|
||||
lastSuccessAgeMs: 1_000,
|
||||
graceMs: RELAY_READINESS_JWKS_GRACE_MS
|
||||
},
|
||||
{
|
||||
dependency: 'jwks',
|
||||
grace: 'recovered',
|
||||
lastSuccessAgeMs: 0,
|
||||
graceMs: RELAY_READINESS_JWKS_GRACE_MS
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('reports an expired window once when the dependency never comes back', async () => {
|
||||
let now = 1_000
|
||||
let jwksOk = true
|
||||
const { readiness, graceEvents } = graceProbe({
|
||||
jwksGraceMs: 10_000,
|
||||
jwksOk: () => jwksOk,
|
||||
sqlOk: () => true,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = false
|
||||
now += 1_000
|
||||
expect(await readiness.check()).toBe(true)
|
||||
now += 20_000
|
||||
expect(await readiness.check()).toBe(false)
|
||||
expect(await readiness.check()).toBe(false)
|
||||
expect(graceEvents.map((event) => event.grace)).toEqual(['entered', 'expired'])
|
||||
})
|
||||
|
||||
it('restarts the grace window from the most recent success', async () => {
|
||||
let now = 1_000
|
||||
let jwksOk = true
|
||||
const { readiness } = graceProbe({
|
||||
jwksGraceMs: 10_000,
|
||||
jwksOk: () => jwksOk,
|
||||
sqlOk: () => true,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = false
|
||||
now += 9_000
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = true
|
||||
now += 1_000
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = false
|
||||
now += 9_000
|
||||
expect(await readiness.check()).toBe(true)
|
||||
})
|
||||
|
||||
it('never serves grace when the window is disabled', async () => {
|
||||
let now = 1_000
|
||||
let jwksOk = true
|
||||
const { readiness, graceEvents } = graceProbe({
|
||||
jwksGraceMs: 0,
|
||||
jwksOk: () => jwksOk,
|
||||
sqlOk: () => true,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = false
|
||||
expect(await readiness.check()).toBe(false)
|
||||
expect(graceEvents).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps one clock per dependency so a healthy JWKS cannot hold SQL open', async () => {
|
||||
let now = 1_000
|
||||
let sqlOk = true
|
||||
const { readiness, graceEvents } = graceProbe({
|
||||
sqlGraceMs: 10_000,
|
||||
jwksOk: () => true,
|
||||
sqlOk: () => sqlOk,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
expect(await readiness.check()).toBe(true)
|
||||
sqlOk = false
|
||||
for (let elapsed = 1_000; elapsed <= 11_000; elapsed += 1_000) {
|
||||
now = 1_000 + elapsed
|
||||
await readiness.check()
|
||||
}
|
||||
expect(await readiness.check()).toBe(false)
|
||||
expect(graceEvents.map((event) => [event.dependency, event.grace])).toEqual([
|
||||
['sql', 'entered'],
|
||||
['sql', 'expired']
|
||||
])
|
||||
})
|
||||
|
||||
it('logs both sides of an overlap when one dependency recovers as the other fails', async () => {
|
||||
let now = 1_000
|
||||
let jwksOk = true
|
||||
let sqlOk = true
|
||||
const { readiness, graceEvents } = graceProbe({
|
||||
jwksOk: () => jwksOk,
|
||||
sqlOk: () => sqlOk,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = false
|
||||
now += 1_000
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = true
|
||||
sqlOk = false
|
||||
now += 1_000
|
||||
expect(await readiness.check()).toBe(true)
|
||||
expect(readiness.degradedDependencies()).toEqual(['sql'])
|
||||
expect(graceEvents.map((event) => [event.dependency, event.grace])).toEqual([
|
||||
['jwks', 'entered'],
|
||||
['jwks', 'recovered'],
|
||||
['sql', 'entered']
|
||||
])
|
||||
})
|
||||
|
||||
it('probes SQL on every poll even while JWKS is failing', async () => {
|
||||
let now = 1_000
|
||||
let jwksOk = true
|
||||
let sqlOk = true
|
||||
const { readiness, observations, query } = graceProbe({
|
||||
sqlGraceMs: 10_000,
|
||||
jwksOk: () => jwksOk,
|
||||
sqlOk: () => sqlOk,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = false
|
||||
now += 1_000
|
||||
expect(await readiness.check()).toBe(true)
|
||||
sqlOk = false
|
||||
now += 1_000
|
||||
expect(await readiness.check()).toBe(true)
|
||||
expect(observations.at(-1)).toEqual(
|
||||
expect.objectContaining({
|
||||
ready: true,
|
||||
failure: 'jwks_http_failed',
|
||||
failures: ['jwks_http_failed', 'sql_failed'],
|
||||
degradedDependencies: ['jwks', 'sql']
|
||||
})
|
||||
)
|
||||
// The SQL clock runs on evidence, so its window opens at the poll that saw SQL fail.
|
||||
now += 10_000
|
||||
expect(await readiness.check()).toBe(false)
|
||||
expect(query).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('serves a stale ready answer for at most the cache window after grace expires', async () => {
|
||||
let now = 1_000
|
||||
let jwksOk = true
|
||||
const { readiness, fetchImpl } = graceProbe({
|
||||
cacheMs: 10_000,
|
||||
jwksGraceMs: 5_000,
|
||||
jwksOk: () => jwksOk,
|
||||
sqlOk: () => true,
|
||||
now: () => now
|
||||
})
|
||||
|
||||
expect(await readiness.check()).toBe(true)
|
||||
jwksOk = false
|
||||
now += 6_000
|
||||
expect(await readiness.check()).toBe(true)
|
||||
now += 3_999
|
||||
expect(await readiness.check()).toBe(true)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
now += 1
|
||||
expect(await readiness.check()).toBe(false)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,20 +6,58 @@ export type RelayReadinessFailure =
|
||||
| 'jwks_timed_out'
|
||||
| 'sql_failed'
|
||||
|
||||
export type RelayReadinessDependency = 'jwks' | 'sql'
|
||||
|
||||
export type RelayReadinessObservation = {
|
||||
ready: boolean
|
||||
failure?: RelayReadinessFailure
|
||||
// Both dependencies are probed every poll, so both can fail in the same one.
|
||||
failures?: RelayReadinessFailure[]
|
||||
degraded?: true
|
||||
degradedDependencies?: RelayReadinessDependency[]
|
||||
jwksLatencyMs: number
|
||||
sqlLatencyMs: number
|
||||
totalLatencyMs: number
|
||||
}
|
||||
|
||||
export type RelayReadinessGraceEvent = {
|
||||
dependency: RelayReadinessDependency
|
||||
grace: 'entered' | 'recovered' | 'expired'
|
||||
failure?: RelayReadinessFailure
|
||||
lastSuccessAgeMs?: number
|
||||
graceMs: number
|
||||
}
|
||||
|
||||
export type RelayReadinessProbe = {
|
||||
check: () => Promise<boolean>
|
||||
degradedDependencies: () => RelayReadinessDependency[]
|
||||
}
|
||||
|
||||
// The token verifier caches keys in process, so a cell keeps verifying tokens right through a JWKS
|
||||
// outage: the only thing an unreachable JWKS endpoint blocks is a key rotation nobody is running.
|
||||
export const RELAY_READINESS_JWKS_GRACE_MS = 900_000
|
||||
// Each cell is its own load balancer backend, so failing readiness never re-routes a host, it only
|
||||
// makes that hostname unreachable. A host that lands on a SQL-dead cell gets WRONG_CELL and is
|
||||
// re-placed by the director, and existing control sockets survive a generic Postgres error. Three
|
||||
// minutes rides a Cloud SQL failover without hiding a per-cell fault for a quarter of an hour.
|
||||
export const RELAY_READINESS_SQL_GRACE_MS = 180_000
|
||||
export const RELAY_MAX_READINESS_GRACE_MS = 3_600_000
|
||||
|
||||
type RelayReadinessOptions = {
|
||||
fetch?: typeof fetch
|
||||
timeoutMs?: number
|
||||
cacheMs?: number
|
||||
jwksGraceMs?: number
|
||||
sqlGraceMs?: number
|
||||
now?: () => number
|
||||
observe?: (observation: RelayReadinessObservation) => void
|
||||
observeGrace?: (event: RelayReadinessGraceEvent) => void
|
||||
}
|
||||
|
||||
type DependencySettlement = {
|
||||
satisfied: boolean
|
||||
degraded: boolean
|
||||
event?: RelayReadinessGraceEvent
|
||||
}
|
||||
|
||||
function fetchFailure(error: unknown): RelayReadinessFailure {
|
||||
@@ -28,62 +66,124 @@ function fetchFailure(error: unknown): RelayReadinessFailure {
|
||||
: 'jwks_fetch_failed'
|
||||
}
|
||||
|
||||
function graceTransition(
|
||||
degraded: boolean,
|
||||
failure: RelayReadinessFailure | undefined
|
||||
): RelayReadinessGraceEvent['grace'] {
|
||||
if (degraded) return 'entered'
|
||||
return failure === undefined ? 'recovered' : 'expired'
|
||||
}
|
||||
|
||||
// One dependency's own last-known-good clock; collapsing the two would let a healthy JWKS poll keep
|
||||
// a dead Postgres inside its window forever.
|
||||
function createDependencyGrace(dependency: RelayReadinessDependency, graceMs: number) {
|
||||
let lastSuccessAt: number | undefined
|
||||
let inGrace = false
|
||||
|
||||
return (at: number, failure: RelayReadinessFailure | undefined): DependencySettlement => {
|
||||
if (failure === undefined) lastSuccessAt = at
|
||||
const lastSuccessAgeMs =
|
||||
lastSuccessAt === undefined ? undefined : Math.max(0, at - lastSuccessAt)
|
||||
const degraded =
|
||||
failure !== undefined && lastSuccessAgeMs !== undefined && lastSuccessAgeMs < graceMs
|
||||
const crossed = degraded !== inGrace
|
||||
inGrace = degraded
|
||||
return {
|
||||
satisfied: failure === undefined || degraded,
|
||||
degraded,
|
||||
...(crossed
|
||||
? {
|
||||
event: {
|
||||
dependency,
|
||||
grace: graceTransition(degraded, failure),
|
||||
...(failure ? { failure } : {}),
|
||||
...(lastSuccessAgeMs === undefined ? {} : { lastSuccessAgeMs }),
|
||||
graceMs
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function timed<T>(
|
||||
now: () => number,
|
||||
run: () => Promise<T>
|
||||
): Promise<{ value: T; latencyMs: number }> {
|
||||
const startedAt = now()
|
||||
const value = await run()
|
||||
return { value, latencyMs: Math.max(0, now() - startedAt) }
|
||||
}
|
||||
|
||||
export function createRelayReadiness(
|
||||
database: RelayDatabase,
|
||||
jwksUrl: string,
|
||||
options: RelayReadinessOptions = {}
|
||||
): () => Promise<boolean> {
|
||||
): RelayReadinessProbe {
|
||||
const fetchImpl = options.fetch ?? fetch
|
||||
const timeoutMs = options.timeoutMs ?? 2_000
|
||||
const cacheMs = options.cacheMs ?? 10_000
|
||||
const now = options.now ?? Date.now
|
||||
const settleJwks = createDependencyGrace(
|
||||
'jwks',
|
||||
options.jwksGraceMs ?? RELAY_READINESS_JWKS_GRACE_MS
|
||||
)
|
||||
const settleSql = createDependencyGrace('sql', options.sqlGraceMs ?? RELAY_READINESS_SQL_GRACE_MS)
|
||||
let cachedAt = Number.NEGATIVE_INFINITY
|
||||
let cached = false
|
||||
let lastObservedReady: boolean | undefined
|
||||
let degraded: RelayReadinessDependency[] = []
|
||||
|
||||
return async () => {
|
||||
const probeJwks = async (): Promise<RelayReadinessFailure | undefined> => {
|
||||
try {
|
||||
const response = await fetchImpl(jwksUrl, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
return response.ok ? undefined : 'jwks_http_failed'
|
||||
} catch (error) {
|
||||
return fetchFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
const probeSql = async (): Promise<RelayReadinessFailure | undefined> => {
|
||||
try {
|
||||
await database.query('SELECT 1 AS ready')
|
||||
return undefined
|
||||
} catch {
|
||||
// The load balancer only needs the boolean; the safe reason is all that is emitted.
|
||||
return 'sql_failed'
|
||||
}
|
||||
}
|
||||
|
||||
const check = async (): Promise<boolean> => {
|
||||
if (now() - cachedAt < cacheMs) return cached
|
||||
const startedAt = now()
|
||||
let jwksCompletedAt = startedAt
|
||||
let sqlStartedAt = startedAt
|
||||
let failure: RelayReadinessFailure | undefined
|
||||
try {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchImpl(jwksUrl, { signal: AbortSignal.timeout(timeoutMs) })
|
||||
} catch (error) {
|
||||
failure = fetchFailure(error)
|
||||
throw error
|
||||
} finally {
|
||||
jwksCompletedAt = now()
|
||||
}
|
||||
if (!response.ok) {
|
||||
failure = 'jwks_http_failed'
|
||||
throw new Error(failure)
|
||||
}
|
||||
sqlStartedAt = now()
|
||||
try {
|
||||
await database.query('SELECT 1 AS ready')
|
||||
} catch (error) {
|
||||
failure = 'sql_failed'
|
||||
throw error
|
||||
}
|
||||
} catch {
|
||||
// The load balancer only needs the boolean; the safe reason is emitted below.
|
||||
}
|
||||
const [jwks, sql] = await Promise.all([timed(now, probeJwks), timed(now, probeSql)])
|
||||
const completedAt = now()
|
||||
cached = failure === undefined
|
||||
const jwksSettlement = settleJwks(completedAt, jwks.value)
|
||||
const sqlSettlement = settleSql(completedAt, sql.value)
|
||||
const failures = [jwks.value, sql.value].filter((value) => value !== undefined)
|
||||
const failure = failures[0]
|
||||
degraded = []
|
||||
if (jwksSettlement.degraded) degraded.push('jwks')
|
||||
if (sqlSettlement.degraded) degraded.push('sql')
|
||||
cached = jwksSettlement.satisfied && sqlSettlement.satisfied
|
||||
cachedAt = completedAt
|
||||
if (!cached || cached !== lastObservedReady) {
|
||||
if (failure !== undefined || cached !== lastObservedReady) {
|
||||
options.observe?.({
|
||||
ready: cached,
|
||||
...(failure ? { failure } : {}),
|
||||
jwksLatencyMs: Math.max(0, jwksCompletedAt - startedAt),
|
||||
sqlLatencyMs: failure?.startsWith('jwks_') ? 0 : Math.max(0, completedAt - sqlStartedAt),
|
||||
...(failures.length > 1 ? { failures } : {}),
|
||||
...(degraded.length > 0 ? { degraded: true, degradedDependencies: [...degraded] } : {}),
|
||||
jwksLatencyMs: jwks.latencyMs,
|
||||
sqlLatencyMs: sql.latencyMs,
|
||||
totalLatencyMs: Math.max(0, completedAt - startedAt)
|
||||
})
|
||||
}
|
||||
for (const event of [jwksSettlement.event, sqlSettlement.event]) {
|
||||
if (event) options.observeGrace?.(event)
|
||||
}
|
||||
lastObservedReady = cached
|
||||
return cached
|
||||
}
|
||||
|
||||
return { check, degradedDependencies: () => [...degraded] }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createRelayApp } from './app.js'
|
||||
import type { RelayConfig } from './config.js'
|
||||
import type { RelayReadinessDependency } from './relay-readiness.js'
|
||||
|
||||
function readyApp(input: { ready: boolean; degraded?: RelayReadinessDependency[] }) {
|
||||
// SAFETY: /ready reads only ready and readinessDegradation, so the rest of the surface, which
|
||||
// every other app route test also stubs this way, stays unbuilt.
|
||||
const operations = {
|
||||
store: {} as never,
|
||||
assignments: {} as never,
|
||||
drain: vi.fn(),
|
||||
ready: vi.fn(async () => input.ready),
|
||||
readinessDegradation: () => input.degraded ?? []
|
||||
} as Parameters<typeof createRelayApp>[1]
|
||||
return createRelayApp(config(), operations)
|
||||
}
|
||||
|
||||
describe('relay readiness endpoint', () => {
|
||||
it('answers a healthy cell with the unchanged body', async () => {
|
||||
const response = await readyApp({ ready: true }).request('/ready')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('keeps the 200 the load balancer needs but marks a remembered answer', async () => {
|
||||
const response = await readyApp({ ready: true, degraded: ['sql'] }).request('/ready')
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ ok: true, degraded: true, dependency: ['sql'] })
|
||||
})
|
||||
|
||||
it('still fails the cell out of rotation once no window covers the failure', async () => {
|
||||
const response = await readyApp({ ready: false }).request('/ready')
|
||||
|
||||
expect(response.status).toBe(503)
|
||||
expect(await response.json()).toEqual({ error: 'dependency_unavailable' })
|
||||
})
|
||||
})
|
||||
|
||||
function config(): RelayConfig {
|
||||
return {
|
||||
port: 8080,
|
||||
publicUrl: 'https://c7.relay.example.test',
|
||||
cellUrl: 'https://c7.relay.example.test',
|
||||
region: 'us-central1',
|
||||
authIssuer: 'https://auth.example.test',
|
||||
authAudience: 'orca-relay',
|
||||
jwksUrl: 'https://auth.example.test/jwks',
|
||||
assignmentSigningKey: new Uint8Array(32),
|
||||
role: 'cell',
|
||||
cellId: 'production-gce-c7',
|
||||
cells: [],
|
||||
adminAudience: 'https://relay.example.test/v1/admin/drain',
|
||||
deployServiceAccount: 'deploy@example.test',
|
||||
runtimeServiceAccount: 'relay-cell@example.test',
|
||||
adminJwksUrl: 'https://auth.example.test/jwks',
|
||||
databasePoolMax: 10,
|
||||
publicAssignmentsEnabled: true,
|
||||
publicAssignmentConcurrency: 2,
|
||||
publicAssignmentQueueMax: 128,
|
||||
publicAssignmentWaitMs: 4_000,
|
||||
publicResolveConcurrency: 1,
|
||||
publicResolveWaitMs: 5_000,
|
||||
publicAssignmentRetryAfterSeconds: 5,
|
||||
dataDir: './data'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import pg from 'pg'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
applyPostgresSchema,
|
||||
catalogObjectPresence,
|
||||
schemaLockTarget,
|
||||
takesRelationLock
|
||||
} from '@orca-cloud/postgres-schema'
|
||||
import {
|
||||
openRelayDatabase,
|
||||
POSTGRES_LOCK_TIMEOUT_MS,
|
||||
relayPostgresSchemaStatements,
|
||||
type RelayDatabase
|
||||
} from './database.js'
|
||||
|
||||
// The outage this guards against: CREATE INDEX IF NOT EXISTS takes its relation lock before the
|
||||
// server evaluates the existence test, so on a database that already has the index the boot still
|
||||
// joins the lock queue, and relation locks are granted in queue order, so every writer queues
|
||||
// behind it. Only a real server can show that the catalog pre-check removes those statements.
|
||||
const databaseUrl = process.env.ORCA_RELAY_TEST_POSTGRES_URL
|
||||
const describePostgres = databaseUrl ? describe : describe.skip
|
||||
const schema = 'relay_schema_precheck_test'
|
||||
|
||||
// A table the boot would otherwise touch with two CREATE INDEX statements, and the largest table
|
||||
// in production.
|
||||
const LOCKED_TABLE = 'relay_control_connection_reservations'
|
||||
|
||||
function scopedUrl(): string {
|
||||
const url = new URL(databaseUrl!)
|
||||
url.searchParams.set('options', `-c search_path=${schema}`)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function onAdmin<T>(operation: (client: pg.Client) => Promise<T>): Promise<T> {
|
||||
const client = new pg.Client({ connectionString: databaseUrl })
|
||||
await client.connect()
|
||||
try {
|
||||
return await operation(client)
|
||||
} finally {
|
||||
await client.end()
|
||||
}
|
||||
}
|
||||
|
||||
describePostgres('relay boot-time schema against PostgreSQL', () => {
|
||||
let url = ''
|
||||
let pool: pg.Pool
|
||||
let sent: string[]
|
||||
const opened: RelayDatabase[] = []
|
||||
|
||||
beforeAll(() => {
|
||||
url = scopedUrl()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
await onAdmin(async (client) => {
|
||||
await client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`)
|
||||
await client.query(`CREATE SCHEMA ${schema}`)
|
||||
})
|
||||
// Same lock bound the boot pool uses, so a statement that still queues fails instead of
|
||||
// hanging the test.
|
||||
pool = new pg.Pool({ connectionString: url, max: 1, lock_timeout: POSTGRES_LOCK_TIMEOUT_MS })
|
||||
sent = []
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await Promise.all(opened.map((database) => database.close().catch(() => undefined)))
|
||||
await onAdmin((client) => client.query(`DROP SCHEMA IF EXISTS ${schema} CASCADE`))
|
||||
})
|
||||
|
||||
async function applyRecording(): Promise<{ ran: number; skipped: number }> {
|
||||
const record = async (statement: string, params: unknown[] = []): Promise<pg.QueryResult> => {
|
||||
sent.push(statement)
|
||||
return await pool.query(statement, params)
|
||||
}
|
||||
return await applyPostgresSchema(
|
||||
relayPostgresSchemaStatements(),
|
||||
(statement) => record(statement),
|
||||
{ catalogQuery: async (sql, params) => (await record(sql, params)).rows }
|
||||
)
|
||||
}
|
||||
|
||||
function lockTaking(): string[] {
|
||||
return sent.filter(takesRelationLock)
|
||||
}
|
||||
|
||||
it('creates the schema cold, then issues no lock-taking statement on the next boot', async () => {
|
||||
const cold = await applyRecording()
|
||||
expect(lockTaking().length).toBeGreaterThan(0)
|
||||
expect(cold.skipped).toBeGreaterThan(0)
|
||||
|
||||
sent = []
|
||||
const warm = await applyRecording()
|
||||
// Zero, with no exceptions: every statement that takes a relation lock has a pre-check.
|
||||
expect(lockTaking()).toEqual([])
|
||||
const preCheckedCount = relayPostgresSchemaStatements().filter(
|
||||
(statement) => schemaLockTarget(statement) !== undefined
|
||||
).length
|
||||
expect(warm.skipped).toBe(preCheckedCount)
|
||||
expect(warm.ran).toBe(relayPostgresSchemaStatements().length - preCheckedCount)
|
||||
// The CREATE TABLEs still run: they resolve a name and take no lock on an existing table.
|
||||
expect(warm.ran).toBeGreaterThan(0)
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
it('skips an index a failed concurrent build left invalid instead of rebuilding it', async () => {
|
||||
await applyRecording()
|
||||
// A cancelled CREATE INDEX CONCURRENTLY leaves exactly this state, and IF NOT EXISTS skips it
|
||||
// too, so reading indisvalid as a condition would newly take the lock it used to avoid.
|
||||
await pool.query(
|
||||
`UPDATE pg_catalog.pg_index SET indisvalid = false
|
||||
WHERE indexrelid = to_regclass('${schema}.relay_audit_events_at')`
|
||||
)
|
||||
sent = []
|
||||
await applyRecording()
|
||||
expect(lockTaking()).toEqual([])
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
it('re-adds a constraint an operator dropped, then skips it again', async () => {
|
||||
// The pre-check matches the constraint by name only, so this is the one shape where a changed
|
||||
// body needs an operator: drop it, and the next boot puts the current definition back.
|
||||
await applyRecording()
|
||||
const named = async (): Promise<number> =>
|
||||
Number(
|
||||
(
|
||||
await pool.query(
|
||||
`SELECT count(*) AS present FROM pg_catalog.pg_constraint
|
||||
WHERE conrelid = to_regclass('relay_region_rehome_attempts')
|
||||
AND conname = 'relay_region_rehome_attempts_preferred_region_valid'`
|
||||
)
|
||||
).rows[0]?.present
|
||||
)
|
||||
expect(await named()).toBe(1)
|
||||
|
||||
await pool.query(
|
||||
`ALTER TABLE relay_region_rehome_attempts
|
||||
DROP CONSTRAINT relay_region_rehome_attempts_preferred_region_valid`
|
||||
)
|
||||
sent = []
|
||||
await applyRecording()
|
||||
expect(lockTaking()).toHaveLength(1)
|
||||
expect(await named()).toBe(1)
|
||||
|
||||
sent = []
|
||||
await applyRecording()
|
||||
expect(lockTaking()).toEqual([])
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
it('does not let a same-named index on a sibling table answer for this one', async () => {
|
||||
// Index names are unique per schema, not per table, so a name freed on one table and taken on
|
||||
// another is reachable. Without tying the index to the table, the pre-check reads that sibling
|
||||
// as this table's index and skips the real CREATE INDEX for good.
|
||||
await applyRecording()
|
||||
const ask = async (table: string): Promise<boolean> =>
|
||||
(
|
||||
await catalogObjectPresence(
|
||||
async (sql, params) => (await pool.query(sql, params)).rows,
|
||||
{ kind: 'index', table, name: 'relay_audit_events_at', skipWhen: 'present' }
|
||||
)
|
||||
).present
|
||||
|
||||
expect(await ask('relay_audit_events')).toBe(true)
|
||||
await pool.query(`DROP INDEX ${schema}.relay_audit_events_at`)
|
||||
await pool.query(`CREATE TABLE ${schema}.precheck_sibling (at BIGINT)`)
|
||||
await pool.query(`CREATE INDEX relay_audit_events_at ON ${schema}.precheck_sibling(at)`)
|
||||
|
||||
expect(await ask('relay_audit_events')).toBe(false)
|
||||
expect(await ask('precheck_sibling')).toBe(true)
|
||||
await pool.end()
|
||||
})
|
||||
|
||||
it('boots while another session holds ACCESS EXCLUSIVE on the largest table', async () => {
|
||||
// The end-to-end proof through openRelayDatabase: with the lock held, any DDL the boot still
|
||||
// sent against this table would hit lock_timeout, and 55P03 is no longer retried.
|
||||
const cold = await openRelayDatabase({ databaseUrl: url, dataDir: '' })
|
||||
opened.push(cold)
|
||||
await pool.end()
|
||||
|
||||
const holder = new pg.Client({ connectionString: url })
|
||||
await holder.connect()
|
||||
await holder.query('BEGIN')
|
||||
await holder.query(`LOCK TABLE ${LOCKED_TABLE} IN ACCESS EXCLUSIVE MODE`)
|
||||
try {
|
||||
const warm = await openRelayDatabase({ databaseUrl: url, dataDir: '' })
|
||||
opened.push(warm)
|
||||
} finally {
|
||||
await holder.query('ROLLBACK')
|
||||
await holder.end()
|
||||
}
|
||||
})
|
||||
|
||||
it('fails that same boot with 55P03 when the pre-check is not wired in', async () => {
|
||||
// Keeps the test above from passing vacuously: the lock really does block relay's DDL.
|
||||
const cold = await openRelayDatabase({ databaseUrl: url, dataDir: '' })
|
||||
opened.push(cold)
|
||||
|
||||
const holder = new pg.Client({ connectionString: url })
|
||||
await holder.connect()
|
||||
await holder.query('BEGIN')
|
||||
await holder.query(`LOCK TABLE ${LOCKED_TABLE} IN ACCESS EXCLUSIVE MODE`)
|
||||
try {
|
||||
await expect(
|
||||
applyPostgresSchema(
|
||||
relayPostgresSchemaStatements(),
|
||||
(statement) => pool.query(statement),
|
||||
{ retryDeadlineMs: 0 }
|
||||
)
|
||||
).rejects.toMatchObject({ code: '55P03' })
|
||||
} finally {
|
||||
await holder.query('ROLLBACK')
|
||||
await holder.end()
|
||||
await pool.end()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,200 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
requireSchemaLockTarget,
|
||||
schemaLockTarget,
|
||||
sqlWithoutComments,
|
||||
takesRelationLock,
|
||||
type SchemaLockTarget
|
||||
} from '@orca-cloud/postgres-schema'
|
||||
import { relayPostgresSchemaStatements } from './database.js'
|
||||
|
||||
// Golden pin of every boot-time statement that takes a relation lock on Postgres. Each entry with
|
||||
// a kind is gated by the catalog pre-check, so it costs a catalog read on a migrated database and
|
||||
// nothing more. An addition to this list is the case the RULE comment beside SCHEMA forbids: a
|
||||
// brand-new index reports missing on every director at once and each one runs a non-concurrent
|
||||
// build over the whole table, which is how a boot takes the site down. Build it out of band with
|
||||
// CREATE INDEX CONCURRENTLY first, then add it to SCHEMA and update this list.
|
||||
const GOLDEN_LOCK_TAKING: SchemaLockTarget[] = [
|
||||
{ kind: 'index', table: 'relay_invites', name: 'relay_invites_device', skipWhen: 'present' },
|
||||
{ kind: 'index', table: 'relay_devices', name: 'relay_devices_current_hash', skipWhen: 'present' },
|
||||
{ kind: 'index', table: 'relay_devices', name: 'relay_devices_grace_hash', skipWhen: 'present' },
|
||||
{ kind: 'index', table: 'relay_connection_bases', name: 'relay_connection_bases_active_deadline', skipWhen: 'present' },
|
||||
{
|
||||
kind: 'index',
|
||||
table: 'relay_assignment_region_preferences',
|
||||
name: 'relay_assignment_region_preferences_observed',
|
||||
skipWhen: 'present'
|
||||
},
|
||||
{
|
||||
kind: 'index',
|
||||
table: 'relay_region_rehome_attempts',
|
||||
name: 'relay_region_rehome_attempts_pending',
|
||||
skipWhen: 'present'
|
||||
},
|
||||
{
|
||||
kind: 'index',
|
||||
table: 'relay_region_rehome_attempts',
|
||||
name: 'relay_region_rehome_attempts_host_recency',
|
||||
skipWhen: 'present'
|
||||
},
|
||||
{ kind: 'index', table: 'relay_cell_runtime', name: 'relay_cell_runtime_heartbeat', skipWhen: 'present' },
|
||||
{
|
||||
kind: 'index',
|
||||
table: 'relay_cell_connection_runtime',
|
||||
name: 'relay_cell_connection_runtime_heartbeat',
|
||||
skipWhen: 'present'
|
||||
},
|
||||
{
|
||||
kind: 'index',
|
||||
table: 'relay_cell_connection_snapshots',
|
||||
name: 'relay_cell_connection_snapshot_freshness',
|
||||
skipWhen: 'present'
|
||||
},
|
||||
{ kind: 'index', table: 'relay_cell_fences', name: 'relay_cell_fences_expiry', skipWhen: 'present' },
|
||||
{ kind: 'index', table: 'relay_cell_committed_fences', name: 'relay_cell_committed_fences_expiry', skipWhen: 'present' },
|
||||
{
|
||||
kind: 'index',
|
||||
table: 'relay_cell_legacy_fence_adoptions',
|
||||
name: 'relay_cell_legacy_fence_adoptions_expiry',
|
||||
skipWhen: 'present'
|
||||
},
|
||||
{ kind: 'index', table: 'relay_cell_fence_attempts', name: 'relay_cell_fence_attempts_expiry', skipWhen: 'present' },
|
||||
{ kind: 'index', table: 'relay_cell_fence_attempts', name: 'relay_cell_fence_attempts_cell', skipWhen: 'present' },
|
||||
{
|
||||
kind: 'index',
|
||||
table: 'relay_cell_fence_apply_invocations',
|
||||
name: 'relay_cell_fence_apply_invocations_attempt',
|
||||
skipWhen: 'present'
|
||||
},
|
||||
{
|
||||
kind: 'index',
|
||||
table: 'relay_cell_drain_attempt_states',
|
||||
name: 'relay_cell_drain_attempt_states_cell',
|
||||
skipWhen: 'present'
|
||||
},
|
||||
{
|
||||
kind: 'index',
|
||||
table: 'relay_assignment_activity_leases',
|
||||
name: 'relay_assignment_activity_expiry',
|
||||
skipWhen: 'present'
|
||||
},
|
||||
{
|
||||
kind: 'index',
|
||||
table: 'relay_control_connection_reservations',
|
||||
name: 'relay_control_connection_reservation_headroom',
|
||||
skipWhen: 'present'
|
||||
},
|
||||
{
|
||||
kind: 'index',
|
||||
table: 'relay_control_connection_reservations',
|
||||
name: 'relay_control_connection_reservation_assignment',
|
||||
skipWhen: 'present'
|
||||
},
|
||||
{ kind: 'index', table: 'relay_assignment_migrations', name: 'relay_assignment_migrations_active', skipWhen: 'present' },
|
||||
{
|
||||
kind: 'index',
|
||||
table: 'relay_post_drain_migration_pins',
|
||||
name: 'relay_post_drain_migration_pins_attempt',
|
||||
skipWhen: 'present'
|
||||
},
|
||||
{ kind: 'index', table: 'relay_audit_events', name: 'relay_audit_events_at', skipWhen: 'present' },
|
||||
{ kind: 'column', table: 'relay_region_decisions', name: 'last_considered_at', skipWhen: 'present' },
|
||||
{ kind: 'column', table: 'relay_region_decisions', name: 'cohort_bucket', skipWhen: 'present' },
|
||||
// Constraint swaps are matched by name in pg_constraint, with opposite polarities: nothing to
|
||||
// drop is nothing to do, and a name already there is nothing to add.
|
||||
{
|
||||
kind: 'constraint',
|
||||
table: 'relay_region_rehome_attempts',
|
||||
name: 'relay_region_rehome_attempts_preferred_region_check',
|
||||
skipWhen: 'absent'
|
||||
},
|
||||
{
|
||||
kind: 'constraint',
|
||||
table: 'relay_region_rehome_attempts',
|
||||
name: 'relay_region_rehome_attempts_preferred_region_valid',
|
||||
skipWhen: 'present'
|
||||
},
|
||||
{ kind: 'column', table: 'relay_region_rehome_control', name: 'host_cooldown_ms', skipWhen: 'present' },
|
||||
{ kind: 'column', table: 'relay_control_capabilities', name: 'idle_regional_rehome', skipWhen: 'present' },
|
||||
{ kind: 'column', table: 'relay_region_rehome_attempts', name: 'source_generation', skipWhen: 'present' }
|
||||
]
|
||||
|
||||
const INDEX_OR_ADD_COLUMN = /^(?:CREATE\s+(?:UNIQUE\s+)?INDEX|ALTER\s+TABLE\s+[^\s]+\s+ADD\s+COLUMN)/i
|
||||
|
||||
function lockTakingStatements(): string[] {
|
||||
return relayPostgresSchemaStatements().filter(takesRelationLock)
|
||||
}
|
||||
|
||||
describe('relay boot-time lock targets', () => {
|
||||
it('matches the pinned list of lock-taking statements', () => {
|
||||
expect(lockTakingStatements().map(schemaLockTarget)).toEqual(GOLDEN_LOCK_TAKING)
|
||||
})
|
||||
|
||||
it('derives a target for every CREATE INDEX and every ALTER TABLE ADD COLUMN', () => {
|
||||
// A census over the real schema, not two hand-picked cases: a statement that lands here
|
||||
// without a target is sent on every boot and takes the lock the pre-check exists to avoid.
|
||||
// requireSchemaLockTarget is what boot calls, so this fails the same way boot would.
|
||||
for (const statement of relayPostgresSchemaStatements()) {
|
||||
expect(() => requireSchemaLockTarget(statement)).not.toThrow()
|
||||
}
|
||||
const unparsed = relayPostgresSchemaStatements().filter(
|
||||
(statement) =>
|
||||
INDEX_OR_ADD_COLUMN.test(sqlWithoutComments(statement)) &&
|
||||
schemaLockTarget(statement) === undefined
|
||||
)
|
||||
expect(unparsed).toEqual([])
|
||||
})
|
||||
|
||||
it('reads every derived name as a bare identifier, never a keyword or a qualified name', () => {
|
||||
for (const statement of relayPostgresSchemaStatements()) {
|
||||
const target = schemaLockTarget(statement)
|
||||
if (!target) continue
|
||||
expect(target.name).toMatch(/^[a-z_][a-z0-9_]*$/)
|
||||
expect(target.table).toMatch(/^[a-z_][a-z0-9_]*$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('pre-checks every lock-taking statement, with no exceptions', () => {
|
||||
// The invariant the rule comment beside SCHEMA depends on: nothing that takes a relation lock
|
||||
// reaches the server on a warm boot. A statement with no target breaks it.
|
||||
const unchecked = lockTakingStatements().filter(
|
||||
(statement) => schemaLockTarget(statement) === undefined
|
||||
)
|
||||
expect(unchecked).toEqual([])
|
||||
expect(lockTakingStatements()).toHaveLength(GOLDEN_LOCK_TAKING.length)
|
||||
})
|
||||
|
||||
it('derives a target through the comment block a split schema glues on', () => {
|
||||
// Not vacuous: SCHEMA really does carry a comment-prefixed statement, and it is a CREATE INDEX
|
||||
// on relay_connection_bases. Classifying the raw text would give it no target at all.
|
||||
const commented = relayPostgresSchemaStatements().filter((statement) =>
|
||||
statement.startsWith('--')
|
||||
)
|
||||
expect(commented.length).toBeGreaterThan(0)
|
||||
for (const statement of commented) {
|
||||
if (!takesRelationLock(statement)) continue
|
||||
expect(schemaLockTarget(statement)).toBeDefined()
|
||||
}
|
||||
expect(commented.map(schemaLockTarget)).toContainEqual({
|
||||
kind: 'index',
|
||||
table: 'relay_connection_bases',
|
||||
name: 'relay_connection_bases_active_deadline',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves the dollar-quoted statement-stats migration byte-identical', () => {
|
||||
// Its body is a PL/pgSQL block full of commas and parentheses. Reading the tag as anything but
|
||||
// opaque would change the text classification sees, and it is the only such statement relay has.
|
||||
const doBlock = relayPostgresSchemaStatements().find((statement) => statement.startsWith('DO '))
|
||||
expect(doBlock).toBeDefined()
|
||||
expect(sqlWithoutComments(doBlock!)).toBe(doBlock)
|
||||
expect(takesRelationLock(doBlock!)).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves every statement classifiable once its leading comments are stripped', () => {
|
||||
for (const statement of relayPostgresSchemaStatements()) {
|
||||
expect(sqlWithoutComments(statement)).toMatch(/^(?:CREATE|ALTER|DO)\s/i)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -114,9 +114,13 @@ export function createRelayServer(
|
||||
recordControlRenewal: (durationMs, outcome) =>
|
||||
observability.recordControlRenewal?.(durationMs, outcome)
|
||||
})
|
||||
const ready = createRelayReadiness(observedDatabase, config.jwksUrl, {
|
||||
observe: (observation) => observability.recordReadiness(observation)
|
||||
const readiness = createRelayReadiness(observedDatabase, config.jwksUrl, {
|
||||
jwksGraceMs: config.readinessJwksGraceMs,
|
||||
sqlGraceMs: config.readinessSqlGraceMs,
|
||||
observe: (observation) => observability.recordReadiness(observation),
|
||||
observeGrace: (event) => observability.recordReadinessGrace(event)
|
||||
})
|
||||
const ready = readiness.check
|
||||
const queuedBytes = new ProcessQueuedByteBudget()
|
||||
const sessions = new HostSessionRegistry(
|
||||
config,
|
||||
@@ -156,6 +160,7 @@ export function createRelayServer(
|
||||
...readRelayDatabasePoolPressure(database)
|
||||
}),
|
||||
ready,
|
||||
readinessDegradation: () => readiness.degradedDependencies(),
|
||||
recordAssignmentAdmission: (outcome) => observability.recordAssignmentAdmission?.(outcome),
|
||||
recordAssignmentRejectionReason: (lane, reason) =>
|
||||
observability.recordAssignmentRejectionReason?.(lane, reason),
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
"authPoolMax": 10,
|
||||
"apiInstances": 10,
|
||||
"apiPoolMax": 5,
|
||||
"maxConnections": 400,
|
||||
"maxConnections": 500,
|
||||
"sources": {
|
||||
"authInstances": "private apps tfvars: auth service max instances",
|
||||
"authPoolMax": "private auth service: pg.Pool max",
|
||||
"apiInstances": "private apps tfvars: API service max instances",
|
||||
"apiPoolMax": "private API service: pg.Pool max",
|
||||
"maxConnections": "Cloud SQL tier default; no max_connections flag is set"
|
||||
"maxConnections": "measured SHOW max_connections = 500 on the live instance 2026-09-16; no max_connections flag is set, so this is the tier default and the previous 400 was an unverified assumption about it"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
} from './relay-cloud-sql-connection-budget.mjs'
|
||||
|
||||
test('production shared consumers keep allowance and reserve below the ceiling', () => {
|
||||
// cells: 20 pools at 10 (200) + the three asia-east2 pools at 16 (48).
|
||||
const report = readRelayCloudSqlConnectionBudget()
|
||||
|
||||
assert.deepEqual(report.consumers, { cells: 230, directors: 15, auth: 20, api: 50 })
|
||||
assert.deepEqual(report.asia, { cells: 3, poolMax: 10 })
|
||||
assert.equal(report.configuredMaximum, 315)
|
||||
assert.deepEqual(report.consumers, { cells: 248, directors: 15, auth: 20, api: 50 })
|
||||
assert.deepEqual(report.asia, { cells: 3, poolMax: 16 })
|
||||
assert.equal(report.configuredMaximum, 333)
|
||||
assert.equal(report.rolloutOverlap.relayDirectorCandidate, 30)
|
||||
assert.equal(report.rolloutOverlap.apiCandidate, 65)
|
||||
assert.equal(report.rolloutOverlap.authCandidate, 35)
|
||||
@@ -20,11 +21,11 @@ test('production shared consumers keep allowance and reserve below the ceiling',
|
||||
assert.equal(report.rolloutOverlap.maximum, 65)
|
||||
assert.equal(report.maintenanceAdminAllowance, 5)
|
||||
assert.equal(report.explicitReserve, 10)
|
||||
assert.equal(report.usableCeiling, 390)
|
||||
assert.equal(report.operatingMaximum, 385)
|
||||
assert.equal(report.remainingWithinUsableCeiling, 5)
|
||||
assert.equal(report.budgetedTotal, 395)
|
||||
assert.equal(report.unallocated, 5)
|
||||
assert.equal(report.usableCeiling, 490)
|
||||
assert.equal(report.operatingMaximum, 403)
|
||||
assert.equal(report.remainingWithinUsableCeiling, 87)
|
||||
assert.equal(report.budgetedTotal, 413)
|
||||
assert.equal(report.unallocated, 87)
|
||||
assert.equal(report.withinBudget, true)
|
||||
})
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ test('same-cap wrapper is reusable, canary-bound, and sequential', () => {
|
||||
)
|
||||
assert.match(
|
||||
job,
|
||||
/host-drain \\\n {16}--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}" \\\n {14}\| jq -e '\.changes == 2' >\/dev\/null/
|
||||
/host-drain \\\n {16}--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}" \\\n {16}"\$\{POOL_ARGUMENTS\[@\]\}" \\\n {14}\| jq -e '\.changes == 2' >\/dev\/null/
|
||||
)
|
||||
assert.match(job, /resume requires the isolated migration-only cell/)
|
||||
assert.match(job, /test "\$\{TARGET_INCARNATION\}" = "\$\{SOURCE_INCARNATION\}"/)
|
||||
|
||||
@@ -31,10 +31,21 @@ function rehomeSourceCells() {
|
||||
)
|
||||
}
|
||||
|
||||
function startupScript({ cap, image, trusted }) {
|
||||
// The job cross-checks its pinned pool against the committed map; model the same read.
|
||||
function tfvarsDatabasePoolMax(cellId) {
|
||||
const start = production.indexOf(`"${cellId}" = {`)
|
||||
assert.notEqual(start, -1, `${cellId} is missing from production.tfvars`)
|
||||
const block = production.slice(start, production.indexOf('\n }', start))
|
||||
return /database_pool_max\s*=\s*(\d+)/.exec(block)?.[1] ?? '10'
|
||||
}
|
||||
|
||||
function startupScript({ cap, image, trusted, pool }) {
|
||||
return [
|
||||
` printf 'ORCA_RELAY_CELL_CONNECTION_HARD_CAP=%s\\n' '${cap}'`,
|
||||
` printf 'ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND=%s\\n' '60'`,
|
||||
...(pool === undefined
|
||||
? []
|
||||
: [` printf 'ORCA_RELAY_DATABASE_POOL_MAX=%s\\n' '${pool}'`]),
|
||||
...(trusted ? [
|
||||
` printf 'ORCA_RELAY_REHOME_DIRECTOR_SERVICE_ACCOUNT=%s\\n' '${DIRECTOR_IDENTITY}'`,
|
||||
` printf 'ORCA_RELAY_REHOME_AUDIENCE=%s\\n' '${AUDIENCE}'`
|
||||
@@ -48,7 +59,7 @@ function startupScript({ cap, image, trusted }) {
|
||||
}
|
||||
|
||||
// The exact shape the apply step's plan has: template replaced, MIG rebound to it.
|
||||
function rollPlan({ cellId, cap, protocol }) {
|
||||
function rollPlan({ cellId, cap, protocol, pool }) {
|
||||
return {
|
||||
configuration: {
|
||||
root_module: {
|
||||
@@ -77,14 +88,17 @@ function rollPlan({ cellId, cap, protocol }) {
|
||||
metadata_startup_script: startupScript({
|
||||
cap,
|
||||
image: ROLLBACK_IMAGE,
|
||||
trusted: protocol >= 1
|
||||
trusted: protocol >= 1,
|
||||
// The live template predates the reviewed pool raise, as every asia cell's does.
|
||||
pool: pool === undefined ? undefined : '10'
|
||||
})
|
||||
},
|
||||
after: {
|
||||
metadata_startup_script: startupScript({
|
||||
cap,
|
||||
image: TARGET_IMAGE,
|
||||
trusted: protocol >= 1
|
||||
trusted: protocol >= 1,
|
||||
pool
|
||||
}),
|
||||
self_link: null
|
||||
},
|
||||
@@ -108,7 +122,8 @@ function hostname(cellId) {
|
||||
return cellId.slice('production-gce-'.length)
|
||||
}
|
||||
|
||||
// The job resolves cap and region from the cell id before any admin call; run that block alone.
|
||||
// The job resolves cap, region, and pool from the cell id before any admin call; run that block
|
||||
// alone. An empty pool is the root default, which the startup template emits no line for.
|
||||
function resolveCellShape(cellId) {
|
||||
const start = workflow.indexOf(' TARGET_HOSTNAME="${TARGET_CELL_ID#production-gce-}"')
|
||||
assert.notEqual(start, -1, 'the same-cap cell shape block is missing')
|
||||
@@ -119,10 +134,17 @@ function resolveCellShape(cellId) {
|
||||
'-euo',
|
||||
'pipefail',
|
||||
'-c',
|
||||
`${script}\necho "\${EXPECTED_REGION} \${EXPECTED_HARD_CAP}"`
|
||||
`${script}\necho "\${EXPECTED_REGION} \${EXPECTED_HARD_CAP} pool=\${EXPECTED_DATABASE_POOL_MAX}"`
|
||||
], { env: { ...process.env, TARGET_CELL_ID: cellId }, encoding: 'utf8' })
|
||||
}
|
||||
|
||||
function cellShape(cellId) {
|
||||
const resolved = resolveCellShape(cellId)
|
||||
assert.equal(resolved.status, 0, `${cellId}: ${resolved.stderr}`)
|
||||
const [, cap, pool] = resolved.stdout.trim().split(' ')
|
||||
return { cap: Number(cap), pool: pool.slice('pool='.length) || undefined }
|
||||
}
|
||||
|
||||
describe('same-cap roll scripts accept every same-cap cell', () => {
|
||||
it('parses every wave cell through the same-cap canary allowlist', () => {
|
||||
for (const cellId of SAME_CAP_CELLS) {
|
||||
@@ -143,11 +165,16 @@ describe('same-cap roll scripts accept every same-cap cell', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves a cap and region for every wave cell and refuses anything else', () => {
|
||||
it('resolves a cap, region, and pool for every wave cell and refuses anything else', () => {
|
||||
for (const cellId of SAME_CAP_CELLS) {
|
||||
const resolved = resolveCellShape(cellId)
|
||||
assert.equal(resolved.status, 0, `${cellId}: ${resolved.stderr}`)
|
||||
assert.match(resolved.stdout.trim(), /^(us-central1 1000|asia-east2 3000)$/)
|
||||
assert.match(
|
||||
resolved.stdout.trim(),
|
||||
/^(us-central1 1000 pool=|asia-east2 3000 pool=16)$/,
|
||||
cellId
|
||||
)
|
||||
assert.equal(tfvarsDatabasePoolMax(cellId), cellShape(cellId).pool ?? '10', cellId)
|
||||
}
|
||||
assert.equal(resolveCellShape('production-gce-c17').status, 1)
|
||||
assert.equal(resolveCellShape('production-gce-c30').status, 1)
|
||||
@@ -165,7 +192,7 @@ describe('same-cap roll scripts accept every same-cap cell', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('passes this cell\'s rehome protocol on every plan validation the job runs', () => {
|
||||
it('passes this cell\'s rehome protocol and pool on every plan validation the job runs', () => {
|
||||
const invocations = workflow.split('validate-relay-capacity-plan.mjs').slice(1)
|
||||
assert.equal(invocations.length, 2)
|
||||
for (const invocation of invocations) {
|
||||
@@ -174,25 +201,34 @@ describe('same-cap roll scripts accept every same-cap cell', () => {
|
||||
const call = lines.slice(0, end + 1).join(' ')
|
||||
assert.match(call, /--mode same-cap-cell/)
|
||||
assert.match(call, /--regional-rehome-protocol "\$\{DESIRED_REHOME_PROTOCOL\}"/)
|
||||
assert.match(call, /"\$\{POOL_ARGUMENTS\[@\]\}"/)
|
||||
}
|
||||
// Each of those steps must build the flag from the resolved pool, and only when there is one.
|
||||
const builders = workflow.split(
|
||||
'if test -n "${EXPECTED_DATABASE_POOL_MAX}"; then\n' +
|
||||
' POOL_ARGUMENTS=(--database-pool-max "${EXPECTED_DATABASE_POOL_MAX}")'
|
||||
)
|
||||
assert.equal(builders.length, 3)
|
||||
assert.equal(workflow.split('POOL_ARGUMENTS=()').length, 3)
|
||||
})
|
||||
|
||||
it('validates a correct plan for every wave cell at that cell\'s rehome protocol', () => {
|
||||
for (const [cellId, protocol] of SAME_CAP_CELLS.flatMap((cell) => [[cell, 1], [cell, 3]])) {
|
||||
const [, cap] = resolveCellShape(cellId).stdout.trim().split(' ')
|
||||
const { cap, pool } = cellShape(cellId)
|
||||
assert.equal(REHOME_SOURCE_CELLS.has(cellId), true, cellId)
|
||||
const config = {
|
||||
mode: 'same-cap-cell',
|
||||
cellId,
|
||||
hardCap: Number(cap),
|
||||
hardCap: cap,
|
||||
unobservedBound: 60,
|
||||
image: TARGET_IMAGE,
|
||||
rollbackImage: ROLLBACK_IMAGE,
|
||||
rehomeDirectorServiceAccount: DIRECTOR_IDENTITY,
|
||||
rehomeAudience: AUDIENCE,
|
||||
regionalRehomeProtocol: String(protocol)
|
||||
regionalRehomeProtocol: String(protocol),
|
||||
databasePoolMax: pool
|
||||
}
|
||||
const plan = rollPlan({ cellId, cap, protocol })
|
||||
const plan = rollPlan({ cellId, cap, protocol, pool })
|
||||
assert.deepEqual(
|
||||
validateCapacityPlan(plan, config),
|
||||
{ mode: 'same-cap-cell', changes: 2 },
|
||||
@@ -207,6 +243,15 @@ describe('same-cap roll scripts accept every same-cap cell', () => {
|
||||
/reviewed image and capacity/,
|
||||
cellId
|
||||
)
|
||||
// Dropping the pin must reject a pinned cell, and adding one must reject a default cell.
|
||||
assert.throws(
|
||||
() => validateCapacityPlan(plan, {
|
||||
...config,
|
||||
databasePoolMax: pool === undefined ? '16' : undefined
|
||||
}),
|
||||
/reviewed image and capacity/,
|
||||
cellId
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ const SERVICE_ACCOUNT_EMAIL =
|
||||
const REHOME_CONFIG =
|
||||
/^ printf 'ORCA_RELAY_REHOME_(?:DIRECTOR_SERVICE_ACCOUNT|AUDIENCE)=%s\\n' '[^'\n]+'$/
|
||||
|
||||
const DATABASE_POOL_MAX = /^ printf 'ORCA_RELAY_DATABASE_POOL_MAX=%s\\n' '[0-9]+'$/
|
||||
|
||||
// Only cells listed as regional rehome sources get rehome trust lines in their startup script.
|
||||
function rehomeProtocol({ regionalRehomeProtocol }) {
|
||||
if (![0, 1, 3, '0', '1', '3'].includes(regionalRehomeProtocol)) {
|
||||
@@ -15,6 +17,16 @@ function rehomeProtocol({ regionalRehomeProtocol }) {
|
||||
return Number(regionalRehomeProtocol)
|
||||
}
|
||||
|
||||
// Only cells off the root pool default get a pool line, so the caller states whether to expect one.
|
||||
function databasePoolMax({ databasePoolMax: value }) {
|
||||
if (value === undefined) return undefined
|
||||
const pool = Number(value)
|
||||
if (!/^[0-9]+$/.test(String(value)) || pool < 1 || pool > 100) {
|
||||
throw new Error('same-cap Terraform plan has an invalid database pool max')
|
||||
}
|
||||
return String(pool)
|
||||
}
|
||||
|
||||
export function parseCapacityPlanArguments(argv) {
|
||||
const values = {}
|
||||
for (let index = 0; index < argv.length; index += 2) {
|
||||
@@ -48,6 +60,9 @@ export function parseCapacityPlanArguments(argv) {
|
||||
if (values.mode !== 'same-cap-cell' && values['regional-rehome-protocol'] !== undefined) {
|
||||
throw new Error('--regional-rehome-protocol applies only to same-cap-cell validation')
|
||||
}
|
||||
if (values.mode !== 'same-cap-cell' && values['database-pool-max'] !== undefined) {
|
||||
throw new Error('--database-pool-max applies only to same-cap-cell validation')
|
||||
}
|
||||
if (values.mode === 'same-cap-image' && !values['rollback-image']) {
|
||||
throw new Error('same-cap image validation requires a rollback image')
|
||||
}
|
||||
@@ -67,7 +82,8 @@ export function parseCapacityPlanArguments(argv) {
|
||||
rollbackImage: values['rollback-image'],
|
||||
rehomeDirectorServiceAccount: values['rehome-director-service-account'],
|
||||
rehomeAudience: values['rehome-audience'],
|
||||
regionalRehomeProtocol: values['regional-rehome-protocol']
|
||||
regionalRehomeProtocol: values['regional-rehome-protocol'],
|
||||
databasePoolMax: values['database-pool-max']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +198,8 @@ function normalizedStartupScript(
|
||||
script,
|
||||
stripCapacityIdentity = false,
|
||||
stripRehomeConfig = false,
|
||||
preserveCapacity = false
|
||||
preserveCapacity = false,
|
||||
stripDatabasePoolMax = false
|
||||
) {
|
||||
const image = relayImage(script)
|
||||
if (!image) throw new Error('cell plan startup script has no Relay image')
|
||||
@@ -197,7 +214,8 @@ function normalizedStartupScript(
|
||||
(line) =>
|
||||
(preserveCapacity || !capacityAssignment.test(line)) &&
|
||||
(!stripCapacityIdentity || !capacityIdentity.test(line)) &&
|
||||
(!stripRehomeConfig || !REHOME_CONFIG.test(line))
|
||||
(!stripRehomeConfig || !REHOME_CONFIG.test(line)) &&
|
||||
(!stripDatabasePoolMax || !DATABASE_POOL_MAX.test(line))
|
||||
)
|
||||
.join('\n')
|
||||
.replaceAll(image, '<relay-image>')
|
||||
@@ -245,10 +263,23 @@ function requireDesiredStartupScript(script, config) {
|
||||
config.mode === 'same-cap-cell' &&
|
||||
!rehomeTrusted &&
|
||||
lines.some((line) => REHOME_CONFIG.test(line))
|
||||
const pool = config.mode === 'same-cap-cell' ? databasePoolMax(config) : undefined
|
||||
if (pool !== undefined) {
|
||||
expected.push([
|
||||
DATABASE_POOL_MAX,
|
||||
` printf 'ORCA_RELAY_DATABASE_POOL_MAX=%s\\n' '${pool}'`
|
||||
])
|
||||
}
|
||||
// An unpinned cell sits on the root pool default, so gaining a pool line is real drift.
|
||||
const unexpectedDatabasePoolMax =
|
||||
config.mode === 'same-cap-cell' &&
|
||||
pool === undefined &&
|
||||
lines.some((line) => DATABASE_POOL_MAX.test(line))
|
||||
if (
|
||||
typeof script !== 'string' ||
|
||||
relayImage(script) !== config.image ||
|
||||
unexpectedRehome ||
|
||||
unexpectedDatabasePoolMax ||
|
||||
expected.some(([pattern, line]) => !hasExactSingleAssignment(lines, pattern, line))
|
||||
) {
|
||||
throw new Error('cell plan does not contain the reviewed image and capacity')
|
||||
@@ -421,6 +452,8 @@ function cellPlan(plan, changes, config) {
|
||||
const script = template.change.after?.metadata_startup_script
|
||||
requireDesiredStartupScript(script, config)
|
||||
const sameCap = ['same-cap-cell', 'same-cap-image'].includes(config.mode)
|
||||
// Only a pinned pool may move here; requireDesiredStartupScript holds the after value exactly.
|
||||
const stripPool = config.mode === 'same-cap-cell' && config.databasePoolMax !== undefined
|
||||
if (
|
||||
typeof beforeScript !== 'string' ||
|
||||
(sameCap && relayImage(beforeScript) !== config.rollbackImage) ||
|
||||
@@ -428,12 +461,14 @@ function cellPlan(plan, changes, config) {
|
||||
beforeScript,
|
||||
config.mode === 'bootstrap-cell',
|
||||
config.mode === 'same-cap-cell',
|
||||
sameCap
|
||||
sameCap,
|
||||
stripPool
|
||||
) !== normalizedStartupScript(
|
||||
script,
|
||||
config.mode === 'bootstrap-cell',
|
||||
config.mode === 'same-cap-cell',
|
||||
sameCap
|
||||
sameCap,
|
||||
stripPool
|
||||
)
|
||||
) {
|
||||
throw new Error('cell plan does not contain the reviewed image and capacity')
|
||||
@@ -473,6 +508,7 @@ export function validateCapacityPlan(plan, config) {
|
||||
}
|
||||
if (config.mode === 'same-cap-cell') {
|
||||
rehomeProtocol(config)
|
||||
databasePoolMax(config)
|
||||
}
|
||||
if (
|
||||
config.mode === 'same-cap-cell' &&
|
||||
|
||||
@@ -783,3 +783,134 @@ test('the rehome protocol argument is required by same-cap-cell mode alone', ()
|
||||
/applies only to same-cap-cell validation/
|
||||
)
|
||||
})
|
||||
|
||||
test('the reviewed database pool is pinned for the cells that emit one', () => {
|
||||
const rollbackImage = `us-docker.pkg.dev/project/relay/image@sha256:${'d'.repeat(64)}`
|
||||
const image = `us-docker.pkg.dev/project/relay/image@sha256:${'e'.repeat(64)}`
|
||||
const directorIdentity = 'relay-director@project.iam.gserviceaccount.com'
|
||||
const audience = 'https://relay.example.com/v1/admin/host-drain'
|
||||
const startup = ({ selectedImage, pool }) => [
|
||||
` printf 'ORCA_RELAY_CELL_CONNECTION_HARD_CAP=%s\\n' '3000'`,
|
||||
` printf 'ORCA_RELAY_CELL_CONNECTION_UNOBSERVED_BOUND=%s\\n' '60'`,
|
||||
...(pool === undefined
|
||||
? []
|
||||
: [` printf 'ORCA_RELAY_DATABASE_POOL_MAX=%s\\n' '${pool}'`]),
|
||||
` printf 'ORCA_RELAY_REHOME_DIRECTOR_SERVICE_ACCOUNT=%s\\n' '${directorIdentity}'`,
|
||||
` printf 'ORCA_RELAY_REHOME_AUDIENCE=%s\\n' '${audience}'`,
|
||||
`printf 'ORCA_RELAY_IMAGE_DIGEST=%s\\n' '${selectedImage.split('@')[1]}'`,
|
||||
`docker pull '${selectedImage}'`,
|
||||
'docker run --detach \\',
|
||||
' --name orca-relay \\',
|
||||
` '${selectedImage}'`
|
||||
].join('\n')
|
||||
const rollPlan = (before, after) => ({
|
||||
resource_changes: [
|
||||
{
|
||||
address: 'google_compute_instance_template.relay_gce_cell["production-gce-c27"]',
|
||||
change: {
|
||||
actions: ['create', 'delete'],
|
||||
before: {
|
||||
metadata_startup_script: startup({ selectedImage: rollbackImage, pool: before })
|
||||
},
|
||||
after: {
|
||||
metadata_startup_script: startup({ selectedImage: image, pool: after }),
|
||||
self_link: null
|
||||
},
|
||||
after_unknown: { self_link: true }
|
||||
}
|
||||
},
|
||||
{
|
||||
address: 'google_compute_instance_group_manager.relay_gce_cell["production-gce-c27"]',
|
||||
change: {
|
||||
actions: ['update'],
|
||||
before: { target_size: 1, version: [{ instance_template: 'old' }] },
|
||||
after: { target_size: 1, version: [{ instance_template: null }] },
|
||||
after_unknown: { version: [{ instance_template: true }] }
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
const asiaConfig = {
|
||||
cellId: 'production-gce-c27',
|
||||
hardCap: 3_000,
|
||||
unobservedBound: 60,
|
||||
mode: 'same-cap-cell',
|
||||
image,
|
||||
rollbackImage,
|
||||
rehomeDirectorServiceAccount: directorIdentity,
|
||||
rehomeAudience: audience,
|
||||
regionalRehomeProtocol: '1'
|
||||
}
|
||||
// The live template still says 10 while the reviewed plan says 16; only the pin bridges that.
|
||||
assert.deepEqual(
|
||||
validateCapacityPlan(rollPlan('10', '16'), { ...asiaConfig, databasePoolMax: '16' }),
|
||||
{ mode: 'same-cap-cell', changes: 2 }
|
||||
)
|
||||
assert.throws(
|
||||
() => validateCapacityPlan(rollPlan('10', '16'), asiaConfig),
|
||||
/reviewed image and capacity/
|
||||
)
|
||||
assert.throws(
|
||||
() => validateCapacityPlan(rollPlan('10', '12'), { ...asiaConfig, databasePoolMax: '16' }),
|
||||
/reviewed image and capacity/
|
||||
)
|
||||
// A cell on the root pool default emits no line at all, and gaining one is real drift.
|
||||
assert.deepEqual(
|
||||
validateCapacityPlan(rollPlan(undefined, undefined), asiaConfig),
|
||||
{ mode: 'same-cap-cell', changes: 2 }
|
||||
)
|
||||
assert.throws(
|
||||
() => validateCapacityPlan(rollPlan(undefined, '16'), asiaConfig),
|
||||
/reviewed image and capacity/
|
||||
)
|
||||
// A line already on the live template is still unreviewed without the pin, even standing still.
|
||||
assert.throws(
|
||||
() => validateCapacityPlan(rollPlan('16', '16'), asiaConfig),
|
||||
/reviewed image and capacity/
|
||||
)
|
||||
// A pin must also fail closed when the plan drops the line it names.
|
||||
assert.throws(
|
||||
() => validateCapacityPlan(rollPlan('16', undefined), { ...asiaConfig, databasePoolMax: '16' }),
|
||||
/reviewed image and capacity/
|
||||
)
|
||||
for (const pool of ['', '0', '101', 'ten']) {
|
||||
assert.throws(
|
||||
() => validateCapacityPlan(rollPlan('10', '16'), { ...asiaConfig, databasePoolMax: pool }),
|
||||
/invalid database pool max/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('the database pool argument is accepted by same-cap-cell mode alone', () => {
|
||||
const image = `us-docker.pkg.dev/project/relay/image@sha256:${'e'.repeat(64)}`
|
||||
const rollbackImage = `us-docker.pkg.dev/project/relay/image@sha256:${'d'.repeat(64)}`
|
||||
const sameCapArguments = (...extra) => [
|
||||
'--mode', 'same-cap-cell',
|
||||
'--cell-id', 'production-gce-c27',
|
||||
'--hard-cap', '3000',
|
||||
'--unobserved-bound', '60',
|
||||
'--image', image,
|
||||
'--rollback-image', rollbackImage,
|
||||
'--rehome-director-service-account', 'relay-director@project.iam.gserviceaccount.com',
|
||||
'--rehome-audience', 'https://relay.onorca.dev/v1/admin/host-drain',
|
||||
'--regional-rehome-protocol', '1',
|
||||
...extra
|
||||
]
|
||||
assert.equal(
|
||||
parseCapacityPlanArguments(sameCapArguments('--database-pool-max', '16')).databasePoolMax,
|
||||
'16'
|
||||
)
|
||||
assert.equal(parseCapacityPlanArguments(sameCapArguments()).databasePoolMax, undefined)
|
||||
assert.throws(
|
||||
() => parseCapacityPlanArguments([
|
||||
'--mode', 'bootstrap-cell',
|
||||
'--cell-id', 'staging-gce-c3',
|
||||
'--hard-cap', '1000',
|
||||
'--unobserved-bound', '60',
|
||||
'--image', image,
|
||||
'--capacity-service-account', 'orca-cap@onorca-cloud.iam.gserviceaccount.com',
|
||||
'--database-pool-max', '16'
|
||||
]),
|
||||
/applies only to same-cap-cell validation/
|
||||
)
|
||||
})
|
||||
|
||||
@@ -32,7 +32,16 @@ relay_gce_subnetwork_cidr = "10.42.0.0/24"
|
||||
relay_gce_additional_region_subnetwork_cidrs = {
|
||||
"asia-east2" = "10.42.1.0/24"
|
||||
}
|
||||
relay_gce_fenced_cells = ["production-gce-c1", "production-gce-c2", "production-gce-c3", "production-gce-c6", "production-gce-c11", "production-gce-c12"]
|
||||
# Fenced cells are retired existing-only capacity: the selector can never place on them again,
|
||||
# so their MIGs run at zero rather than holding a VM and 10 Postgres connections each.
|
||||
relay_gce_fenced_cells = [
|
||||
"production-gce-c1",
|
||||
"production-gce-c2",
|
||||
"production-gce-c3",
|
||||
"production-gce-c6",
|
||||
"production-gce-c11",
|
||||
"production-gce-c12"
|
||||
]
|
||||
# Initial cells stay admission-disabled until production preflight and go-live approval.
|
||||
relay_gce_cells = {
|
||||
"production-gce-c1" = {
|
||||
@@ -350,7 +359,7 @@ relay_gce_cells = {
|
||||
boot_disk_gb = 30
|
||||
boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21"
|
||||
capacity_requests = 6000
|
||||
database_pool_max = 10
|
||||
database_pool_max = 16 # 176 ms from us-central1 Postgres saturates 10 (94-156 waiters).
|
||||
image = "us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@sha256:5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70cad1e563"
|
||||
initially_enabled = false
|
||||
connection_hard_cap = 3000
|
||||
@@ -364,7 +373,7 @@ relay_gce_cells = {
|
||||
boot_disk_gb = 30
|
||||
boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21"
|
||||
capacity_requests = 6000
|
||||
database_pool_max = 10
|
||||
database_pool_max = 16 # 176 ms from us-central1 Postgres saturates 10 (94-156 waiters).
|
||||
image = "us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@sha256:5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70cad1e563"
|
||||
initially_enabled = false
|
||||
connection_hard_cap = 3000
|
||||
@@ -378,7 +387,7 @@ relay_gce_cells = {
|
||||
boot_disk_gb = 30
|
||||
boot_image = "https://www.googleapis.com/compute/v1/projects/cos-cloud/global/images/cos-stable-121-18867-528-21"
|
||||
capacity_requests = 6000
|
||||
database_pool_max = 10
|
||||
database_pool_max = 16 # 176 ms from us-central1 Postgres saturates 10 (94-156 waiters).
|
||||
image = "us-central1-docker.pkg.dev/onorca-cloud/orca-cloud/relay@sha256:5aedbca5c86de24c8b4d4bf7e3b444b76c712f281ede916cb9d90f70cad1e563"
|
||||
initially_enabled = false
|
||||
connection_hard_cap = 3000
|
||||
|
||||
@@ -93,6 +93,11 @@ locals {
|
||||
db_waiters_max = { field = "databasePoolWaitersMax", description = "Maximum requests queued for a PostgreSQL connection during the interval." }
|
||||
db_oldest_wait_ms = { field = "databasePoolOldestWaitMs", description = "Current oldest PostgreSQL pool waiter age." }
|
||||
db_wait_ms_max = { field = "databasePoolWaitMsMax", description = "Maximum PostgreSQL pool wait during the interval." }
|
||||
cell_inventory_hold_ms_max = { field = "cellInventoryHoldMsMax", description = "Longest cell-inventory lock hold in the interval." }
|
||||
cell_inventory_hold_ms_p95 = { field = "cellInventoryHoldMsP95", description = "Cell-inventory lock hold p95 in the interval; the bound is tuned against this." }
|
||||
cell_inventory_holds = { field = "cellInventoryHolds", description = "Cell-inventory locks acquired in the interval; the percentiles above summarise these." }
|
||||
cell_inventory_lock_unavailable = { field = "cellInventoryLockUnavailable", description = "Fail-fast cell-inventory acquisitions that found the lock held. Includes background sweeps, which step aside by design, so this is contention pressure rather than user-visible failure." }
|
||||
cell_inventory_lock_timeouts = { field = "cellInventoryLockTimeouts", description = "Bounded cell-inventory waits that expired, counted per attempt rather than per request. This is the user-visible lane." }
|
||||
}
|
||||
|
||||
# Regions the director can hint or select. Pinned to relay-contract's RELAY_REGIONS by
|
||||
|
||||
+1
-1
@@ -28,6 +28,6 @@
|
||||
"@types/node": "^24.10.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.8"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
"lint": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "pnpm build",
|
||||
"test": "pnpm build && vitest run",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.8"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { applyPostgresSchema } from './apply-postgres-schema.js'
|
||||
import type { SchemaCatalogRow } from './catalog-object-precheck.js'
|
||||
|
||||
function postgresError(code: string, constraint?: string): Error {
|
||||
return Object.assign(new Error(code), constraint === undefined ? { code } : { code, constraint })
|
||||
}
|
||||
|
||||
const COMMENTED_INDEX = `-- Why the sweep needs this index
|
||||
CREATE INDEX IF NOT EXISTS relay_bases_active ON relay_connection_bases(active, deadline)`
|
||||
|
||||
const COMMENTED_TABLE = `-- Two comment lines, the other shape a split schema carries
|
||||
-- above a statement
|
||||
CREATE TABLE IF NOT EXISTS relay_cells (
|
||||
cell_id TEXT PRIMARY KEY
|
||||
)`
|
||||
|
||||
// Answers absent first and present afterwards, the state a concurrent create leaves behind.
|
||||
function catalogAnswersInSequence(answers: SchemaCatalogRow[][]): {
|
||||
catalogQuery: (sql: string, params: unknown[]) => Promise<SchemaCatalogRow[]>
|
||||
asked: unknown[][]
|
||||
} {
|
||||
const asked: unknown[][] = []
|
||||
return {
|
||||
asked,
|
||||
catalogQuery: async (sql, params) => {
|
||||
asked.push([sql, ...params])
|
||||
return answers[asked.length - 1] ?? []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function catalogAnswers(rows: SchemaCatalogRow[]): {
|
||||
catalogQuery: (sql: string, params: unknown[]) => Promise<SchemaCatalogRow[]>
|
||||
asked: unknown[][]
|
||||
} {
|
||||
const asked: unknown[][] = []
|
||||
return {
|
||||
asked,
|
||||
catalogQuery: async (sql, params) => {
|
||||
asked.push([sql, ...params])
|
||||
return rows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('applyPostgresSchema classification', () => {
|
||||
it('classifies a comment-prefixed CREATE INDEX by its first SQL keyword', async () => {
|
||||
let calls = 0
|
||||
const query = vi.fn(async () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw postgresError('42P07')
|
||||
return undefined
|
||||
})
|
||||
await applyPostgresSchema([COMMENTED_INDEX], query, { wait: async () => undefined })
|
||||
expect(query).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('classifies a comment-prefixed CREATE TABLE by its own collision codes', async () => {
|
||||
// pg_type_typname_nsp_index is reached only through the CREATE TABLE branch, so a statement
|
||||
// misread as unknown would fail the boot on a benign concurrent create instead of retrying.
|
||||
let calls = 0
|
||||
const query = vi.fn(async () => {
|
||||
calls += 1
|
||||
if (calls === 1) throw postgresError('23505', 'pg_type_typname_nsp_index')
|
||||
return undefined
|
||||
})
|
||||
await applyPostgresSchema([COMMENTED_TABLE], query, { wait: async () => undefined })
|
||||
expect(query).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retries a concurrent index collision until it succeeds', async () => {
|
||||
let calls = 0
|
||||
const query = vi.fn(async () => {
|
||||
calls += 1
|
||||
if (calls < 3) throw postgresError('23505', 'pg_class_relname_nsp_index')
|
||||
return undefined
|
||||
})
|
||||
const summary = await applyPostgresSchema(['CREATE INDEX IF NOT EXISTS i ON t(c)'], query, {
|
||||
wait: async () => undefined
|
||||
})
|
||||
expect(query).toHaveBeenCalledTimes(3)
|
||||
expect(summary).toEqual({ ran: 1, skipped: 0 })
|
||||
})
|
||||
|
||||
it('treats an already-applied constraint as skipped rather than an error', async () => {
|
||||
// Still the answer for a caller with no pre-check, and for a constraint another director
|
||||
// committed between this boot's pre-check and its ALTER TABLE.
|
||||
const query = vi.fn(async () => {
|
||||
throw postgresError('42710')
|
||||
})
|
||||
const summary = await applyPostgresSchema(['ALTER TABLE t ADD CONSTRAINT c CHECK (x > 0)'], query)
|
||||
expect(summary).toEqual({ ran: 0, skipped: 1 })
|
||||
})
|
||||
|
||||
it('propagates an unrelated error without retrying', async () => {
|
||||
const query = vi.fn(async () => {
|
||||
throw postgresError('42501')
|
||||
})
|
||||
await expect(
|
||||
applyPostgresSchema(['CREATE TABLE IF NOT EXISTS t (id TEXT)'], query)
|
||||
).rejects.toThrow(/42501/)
|
||||
expect(query).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPostgresSchema lock timeouts', () => {
|
||||
it('does not retry a lock timeout', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
const query = vi.fn(async () => {
|
||||
throw postgresError('55P03')
|
||||
})
|
||||
await expect(
|
||||
applyPostgresSchema(['CREATE INDEX IF NOT EXISTS i ON t(c)'], query, {
|
||||
wait: async () => undefined
|
||||
})
|
||||
).rejects.toThrow(/55P03/)
|
||||
expect(query).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('names the statement that could not take its lock', async () => {
|
||||
const lines: string[] = []
|
||||
vi.spyOn(console, 'error').mockImplementation((line: string) => {
|
||||
lines.push(line)
|
||||
})
|
||||
const query = vi.fn(async () => {
|
||||
throw postgresError('55P03')
|
||||
})
|
||||
await expect(applyPostgresSchema([COMMENTED_INDEX], query)).rejects.toThrow(/55P03/)
|
||||
expect(JSON.parse(lines[0] ?? '{}')).toMatchObject({
|
||||
event: 'orca_relay_postgres_schema_lock_timeout',
|
||||
code: '55P03',
|
||||
statement: 'CREATE INDEX IF NOT EXISTS relay_bases_active ON relay_connection_bases(active, deadline)'
|
||||
})
|
||||
})
|
||||
|
||||
it('still retries a lock timeout for a caller that opts in', async () => {
|
||||
// A caller with no catalog pre-check learns nothing from a lock timeout about whether the
|
||||
// object exists, so its old bounded retry is the correct behaviour there.
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
let calls = 0
|
||||
const query = vi.fn(async () => {
|
||||
calls += 1
|
||||
if (calls < 3) throw postgresError('55P03')
|
||||
return undefined
|
||||
})
|
||||
await applyPostgresSchema(['CREATE INDEX IF NOT EXISTS i ON t(c)'], query, {
|
||||
retryLockTimeout: true,
|
||||
wait: async () => undefined
|
||||
})
|
||||
expect(query).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPostgresSchema catalog pre-check', () => {
|
||||
it('sends no lock-taking statement when the catalog has the object', async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const query = vi.fn(async (_statement: string) => undefined)
|
||||
const { catalogQuery, asked } = catalogAnswers([{ indisvalid: true }])
|
||||
const summary = await applyPostgresSchema([COMMENTED_TABLE, COMMENTED_INDEX], query, {
|
||||
catalogQuery
|
||||
})
|
||||
expect(query.mock.calls.map(([sql]) => sql)).toEqual([COMMENTED_TABLE])
|
||||
expect(asked).toEqual([
|
||||
[expect.stringContaining('pg_catalog.pg_index'), 'relay_connection_bases', 'relay_bases_active']
|
||||
])
|
||||
expect(summary).toEqual({ ran: 1, skipped: 1 })
|
||||
})
|
||||
|
||||
it('skips an index the catalog reports as invalid rather than rebuilding it', async () => {
|
||||
// A cancelled CREATE INDEX CONCURRENTLY leaves exactly this state, and IF NOT EXISTS skips it
|
||||
// too, so reading indisvalid as a condition would newly take the lock it used to avoid.
|
||||
const logged: { event?: string }[] = []
|
||||
vi.spyOn(console, 'log').mockImplementation((line: string) => {
|
||||
logged.push(JSON.parse(line))
|
||||
})
|
||||
const query = vi.fn(async (_statement: string) => undefined)
|
||||
const { catalogQuery } = catalogAnswers([{ indisvalid: false }])
|
||||
await applyPostgresSchema([COMMENTED_INDEX], query, { catalogQuery })
|
||||
expect(query).not.toHaveBeenCalled()
|
||||
expect(logged.filter((entry) => entry.event?.endsWith('_object_present'))).toEqual([
|
||||
{
|
||||
event: 'orca_relay_postgres_schema_object_present',
|
||||
kind: 'index',
|
||||
table: 'relay_connection_bases',
|
||||
name: 'relay_bases_active',
|
||||
indisvalid: false
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('reports how many statements ran and how many were skipped', async () => {
|
||||
const logged: string[] = []
|
||||
vi.spyOn(console, 'log').mockImplementation((line: string) => {
|
||||
logged.push(line)
|
||||
})
|
||||
const { catalogQuery } = catalogAnswers([{ indisvalid: true }])
|
||||
await applyPostgresSchema([COMMENTED_TABLE, COMMENTED_INDEX], vi.fn(async () => undefined), {
|
||||
catalogQuery,
|
||||
eventPrefix: 'orca_push_postgres_schema'
|
||||
})
|
||||
expect(JSON.parse(logged[logged.length - 1] ?? '{}')).toEqual({
|
||||
event: 'orca_push_postgres_schema_applied',
|
||||
ran: 1,
|
||||
skipped: 1
|
||||
})
|
||||
})
|
||||
|
||||
it('asks pg_attribute for a column and sends the ALTER TABLE when no row comes back', async () => {
|
||||
const query = vi.fn(async (_statement: string) => undefined)
|
||||
const { catalogQuery, asked } = catalogAnswers([])
|
||||
const statement = 'ALTER TABLE relay_control_capabilities ADD COLUMN IF NOT EXISTS idle BIGINT'
|
||||
const summary = await applyPostgresSchema([statement], query, { catalogQuery })
|
||||
expect(asked).toEqual([
|
||||
[expect.stringContaining('pg_catalog.pg_attribute'), 'relay_control_capabilities', 'idle']
|
||||
])
|
||||
expect(query.mock.calls.map(([sql]) => sql)).toEqual([statement])
|
||||
expect(summary).toEqual({ ran: 1, skipped: 0 })
|
||||
})
|
||||
|
||||
it('never probes the catalog for a statement that takes no relation lock', async () => {
|
||||
const query = vi.fn(async (_statement: string) => undefined)
|
||||
const { catalogQuery, asked } = catalogAnswers([{ indisvalid: true }])
|
||||
await applyPostgresSchema([COMMENTED_TABLE], query, { catalogQuery })
|
||||
expect(asked).toEqual([])
|
||||
expect(query).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips an ADD CONSTRAINT the catalog already names', async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const query = vi.fn(async (_statement: string) => undefined)
|
||||
const { catalogQuery, asked } = catalogAnswers([{}])
|
||||
const summary = await applyPostgresSchema(
|
||||
['ALTER TABLE relay_region_rehome_attempts ADD CONSTRAINT region_valid CHECK (r IN (1))'],
|
||||
query,
|
||||
{ catalogQuery }
|
||||
)
|
||||
expect(asked).toEqual([
|
||||
[
|
||||
expect.stringContaining('pg_catalog.pg_constraint'),
|
||||
'relay_region_rehome_attempts',
|
||||
'region_valid'
|
||||
]
|
||||
])
|
||||
expect(query).not.toHaveBeenCalled()
|
||||
expect(summary).toEqual({ ran: 0, skipped: 1 })
|
||||
})
|
||||
|
||||
it('skips a DROP CONSTRAINT IF EXISTS when the constraint is already gone', async () => {
|
||||
// Inverse polarity: an absent constraint is what means there is nothing to drop. Sending it
|
||||
// anyway takes ACCESS EXCLUSIVE to discover the same thing.
|
||||
const logged: { event?: string }[] = []
|
||||
vi.spyOn(console, 'log').mockImplementation((line: string) => {
|
||||
logged.push(JSON.parse(line))
|
||||
})
|
||||
const query = vi.fn(async (_statement: string) => undefined)
|
||||
const { catalogQuery } = catalogAnswers([])
|
||||
const summary = await applyPostgresSchema(
|
||||
['ALTER TABLE relay_region_rehome_attempts DROP CONSTRAINT IF EXISTS region_check'],
|
||||
query,
|
||||
{ catalogQuery }
|
||||
)
|
||||
expect(query).not.toHaveBeenCalled()
|
||||
expect(summary).toEqual({ ran: 0, skipped: 1 })
|
||||
expect(logged).toContainEqual({
|
||||
event: 'orca_relay_postgres_schema_object_absent',
|
||||
kind: 'constraint',
|
||||
table: 'relay_region_rehome_attempts',
|
||||
name: 'region_check',
|
||||
indisvalid: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('sends a DROP CONSTRAINT IF EXISTS when the constraint is still there', async () => {
|
||||
const query = vi.fn(async (_statement: string) => undefined)
|
||||
const { catalogQuery } = catalogAnswers([{}])
|
||||
const statement = 'ALTER TABLE t DROP CONSTRAINT IF EXISTS region_check'
|
||||
const summary = await applyPostgresSchema([statement], query, { catalogQuery })
|
||||
expect(query.mock.calls.map(([sql]) => sql)).toEqual([statement])
|
||||
expect(summary).toEqual({ ran: 1, skipped: 0 })
|
||||
})
|
||||
|
||||
it('sends an ADD CONSTRAINT the catalog does not name yet', async () => {
|
||||
const query = vi.fn(async (_statement: string) => undefined)
|
||||
const { catalogQuery } = catalogAnswers([])
|
||||
const statement = 'ALTER TABLE t ADD CONSTRAINT region_valid CHECK (r IN (1))'
|
||||
const summary = await applyPostgresSchema([statement], query, { catalogQuery })
|
||||
expect(query.mock.calls.map(([sql]) => sql)).toEqual([statement])
|
||||
expect(summary).toEqual({ ran: 1, skipped: 0 })
|
||||
})
|
||||
|
||||
it('sends every statement when no catalog query is supplied', async () => {
|
||||
const query = vi.fn(async (_statement: string) => undefined)
|
||||
const summary = await applyPostgresSchema([COMMENTED_TABLE, COMMENTED_INDEX], query)
|
||||
expect(query).toHaveBeenCalledTimes(2)
|
||||
expect(summary).toEqual({ ran: 2, skipped: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPostgresSchema concurrent creates', () => {
|
||||
it('re-asks the catalog on a collision instead of retrying the CREATE INDEX', async () => {
|
||||
// Another director created the index between the pre-check and this statement. Retrying would
|
||||
// take SHARE on the table again for an object that is already there.
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const query = vi.fn(async (_statement: string) => {
|
||||
throw postgresError('42P07')
|
||||
})
|
||||
const { catalogQuery, asked } = catalogAnswersInSequence([[], [{ indisvalid: true }]])
|
||||
const summary = await applyPostgresSchema([COMMENTED_INDEX], query, {
|
||||
catalogQuery,
|
||||
wait: async () => undefined
|
||||
})
|
||||
expect(query).toHaveBeenCalledTimes(1)
|
||||
expect(asked).toHaveLength(2)
|
||||
expect(summary).toEqual({ ran: 0, skipped: 1 })
|
||||
})
|
||||
|
||||
it('still retries when the catalog says the object is not there after all', async () => {
|
||||
let calls = 0
|
||||
const query = vi.fn(async (_statement: string) => {
|
||||
calls += 1
|
||||
if (calls === 1) throw postgresError('23505', 'pg_class_relname_nsp_index')
|
||||
return undefined
|
||||
})
|
||||
const { catalogQuery } = catalogAnswersInSequence([[], []])
|
||||
const summary = await applyPostgresSchema([COMMENTED_INDEX], query, {
|
||||
catalogQuery,
|
||||
wait: async () => undefined
|
||||
})
|
||||
expect(query).toHaveBeenCalledTimes(2)
|
||||
expect(summary).toEqual({ ran: 1, skipped: 0 })
|
||||
})
|
||||
|
||||
it('retries a CREATE TABLE collision without a catalog re-ask, having no target to ask about', async () => {
|
||||
let calls = 0
|
||||
const query = vi.fn(async (_statement: string) => {
|
||||
calls += 1
|
||||
if (calls === 1) throw postgresError('42710')
|
||||
return undefined
|
||||
})
|
||||
const { catalogQuery, asked } = catalogAnswersInSequence([[], []])
|
||||
await applyPostgresSchema([COMMENTED_TABLE], query, {
|
||||
catalogQuery,
|
||||
wait: async () => undefined
|
||||
})
|
||||
expect(asked).toEqual([])
|
||||
expect(query).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPostgresSchema unparseable statements', () => {
|
||||
it('fails the boot rather than sending an index whose target cannot be read', async () => {
|
||||
const query = vi.fn(async (_statement: string) => undefined)
|
||||
await expect(applyPostgresSchema(['CREATE INDEX ON t(c)'], query)).rejects.toThrow(
|
||||
/unparsed_schema_lock_target/
|
||||
)
|
||||
expect(query).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails even with no catalog query, because the statement would take the lock either way', async () => {
|
||||
const query = vi.fn(async (_statement: string) => undefined)
|
||||
await expect(
|
||||
applyPostgresSchema(['ALTER TABLE t ADD COLUMN IF NOT EXISTS'], query)
|
||||
).rejects.toThrow(/unparsed_schema_lock_target/)
|
||||
expect(query).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPostgresSchema statement text', () => {
|
||||
it('sends the original statement, comments included, not the classified form', async () => {
|
||||
// Classification reads a comment-free copy. Rewriting what the server runs would change the
|
||||
// DDL itself, and a comment inside a string literal or a quoted name is part of the statement.
|
||||
const statement = `ALTER TABLE t ADD /* note */ COLUMN c TEXT DEFAULT '-- keep'`
|
||||
const query = vi.fn(async (_sql: string) => undefined)
|
||||
const { catalogQuery, asked } = catalogAnswers([])
|
||||
await applyPostgresSchema([statement], query, { catalogQuery })
|
||||
expect(query.mock.calls.map(([sql]) => sql)).toEqual([statement])
|
||||
expect(asked).toEqual([[expect.stringContaining('pg_catalog.pg_attribute'), 't', 'c']])
|
||||
})
|
||||
|
||||
it('sends a comment-prefixed statement unchanged too', async () => {
|
||||
const query = vi.fn(async (_sql: string) => undefined)
|
||||
await applyPostgresSchema([COMMENTED_TABLE], query)
|
||||
expect(query.mock.calls.map(([sql]) => sql)).toEqual([COMMENTED_TABLE])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import { catalogObjectPresence, type SchemaCatalogQuery } from './catalog-object-precheck.js'
|
||||
import {
|
||||
requireSchemaLockTarget,
|
||||
sqlWithoutComments,
|
||||
type SchemaLockTarget
|
||||
} from './schema-lock-target.js'
|
||||
|
||||
const RETRYABLE_SCHEMA_CODES = new Set(['57014'])
|
||||
const LOCK_NOT_AVAILABLE = '55P03'
|
||||
const DEFAULT_RETRY_DEADLINE_MS = 30_000
|
||||
const RETRY_BASE_DELAY_MS = 250
|
||||
const RETRY_MAX_DELAY_MS = 2_000
|
||||
const DEFAULT_EVENT_PREFIX = 'orca_relay_postgres_schema'
|
||||
|
||||
export type SchemaStartupOptions = {
|
||||
// Enables the catalog pre-check. Without it every lock-taking statement is sent as before.
|
||||
catalogQuery?: SchemaCatalogQuery
|
||||
eventPrefix?: string
|
||||
now?: () => number
|
||||
random?: () => number
|
||||
retryDeadlineMs?: number
|
||||
// Only for a caller with no catalog pre-check, where a lock timeout still says nothing about
|
||||
// whether the object exists.
|
||||
retryLockTimeout?: boolean
|
||||
wait?: (delayMs: number) => Promise<void>
|
||||
}
|
||||
|
||||
export type SchemaApplySummary = { ran: number; skipped: number }
|
||||
|
||||
function retryDelayMs(attempt: number, random: () => number): number {
|
||||
const ceiling = Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS)
|
||||
return Math.ceil(ceiling * (0.5 + random() * 0.5))
|
||||
}
|
||||
|
||||
function wait(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
const CREATE_TABLE_IF_NOT_EXISTS = /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i
|
||||
const CREATE_INDEX_IF_NOT_EXISTS = /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i
|
||||
const ALTER_TABLE_ADD_CONSTRAINT = /^ALTER\s+TABLE\s+\S+\s+ADD\s+CONSTRAINT\b/i
|
||||
|
||||
// `IF NOT EXISTS` only checks the name before the catalog inserts, so the loser of a concurrent
|
||||
// CREATE can fail on the catalog unique index (23505) or, when the winner has already committed by
|
||||
// the time the loser reaches TypeCreate/heap_create_with_catalog, on the name check those routines
|
||||
// repeat (42710 duplicate type, 42P07 duplicate relation). Each is a no-op on the next attempt.
|
||||
function concurrentCreateCollision(
|
||||
value: { code?: unknown; constraint?: unknown },
|
||||
sql: string
|
||||
): boolean {
|
||||
if (CREATE_TABLE_IF_NOT_EXISTS.test(sql)) {
|
||||
return (
|
||||
(value.code === '23505' && value.constraint === 'pg_type_typname_nsp_index') ||
|
||||
value.code === '42710' ||
|
||||
value.code === '42P07'
|
||||
)
|
||||
}
|
||||
if (CREATE_INDEX_IF_NOT_EXISTS.test(sql)) {
|
||||
return (
|
||||
(value.code === '23505' && value.constraint === 'pg_class_relname_nsp_index') ||
|
||||
value.code === '42P07'
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function constraintAlreadyApplied(error: unknown, sql: string): boolean {
|
||||
return (
|
||||
ALTER_TABLE_ADD_CONSTRAINT.test(sql) && (error as { code?: unknown } | null)?.code === '42710'
|
||||
)
|
||||
}
|
||||
|
||||
function retryableSchemaError(error: unknown, sql: string): boolean {
|
||||
const value = (error as { code?: unknown; constraint?: unknown } | null) ?? {}
|
||||
return RETRYABLE_SCHEMA_CODES.has(String(value.code)) || concurrentCreateCollision(value, sql)
|
||||
}
|
||||
|
||||
// Evaluated immediately before each statement, so a pre-check still sees the objects the statements
|
||||
// ahead of it created in this same boot.
|
||||
async function nothingToDo(
|
||||
target: SchemaLockTarget | undefined,
|
||||
options: SchemaStartupOptions,
|
||||
eventPrefix: string
|
||||
): Promise<boolean> {
|
||||
const catalogQuery = options.catalogQuery
|
||||
if (!catalogQuery || !target) return false
|
||||
const presence = await catalogObjectPresence(catalogQuery, target)
|
||||
if (presence.present !== (target.skipWhen === 'present')) return false
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
event: `${eventPrefix}_object_${target.skipWhen}`,
|
||||
kind: target.kind,
|
||||
table: target.table,
|
||||
name: target.name,
|
||||
indisvalid: presence.indisvalid
|
||||
})
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
export async function applyPostgresSchema(
|
||||
statements: string[],
|
||||
query: (statement: string) => Promise<unknown>,
|
||||
options: SchemaStartupOptions = {}
|
||||
): Promise<SchemaApplySummary> {
|
||||
const eventPrefix = options.eventPrefix ?? DEFAULT_EVENT_PREFIX
|
||||
const now = options.now ?? Date.now
|
||||
const random = options.random ?? Math.random
|
||||
const pause = options.wait ?? wait
|
||||
const deadlineAt = now() + (options.retryDeadlineMs ?? DEFAULT_RETRY_DEADLINE_MS)
|
||||
const summary: SchemaApplySummary = { ran: 0, skipped: 0 }
|
||||
|
||||
for (const statement of statements) {
|
||||
// Throws when an index or column statement's target cannot be read, rather than sending it
|
||||
// unchecked into the lock queue.
|
||||
const target = requireSchemaLockTarget(statement)
|
||||
if (await nothingToDo(target, options, eventPrefix)) {
|
||||
summary.skipped += 1
|
||||
continue
|
||||
}
|
||||
const sql = sqlWithoutComments(statement)
|
||||
let attempt = 1
|
||||
while (true) {
|
||||
try {
|
||||
await query(statement)
|
||||
summary.ran += 1
|
||||
break
|
||||
} catch (error) {
|
||||
if (constraintAlreadyApplied(error, sql)) {
|
||||
summary.skipped += 1
|
||||
break
|
||||
}
|
||||
const code = String((error as { code?: unknown } | null)?.code)
|
||||
// With the pre-check ahead of it a lock timeout means the object is genuinely missing and
|
||||
// this boot lost the queue. Relation locks are granted in queue order, so each retry parks
|
||||
// every writer behind it again for another timeout. Fail once, loudly.
|
||||
if (code === LOCK_NOT_AVAILABLE && !options.retryLockTimeout) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
event: `${eventPrefix}_lock_timeout`,
|
||||
code,
|
||||
statement: sql.split('\n')[0],
|
||||
detail: 'boot-time DDL could not take its lock; retrying would requeue every writer'
|
||||
})
|
||||
)
|
||||
throw error
|
||||
}
|
||||
// The object was created between the pre-check and this statement. Re-asking the catalog
|
||||
// is the cheap answer; retrying the CREATE INDEX would take SHARE on the table again for
|
||||
// an object that is already there.
|
||||
if (
|
||||
concurrentCreateCollision((error as { code?: unknown; constraint?: unknown }) ?? {}, sql) &&
|
||||
(await nothingToDo(target, options, eventPrefix))
|
||||
) {
|
||||
summary.skipped += 1
|
||||
break
|
||||
}
|
||||
const remainingMs = deadlineAt - now()
|
||||
const retryable =
|
||||
retryableSchemaError(error, sql) ||
|
||||
(code === LOCK_NOT_AVAILABLE && options.retryLockTimeout === true)
|
||||
if (!retryable || remainingMs <= 0) {
|
||||
if (retryable) {
|
||||
console.warn(
|
||||
JSON.stringify({ event: `${eventPrefix}_retry_exhausted`, code, attempts: attempt })
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const delayMs = Math.min(remainingMs, retryDelayMs(attempt, random))
|
||||
console.warn(JSON.stringify({ event: `${eventPrefix}_retry`, code, attempt, delayMs }))
|
||||
await pause(delayMs)
|
||||
attempt += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify({ event: `${eventPrefix}_applied`, ...summary }))
|
||||
return summary
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { SchemaLockTarget } from './schema-lock-target.js'
|
||||
|
||||
export type SchemaCatalogRow = Record<string, unknown>
|
||||
|
||||
// Runs with `$n` placeholders bound to the lock target, on the same connection the DDL would use.
|
||||
export type SchemaCatalogQuery = (
|
||||
sql: string,
|
||||
params: unknown[]
|
||||
) => Promise<SchemaCatalogRow[]>
|
||||
|
||||
// The index name is resolved inside the table's own namespace, and `i.indrelid = t.oid` ties it to
|
||||
// this table: index names are unique per schema, not per table, so without that condition a
|
||||
// same-named index on a sibling table answers yes and the real index is skipped forever.
|
||||
// `to_regclass` returns NULL rather than erroring when the table does not exist yet, which is the
|
||||
// whole of a cold start.
|
||||
const INDEX_PRESENT = `SELECT i.indisvalid FROM pg_catalog.pg_class t
|
||||
JOIN pg_catalog.pg_class c ON c.relnamespace = t.relnamespace AND c.relname = $2
|
||||
JOIN pg_catalog.pg_index i ON i.indexrelid = c.oid AND i.indrelid = t.oid
|
||||
WHERE t.oid = to_regclass($1)`
|
||||
|
||||
const COLUMN_PRESENT = `SELECT 1 FROM pg_catalog.pg_attribute
|
||||
WHERE attrelid = to_regclass($1) AND attname = $2 AND attnum > 0 AND NOT attisdropped`
|
||||
|
||||
// Name only. The CHECK body is generated from RELAY_REGIONS, so comparing it would re-run the swap
|
||||
// on every region change, and an ADD CONSTRAINT is the one statement here that scans the table.
|
||||
const CONSTRAINT_PRESENT = `SELECT 1 FROM pg_catalog.pg_constraint
|
||||
WHERE conrelid = to_regclass($1) AND conname = $2`
|
||||
|
||||
const PRESENCE_SQL = {
|
||||
index: INDEX_PRESENT,
|
||||
column: COLUMN_PRESENT,
|
||||
constraint: CONSTRAINT_PRESENT
|
||||
} as const
|
||||
|
||||
export type SchemaCatalogPresence = { present: boolean; indisvalid: unknown }
|
||||
|
||||
// Row presence is the answer, whatever the row says. An index left invalid by a cancelled
|
||||
// concurrent build is skipped by `IF NOT EXISTS` today as well, so reading `indisvalid` as a
|
||||
// condition would newly take the lock for exactly the indexes a failed build left behind.
|
||||
export async function catalogObjectPresence(
|
||||
query: SchemaCatalogQuery,
|
||||
target: SchemaLockTarget
|
||||
): Promise<SchemaCatalogPresence> {
|
||||
const sql = PRESENCE_SQL[target.kind]
|
||||
const rows = await query(sql, [target.table, target.name])
|
||||
const row = rows[0]
|
||||
return row ? { present: true, indisvalid: row.indisvalid } : { present: false, indisvalid: undefined }
|
||||
}
|
||||
@@ -1,113 +1,18 @@
|
||||
const RETRYABLE_SCHEMA_CODES = new Set(['55P03', '57014'])
|
||||
const DEFAULT_RETRY_DEADLINE_MS = 30_000
|
||||
const RETRY_BASE_DELAY_MS = 250
|
||||
const RETRY_MAX_DELAY_MS = 2_000
|
||||
|
||||
type SchemaStartupOptions = {
|
||||
eventPrefix?: string
|
||||
now?: () => number
|
||||
random?: () => number
|
||||
retryDeadlineMs?: number
|
||||
wait?: (delayMs: number) => Promise<void>
|
||||
}
|
||||
|
||||
function retryDelayMs(attempt: number, random: () => number): number {
|
||||
const ceiling = Math.min(RETRY_BASE_DELAY_MS * 2 ** (attempt - 1), RETRY_MAX_DELAY_MS)
|
||||
return Math.ceil(ceiling * (0.5 + random() * 0.5))
|
||||
}
|
||||
|
||||
function wait(delayMs: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
|
||||
const CREATE_TABLE_IF_NOT_EXISTS = /^\s*CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\b/i
|
||||
const CREATE_INDEX_IF_NOT_EXISTS = /^\s*CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\b/i
|
||||
|
||||
// `IF NOT EXISTS` only checks the name before the catalog inserts, so the loser of a concurrent
|
||||
// CREATE can fail on the catalog unique index (23505) or, when the winner has already committed by
|
||||
// the time the loser reaches TypeCreate/heap_create_with_catalog, on the name check those routines
|
||||
// repeat (42710 duplicate type, 42P07 duplicate relation). Each is a no-op on the next attempt.
|
||||
function concurrentCreateCollision(
|
||||
value: { code?: unknown; constraint?: unknown },
|
||||
statement: string
|
||||
): boolean {
|
||||
if (CREATE_TABLE_IF_NOT_EXISTS.test(statement)) {
|
||||
return (
|
||||
(value.code === '23505' && value.constraint === 'pg_type_typname_nsp_index') ||
|
||||
value.code === '42710' ||
|
||||
value.code === '42P07'
|
||||
)
|
||||
}
|
||||
if (CREATE_INDEX_IF_NOT_EXISTS.test(statement)) {
|
||||
return (
|
||||
(value.code === '23505' && value.constraint === 'pg_class_relname_nsp_index') ||
|
||||
value.code === '42P07'
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const ALTER_TABLE_ADD_CONSTRAINT = /^\s*ALTER\s+TABLE\s+\S+\s+ADD\s+CONSTRAINT\b/i
|
||||
|
||||
function constraintAlreadyApplied(error: unknown, statement: string): boolean {
|
||||
return (
|
||||
ALTER_TABLE_ADD_CONSTRAINT.test(statement) &&
|
||||
(error as { code?: unknown }).code === '42710'
|
||||
)
|
||||
}
|
||||
|
||||
function retryableSchemaError(error: unknown, statement: string): boolean {
|
||||
const value = error as { code?: unknown; constraint?: unknown }
|
||||
return (
|
||||
RETRYABLE_SCHEMA_CODES.has(String(value.code)) || concurrentCreateCollision(value, statement)
|
||||
)
|
||||
}
|
||||
|
||||
export async function applyPostgresSchema(
|
||||
statements: string[],
|
||||
query: (statement: string) => Promise<unknown>,
|
||||
options: SchemaStartupOptions = {}
|
||||
): Promise<void> {
|
||||
const now = options.now ?? Date.now
|
||||
const random = options.random ?? Math.random
|
||||
const pause = options.wait ?? wait
|
||||
const deadlineAt = now() + (options.retryDeadlineMs ?? DEFAULT_RETRY_DEADLINE_MS)
|
||||
|
||||
for (const statement of statements) {
|
||||
let attempt = 1
|
||||
while (true) {
|
||||
try {
|
||||
await query(statement)
|
||||
break
|
||||
} catch (error) {
|
||||
if (constraintAlreadyApplied(error, statement)) break
|
||||
const code = String((error as { code?: unknown }).code)
|
||||
const remainingMs = deadlineAt - now()
|
||||
const retryable = retryableSchemaError(error, statement)
|
||||
if (!retryable || remainingMs <= 0) {
|
||||
if (retryable) {
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
event: `${options.eventPrefix ?? 'orca_relay_postgres_schema'}_retry_exhausted`,
|
||||
code,
|
||||
attempts: attempt
|
||||
})
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const delayMs = Math.min(remainingMs, retryDelayMs(attempt, random))
|
||||
console.warn(
|
||||
JSON.stringify({
|
||||
event: `${options.eventPrefix ?? 'orca_relay_postgres_schema'}_retry`,
|
||||
code,
|
||||
attempt,
|
||||
delayMs
|
||||
})
|
||||
)
|
||||
await pause(delayMs)
|
||||
attempt += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export {
|
||||
applyPostgresSchema,
|
||||
type SchemaApplySummary,
|
||||
type SchemaStartupOptions
|
||||
} from './apply-postgres-schema.js'
|
||||
export {
|
||||
catalogObjectPresence,
|
||||
type SchemaCatalogPresence,
|
||||
type SchemaCatalogQuery,
|
||||
type SchemaCatalogRow
|
||||
} from './catalog-object-precheck.js'
|
||||
export {
|
||||
requireSchemaLockTarget,
|
||||
schemaLockTarget,
|
||||
sqlWithoutComments,
|
||||
takesRelationLock,
|
||||
type SchemaLockTarget
|
||||
} from './schema-lock-target.js'
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
requireSchemaLockTarget,
|
||||
schemaLockTarget,
|
||||
sqlWithoutComments,
|
||||
takesRelationLock
|
||||
} from './schema-lock-target.js'
|
||||
|
||||
// The shape a schema string split on ';' actually produces: the comment written above a statement
|
||||
// arrives glued to the front of it.
|
||||
const COMMENTED_INDEX = `-- Why: the maintenance sweep matches (active, deadline) while inactive
|
||||
-- bases accumulate unboundedly.
|
||||
CREATE INDEX IF NOT EXISTS relay_connection_bases_active_deadline
|
||||
ON relay_connection_bases(active, deadline)`
|
||||
|
||||
const COMMENTED_TABLE = `-- Rehoming is bidirectional, but tables created before that carry the
|
||||
-- original single-region column check.
|
||||
CREATE TABLE IF NOT EXISTS relay_cells (
|
||||
cell_id TEXT PRIMARY KEY
|
||||
)`
|
||||
|
||||
describe('sqlWithoutComments', () => {
|
||||
it('strips the line comments a split schema glues above a statement', () => {
|
||||
expect(sqlWithoutComments(COMMENTED_INDEX)).toMatch(/^CREATE INDEX IF NOT EXISTS/)
|
||||
})
|
||||
|
||||
it('strips a leading block comment', () => {
|
||||
expect(sqlWithoutComments('/* note */\n ALTER TABLE t ADD COLUMN c TEXT')).toBe(
|
||||
'ALTER TABLE t ADD COLUMN c TEXT'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips a comment sitting between two keywords', () => {
|
||||
expect(sqlWithoutComments('ALTER TABLE t ADD /* note */ COLUMN c TEXT')).toBe(
|
||||
'ALTER TABLE t ADD COLUMN c TEXT'
|
||||
)
|
||||
})
|
||||
|
||||
it('strips a trailing comment', () => {
|
||||
expect(sqlWithoutComments('SELECT 1 -- note')).toBe('SELECT 1')
|
||||
})
|
||||
|
||||
it('closes the inner block comment first when they nest', () => {
|
||||
expect(sqlWithoutComments('ALTER TABLE t /* a /* b */ c */ ADD COLUMN d TEXT')).toBe(
|
||||
'ALTER TABLE t ADD COLUMN d TEXT'
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a comment marker inside a string literal alone', () => {
|
||||
expect(sqlWithoutComments(`ALTER TABLE t ADD COLUMN c TEXT DEFAULT '-- not a comment'`)).toBe(
|
||||
`ALTER TABLE t ADD COLUMN c TEXT DEFAULT '-- not a comment'`
|
||||
)
|
||||
})
|
||||
|
||||
it('leaves a comment marker inside a quoted identifier alone', () => {
|
||||
expect(sqlWithoutComments('ALTER TABLE t ADD COLUMN "a/* b */c" TEXT')).toBe(
|
||||
'ALTER TABLE t ADD COLUMN "a/* b */c" TEXT'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('comments between keywords', () => {
|
||||
// Before this, the classification regexes and the must-parse shapes both needed `ADD COLUMN`
|
||||
// contiguous, so this statement derived NO target and threw NOTHING: the DDL ran with no
|
||||
// pre-check, taking ACCESS EXCLUSIVE on every boot.
|
||||
it('derives a column target through a comment between ADD and COLUMN', () => {
|
||||
expect(requireSchemaLockTarget('ALTER TABLE t ADD /* note */ COLUMN c TEXT')).toEqual({
|
||||
kind: 'column',
|
||||
table: 't',
|
||||
name: 'c',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('derives an index target through a line comment before ON', () => {
|
||||
expect(
|
||||
requireSchemaLockTarget('CREATE INDEX IF NOT EXISTS i\n-- why this index exists\nON t(c)')
|
||||
).toEqual({ kind: 'index', table: 't', name: 'i', skipWhen: 'present' })
|
||||
})
|
||||
|
||||
it('still counts a commented statement as taking a relation lock', () => {
|
||||
expect(takesRelationLock('/* note */ ALTER TABLE t ADD COLUMN c TEXT')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not read a comment marker inside a quoted name as a comment', () => {
|
||||
expect(schemaLockTarget('ALTER TABLE t ADD COLUMN "a--b" TEXT')).toEqual({
|
||||
kind: 'column',
|
||||
table: 't',
|
||||
name: 'a--b',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('catalog name folding', () => {
|
||||
it('reads a quoted identifier containing a dot as one name', () => {
|
||||
expect(schemaLockTarget('CREATE INDEX IF NOT EXISTS "a.b" ON t(c)')).toEqual({
|
||||
kind: 'index',
|
||||
table: 't',
|
||||
name: 'a.b',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('folds an unquoted name to lower case, the form the catalog stores', () => {
|
||||
expect(schemaLockTarget('CREATE INDEX IF NOT EXISTS Foo ON Bar(c)')).toEqual({
|
||||
kind: 'index',
|
||||
table: 'Bar',
|
||||
name: 'foo',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a quoted name in its written case', () => {
|
||||
expect(schemaLockTarget('CREATE INDEX IF NOT EXISTS public."Mixed.Name" ON t(c)')).toEqual({
|
||||
kind: 'index',
|
||||
table: 't',
|
||||
name: 'Mixed.Name',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('unescapes a doubled quote and leaves the qualified table text as written', () => {
|
||||
expect(schemaLockTarget('ALTER TABLE App."My Table" ADD COLUMN "od""d" TEXT')).toEqual({
|
||||
kind: 'column',
|
||||
table: 'App."My Table"',
|
||||
name: 'od"d',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('folds an unquoted column name too', () => {
|
||||
expect(schemaLockTarget('ALTER TABLE t ADD COLUMN IF NOT EXISTS HostCooldownMs BIGINT')).toEqual(
|
||||
{ kind: 'column', table: 't', name: 'hostcooldownms', skipWhen: 'present' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('square brackets in an ALTER TABLE', () => {
|
||||
it.each([
|
||||
['an array type', 'ALTER TABLE t ADD COLUMN c bigint[] DEFAULT ARRAY[1, 2]'],
|
||||
['a nested array default', "ALTER TABLE t ADD COLUMN c TEXT[] DEFAULT ARRAY['a', 'b']"]
|
||||
])('does not read a comma inside %s as a second subcommand', (_label, statement) => {
|
||||
expect(() => requireSchemaLockTarget(statement)).not.toThrow()
|
||||
})
|
||||
|
||||
it('still catches a second subcommand after an array default', () => {
|
||||
expect(() =>
|
||||
requireSchemaLockTarget('ALTER TABLE t ADD COLUMN a bigint[] DEFAULT ARRAY[1, 2], ADD COLUMN b TEXT')
|
||||
).toThrow(/unparsed_schema_lock_target/)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('takesRelationLock', () => {
|
||||
it('classifies a comment-prefixed CREATE INDEX by its first SQL keyword', () => {
|
||||
expect(takesRelationLock(COMMENTED_INDEX)).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies a comment-prefixed CREATE TABLE as taking no relation lock', () => {
|
||||
expect(takesRelationLock(COMMENTED_TABLE)).toBe(false)
|
||||
})
|
||||
|
||||
it('counts every ALTER TABLE, including the constraint swaps', () => {
|
||||
expect(takesRelationLock('ALTER TABLE t DROP CONSTRAINT IF EXISTS c')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('schemaLockTarget', () => {
|
||||
it('derives an index target through the comments above it', () => {
|
||||
expect(schemaLockTarget(COMMENTED_INDEX)).toEqual({
|
||||
kind: 'index',
|
||||
table: 'relay_connection_bases',
|
||||
name: 'relay_connection_bases_active_deadline',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('derives an index target across the line break before ON', () => {
|
||||
expect(
|
||||
schemaLockTarget(`CREATE INDEX IF NOT EXISTS relay_reservation_assignment
|
||||
ON relay_control_connection_reservations(
|
||||
user_id, relay_host_id
|
||||
)`)
|
||||
).toEqual({
|
||||
kind: 'index',
|
||||
table: 'relay_control_connection_reservations',
|
||||
name: 'relay_reservation_assignment',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a unique concurrent index target', () => {
|
||||
expect(schemaLockTarget('CREATE UNIQUE INDEX CONCURRENTLY i ON t(c)')).toEqual({
|
||||
kind: 'index',
|
||||
table: 't',
|
||||
name: 'i',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the schema qualification on the table and drops it from the object name', () => {
|
||||
// `table` is fed to to_regclass, which needs the qualification; `name` is matched against
|
||||
// relname, which stores the bare identifier.
|
||||
expect(schemaLockTarget('CREATE INDEX IF NOT EXISTS app.i ON app.t(c)')).toEqual({
|
||||
kind: 'index',
|
||||
table: 'app.t',
|
||||
name: 'i',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('unquotes a quoted identifier, doubled quote included', () => {
|
||||
expect(schemaLockTarget('CREATE INDEX IF NOT EXISTS "od""d" ON "My Table"(c)')).toEqual({
|
||||
kind: 'index',
|
||||
table: '"My Table"',
|
||||
name: 'od"d',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a column target from a multi-line ADD COLUMN IF NOT EXISTS', () => {
|
||||
expect(
|
||||
schemaLockTarget(`ALTER TABLE relay_region_rehome_control
|
||||
ADD COLUMN IF NOT EXISTS host_cooldown_ms BIGINT NOT NULL
|
||||
DEFAULT 604800000`)
|
||||
).toEqual({
|
||||
kind: 'column',
|
||||
table: 'relay_region_rehome_control',
|
||||
name: 'host_cooldown_ms',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a column target without IF NOT EXISTS', () => {
|
||||
expect(schemaLockTarget('ALTER TABLE ONLY t ADD COLUMN c TEXT')).toEqual({
|
||||
kind: 'column',
|
||||
table: 't',
|
||||
name: 'c',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips an ADD CONSTRAINT once the constraint name is there', () => {
|
||||
expect(schemaLockTarget('ALTER TABLE t ADD CONSTRAINT c CHECK (x > 0)')).toEqual({
|
||||
kind: 'constraint',
|
||||
table: 't',
|
||||
name: 'c',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('skips a DROP CONSTRAINT IF EXISTS when the constraint is already gone', () => {
|
||||
// The inverse polarity: nothing to drop is nothing to do.
|
||||
expect(schemaLockTarget('ALTER TABLE t DROP CONSTRAINT IF EXISTS c')).toEqual({
|
||||
kind: 'constraint',
|
||||
table: 't',
|
||||
name: 'c',
|
||||
skipWhen: 'absent'
|
||||
})
|
||||
})
|
||||
|
||||
it('derives a constraint target across a line break', () => {
|
||||
expect(
|
||||
schemaLockTarget(`ALTER TABLE relay_region_rehome_attempts
|
||||
ADD CONSTRAINT relay_region_rehome_attempts_preferred_region_valid
|
||||
CHECK (preferred_region IN ('us-central1'))`)
|
||||
).toEqual({
|
||||
kind: 'constraint',
|
||||
table: 'relay_region_rehome_attempts',
|
||||
name: 'relay_region_rehome_attempts_preferred_region_valid',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('gives CREATE TABLE IF NOT EXISTS no target', () => {
|
||||
expect(schemaLockTarget(COMMENTED_TABLE)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
// Each of these reads as an index or column statement and each fails to yield a target. Letting any
|
||||
// of them through would send an unchecked lock-taking statement on every boot.
|
||||
const MALFORMED = [
|
||||
['an index with no ON clause', 'CREATE INDEX IF NOT EXISTS i'],
|
||||
['an auto-named index', 'CREATE INDEX ON t(c)'],
|
||||
['an auto-named unique concurrent index', 'CREATE UNIQUE INDEX CONCURRENTLY ON t(c)'],
|
||||
['an index whose name ran into a comment', '-- note\nCREATE INDEX IF NOT EXISTS\nON t(c)'],
|
||||
['an ALTER TABLE with no table', 'ALTER TABLE ADD COLUMN c TEXT'],
|
||||
['an ADD COLUMN with no column', 'ALTER TABLE t ADD COLUMN'],
|
||||
['an ADD COLUMN IF NOT EXISTS with no column', 'ALTER TABLE t ADD COLUMN IF NOT EXISTS']
|
||||
] as const
|
||||
|
||||
describe('requireSchemaLockTarget', () => {
|
||||
it.each(MALFORMED)('throws with the statement text on %s', (_label, statement) => {
|
||||
expect(() => requireSchemaLockTarget(statement)).toThrow(/unparsed_schema_lock_target/)
|
||||
})
|
||||
|
||||
it('names the offending statement in the error', () => {
|
||||
expect(() => requireSchemaLockTarget('CREATE INDEX ON t(c)')).toThrow(
|
||||
'unparsed_schema_lock_target: CREATE INDEX ON t(c)'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the target for a statement that parses', () => {
|
||||
expect(requireSchemaLockTarget(COMMENTED_INDEX)).toEqual({
|
||||
kind: 'index',
|
||||
table: 'relay_connection_bases',
|
||||
name: 'relay_connection_bases_active_deadline',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['CREATE TABLE IF NOT EXISTS t (id TEXT)'],
|
||||
['ALTER TABLE t ALTER COLUMN c SET DEFAULT 0']
|
||||
])('leaves %s alone, because no target is expected of it', (statement) => {
|
||||
expect(requireSchemaLockTarget(statement)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('multi-action ALTER TABLE', () => {
|
||||
it('throws rather than deriving only the first subcommand', () => {
|
||||
// Deriving `a` and skipping on it would drop `b` for the life of the database, and the first
|
||||
// subcommand parses fine, so nothing else here would catch it.
|
||||
const statement =
|
||||
'ALTER TABLE t ADD COLUMN IF NOT EXISTS a TEXT, ADD COLUMN IF NOT EXISTS b TEXT'
|
||||
expect(schemaLockTarget(statement)).toEqual({
|
||||
kind: 'column',
|
||||
table: 't',
|
||||
name: 'a',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
expect(() => requireSchemaLockTarget(statement)).toThrow(/unparsed_schema_lock_target/)
|
||||
})
|
||||
|
||||
it('throws on a constraint swap written as one statement', () => {
|
||||
expect(() =>
|
||||
requireSchemaLockTarget(
|
||||
'ALTER TABLE t DROP CONSTRAINT IF EXISTS old, ADD CONSTRAINT new CHECK (x > 0)'
|
||||
)
|
||||
).toThrow(/unparsed_schema_lock_target/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a parenthesised type', 'ALTER TABLE t ADD COLUMN IF NOT EXISTS a NUMERIC(10, 2)'],
|
||||
['a CHECK body', "ALTER TABLE t ADD CONSTRAINT c CHECK (r IN ('us-central1', 'asia-east2'))"],
|
||||
['a quoted comma', `ALTER TABLE t ADD COLUMN IF NOT EXISTS a TEXT DEFAULT 'x, y'`],
|
||||
['a doubled quote before a comma', `ALTER TABLE t ADD COLUMN a TEXT DEFAULT 'it''s, fine'`],
|
||||
['a trailing line comment', 'ALTER TABLE t ADD COLUMN a TEXT -- one, two'],
|
||||
['a trailing block comment', 'ALTER TABLE t ADD COLUMN a TEXT /* one, two */']
|
||||
])('does not throw on %s', (_label, statement) => {
|
||||
expect(() => requireSchemaLockTarget(statement)).not.toThrow()
|
||||
})
|
||||
|
||||
it('derives through a block comment sitting where the column name belongs', () => {
|
||||
expect(requireSchemaLockTarget('ALTER TABLE t ADD COLUMN /* note */ a TEXT')).toEqual({
|
||||
kind: 'column',
|
||||
table: 't',
|
||||
name: 'a',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a multi-column CREATE INDEX alone', () => {
|
||||
expect(() => requireSchemaLockTarget('CREATE INDEX IF NOT EXISTS i ON t(a, b)')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('dollar-quoted bodies', () => {
|
||||
it('does not read a comment marker inside a dollar-quoted default as a comment', () => {
|
||||
expect(sqlWithoutComments('ALTER TABLE t ADD COLUMN c TEXT DEFAULT $$--$$')).toBe(
|
||||
'ALTER TABLE t ADD COLUMN c TEXT DEFAULT $$--$$'
|
||||
)
|
||||
expect(requireSchemaLockTarget('ALTER TABLE t ADD COLUMN c TEXT DEFAULT $$--$$')).toEqual({
|
||||
kind: 'column',
|
||||
table: 't',
|
||||
name: 'c',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('does not count a comma inside a dollar-quoted default as a second subcommand', () => {
|
||||
expect(() =>
|
||||
requireSchemaLockTarget('ALTER TABLE t ADD COLUMN c TEXT DEFAULT $$a, b$$')
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('reads an inner $$ inside a tagged body as text, not as the close', () => {
|
||||
// The closing delimiter has to match the opening tag, so the comma and the comment marker
|
||||
// between the inner $$ pair are still inside the body.
|
||||
const statement = 'ALTER TABLE t ADD COLUMN c TEXT DEFAULT $tag$ a $$ -- b, c $$ d $tag$'
|
||||
expect(sqlWithoutComments(statement)).toBe(statement)
|
||||
expect(() => requireSchemaLockTarget(statement)).not.toThrow()
|
||||
expect(schemaLockTarget(statement)).toEqual({
|
||||
kind: 'column',
|
||||
table: 't',
|
||||
name: 'c',
|
||||
skipWhen: 'present'
|
||||
})
|
||||
})
|
||||
|
||||
it('still catches a second subcommand after a dollar-quoted default', () => {
|
||||
expect(() =>
|
||||
requireSchemaLockTarget('ALTER TABLE t ADD COLUMN a TEXT DEFAULT $$x, y$$, ADD COLUMN b TEXT')
|
||||
).toThrow(/unparsed_schema_lock_target/)
|
||||
})
|
||||
|
||||
it('leaves a numbered placeholder alone, because a tag cannot start with a digit', () => {
|
||||
expect(sqlWithoutComments('ALTER TABLE t ADD COLUMN c TEXT -- $1 and $2')).toBe(
|
||||
'ALTER TABLE t ADD COLUMN c TEXT'
|
||||
)
|
||||
})
|
||||
|
||||
it('treats an unterminated dollar quote as opaque to the end', () => {
|
||||
expect(sqlWithoutComments('ALTER TABLE t ADD COLUMN c TEXT DEFAULT $$ -- unterminated')).toBe(
|
||||
'ALTER TABLE t ADD COLUMN c TEXT DEFAULT $$ -- unterminated'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
// The object a boot-time DDL statement locks on Postgres, so the catalog can be asked whether it
|
||||
// already exists before the statement joins the lock queue. `table` is kept exactly as the
|
||||
// statement wrote it, schema qualification and quoting included, because it is fed to
|
||||
// `to_regclass`; `name` is the bare identifier the catalog stores in `relname`/`attname`.
|
||||
export type SchemaLockTarget = {
|
||||
kind: 'index' | 'column' | 'constraint'
|
||||
table: string
|
||||
name: string
|
||||
// The catalog answer that means this statement has nothing left to do. Creating statements skip
|
||||
// on present; `DROP CONSTRAINT IF EXISTS` is the inverse, because nothing to drop is done.
|
||||
skipWhen: 'present' | 'absent'
|
||||
}
|
||||
|
||||
// Keywords that sit in an identifier position when the optional clause before them is absent.
|
||||
// Without this, `CREATE UNIQUE INDEX CONCURRENTLY ON t(c)` reads CONCURRENTLY as the index name and
|
||||
// `ADD COLUMN IF NOT EXISTS` with no column reads IF as the column: a silently wrong target, which
|
||||
// is worse than no target. Excluding them makes both throw instead. A column genuinely named `if`
|
||||
// has to be quoted to be derivable, which is the safe direction to fail in.
|
||||
const NOT_KEYWORD = '(?!(?:CONCURRENTLY|IF|NOT|EXISTS|ON|ONLY)\\b)'
|
||||
const IDENTIFIER = `"(?:[^"]|"")*"|${NOT_KEYWORD}[A-Za-z_][A-Za-z0-9_$]*`
|
||||
const QUALIFIED = `((?:${IDENTIFIER})(?:\\.(?:${IDENTIFIER}))?)`
|
||||
|
||||
// `$$...$$` and `$tag$...$tag$` are opaque: a comment marker, comma, parenthesis or bracket inside
|
||||
// one is text. The closing delimiter must match the opening tag exactly, so an inner `$$` inside a
|
||||
// `$tag$` body is more text rather than the end. The tag cannot start with a digit, which is what
|
||||
// keeps a `$1` placeholder from reading as an opener.
|
||||
const DOLLAR_QUOTE = /\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$/y
|
||||
|
||||
function dollarQuoteEnd(sql: string, index: number): number | undefined {
|
||||
DOLLAR_QUOTE.lastIndex = index
|
||||
const opener = DOLLAR_QUOTE.exec(sql)?.[0]
|
||||
if (opener === undefined) return undefined
|
||||
const close = sql.indexOf(opener, index + opener.length)
|
||||
return close === -1 ? sql.length : close + opener.length
|
||||
}
|
||||
|
||||
// Every comment, not only the block a ';'-split schema glues above a statement. A comment between
|
||||
// two keywords (`ADD /* note */ COLUMN`) is invisible to the classification regexes AND to the
|
||||
// must-parse shapes, so it used to yield no target and no throw: the statement ran with no
|
||||
// pre-check at all, which is the one direction this must never fail in. Postgres treats a comment
|
||||
// as whitespace, so each becomes a single space. Only classification reads this; the server is
|
||||
// always sent the original text.
|
||||
export function sqlWithoutComments(statement: string): string {
|
||||
let stripped = ''
|
||||
let quote: string | undefined
|
||||
for (let index = 0; index < statement.length; index += 1) {
|
||||
const character = statement[index]!
|
||||
if (quote !== undefined) {
|
||||
stripped += character
|
||||
if (character !== quote) continue
|
||||
if (statement[index + 1] === quote) {
|
||||
stripped += quote
|
||||
index += 1
|
||||
} else quote = undefined
|
||||
continue
|
||||
}
|
||||
if (character === "'" || character === '"') {
|
||||
quote = character
|
||||
stripped += character
|
||||
continue
|
||||
}
|
||||
if (character === '$') {
|
||||
const end = dollarQuoteEnd(statement, index)
|
||||
if (end !== undefined) {
|
||||
stripped += statement.slice(index, end)
|
||||
index = end - 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (character === '-' && statement[index + 1] === '-') {
|
||||
const newline = statement.indexOf('\n', index)
|
||||
index = newline === -1 ? statement.length : newline
|
||||
stripped += ' '
|
||||
continue
|
||||
}
|
||||
if (character === '/' && statement[index + 1] === '*') {
|
||||
// Postgres nests block comments, so a depth counter is what closes the right one.
|
||||
let depth = 1
|
||||
index += 2
|
||||
while (index < statement.length && depth > 0) {
|
||||
if (statement[index] === '/' && statement[index + 1] === '*') {
|
||||
depth += 1
|
||||
index += 2
|
||||
} else if (statement[index] === '*' && statement[index + 1] === '/') {
|
||||
depth -= 1
|
||||
index += 2
|
||||
} else index += 1
|
||||
}
|
||||
index -= 1
|
||||
stripped += ' '
|
||||
continue
|
||||
}
|
||||
stripped += character
|
||||
}
|
||||
return stripped.trim()
|
||||
}
|
||||
|
||||
const CREATE_INDEX = new RegExp(
|
||||
`^CREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+(?:CONCURRENTLY\\s+)?(?:IF\\s+NOT\\s+EXISTS\\s+)?` +
|
||||
`${QUALIFIED}\\s+ON\\s+(?:ONLY\\s+)?${QUALIFIED}`,
|
||||
'i'
|
||||
)
|
||||
const ADD_COLUMN = new RegExp(
|
||||
`^ALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?(?:ONLY\\s+)?${QUALIFIED}\\s+` +
|
||||
`ADD\\s+COLUMN\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?${QUALIFIED}`,
|
||||
'i'
|
||||
)
|
||||
const ADD_CONSTRAINT = new RegExp(
|
||||
`^ALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?(?:ONLY\\s+)?${QUALIFIED}\\s+` +
|
||||
`ADD\\s+CONSTRAINT\\s+${QUALIFIED}`,
|
||||
'i'
|
||||
)
|
||||
// `IF EXISTS` is required, not optional. A bare `DROP CONSTRAINT` on a missing constraint is an
|
||||
// error the server is supposed to raise, and skipping it would swallow that. Without a target the
|
||||
// statement throws at boot instead, which tells the author to write `IF EXISTS`.
|
||||
const DROP_CONSTRAINT = new RegExp(
|
||||
`^ALTER\\s+TABLE\\s+(?:IF\\s+EXISTS\\s+)?(?:ONLY\\s+)?${QUALIFIED}\\s+` +
|
||||
`DROP\\s+CONSTRAINT\\s+IF\\s+EXISTS\\s+${QUALIFIED}`,
|
||||
'i'
|
||||
)
|
||||
|
||||
// Every statement shape that takes a relation lock before Postgres evaluates its existence test.
|
||||
// `CREATE TABLE IF NOT EXISTS` is absent on purpose: it resolves a name against the schema and
|
||||
// takes no lock on an existing table.
|
||||
const TAKES_RELATION_LOCK = /^(?:CREATE\s+(?:UNIQUE\s+)?INDEX|ALTER\s+TABLE)\b/i
|
||||
|
||||
export function takesRelationLock(statement: string): boolean {
|
||||
return TAKES_RELATION_LOCK.test(sqlWithoutComments(statement))
|
||||
}
|
||||
|
||||
// Splitting on '.' is not enough: `"a.b"` is one identifier containing a dot, not two parts. Each
|
||||
// part is read quote-aware, with a doubled quote unescaped to one.
|
||||
function qualifiedParts(written: string): { text: string; quoted: boolean }[] {
|
||||
const parts: { text: string; quoted: boolean }[] = []
|
||||
let text = ''
|
||||
let quoted = false
|
||||
let wasQuoted = false
|
||||
for (let index = 0; index < written.length; index += 1) {
|
||||
const character = written[index]!
|
||||
if (quoted) {
|
||||
if (character !== '"') {
|
||||
text += character
|
||||
continue
|
||||
}
|
||||
if (written[index + 1] === '"') {
|
||||
text += '"'
|
||||
index += 1
|
||||
} else quoted = false
|
||||
continue
|
||||
}
|
||||
if (character === '"') {
|
||||
quoted = true
|
||||
wasQuoted = true
|
||||
} else if (character === '.') {
|
||||
parts.push({ text, quoted: wasQuoted })
|
||||
text = ''
|
||||
wasQuoted = false
|
||||
} else text += character
|
||||
}
|
||||
parts.push({ text, quoted: wasQuoted })
|
||||
return parts
|
||||
}
|
||||
|
||||
// Postgres folds an unquoted identifier to lower case before storing it, so `Foo` is `foo` in
|
||||
// relname, attname and conname. Comparing the written case would miss the row and rebuild the
|
||||
// object on every boot.
|
||||
function catalogName(written: string): string {
|
||||
const last = qualifiedParts(written).pop()
|
||||
if (!last) return written
|
||||
return last.quoted ? last.text : last.text.toLowerCase()
|
||||
}
|
||||
|
||||
// Shapes whose lock target the pre-check must be able to derive. Deliberately looser than the
|
||||
// regexes that parse them, so a statement that reads as one of these but does not parse is caught
|
||||
// rather than falling through to the lock path.
|
||||
const MUST_PARSE = [
|
||||
/^CREATE\s+(?:UNIQUE\s+)?INDEX\b/i,
|
||||
/^ALTER\s+TABLE\b[\s\S]*\bADD\s+COLUMN\b/i,
|
||||
/^ALTER\s+TABLE\b[\s\S]*\bADD\s+CONSTRAINT\b/i,
|
||||
/^ALTER\s+TABLE\b[\s\S]*\bDROP\s+CONSTRAINT\b/i
|
||||
]
|
||||
|
||||
// Derived from the statement itself so a renamed index cannot drift away from its pre-check.
|
||||
export function schemaLockTarget(statement: string): SchemaLockTarget | undefined {
|
||||
const sql = sqlWithoutComments(statement)
|
||||
const index = CREATE_INDEX.exec(sql)
|
||||
if (index?.[1] && index[2]) {
|
||||
return { kind: 'index', table: index[2], name: catalogName(index[1]), skipWhen: 'present' }
|
||||
}
|
||||
const column = ADD_COLUMN.exec(sql)
|
||||
if (column?.[1] && column[2]) {
|
||||
return { kind: 'column', table: column[1], name: catalogName(column[2]), skipWhen: 'present' }
|
||||
}
|
||||
const added = ADD_CONSTRAINT.exec(sql)
|
||||
if (added?.[1] && added[2]) {
|
||||
return {
|
||||
kind: 'constraint',
|
||||
table: added[1],
|
||||
name: catalogName(added[2]),
|
||||
skipWhen: 'present'
|
||||
}
|
||||
}
|
||||
const dropped = DROP_CONSTRAINT.exec(sql)
|
||||
if (dropped?.[1] && dropped[2]) {
|
||||
return {
|
||||
kind: 'constraint',
|
||||
table: dropped[1],
|
||||
name: catalogName(dropped[2]),
|
||||
skipWhen: 'absent'
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const ALTER_TABLE = /^ALTER\s+TABLE\b/i
|
||||
|
||||
// A comma that separates ALTER TABLE subcommands rather than sitting inside a type, a default, a
|
||||
// CHECK body or a dollar-quoted body. Takes comment-free SQL. Square brackets count as depth too,
|
||||
// or an array type or `DEFAULT ARRAY[1, 2]` reads as a second subcommand and fails the boot.
|
||||
function hasTopLevelComma(sql: string): boolean {
|
||||
let depth = 0
|
||||
let quote: string | undefined
|
||||
for (let index = 0; index < sql.length; index += 1) {
|
||||
const character = sql[index]
|
||||
if (quote !== undefined) {
|
||||
if (character !== quote) continue
|
||||
if (sql[index + 1] === quote) index += 1
|
||||
else quote = undefined
|
||||
continue
|
||||
}
|
||||
if (character === '$') {
|
||||
const end = dollarQuoteEnd(sql, index)
|
||||
if (end !== undefined) {
|
||||
index = end - 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (character === "'" || character === '"') quote = character
|
||||
else if (character === '(' || character === '[') depth += 1
|
||||
else if (character === ')' || character === ']') depth -= 1
|
||||
else if (character === ',' && depth === 0) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// An index or column statement whose target cannot be read is the dangerous case: it would be sent
|
||||
// unchecked and take the lock the pre-check exists to avoid, silently and on every boot. An
|
||||
// auto-named `CREATE INDEX ON t(c)` lands here too, because nothing in the text says what the
|
||||
// catalog will call it. Fail the boot with the statement instead.
|
||||
export function requireSchemaLockTarget(statement: string): SchemaLockTarget | undefined {
|
||||
const sql = sqlWithoutComments(statement)
|
||||
// A multi-action ALTER TABLE parses to its FIRST subcommand's target only, so skipping on that
|
||||
// one object would silently drop every later action for the life of the database. One action per
|
||||
// statement, or no pre-check is possible.
|
||||
if (ALTER_TABLE.test(sql) && hasTopLevelComma(sql)) {
|
||||
throw new Error(`unparsed_schema_lock_target: ${sql}`)
|
||||
}
|
||||
const target = schemaLockTarget(statement)
|
||||
if (target) return target
|
||||
if (MUST_PARSE.some((shape) => shape.test(sql))) {
|
||||
throw new Error(`unparsed_schema_lock_target: ${sql}`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -18,6 +18,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.8"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.8"
|
||||
"vitest": "^4.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+134
-127
@@ -18,8 +18,8 @@ importers:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.8
|
||||
version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
|
||||
apps/push:
|
||||
dependencies:
|
||||
@@ -64,8 +64,8 @@ importers:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.8
|
||||
version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
|
||||
apps/relay:
|
||||
dependencies:
|
||||
@@ -113,8 +113,8 @@ importers:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.8
|
||||
version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
|
||||
apps/relay-fence-broker:
|
||||
dependencies:
|
||||
@@ -138,8 +138,8 @@ importers:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.8
|
||||
version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
|
||||
apps/relay-ops:
|
||||
dependencies:
|
||||
@@ -163,8 +163,8 @@ importers:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.8
|
||||
version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
|
||||
packages/postgres-schema:
|
||||
devDependencies:
|
||||
@@ -175,8 +175,8 @@ importers:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.8
|
||||
version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
|
||||
packages/push-contract:
|
||||
dependencies:
|
||||
@@ -191,8 +191,8 @@ importers:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.8
|
||||
version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
|
||||
packages/relay-contract:
|
||||
dependencies:
|
||||
@@ -207,8 +207,8 @@ importers:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.8
|
||||
version: 4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
specifier: ^4.1.11
|
||||
version: 4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -386,11 +386,12 @@ packages:
|
||||
'@jridgewell/sourcemap-codec@1.5.5':
|
||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.5':
|
||||
resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==}
|
||||
'@napi-rs/wasm-runtime@1.2.4':
|
||||
resolution: {integrity: sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0}
|
||||
peerDependencies:
|
||||
'@emnapi/core': ^1.7.1
|
||||
'@emnapi/runtime': ^1.7.1
|
||||
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
|
||||
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
|
||||
|
||||
'@oxc-project/types@0.133.0':
|
||||
resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==}
|
||||
@@ -490,8 +491,8 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@tybys/wasm-util@0.10.2':
|
||||
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
|
||||
'@tybys/wasm-util@0.10.4':
|
||||
resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
@@ -511,11 +512,11 @@ packages:
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
|
||||
'@vitest/expect@4.1.11':
|
||||
resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==}
|
||||
|
||||
'@vitest/mocker@4.1.9':
|
||||
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
|
||||
'@vitest/mocker@4.1.11':
|
||||
resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -525,20 +526,20 @@ packages:
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
|
||||
'@vitest/pretty-format@4.1.11':
|
||||
resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==}
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
|
||||
'@vitest/runner@4.1.11':
|
||||
resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==}
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
|
||||
'@vitest/snapshot@4.1.11':
|
||||
resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==}
|
||||
|
||||
'@vitest/spy@4.1.9':
|
||||
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
|
||||
'@vitest/spy@4.1.11':
|
||||
resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==}
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
|
||||
'@vitest/utils@4.1.11':
|
||||
resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==}
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
@@ -660,74 +661,74 @@ packages:
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
lightningcss-android-arm64@1.33.0:
|
||||
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
lightningcss-darwin-arm64@1.32.0:
|
||||
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
|
||||
lightningcss-darwin-arm64@1.33.0:
|
||||
resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-darwin-x64@1.32.0:
|
||||
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
|
||||
lightningcss-darwin-x64@1.33.0:
|
||||
resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-freebsd-x64@1.32.0:
|
||||
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
|
||||
lightningcss-freebsd-x64@1.33.0:
|
||||
resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.32.0:
|
||||
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
|
||||
lightningcss-linux-arm-gnueabihf@1.33.0:
|
||||
resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
|
||||
lightningcss-linux-arm64-gnu@1.33.0:
|
||||
resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
lightningcss-linux-arm64-musl@1.33.0:
|
||||
resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
lightningcss-linux-x64-gnu@1.33.0:
|
||||
resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
lightningcss-linux-x64-musl@1.33.0:
|
||||
resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
lightningcss-win32-arm64-msvc@1.33.0:
|
||||
resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss-win32-x64-msvc@1.32.0:
|
||||
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
|
||||
lightningcss-win32-x64-msvc@1.33.0:
|
||||
resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss@1.32.0:
|
||||
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
|
||||
lightningcss@1.33.0:
|
||||
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
magic-string@0.30.21:
|
||||
@@ -736,8 +737,8 @@ packages:
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
nanoid@3.3.13:
|
||||
resolution: {integrity: sha512-sPdqC6ByMVVGvF1ynvvMo0/o+oD1VX7DaHhijt1bFgjvBkHBib4t49GoNDhf2NDta4oeUNlaGbSt5K7qjZ955Q==}
|
||||
nanoid@3.3.19:
|
||||
resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
@@ -798,8 +799,12 @@ packages:
|
||||
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
postcss@8.5.15:
|
||||
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
|
||||
picomatch@4.0.7:
|
||||
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
postcss@8.5.28:
|
||||
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
postgres-array@2.0.0:
|
||||
@@ -920,20 +925,20 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.1.9:
|
||||
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
|
||||
vitest@4.1.11:
|
||||
resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.9
|
||||
'@vitest/browser-preview': 4.1.9
|
||||
'@vitest/browser-webdriverio': 4.1.9
|
||||
'@vitest/coverage-istanbul': 4.1.9
|
||||
'@vitest/coverage-v8': 4.1.9
|
||||
'@vitest/ui': 4.1.9
|
||||
'@vitest/browser-playwright': 4.1.11
|
||||
'@vitest/browser-preview': 4.1.11
|
||||
'@vitest/browser-webdriverio': 4.1.11
|
||||
'@vitest/coverage-istanbul': 4.1.11
|
||||
'@vitest/coverage-v8': 4.1.11
|
||||
'@vitest/ui': 4.1.11
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
@@ -1091,11 +1096,11 @@ snapshots:
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
'@napi-rs/wasm-runtime@1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@tybys/wasm-util': 0.10.2
|
||||
'@tybys/wasm-util': 0.10.4
|
||||
optional: true
|
||||
|
||||
'@oxc-project/types@0.133.0': {}
|
||||
@@ -1140,7 +1145,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
'@emnapi/runtime': 1.10.0
|
||||
'@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
'@napi-rs/wasm-runtime': 1.2.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
|
||||
optional: true
|
||||
|
||||
'@rolldown/binding-win32-arm64-msvc@1.0.3':
|
||||
@@ -1153,7 +1158,7 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@tybys/wasm-util@0.10.2':
|
||||
'@tybys/wasm-util@0.10.4':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
optional: true
|
||||
@@ -1181,44 +1186,44 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.13.2
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
'@vitest/expect@4.1.11':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
'@vitest/spy': 4.1.11
|
||||
'@vitest/utils': 4.1.11
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.9(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))':
|
||||
'@vitest/mocker@4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/spy': 4.1.11
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
'@vitest/pretty-format@4.1.11':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
'@vitest/runner@4.1.11':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.9
|
||||
'@vitest/utils': 4.1.11
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
'@vitest/snapshot@4.1.11':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
'@vitest/utils': 4.1.11
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.1.9': {}
|
||||
'@vitest/spy@4.1.11': {}
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
'@vitest/utils@4.1.11':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
@@ -1358,54 +1363,54 @@ snapshots:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
lightningcss-android-arm64@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-arm64@1.32.0:
|
||||
lightningcss-darwin-arm64@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-x64@1.32.0:
|
||||
lightningcss-darwin-x64@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-freebsd-x64@1.32.0:
|
||||
lightningcss-freebsd-x64@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.32.0:
|
||||
lightningcss-linux-arm-gnueabihf@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.32.0:
|
||||
lightningcss-linux-arm64-gnu@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-musl@1.32.0:
|
||||
lightningcss-linux-arm64-musl@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-gnu@1.32.0:
|
||||
lightningcss-linux-x64-gnu@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-musl@1.32.0:
|
||||
lightningcss-linux-x64-musl@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.32.0:
|
||||
lightningcss-win32-arm64-msvc@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-x64-msvc@1.32.0:
|
||||
lightningcss-win32-x64-msvc@1.33.0:
|
||||
optional: true
|
||||
|
||||
lightningcss@1.32.0:
|
||||
lightningcss@1.33.0:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
optionalDependencies:
|
||||
lightningcss-android-arm64: 1.32.0
|
||||
lightningcss-darwin-arm64: 1.32.0
|
||||
lightningcss-darwin-x64: 1.32.0
|
||||
lightningcss-freebsd-x64: 1.32.0
|
||||
lightningcss-linux-arm-gnueabihf: 1.32.0
|
||||
lightningcss-linux-arm64-gnu: 1.32.0
|
||||
lightningcss-linux-arm64-musl: 1.32.0
|
||||
lightningcss-linux-x64-gnu: 1.32.0
|
||||
lightningcss-linux-x64-musl: 1.32.0
|
||||
lightningcss-win32-arm64-msvc: 1.32.0
|
||||
lightningcss-win32-x64-msvc: 1.32.0
|
||||
lightningcss-android-arm64: 1.33.0
|
||||
lightningcss-darwin-arm64: 1.33.0
|
||||
lightningcss-darwin-x64: 1.33.0
|
||||
lightningcss-freebsd-x64: 1.33.0
|
||||
lightningcss-linux-arm-gnueabihf: 1.33.0
|
||||
lightningcss-linux-arm64-gnu: 1.33.0
|
||||
lightningcss-linux-arm64-musl: 1.33.0
|
||||
lightningcss-linux-x64-gnu: 1.33.0
|
||||
lightningcss-linux-x64-musl: 1.33.0
|
||||
lightningcss-win32-arm64-msvc: 1.33.0
|
||||
lightningcss-win32-x64-msvc: 1.33.0
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
@@ -1413,7 +1418,7 @@ snapshots:
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.13: {}
|
||||
nanoid@3.3.19: {}
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
@@ -1466,9 +1471,11 @@ snapshots:
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
|
||||
postcss@8.5.15:
|
||||
picomatch@4.0.7: {}
|
||||
|
||||
postcss@8.5.28:
|
||||
dependencies:
|
||||
nanoid: 3.3.13
|
||||
nanoid: 3.3.19
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
@@ -1543,9 +1550,9 @@ snapshots:
|
||||
|
||||
vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.15
|
||||
lightningcss: 1.33.0
|
||||
picomatch: 4.0.7
|
||||
postcss: 8.5.28
|
||||
rolldown: 1.0.3
|
||||
tinyglobby: 0.2.17
|
||||
optionalDependencies:
|
||||
@@ -1554,15 +1561,15 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
tsx: 4.22.4
|
||||
|
||||
vitest@4.1.9(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)):
|
||||
vitest@4.1.11(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.9
|
||||
'@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/runner': 4.1.9
|
||||
'@vitest/snapshot': 4.1.9
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
'@vitest/expect': 4.1.11
|
||||
'@vitest/mocker': 4.1.11(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(tsx@4.22.4))
|
||||
'@vitest/pretty-format': 4.1.11
|
||||
'@vitest/runner': 4.1.11
|
||||
'@vitest/snapshot': 4.1.11
|
||||
'@vitest/spy': 4.1.11
|
||||
'@vitest/utils': 4.1.11
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
|
||||
@@ -40,7 +40,8 @@ const WORKER_THREAD_ENTRY_NAMES = [
|
||||
'session-scanner-opencode-sqlite-worker-entry',
|
||||
'session-scanner-worker-entry',
|
||||
'main-thread-hang-watchdog-entry',
|
||||
'port-scan-command-worker-entry'
|
||||
'port-scan-command-worker-entry',
|
||||
'usage-scan-worker-entry'
|
||||
] as const
|
||||
|
||||
export const GUARDED_ENTRY_NAMES = [
|
||||
|
||||
@@ -16,7 +16,7 @@ const { verifyLinuxGlibcFloor } = require('./scripts/verify-linux-glibc-floor.cj
|
||||
const { writeMacBuildCompatibility } = require('./scripts/mac-build-compatibility.cjs')
|
||||
const { verifyPackagedPluginResources } = require('./scripts/verify-packaged-plugin-resources.cjs')
|
||||
const {
|
||||
verifyPackagedNodePtyJobOwnership
|
||||
verifyPackagedWindowsNodePty
|
||||
} = require('./scripts/verify-packaged-node-pty-job-ownership.cjs')
|
||||
const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs')
|
||||
const { verifyStaticAppImagePackage } = require('./scripts/static-appimage-package-contract.cjs')
|
||||
@@ -353,11 +353,7 @@ module.exports = {
|
||||
const hostArchEnum = archEnumByNodeArch[process.arch]
|
||||
const canExecuteTargetArch = context.arch === hostArchEnum || context.arch === 4
|
||||
if (context.electronPlatformName === 'win32') {
|
||||
if (process.platform === 'win32' && canExecuteTargetArch) {
|
||||
verifyPackagedNodePtyJobOwnership(resourcesDir)
|
||||
} else {
|
||||
console.log('[verify-packaged-node-pty] skipped cross-platform or cross-arch package')
|
||||
}
|
||||
verifyPackagedWindowsNodePty(resourcesDir, context.arch, { canExecuteTargetArch })
|
||||
}
|
||||
verifySkillsCliRuntime(join(resourcesDir, 'app.asar.unpacked', 'out'), resourcesDir, {
|
||||
executeCommands: canExecuteTargetArch
|
||||
|
||||
@@ -89,5 +89,131 @@
|
||||
"text": "ghostty",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:title",
|
||||
"text": "Default shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:description",
|
||||
"text": "Shell used for new terminal panes",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "terminal",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "fish",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "zsh",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "bash",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "nushell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:keywords",
|
||||
"text": "default",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:title",
|
||||
"text": "Terminal shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:description",
|
||||
"text": "Choose what Orca opens for new local terminal panes.",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:ariaLabel",
|
||||
"text": "Terminal shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:label",
|
||||
"text": "System shell (",
|
||||
"dynamic": true,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "object-property:label",
|
||||
"text": "Custom shell",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:placeholder",
|
||||
"text": "fish, nu, or /bin/zsh",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-attribute:aria-label",
|
||||
"text": "Custom shell executable",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-text",
|
||||
"text": "Enter a shell name on PATH or an executable path. Orca starts it as a login shell.",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
},
|
||||
{
|
||||
"filePath": "src/renderer/src/components/settings/TerminalPane.tsx",
|
||||
"kind": "jsx-text",
|
||||
"text": ". Switch to System shell or choose an executable on this host.",
|
||||
"dynamic": false,
|
||||
"count": 1
|
||||
}
|
||||
]
|
||||
|
||||
@@ -607,6 +607,7 @@ module.exports = {
|
||||
createPackagedRuntimeNodeModuleResources,
|
||||
findAsarEntry,
|
||||
isPackagedExternalSpecifier,
|
||||
normalizeNodePtyWindowsArch,
|
||||
packageNameFromSpecifier,
|
||||
prunePackagedNodePty,
|
||||
prunePackagedParcelWatcher,
|
||||
|
||||
@@ -482,6 +482,145 @@
|
||||
"demotionRule": "Keep experimental until soak and platform evidence support promotion; do not relax value or identity assertions to hide failures."
|
||||
},
|
||||
|
||||
{
|
||||
"id": "workspace-session.ssh-host-partition-round-trip",
|
||||
"title": "An SSH workspace round-trips through its own partition without losing tabs, editor files or browser state",
|
||||
"maturity": "experimental",
|
||||
"protection": "partial",
|
||||
"owner": "workspace-session-persistence",
|
||||
"layer": "persistence-integration",
|
||||
"surfaces": [
|
||||
"workspace session partitions",
|
||||
"direct SSH remote workspace sync",
|
||||
"boot session hydration"
|
||||
],
|
||||
"platforms": ["macos", "linux", "windows"],
|
||||
"providers": ["local", "ssh"],
|
||||
"coveredPlatforms": ["macos"],
|
||||
"coveredProviders": ["local", "ssh"],
|
||||
"coverageNotes": "Drives the real Store against a temp profile, the real remoteWorkspace:setForConnectedTargets handler, the real session projection and the real pull-side merge. The relay transport is faked at the multiplexer boundary, so no live SSH host or relay is exercised; the partition routing and projection code under test is platform-independent. Runtime (orca environment) partitions, PTY lifecycle and mobile rendering are unaffected.",
|
||||
"motivatingLinks": [
|
||||
"https://github.com/stablyai/orca/issues/12721",
|
||||
"https://github.com/stablyai/orca/issues/18173",
|
||||
"https://github.com/stablyai/orca/blob/main/src/shared/workspace-session-partition-owner.ts"
|
||||
],
|
||||
"invariant": "Every workspace the `ssh:<targetId>` partition names is hydrated at boot and published to the host, whatever kind of state it holds - terminal tabs, open editor files with unsaved hot-exit drafts, browser workspaces, tab groups, or host-qualified visit recency. An empty tab row is read as a gap, never as evidence the tabs were closed, so a replace-session upload can never delete a populated host list. A workspace the local partition already holds terminal tabs for is left untouched, and every workspace routes back to the partition that owns it. Adoption never destroys what it was not told about: a host row the partition holds nothing for cannot replace a populated base row, and a bare id the read found contested is gap-filled rather than replaced, because that is exactly the id where `local` and `ssh:<targetId>` are not one workspace written twice. Tab-, pane- and file-keyed rows are recovered through the same indexes the split routed them by. An explicit empty tab row written by this build survives the round trip as a present empty row and is declined by the shared seeding predicate. Boot enumerates the partitions persistence actually holds rather than inferring them from the repo catalog, so a target whose only workspace is a folder is read like any other; a folder workspace routes to the same partition main's RuntimeWorkspaceSessionController writes it to, so one workspace is never written into two stores; and a workspace adopted out of a partition routes back to that partition even on a boot whose repo catalog cannot name the host, so the write cannot re-strand what the read just reunited. `contested` means a repo id the catalog registers on more than one host, or two SSH partitions naming one id - never the mere co-presence of 'local' and `ssh:<targetId>`, which is the repair's own input.",
|
||||
"oracle": "Assert the renderer routes an SSH folder workspace to `ssh:<targetId>`, that its save carries the folder row to that partition and leaves none behind in 'local' (main applies a patch field-wise, so a partition write that omits the row erases it), and that an SSH partition no repo names is still read. Assert a bare id whose repo the catalog registers on two hosts keeps the local workspace's unsaved dirtyDraftContent and never names the rival partition as its write target, while two SSH partitions naming one id resolve to the same winner on every boot. Seed a real Store the way shipping builds leave it: the local blob holds the worktree key with an empty list while `ssh:<targetId>` holds the real one, with a second populated SSH partition present. Publish through the IPC handler with no session argument (the path the debounced writer takes) and assert the host snapshot carries the runtime-authored tabs rather than []. Separately drive the shipping split (buildWorkspaceSessionHostSnapshots) and feed its own output back through the real boot read, pinning the write and read halves to each other rather than to a hand-built fixture: an SSH workspace with open editor files, an unsaved dirtyDraftContent and no terminal tabs must come back intact, as must one with no tabsByWorktree key at all. Export and re-import through the real projection and merge through mergeDirectSshRemoteWorkspaceSession, asserting tabs survive a publish, the next pull, and an older client publishing an empty list for them. Assert the reunited workspace routes to `ssh:<targetId>`, that a workspace the base holds tabs for is not modified, and that a contested id claimed by an SSH and a runtime host does not send the SSH rows into the rotating runtime partition.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-host-partition-session-export.test.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/ipc/ssh-host-partition-session-export.test.ts",
|
||||
"src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts",
|
||||
"src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts",
|
||||
"src/renderer/src/lib/workspace-session-host-contention.test.ts",
|
||||
"src/shared/workspace-session-partition-owner.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/ipc/ssh-host-partition-session-export.test.ts",
|
||||
"assertions": [
|
||||
"publishes tabs the runtime persisted into the target ssh partition",
|
||||
"never replaces the host snapshot with an empty list for a worktree that has tabs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts",
|
||||
"assertions": [
|
||||
"hydrates tabs the runtime persisted into the ssh partition",
|
||||
"adopts the stranded workspace rows alongside its tabs",
|
||||
"adopts a hibernated agent record the host partition alone holds",
|
||||
"leaves a workspace the local partition already holds tabs for untouched",
|
||||
"routes the reunited workspace back to the partition that owns it",
|
||||
"does not delete the worktree tabs across a publish and the next pull",
|
||||
"publishes the stranded tabs rather than an empty list",
|
||||
"does not let an empty host row destroy an unsaved draft the base alone holds",
|
||||
"still adopts a populated host row over the base leftovers",
|
||||
"adopts the layout of a tab the host slice names only in unifiedTabs",
|
||||
"does not overwrite a contested workspace's own rows with the ssh workspace's",
|
||||
"still fills a gap on a contested id",
|
||||
"does not let a legacy bare recency key move another host workspace of the same id",
|
||||
"still adopts a host-qualified recency key, which names its own owner",
|
||||
"writes an SSH workspace emptied by this build into the partition that owns it",
|
||||
"restores that tombstone as an explicit empty row, not a deleted key",
|
||||
"leaves the restored workspace un-seeded by the shared seeding predicate",
|
||||
"does not adopt a stale populated ssh row over a tombstone in the owning partition",
|
||||
"resurrects the legacy-transition shape exactly once and not again",
|
||||
"publishes the tombstone rather than a row the host can read as unknown"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts",
|
||||
"assertions": [
|
||||
"routes a folder workspace to the partition main writes it to",
|
||||
"keeps a folder row out of the local partition so a save cannot erase main\u2019s copy",
|
||||
"reads an ssh partition that only a folder workspace owns",
|
||||
"returns adopted rows to the partition they were read from with no repo catalog",
|
||||
"gap-fills instead of replacing, so the local workspace keeps its unsaved draft",
|
||||
"never names the rival partition as the write target for a contested id",
|
||||
"gap-fills a contested id from the same partition on every boot",
|
||||
"does not adopt a partition the catalog says does not own the workspace",
|
||||
"does not adopt a contested id the assembled session holds no row for"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/renderer/src/lib/workspace-session-host-contention.test.ts",
|
||||
"assertions": [
|
||||
"keeps an SSH claimant out of the rotating runtime partition",
|
||||
"does not strand the runtime co-claimant when the SSH row is written"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/shared/workspace-session-partition-owner.test.ts",
|
||||
"assertions": [
|
||||
"gives an SSH host its own partition, matching what the runtime already writes"
|
||||
]
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-09-15",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/ipc/ssh-host-partition-session-export.test.ts src/renderer/src/lib/workspace-session-ssh-partition-round-trip.test.ts src/renderer/src/lib/workspace-session-ssh-partition-ownership.test.ts src/renderer/src/lib/workspace-session-host-contention.test.ts src/shared/workspace-session-partition-owner.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 7.13,
|
||||
"summary": "64 tests passed across 5 files: the real Store publish, the shipping write/read split round trip, the older-client empty-publish skew direction, the contested-bare-id and empty-host-row guards, the closed-last-terminal tombstone boundary, and the partition-ownership suite covering folder-workspace routing, the persistence-side partition census, read-source write-back, and the catalog attribution that keeps a residue partition from winning the read."
|
||||
}
|
||||
],
|
||||
"runtimeBudget": {
|
||||
"p95Seconds": 30,
|
||||
"scope": "Real Store on a temp profile plus renderer partition units; no launched app and no relay."
|
||||
},
|
||||
"flakeHistory": {
|
||||
"status": "not-started",
|
||||
"evidence": "Deterministic local validation only; no CI soak yet. The tests have no timers, network or real relay."
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "Against pristine main 12f53da542d, 9 of the 11 assertions that target the original defect fail: the boot read returns no tabs, the export publishes an empty tabsByWorktreePath row, the round trip ends with the tabs deleted, routing answers local instead of ssh:target-1, and an older client's empty publish deletes the tabs on pull. The remaining assertions guard the fix itself rather than main's bug: review found that gating adoption on terminal tabs stranded editor-only and browser-only SSH workspaces, destroying unsaved hot-exit drafts no other channel can recover. The gate now discovers a workspace through an exhaustive switch over the field-ownership table, and reintroducing the tabs-only gate fails 6 assertions including the write/read round trip."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": false,
|
||||
"evidence": "Boot adds one session:get per SSH host that owns a repo, issued in parallel with the existing runtime partition reads and served from already-loaded main state. The publish fallback adds one partition read and a shallow keyed-record merge per target, which returns its input unchanged when nothing is stranded and stays the same order as the projection it feeds."
|
||||
},
|
||||
"knownGaps": [
|
||||
"No live SSH host or relay is exercised; the multiplexer is faked at the request boundary.",
|
||||
"The publish fallback reads only 'local' and the target's own partition, so it cannot see the local/runtime rivalry or a second SSH partition the renderer's read can. Bounded rather than closed: a key either side would judge differently is one whose repo id resolves ambiguously or to another host, and the export already publishes those to nobody.",
|
||||
"A lingering `ssh:<targetId>` partition for a connection the user removed is now read by the census. Its rows stay in that partition rather than spilling into 'local', and the deregistered-repo residue sweep already clears repo-backed ones, but a folder workspace left behind by a removed connection is not swept.",
|
||||
"Rows stranded beside a workspace the local partition already holds terminal tabs for are deliberately not recovered, and no assertion claims they are.",
|
||||
"One-shot resurrection in the legacy-transition shape: where an older build left an empty local row while the runtime partition still holds that workspace's tabs, boot adopts them back once. Non-destructive, and non-recurrence is now asserted rather than argued - a workspace this build empties writes its empty row to the owning partition and leaves no local row behind, so there is nothing left to resurrect from.",
|
||||
"A contested bare id is gap-filled rather than replaced, so rows stranded beside a contested workspace stay stranded. Separating them needs host-qualified keys through the tab store, which is the same open gap `workspace-session-host-contention.ts` records.",
|
||||
"A `local` row left by a repo registration the user removed, whose repo id the catalog now resolves to the SSH host alone, is neither ambiguous nor foreign, so a populated host row still replaces it - including an unsaved `dirtyDraftContent`. Unchanged from main; closing it needs host-qualified keys, the same gap `workspace-session-host-contention.ts` records.",
|
||||
"A bare `lastVisitedAtByWorktreeId` stamp left in 'local' for a workspace now owned by an SSH partition gap-fills rather than replaces, so the workspace can keep an older Cmd+J position for one boot after the migration.",
|
||||
"`lastVisitedAtByWorktreeId` still discovers a workspace as adoptable from a recency entry alone. Harmless now that a recency row cannot replace another host's entry for the same bare id, but it means recency is a discovery trigger and no other field of its kind is."
|
||||
],
|
||||
"promotionCriteria": [
|
||||
"Complete the CI soak requirement with no unexplained flakes.",
|
||||
"Add coverage for a live SSH target before claiming the ssh provider is exercised end to end."
|
||||
],
|
||||
"demotionRule": "Keep experimental until CI soak completes. Investigate any failure without weakening the empty-row-is-a-gap oracle, which is the assertion the data-loss fix rests on."
|
||||
},
|
||||
{
|
||||
"id": "agent-session.history-forward-read-budget",
|
||||
"title": "Journal catch-up reads only the next page and one lookahead row",
|
||||
|
||||
@@ -19,16 +19,8 @@
|
||||
* node config/scripts/build-windows-process-tree-relay-addon.mjs --arch=arm64
|
||||
*/
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import {
|
||||
closeSync,
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readFileSync,
|
||||
readSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { RELAY_WINDOWS_PROCESS_TREE_FILENAME } from '../../src/shared/relay-artifacts.ts'
|
||||
import {
|
||||
@@ -39,12 +31,13 @@ import {
|
||||
WINDOWS_PROCESS_TREE_PACKAGE_DIR as PACKAGE_DIR
|
||||
} from './windows-process-tree-gyp-rebuild.mjs'
|
||||
|
||||
const { PE_MACHINE, describePeMachine, readPeMachine } = createRequire(import.meta.url)(
|
||||
'./windows-pe-machine.cjs'
|
||||
)
|
||||
|
||||
const ROOT = resolve(import.meta.dirname, '..', '..')
|
||||
const SUPPORTED_ARCHES = ['x64', 'arm64']
|
||||
|
||||
/** PE `IMAGE_FILE_HEADER.Machine` values, so a cross-build cannot silently emit host arch. */
|
||||
const PE_MACHINE = { x64: 0x8664, arm64: 0xaa64 }
|
||||
|
||||
function parseArgs(argv) {
|
||||
const arch = argv.find((a) => a.startsWith('--arch='))?.slice('--arch='.length) ?? process.arch
|
||||
const outDir = argv.find((a) => a.startsWith('--out='))?.slice('--out='.length)
|
||||
@@ -363,21 +356,6 @@ function applyWindowsProcessTreeBuildFixes() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the PE machine field, so an arm64 request cannot ship an x64 binary. */
|
||||
function readPeMachine(binaryPath) {
|
||||
const fd = openSync(binaryPath, 'r')
|
||||
try {
|
||||
const header = Buffer.alloc(4)
|
||||
readSync(fd, header, 0, 4, 0x3c)
|
||||
const peOffset = header.readUInt32LE(0)
|
||||
const machine = Buffer.alloc(2)
|
||||
readSync(fd, machine, 0, 2, peOffset + 4)
|
||||
return machine.readUInt16LE(0)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { arch, outDir } = parseArgs(process.argv.slice(2))
|
||||
if (process.platform !== 'win32') {
|
||||
@@ -410,9 +388,12 @@ function main() {
|
||||
}
|
||||
const machine = readPeMachine(built)
|
||||
if (machine !== PE_MACHINE[arch]) {
|
||||
const cause =
|
||||
machine === null
|
||||
? 'A truncated or quarantined build output looks like this; a relay would get a binary no host can load.'
|
||||
: 'node-gyp ignored --arch; a relay would get a binary its host cannot load.'
|
||||
throw new Error(
|
||||
`Built binary is machine 0x${machine.toString(16)}, expected 0x${PE_MACHINE[arch].toString(16)} for ${arch}. ` +
|
||||
'node-gyp ignored --arch; a relay would get a binary its host cannot load.'
|
||||
`Built binary is ${describePeMachine(machine)}, expected 0x${PE_MACHINE[arch].toString(16)} for ${arch}. ${cause}`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,17 @@ export const OXLINT_SCANS = [
|
||||
label: 'casting code quality',
|
||||
args: ['--config', 'config/oxlint-code-quality-casting.json']
|
||||
},
|
||||
{
|
||||
// Why the allow: CI's `audit:code-quality:native` runs before the mobile install, so it can
|
||||
// never see a cycle inside mobile/ — locally, where mobile/node_modules exists, it would.
|
||||
label: 'focused plugins',
|
||||
args: [
|
||||
'--config',
|
||||
'config/oxlint-code-quality-native-plugins.json',
|
||||
'--allow',
|
||||
'import/no-cycle'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'type-aware code quality',
|
||||
args: ['--type-aware', '--config', 'config/oxlint-code-quality-type-aware.json']
|
||||
|
||||
@@ -57,6 +57,16 @@ describe('changed-code quality line matching', () => {
|
||||
expect(scan.args).not.toContain('--disable-nested-config')
|
||||
})
|
||||
|
||||
// Why: import/no-duplicates was reachable only through the repo-wide CI audit, so it first
|
||||
// surfaced after push. The cycle rule stays out because CI's audit runs before the mobile install.
|
||||
it('runs the focused plugin config the repo-wide audit enforces, minus the cycle rule', () => {
|
||||
const scan = OXLINT_SCANS.find((candidate) => candidate.label === 'focused plugins')
|
||||
|
||||
expect(scan.args).toContain('config/oxlint-code-quality-native-plugins.json')
|
||||
expect(scan.args).toContain('import/no-cycle')
|
||||
expect(scan.args[scan.args.indexOf('import/no-cycle') - 1]).toBe('--allow')
|
||||
})
|
||||
|
||||
it('leaves Cloud source to the independent Cloud quality checks', () => {
|
||||
expect(isRootCodeQualityPath('cloud/apps/relay/src/index.ts')).toBe(false)
|
||||
expect(isRootCodeQualityPath('src/main/index.ts')).toBe(true)
|
||||
|
||||
@@ -12,14 +12,23 @@ const stubPath = join(projectDir, 'skills', 'computer-use', 'SKILL.md')
|
||||
const bundledGuide = BUNDLED_SKILL_GUIDES.find((guide) => guide.name === 'computer-use')?.markdown
|
||||
|
||||
describe('computer-use skill guidance', () => {
|
||||
it('keeps discovery scoped to desktop control and out of the embedded browser', () => {
|
||||
it('keeps discovery scoped to last-resort GUI and out of the embedded browser', () => {
|
||||
const frontmatter = /^---\n([\s\S]*?)\n---\n/u.exec(readFileSync(guidePath, 'utf8'))?.[1] ?? ''
|
||||
const description = frontmatter.replace(/\s+/gu, ' ')
|
||||
|
||||
expect(description).toContain('OS/window-level inspection and input')
|
||||
expect(description).toContain('external browser window')
|
||||
expect(description).toContain("Not for Orca's embedded browser (use `orca-cli`)")
|
||||
expect(description).toContain('page-only automation (use Playwright or CDP)')
|
||||
expect(description).toContain('Drives the GUI of a visible local app window')
|
||||
expect(description).toContain(
|
||||
'Prefer a programmatic path (shell, filesystem, git, HTTP, existing CLIs) whenever it can complete the task.'
|
||||
)
|
||||
expect(description).toContain(
|
||||
'Use only when a visible window needs GUI control those cannot reach.'
|
||||
)
|
||||
expect(description).toContain('external browser windows')
|
||||
expect(description).toContain("Do not use for Orca's embedded browser (`orca-cli`)")
|
||||
expect(description).not.toMatch(/Playwright/iu)
|
||||
expect(description).not.toContain('page-only')
|
||||
expect(description).not.toContain('OS/window-level')
|
||||
expect(description).not.toContain('Desktop or Documents')
|
||||
expect(description).not.toContain('read Slack')
|
||||
expect(description).not.toContain('get app state')
|
||||
})
|
||||
@@ -27,8 +36,15 @@ describe('computer-use skill guidance', () => {
|
||||
it('keeps web-app targeting on the computer-use surface', () => {
|
||||
const skill = readFileSync(guidePath, 'utf8')
|
||||
|
||||
expect(skill).toContain('Use this skill for desktop UI through `orca computer`')
|
||||
expect(skill).toContain('external desktop browser window that needs desktop-level control')
|
||||
expect(skill).toContain('Use this skill to drive a visible app window through `orca computer`')
|
||||
expect(skill).toContain(
|
||||
'Prefer a programmatic path (shell, filesystem, git, HTTP, existing CLIs) whenever it can complete the task'
|
||||
)
|
||||
expect(skill).toContain(
|
||||
'use this skill only when a visible window needs GUI control those cannot reach'
|
||||
)
|
||||
expect(skill).toContain('browser windows (Chrome, Edge, Safari)')
|
||||
expect(skill).not.toMatch(/Playwright/iu)
|
||||
expect(skill).not.toMatch(/\borca goto\b/iu)
|
||||
expect(skill).not.toMatch(/\borca snapshot\b/iu)
|
||||
expect(skill).not.toMatch(/\borca click\b/iu)
|
||||
|
||||
@@ -1,22 +1,48 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { peImage } from './windows-pe-image-fixture.mjs'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
const {
|
||||
CYGWIN_BREAKAWAY_MARKER,
|
||||
CYGWIN_BREAKAWAY_MARKER_TEXT,
|
||||
assertNodePtyJobOwnership,
|
||||
assertRebuiltConptyDeniesMsysBreakaway,
|
||||
nodePtyAddonPath
|
||||
} = require('./node-pty-job-ownership.cjs')
|
||||
const NODE_PTY_PATCH = readFileSync(
|
||||
new URL('../patches/node-pty@1.1.0.patch', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
const PATCHED = {
|
||||
dir: 'build/Release/',
|
||||
module: {
|
||||
listJobProcessIds: () => [],
|
||||
terminateJob: () => true,
|
||||
assignCurrentProcessToJob: () => true
|
||||
}
|
||||
const JOB_EXPORTS = {
|
||||
listJobProcessIds: () => [],
|
||||
terminateJob: () => true,
|
||||
assignCurrentProcessToJob: () => true
|
||||
}
|
||||
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), 'node-pty-job-ownership-'))
|
||||
|
||||
/** A stand-in addon; only the wide literal the gate reads has to be real. */
|
||||
function writeAddon(name, { cygwinBreakawayDenied }) {
|
||||
const path = join(fixtureDir, name)
|
||||
writeFileSync(
|
||||
path,
|
||||
Buffer.concat([
|
||||
Buffer.from('MZ fake addon '),
|
||||
cygwinBreakawayDenied ? CYGWIN_BREAKAWAY_MARKER : Buffer.alloc(0)
|
||||
])
|
||||
)
|
||||
return path
|
||||
}
|
||||
|
||||
const CURRENT_ADDON = writeAddon('current.node', { cygwinBreakawayDenied: true })
|
||||
const PRE_MSYS_ADDON = writeAddon('pre-msys.node', { cygwinBreakawayDenied: false })
|
||||
|
||||
const PATCHED = { dir: 'build/Release/', module: JOB_EXPORTS }
|
||||
const PREBUILD = {
|
||||
dir: 'prebuilds/win32-x64/',
|
||||
module: {
|
||||
@@ -28,6 +54,13 @@ const PREBUILD = {
|
||||
}
|
||||
}
|
||||
|
||||
const onWindows = (native, addonPath) => ({
|
||||
platform: 'win32',
|
||||
nativeName: 'conpty',
|
||||
native,
|
||||
addonPath
|
||||
})
|
||||
|
||||
describe('assertNodePtyJobOwnership', () => {
|
||||
it('keeps node-addon-api project paths absolute during Windows source builds', () => {
|
||||
expect(NODE_PTY_PATCH).toContain(
|
||||
@@ -40,28 +73,227 @@ describe('assertNodePtyJobOwnership', () => {
|
||||
expect(NODE_PTY_PATCH).toContain("- 'target_name': 'pty'")
|
||||
})
|
||||
|
||||
// The gate's whole case rests on this literal, and nothing else ties the
|
||||
// constant to the C++ that compiles it in. Drift either way has to fail HERE:
|
||||
// otherwise it fails every correctly rebuilt addon, and no rebuild can fix it.
|
||||
it('sniffs for a literal the patch really adds to conpty.cc', () => {
|
||||
const conptyHunk = NODE_PTY_PATCH.split(/^diff --git /m).find((section) =>
|
||||
section.startsWith('a/src/win/conpty.cc ')
|
||||
)
|
||||
expect(conptyHunk, 'the patch no longer touches src/win/conpty.cc').toBeDefined()
|
||||
const addedCode = conptyHunk
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith('+') && !/^\+\s*(\/\/|\*)/.test(line))
|
||||
expect(
|
||||
addedCode.some((line) => line.includes(`L"${CYGWIN_BREAKAWAY_MARKER_TEXT}"`)),
|
||||
`No added conpty.cc line carries L"${CYGWIN_BREAKAWAY_MARKER_TEXT}". Either the patch ` +
|
||||
'stopped adding it or CYGWIN_BREAKAWAY_MARKER_TEXT drifted; until they agree the gate ' +
|
||||
'rejects every correctly rebuilt addon.'
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
// MSVC compiles L"" to UTF-16LE; reading the addon as anything else finds nothing.
|
||||
it('looks for that literal in the encoding the compiler stores it in', () => {
|
||||
expect(CYGWIN_BREAKAWAY_MARKER.toString('utf16le')).toBe(CYGWIN_BREAKAWAY_MARKER_TEXT)
|
||||
expect(CYGWIN_BREAKAWAY_MARKER.length).toBe(CYGWIN_BREAKAWAY_MARKER_TEXT.length * 2)
|
||||
})
|
||||
|
||||
it('rejects the prebuild that shipped without the job exports', () => {
|
||||
expect(() =>
|
||||
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD })
|
||||
).toThrow(/listJobProcessIds, terminateJob, assignCurrentProcessToJob/)
|
||||
expect(() => assertNodePtyJobOwnership(onWindows(PREBUILD, CURRENT_ADDON))).toThrow(
|
||||
/listJobProcessIds, terminateJob, assignCurrentProcessToJob/
|
||||
)
|
||||
})
|
||||
|
||||
it('names where the bad native came from, so the fix is obvious', () => {
|
||||
expect(() =>
|
||||
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD })
|
||||
).toThrow(/prebuilds\/win32-x64/)
|
||||
expect(() => assertNodePtyJobOwnership(onWindows(PREBUILD, CURRENT_ADDON))).toThrow(
|
||||
/prebuilds\/win32-x64/
|
||||
)
|
||||
})
|
||||
|
||||
it('accepts a source build carrying the patch', () => {
|
||||
expect(() =>
|
||||
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PATCHED })
|
||||
).not.toThrow()
|
||||
expect(() => assertNodePtyJobOwnership(onWindows(PATCHED, CURRENT_ADDON))).not.toThrow()
|
||||
})
|
||||
|
||||
// The reason this gate reads the binary at all: every export above predates
|
||||
// the Cygwin/MSYS breakaway denial, so a build that leaks every Git Bash
|
||||
// child out of its pane's job satisfies all of them.
|
||||
it('rejects a source build that predates the Cygwin/MSYS breakaway denial', () => {
|
||||
expect(() => assertNodePtyJobOwnership(onWindows(PATCHED, PRE_MSYS_ADDON))).toThrow(
|
||||
/predates the Cygwin\/MSYS job-breakaway denial/
|
||||
)
|
||||
})
|
||||
|
||||
it('tells that build apart by path, and says to rebuild', () => {
|
||||
expect(() => assertNodePtyJobOwnership(onWindows(PATCHED, PRE_MSYS_ADDON))).toThrow(
|
||||
/pre-msys\.node[\s\S]*Rebuild node-pty from source/
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['non-Windows hosts', { platform: 'darwin', nativeName: 'pty' }],
|
||||
['no path at all', undefined],
|
||||
['a path that is not there', join(fixtureDir, 'absent.node')]
|
||||
])('refuses rather than skip when the addon cannot be read: %s', (_case, addonPath) => {
|
||||
expect(() => assertNodePtyJobOwnership(onWindows(PATCHED, addonPath))).toThrow(
|
||||
/Cannot read node-pty's conpty native/
|
||||
)
|
||||
})
|
||||
|
||||
// Passing the conpty name and no readable addon: on win32 every remaining
|
||||
// branch throws, so only the platform gate can keep these quiet. The MSYS
|
||||
// breakaway denial is a Windows concern and must cost other hosts nothing.
|
||||
it.each([
|
||||
['non-Windows hosts', { platform: 'darwin', nativeName: 'conpty' }],
|
||||
['non-Windows hosts building for one', { platform: 'linux', nativeName: 'conpty' }],
|
||||
['the pre-ConPTY winpty backend', { platform: 'win32', nativeName: 'pty' }]
|
||||
])('stays out of the way on %s', (_case, spec) => {
|
||||
expect(() => assertNodePtyJobOwnership({ ...spec, native: PREBUILD })).not.toThrow()
|
||||
})
|
||||
|
||||
it('would have thrown on Windows for the very same input', () => {
|
||||
expect(() =>
|
||||
assertNodePtyJobOwnership({ platform: 'win32', nativeName: 'conpty', native: PREBUILD })
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nodePtyAddonPath', () => {
|
||||
// Built from segments rather than a POSIX string: on Windows `resolve` returns
|
||||
// a drive letter and backslashes, so a literal only ever passed off Windows.
|
||||
it('resolves the addon against node-pty lib, which is the only base callers share', () => {
|
||||
expect(
|
||||
nodePtyAddonPath(
|
||||
resolve('/app/node_modules/node-pty/lib/utils.js'),
|
||||
{ dir: '../build/Release/' },
|
||||
'conpty'
|
||||
)
|
||||
).toBe(join(resolve('/app/node_modules/node-pty'), 'build', 'Release', 'conpty.node'))
|
||||
})
|
||||
|
||||
it('handles the bundled layout, where the addon sits beside lib', () => {
|
||||
expect(
|
||||
nodePtyAddonPath(
|
||||
resolve('/app/resources/node-pty/lib/utils.js'),
|
||||
{ dir: './build/Release/' },
|
||||
'conpty'
|
||||
)
|
||||
).toBe(join(resolve('/app/resources/node-pty/lib'), 'build', 'Release', 'conpty.node'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertRebuiltConptyDeniesMsysBreakaway', () => {
|
||||
const rebuiltInto = (files) => {
|
||||
const nodePtyDir = join(mkdtempSync(join(fixtureDir, 'rebuild-')), 'node-pty')
|
||||
for (const [relativePath, options] of Object.entries(files)) {
|
||||
const { arch = 'x64', cygwinBreakawayDenied = true } = options
|
||||
const addonPath = join(nodePtyDir, ...relativePath.split('/'))
|
||||
mkdirSync(dirname(addonPath), { recursive: true })
|
||||
writeFileSync(
|
||||
addonPath,
|
||||
Buffer.concat([
|
||||
peImage({ arch }),
|
||||
cygwinBreakawayDenied ? CYGWIN_BREAKAWAY_MARKER : Buffer.alloc(0)
|
||||
])
|
||||
)
|
||||
}
|
||||
return nodePtyDir
|
||||
}
|
||||
|
||||
it('accepts the addon a good same-host rebuild leaves behind', () => {
|
||||
const nodePtyDir = rebuiltInto({ 'build/Release/conpty.node': {} })
|
||||
expect(() =>
|
||||
assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'x64', crossHost: false })
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects one that predates the denial, wherever the rebuild ran', () => {
|
||||
const nodePtyDir = rebuiltInto({
|
||||
'build/Release/conpty.node': { cygwinBreakawayDenied: false }
|
||||
})
|
||||
expect(() =>
|
||||
assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'x64', crossHost: true })
|
||||
).toThrow(/predates the Cygwin\/MSYS job-breakaway denial/)
|
||||
})
|
||||
|
||||
// On the host that will run this install, no addon means loadNativeModule
|
||||
// falls through to the published prebuild -- the binary that leaks every MSYS
|
||||
// pane child. That is a broken build, not an absence to shrug at.
|
||||
it('refuses a same-host rebuild that reported success and produced nothing', () => {
|
||||
const nodePtyDir = rebuiltInto({ 'package.json': {} })
|
||||
expect(() =>
|
||||
assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'x64', crossHost: false })
|
||||
).toThrow(/the rebuild reported success/)
|
||||
})
|
||||
|
||||
it('names both the addon it wanted and the prebuild that would load instead', () => {
|
||||
const nodePtyDir = rebuiltInto({ 'package.json': {} })
|
||||
expect(() =>
|
||||
assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'arm64', crossHost: false })
|
||||
).toThrow(/build[\\/]Release[\s\S]*prebuilds[\\/]win32-arm64/)
|
||||
})
|
||||
|
||||
// A rebuild that ignored --arch leaves a binary the target cannot load, so
|
||||
// node-pty falls back to the prebuild. Saying so here is two steps closer to
|
||||
// the command that fixes it than saying so at packaging time.
|
||||
it('rejects an addon of an architecture this rebuild did not target', () => {
|
||||
const nodePtyDir = rebuiltInto({ 'build/Release/conpty.node': { arch: 'x64' } })
|
||||
expect(() =>
|
||||
assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'arm64', crossHost: true })
|
||||
).toThrow(/machine 0x8664, but this rebuild targets win32-arm64/)
|
||||
})
|
||||
|
||||
// "node-gyp ignored --arch" is a guess when the file is not a PE at all: that
|
||||
// is a truncated or quarantined artifact, and saying otherwise sends the
|
||||
// reader to the wrong command.
|
||||
it('does not blame --arch for a file that is not a PE image', () => {
|
||||
const nodePtyDir = join(mkdtempSync(join(fixtureDir, 'rebuild-')), 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'build', 'Release'), { recursive: true })
|
||||
writeFileSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'), Buffer.alloc(0x200))
|
||||
expect(() =>
|
||||
assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'x64', crossHost: false })
|
||||
).toThrow(/is not a PE image[\s\S]*truncated or quarantined/)
|
||||
})
|
||||
|
||||
it('still names the consequence for that file, which is the prebuild', () => {
|
||||
const nodePtyDir = join(mkdtempSync(join(fixtureDir, 'rebuild-')), 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'build', 'Release'), { recursive: true })
|
||||
writeFileSync(join(nodePtyDir, 'build', 'Release', 'conpty.node'), Buffer.alloc(0x200))
|
||||
expect(() =>
|
||||
assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'x64', crossHost: false })
|
||||
).toThrow(/fall back to the published prebuild/)
|
||||
})
|
||||
|
||||
it('accepts one a cross-arch rebuild really did emit for the target', () => {
|
||||
const nodePtyDir = rebuiltInto({ 'build/Release/conpty.node': { arch: 'arm64' } })
|
||||
expect(() =>
|
||||
assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'arm64', crossHost: true })
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
// PE_MACHINE covers what Orca ships; an arch it does not know is not one this
|
||||
// can judge, and guessing would fail a rebuild that was fine.
|
||||
it('does not judge an architecture it has no machine value for', () => {
|
||||
const nodePtyDir = rebuiltInto({ 'build/Release/conpty.node': { arch: 'x64' } })
|
||||
expect(() =>
|
||||
assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir, rebuildArch: 'ia32', crossHost: true })
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a cross-host rebuild need not leave a win32 addon here', { crossHost: true }, true],
|
||||
['no node-pty on this disk is not a bad build', { crossHost: false }, false]
|
||||
])('warns instead: %s', (_case, verdict, nodePtyInstalled) => {
|
||||
const nodePtyDir = nodePtyInstalled
|
||||
? rebuiltInto({ 'package.json': {} })
|
||||
: join(fixtureDir, 'no-node-pty-here')
|
||||
const warn = vi.fn()
|
||||
expect(() =>
|
||||
assertRebuiltConptyDeniesMsysBreakaway({
|
||||
nodePtyDir,
|
||||
rebuildArch: 'x64',
|
||||
...verdict,
|
||||
warn
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('could not check the MSYS'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from './windows-process-tree-gyp-rebuild.mjs'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
const { assertNodePtyJobOwnership, nodePtyAddonPath } = require('./node-pty-job-ownership.cjs')
|
||||
const { assertWindowsProcessTreeCreationTime } = require('./windows-process-tree-creation-time.cjs')
|
||||
const scriptPath = import.meta.filename
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
@@ -298,7 +298,11 @@ function loadNodePtyNativeModule() {
|
||||
// terminal is created, so require('node-pty') alone can miss ABI mismatches.
|
||||
const native = loadNativeModule(nativeName)
|
||||
assertNodePtyWindowsConptyRuntime(native?.dir)
|
||||
assertNodePtyJobOwnership({ nativeName, native })
|
||||
assertNodePtyJobOwnership({
|
||||
nativeName,
|
||||
native,
|
||||
addonPath: nodePtyAddonPath(require.resolve('node-pty/lib/utils'), native, nativeName)
|
||||
})
|
||||
if (requiresPatchedNodePtySourceBuild() && !isNodePtyReleaseBuildDir(native?.dir)) {
|
||||
throw new Error(
|
||||
`node-pty resolved to ${native.dir}; expected build/Release so Orca's node-pty patch is active`
|
||||
|
||||
@@ -1,25 +1,188 @@
|
||||
'use strict'
|
||||
|
||||
const { existsSync, readFileSync } = require('node:fs')
|
||||
const { dirname, join, resolve } = require('node:path')
|
||||
const { PE_MACHINE, describePeMachine, readPeMachine } = require('./windows-pe-machine.cjs')
|
||||
|
||||
const NODE_PTY_JOB_EXPORTS = ['listJobProcessIds', 'terminateJob', 'assignCurrentProcessToJob']
|
||||
|
||||
function assertNodePtyJobOwnership({ nativeName, native, platform = process.platform }) {
|
||||
/**
|
||||
* The wide literal `usesCygwinRuntime` probes for in conpty.cc, as it sits in
|
||||
* the compiled addon.
|
||||
*
|
||||
* Why sniff the binary rather than trust the exports: all three job exports
|
||||
* predate the Cygwin/MSYS breakaway denial, so symbol presence cannot tell a
|
||||
* current build from one whose per-PTY job still carries
|
||||
* JOB_OBJECT_LIMIT_BREAKAWAY_OK. Measured on Windows 11: such a build passes
|
||||
* every export check, reports isPtyJobOwnershipAvailable() true, and passes
|
||||
* windows-pty-job.win32.test.ts 6/6, while every child of a Git Bash pane is
|
||||
* created outside the pane's job and survives terminatePtyJob. See
|
||||
* docs/reference/windows-msys-job-breakaway.md.
|
||||
*
|
||||
* Same shape as stagedRelayAddonIsUnpatched() in
|
||||
* src/main/windows/windows-process-table.ts, which already tells a patched
|
||||
* addon from a published one by a binary import name.
|
||||
*/
|
||||
const CYGWIN_BREAKAWAY_MARKER_TEXT = 'msys-2.0.dll'
|
||||
const CYGWIN_BREAKAWAY_MARKER = Buffer.from(CYGWIN_BREAKAWAY_MARKER_TEXT, 'utf16le')
|
||||
|
||||
/** True when the addon carries the denial. Read errors propagate: callers that cannot read it must not pass. */
|
||||
function conptyDeniesCygwinBreakaway(addonPath) {
|
||||
return readFileSync(addonPath).includes(CYGWIN_BREAKAWAY_MARKER)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why here and not only at packaging: a rebuild that did not honour `--arch`
|
||||
* leaves a binary the target cannot load, the app falls back to the published
|
||||
* prebuild, and the packaged gate then reports it two steps from the command
|
||||
* that could fix it. `PE_MACHINE` covers the Windows arches Orca ships; anything
|
||||
* else this cannot judge, so it does not pretend to.
|
||||
*/
|
||||
function assertRebuiltConptyMatchesArch(addonPath, rebuildArch) {
|
||||
const expected = PE_MACHINE[rebuildArch]
|
||||
if (expected === undefined) {
|
||||
return
|
||||
}
|
||||
const machine = readPeMachine(addonPath)
|
||||
if (machine === expected) {
|
||||
return
|
||||
}
|
||||
const consequence = [
|
||||
', so node-pty would fall back to the published prebuild, which predates the',
|
||||
'Cygwin/MSYS job-breakaway denial and leaks every MSYS pane child out of its job.'
|
||||
].join(' ')
|
||||
throw new Error(
|
||||
machine === null
|
||||
? `${addonPath} is not a PE image${consequence} Check the ` +
|
||||
`node-pty build output above; a truncated or quarantined artifact looks like this.`
|
||||
: `${addonPath} is ${describePeMachine(machine)}, but this rebuild targets ` +
|
||||
`win32-${rebuildArch} (0x${expected.toString(16)}): node-gyp did not honour ` +
|
||||
`--arch${consequence}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict on the addon a Windows rebuild just claimed to produce.
|
||||
*
|
||||
* Takes the host as arguments rather than reading `process`, because the branch
|
||||
* that matters -- a rebuild for the very host running it -- is otherwise
|
||||
* reachable only from Windows, and a gate nobody can run is a gate nobody
|
||||
* checks.
|
||||
*
|
||||
* Absent is fatal on that host: `loadNativeModule` falls through to
|
||||
* prebuilds/win32-<arch>, and the published prebuild predates the denial, so
|
||||
* the app would load it with nothing said. A cross-host rebuild need not leave
|
||||
* a win32 addon on this disk, and node-pty may not be installed at all --
|
||||
* neither is evidence of a bad build.
|
||||
*/
|
||||
function assertRebuiltConptyDeniesMsysBreakaway({
|
||||
nodePtyDir,
|
||||
rebuildArch,
|
||||
crossHost,
|
||||
warn = console.warn
|
||||
}) {
|
||||
const addonPath = join(nodePtyDir, 'build', 'Release', 'conpty.node')
|
||||
if (existsSync(addonPath)) {
|
||||
assertRebuiltConptyMatchesArch(addonPath, rebuildArch)
|
||||
assertCygwinBreakawayDenied(addonPath, { dir: addonPath })
|
||||
return
|
||||
}
|
||||
if (crossHost || !existsSync(nodePtyDir)) {
|
||||
warn(`[rebuild] no addon at ${addonPath}; could not check the MSYS job-breakaway denial.`)
|
||||
return
|
||||
}
|
||||
const prebuildPath = join(nodePtyDir, 'prebuilds', `win32-${rebuildArch}`, 'conpty.node')
|
||||
throw new Error(
|
||||
`the rebuild reported success but ${addonPath} is not there, so node-pty would fall through ` +
|
||||
`to ${prebuildPath}. That published prebuild predates the Cygwin/MSYS ` +
|
||||
'job-breakaway denial: every Git Bash pane child would be created outside its job and ' +
|
||||
'survive terminatePtyJob. Check the node-pty build output above; a same-host source ' +
|
||||
'build must leave conpty.node in build/Release.'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path of the addon `loadNativeModule` just resolved.
|
||||
*
|
||||
* `native.dir` is relative to node-pty's own `lib/`, which is the only base
|
||||
* every caller shares -- the project install, a staged rebuild and the packaged
|
||||
* resources tree all reach the addon through a different root.
|
||||
*/
|
||||
function nodePtyAddonPath(nodePtyUtilsPath, native, nativeName) {
|
||||
return resolve(dirname(nodePtyUtilsPath), native.dir, `${nativeName}.node`)
|
||||
}
|
||||
|
||||
function assertNodePtyJobOwnership({ nativeName, native, addonPath, platform = process.platform }) {
|
||||
if (platform !== 'win32' || nativeName !== 'conpty') {
|
||||
return
|
||||
}
|
||||
const exported = native?.module ?? native
|
||||
const missing = NODE_PTY_JOB_EXPORTS.filter((name) => typeof exported?.[name] !== 'function')
|
||||
if (missing.length === 0) {
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`node-pty's conpty native is missing ${missing.join(', ')}.`,
|
||||
`Resolved from: ${native?.dir ?? 'unknown'}`,
|
||||
'That build cannot own a PTY tree, so terminatePtyJob degrades to "unavailable"',
|
||||
'and pane teardown falls back to guessing by PID ancestry.',
|
||||
'Rebuild node-pty from source so config/patches/node-pty@1.1.0.patch applies.'
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
assertCygwinBreakawayDenied(addonPath, native)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why this refuses instead of skipping when the addon cannot be read: an
|
||||
* unreadable binary is exactly the state that used to pass. `loadNativeModule`
|
||||
* has already required this file, so "cannot read it" means the caller did not
|
||||
* say which file it loaded, and a gate that cannot see its subject is not a
|
||||
* gate.
|
||||
*/
|
||||
function assertCygwinBreakawayDenied(addonPath, native) {
|
||||
let binary
|
||||
try {
|
||||
binary = readFileSync(addonPath)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
[
|
||||
`Cannot read node-pty's conpty native at ${addonPath ?? '<no path given>'}`,
|
||||
`(resolved from ${native?.dir ?? 'unknown'}): ${error.message}.`,
|
||||
'Without the binary this cannot tell a current build from one that leaks',
|
||||
'every MSYS pane child out of its job, so it refuses rather than assume.'
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
if (binary.includes(CYGWIN_BREAKAWAY_MARKER)) {
|
||||
return
|
||||
}
|
||||
throw new Error(
|
||||
throw staleConptySourceBuildError(addonPath)
|
||||
}
|
||||
|
||||
/** The verdict on a source build that is simply out of date: rebuild it here. */
|
||||
function staleConptySourceBuildError(addonPath) {
|
||||
return new Error(
|
||||
[
|
||||
`node-pty's conpty native is missing ${missing.join(', ')}.`,
|
||||
`Resolved from: ${native?.dir ?? 'unknown'}`,
|
||||
'That build cannot own a PTY tree, so terminatePtyJob degrades to "unavailable"',
|
||||
'and pane teardown falls back to guessing by PID ancestry.',
|
||||
'Rebuild node-pty from source so config/patches/node-pty@1.1.0.patch applies.'
|
||||
`node-pty's conpty native at ${addonPath} predates the Cygwin/MSYS job-breakaway denial.`,
|
||||
'It exports the job functions, so it looks patched, but its per-PTY job still carries',
|
||||
'JOB_OBJECT_LIMIT_BREAKAWAY_OK and every Git Bash child is created outside the job:',
|
||||
'terminatePtyJob reports "terminated" and leaves the tree running.',
|
||||
'Rebuild node-pty from source so the current config/patches/node-pty@1.1.0.patch applies',
|
||||
'(a worktree sharing node_modules with its main checkout shares that stale addon).',
|
||||
`If that patch no longer adds L"${CYGWIN_BREAKAWAY_MARKER_TEXT}" to conpty.cc then this marker is`,
|
||||
'stale, not the addon, and no rebuild can satisfy it.',
|
||||
'See docs/reference/windows-msys-job-breakaway.md.'
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = { assertNodePtyJobOwnership }
|
||||
module.exports = {
|
||||
CYGWIN_BREAKAWAY_MARKER,
|
||||
CYGWIN_BREAKAWAY_MARKER_TEXT,
|
||||
assertNodePtyJobOwnership,
|
||||
assertCygwinBreakawayDenied,
|
||||
assertRebuiltConptyDeniesMsysBreakaway,
|
||||
conptyDeniesCygwinBreakaway,
|
||||
nodePtyAddonPath,
|
||||
staleConptySourceBuildError
|
||||
}
|
||||
|
||||
@@ -27,11 +27,12 @@ function readSkill(path = guidePath) {
|
||||
describe('orca CLI skill guidance', () => {
|
||||
it('keeps external browser routing at the OS/page boundary', () => {
|
||||
const skill = readSkill(guidePath)
|
||||
const description = skill.replace(/\s+/gu, ' ')
|
||||
const description = (/^---\n([\s\S]*?)\n---\n/u.exec(skill)?.[1] ?? '').replace(/\s+/gu, ' ')
|
||||
|
||||
expect(description).toContain(
|
||||
'Use Computer Use only for external windows or desktop UI that needs OS-level control, and Playwright or CDP for external pages.'
|
||||
'Use Computer Use only when a visible window needs GUI control that a CLI, filesystem, or API cannot do.'
|
||||
)
|
||||
expect(description).not.toMatch(/Playwright/iu)
|
||||
expect(skill).toContain(
|
||||
'For external Chrome/Safari/webviews or Orca app chrome/settings, use the Computer Use skill/tool only when the task requires OS/window-level control'
|
||||
)
|
||||
|
||||
@@ -51,15 +51,12 @@ describe('orchestration skill routing', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps external browser routing at the OS/page boundary', () => {
|
||||
it('does not advertise Computer Use or page automation from orchestration discovery', () => {
|
||||
const description = readDescription()
|
||||
|
||||
expect(description).toContain(
|
||||
"Use Computer Use for external browser windows, webviews, Orca app UI, or desktop UI outside Orca's embedded browser only when the task requires OS/window-level control such as focus, menus, dialogs, coordinates, or screenshots."
|
||||
)
|
||||
expect(description).toContain(
|
||||
"`orca-cli` for Orca's embedded pages and a page-automation tool such as Playwright or CDP for external pages."
|
||||
)
|
||||
expect(description).not.toMatch(/Computer Use/iu)
|
||||
expect(description).not.toMatch(/Playwright/iu)
|
||||
expect(description).not.toContain('embedded pages')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -113,6 +113,8 @@ const CROSS_VERSION_WIRE_PREFIXES = [
|
||||
'src/shared/browser-client-host-protocol',
|
||||
'src/shared/browser-network-tunnel-protocol',
|
||||
'src/shared/browser-client-host-placement',
|
||||
'src/shared/agent-launch-intent',
|
||||
'src/shared/rpc-contract/agent-launch-params',
|
||||
'src/shared/agent-session-wire',
|
||||
'src/shared/agent-session-mutation-envelope',
|
||||
'src/shared/agent-session-journal-',
|
||||
@@ -121,6 +123,7 @@ const CROSS_VERSION_WIRE_PREFIXES = [
|
||||
'src/main/native-chat/agent-session-wire/',
|
||||
'src/main/runtime/agent-session-record-store',
|
||||
'src/main/runtime/rpc/dispatcher',
|
||||
'src/main/runtime/rpc/methods/agent-launch',
|
||||
'src/main/runtime/rpc/methods/ai-vault.ts',
|
||||
'src/main/runtime/rpc/methods/browser-tab-create-schema',
|
||||
'src/main/runtime/rpc/methods/session-tabs.ts',
|
||||
@@ -141,6 +144,9 @@ const NATIVE_RUNTIME_PREFIXES = [
|
||||
'config/scripts/ensure-native-runtime',
|
||||
'config/scripts/rebuild-native-deps',
|
||||
'config/scripts/node-pty-job-ownership',
|
||||
'config/scripts/windows-pe-machine',
|
||||
'config/scripts/windows-pe-image-fixture',
|
||||
'config/scripts/script-module-dependencies',
|
||||
'config/scripts/windows-process-tree-creation-time',
|
||||
'config/scripts/windows-process-tree-gyp-rebuild',
|
||||
'config/scripts/electron-builder-native-rebuild',
|
||||
@@ -217,6 +223,11 @@ const WINDOWS_PACKAGE_TESTS = [
|
||||
...LINUX_PACKAGE_TESTS,
|
||||
'config/scripts/rebuild-native-deps.test.mjs',
|
||||
'config/scripts/rebuild-native-deps-windows-process-tree.test.mjs',
|
||||
'config/scripts/rebuild-native-deps-node-pty.test.mjs',
|
||||
'config/scripts/ensure-native-runtime-job-ownership.test.mjs',
|
||||
'config/scripts/verify-packaged-node-pty-job-ownership.test.mjs',
|
||||
'config/scripts/windows-pe-machine.test.mjs',
|
||||
'config/scripts/script-module-dependencies.test.mjs',
|
||||
'src/main/windows-registry-addon.test.ts',
|
||||
'src/main/providers/windows-conpty-wide-char-duplication.node-pty.test.ts',
|
||||
'src/main/providers/pty-repaint-wide-char-buffer.node-pty.test.ts',
|
||||
|
||||
@@ -260,6 +260,7 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
|
||||
writeFakeLoadableNodePty(projectDir, { ownsPtyJob: false })
|
||||
writeFakeWindowsRegistry(projectDir)
|
||||
writeFakeWindowsProcessTree(projectDir)
|
||||
writeFakeNodePtyConptyPayload(projectDir, process.arch)
|
||||
|
||||
const result = runRebuildScript(projectDir, {
|
||||
ORCA_REBUILD_TEST_LOG: rebuildLogPath,
|
||||
@@ -399,4 +400,106 @@ describe('rebuild-native-deps patched node-pty rebuild', () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The Electron probe carries this check too, but it is skipped whenever the
|
||||
// Electron package binary is unusable. Every job export predates the MSYS
|
||||
// breakaway denial, so without reading the binary this step would hand the
|
||||
// packaged app one that leaks every Git Bash child out of its pane's job.
|
||||
it('fails a Windows rebuild that leaves an addon predating the MSYS breakaway denial', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
|
||||
writeFakeElectronRebuild(projectDir)
|
||||
writeFakeNodePtyConptyPayload(projectDir, 'x64', { cygwinBreakawayDenied: false })
|
||||
writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir)
|
||||
|
||||
const result = runRebuildScript(
|
||||
projectDir,
|
||||
{ npm_config_platform: 'win32', npm_config_arch: 'x64' },
|
||||
['--platform=win32', '--arch=x64', '--force']
|
||||
)
|
||||
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(result.stderr).toContain('predates the Cygwin/MSYS job-breakaway denial')
|
||||
} finally {
|
||||
removeTreeSync(projectDir)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a Windows rebuild whose addon carries the denial', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
|
||||
writeFakeElectronRebuild(projectDir)
|
||||
writeFakeNodePtyConptyPayload(projectDir, 'x64')
|
||||
writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir)
|
||||
|
||||
const result = runRebuildScript(
|
||||
projectDir,
|
||||
{ npm_config_platform: 'win32', npm_config_arch: 'x64' },
|
||||
['--platform=win32', '--arch=x64', '--force']
|
||||
)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr).not.toContain('job-breakaway denial')
|
||||
} finally {
|
||||
removeTreeSync(projectDir)
|
||||
}
|
||||
})
|
||||
|
||||
// A cross-host rebuild does not necessarily leave a win32 addon on this disk,
|
||||
// and neither does a tree with no node-pty in it. That must warn, not fail an
|
||||
// install that was working.
|
||||
it('warns rather than fails when no addon is expected on this disk', () => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
|
||||
writeFakeElectronRebuild(projectDir)
|
||||
writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir)
|
||||
|
||||
const result = runRebuildScript(
|
||||
projectDir,
|
||||
{ npm_config_platform: 'win32', npm_config_arch: 'x64' },
|
||||
['--platform=win32', '--arch=x64', '--force']
|
||||
)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stderr + result.stdout).toContain('could not check the MSYS job-breakaway')
|
||||
} finally {
|
||||
removeTreeSync(projectDir)
|
||||
}
|
||||
})
|
||||
|
||||
// The other half: on the host that will run this install, a missing addon is
|
||||
// not an absence to shrug at. loadNativeModule falls through to the published
|
||||
// prebuild, which is the binary that leaks every MSYS pane child.
|
||||
// Runs only on Windows -- nothing else can make a win32 rebuild same-host.
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'fails a same-host Windows rebuild that left no addon, naming the prebuild that would load',
|
||||
() => {
|
||||
const projectDir = mkTempProject()
|
||||
|
||||
try {
|
||||
writeFakeUsableElectronPackage(projectDir, { platform: 'win32' })
|
||||
writeFakeElectronRebuild(projectDir)
|
||||
writeFakeWindowsProcessTreeWithNodeAddonApi(projectDir)
|
||||
writeFakeLoadableNodePty(projectDir, { nativeDir: `prebuilds/win32-${process.arch}` })
|
||||
|
||||
const result = runRebuildScript(
|
||||
projectDir,
|
||||
{ npm_config_platform: 'win32', npm_config_arch: process.arch },
|
||||
['--platform=win32', `--arch=${process.arch}`, '--force']
|
||||
)
|
||||
|
||||
expect(result.status).not.toBe(0)
|
||||
expect(result.stderr).toContain(join('build', 'Release', 'conpty.node'))
|
||||
expect(result.stderr).toContain(join('prebuilds', `win32-${process.arch}`, 'conpty.node'))
|
||||
} finally {
|
||||
removeTreeSync(projectDir)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -7,10 +7,25 @@ import {
|
||||
readFileSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
|
||||
import { peImage } from './windows-pe-image-fixture.mjs'
|
||||
|
||||
/**
|
||||
* The wide literal `usesCygwinRuntime` holds, as it sits in a real addon. A
|
||||
* fixture addon without it is a build that predates the MSYS breakaway denial,
|
||||
* which is what these tests need to be able to represent.
|
||||
*
|
||||
* Taken from the gate itself: a re-typed copy agrees with a stale gate by
|
||||
* construction, which is the one thing these fixtures must not do.
|
||||
*/
|
||||
const { CYGWIN_BREAKAWAY_MARKER } = createRequire(import.meta.url)('./node-pty-job-ownership.cjs')
|
||||
const { CREATION_TIME_FLAG } = createRequire(import.meta.url)(
|
||||
'./windows-process-tree-creation-time.cjs'
|
||||
)
|
||||
|
||||
const sourceScriptPath = fileURLToPath(new URL('./rebuild-native-deps.mjs', import.meta.url))
|
||||
const sourceInstallScriptPath = fileURLToPath(
|
||||
@@ -22,6 +37,11 @@ const sourceNodePtyJobOwnershipPath = fileURLToPath(
|
||||
const sourceWindowsProcessTreeGypRebuildPath = fileURLToPath(
|
||||
new URL('./windows-process-tree-gyp-rebuild.mjs', import.meta.url)
|
||||
)
|
||||
// Reached through projectRequire, so the module walker cannot see it: that
|
||||
// specifier resolves against the project root, not against the script.
|
||||
const sourceWindowsProcessTreeCreationTimePath = fileURLToPath(
|
||||
new URL('./windows-process-tree-creation-time.cjs', import.meta.url)
|
||||
)
|
||||
const sourceWindowsProcessTreePatchPath = fileURLToPath(
|
||||
new URL('../patches/@vscode__windows-process-tree@0.8.0.patch', import.meta.url)
|
||||
)
|
||||
@@ -90,9 +110,10 @@ export function mkTempProject() {
|
||||
mkdirSync(join(projectDir, 'config', 'scripts'), { recursive: true })
|
||||
copyFileSync(sourceScriptPath, join(projectDir, 'config', 'scripts', 'rebuild-native-deps.mjs'))
|
||||
copyScriptWithLocalModules(sourceInstallScriptPath, join(projectDir, 'config', 'scripts'))
|
||||
copyScriptWithLocalModules(sourceNodePtyJobOwnershipPath, join(projectDir, 'config', 'scripts'))
|
||||
copyFileSync(
|
||||
sourceNodePtyJobOwnershipPath,
|
||||
join(projectDir, 'config', 'scripts', 'node-pty-job-ownership.cjs')
|
||||
sourceWindowsProcessTreeCreationTimePath,
|
||||
join(projectDir, 'config', 'scripts', 'windows-process-tree-creation-time.cjs')
|
||||
)
|
||||
copyFileSync(
|
||||
sourceWindowsProcessTreeGypRebuildPath,
|
||||
@@ -310,10 +331,20 @@ process.exit(result.status ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
export function writeFakeNodePtyConptyPayload(projectDir, arch) {
|
||||
export function writeFakeNodePtyConptyPayload(
|
||||
projectDir,
|
||||
arch,
|
||||
{ cygwinBreakawayDenied = true } = {}
|
||||
) {
|
||||
const releaseDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
|
||||
mkdirSync(releaseDir, { recursive: true })
|
||||
writeFileSync(join(releaseDir, 'conpty.node'), 'native addon')
|
||||
writeFileSync(
|
||||
join(releaseDir, 'conpty.node'),
|
||||
Buffer.concat([
|
||||
peImage({ arch }),
|
||||
cygwinBreakawayDenied ? CYGWIN_BREAKAWAY_MARKER : Buffer.alloc(0)
|
||||
])
|
||||
)
|
||||
const sourceDir = join(
|
||||
projectDir,
|
||||
'node_modules',
|
||||
@@ -328,12 +359,30 @@ export function writeFakeNodePtyConptyPayload(projectDir, arch) {
|
||||
writeFileSync(join(sourceDir, 'OpenConsole.exe'), `OpenConsole.exe ${arch}`)
|
||||
}
|
||||
|
||||
function writeFakeNodePtyAddon(nodePtyDir, nativeDir, { cygwinBreakawayDenied }) {
|
||||
const addonDir = resolve(join(nodePtyDir, 'lib'), nativeDir)
|
||||
mkdirSync(addonDir, { recursive: true })
|
||||
for (const nativeName of ['conpty', 'pty']) {
|
||||
writeFileSync(
|
||||
join(addonDir, `${nativeName}.node`),
|
||||
Buffer.concat([
|
||||
peImage({ arch: process.arch === 'arm64' ? 'arm64' : 'x64' }),
|
||||
cygwinBreakawayDenied ? CYGWIN_BREAKAWAY_MARKER : Buffer.alloc(0)
|
||||
])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function writeFakeLoadableNodePty(
|
||||
projectDir,
|
||||
{ nativeDir = 'prebuilds/pty', ownsPtyJob = true } = {}
|
||||
{ nativeDir = 'prebuilds/pty', ownsPtyJob = true, cygwinBreakawayDenied = true } = {}
|
||||
) {
|
||||
const nodePtyDir = join(projectDir, 'node_modules', 'node-pty')
|
||||
mkdirSync(join(nodePtyDir, 'lib'), { recursive: true })
|
||||
// Why a real file: the job-ownership gate reads the addon it was told about,
|
||||
// because every job export predates the MSYS breakaway denial and so cannot
|
||||
// distinguish a current build from one that leaks Git Bash children.
|
||||
writeFakeNodePtyAddon(nodePtyDir, nativeDir, { cygwinBreakawayDenied })
|
||||
writeFileSync(join(nodePtyDir, 'index.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
join(nodePtyDir, 'lib', 'utils.js'),
|
||||
@@ -366,10 +415,18 @@ export function writeFakeWindowsRegistry(projectDir) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A healthy one: the addon reports CreationTime, which is what a build of the
|
||||
* patched source does and what the probe has required since the creation-time
|
||||
* gate landed. Exporting nothing means "the tarball prebuilt" to that gate.
|
||||
*/
|
||||
export function writeFakeWindowsProcessTree(projectDir) {
|
||||
const processTreeDir = join(projectDir, 'node_modules', '@vscode', 'windows-process-tree')
|
||||
mkdirSync(processTreeDir, { recursive: true })
|
||||
writeFileSync(join(processTreeDir, 'index.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
join(processTreeDir, 'index.js'),
|
||||
`module.exports = { supportedProcessDataFlags: ${CREATION_TIME_FLAG} }\n`
|
||||
)
|
||||
}
|
||||
|
||||
export function writeFakeWindowsProcessTreeWithNodeAddonApi(
|
||||
@@ -434,11 +491,17 @@ export function writeNodePtyPatchFile(projectDir) {
|
||||
writeFileSync(join(projectDir, 'config', 'patches', 'node-pty@1.1.0.patch'), 'patch marker\n')
|
||||
}
|
||||
|
||||
export function writePatchedNodePtyBuildArtifacts(projectDir) {
|
||||
export function writePatchedNodePtyBuildArtifacts(
|
||||
projectDir,
|
||||
{ cygwinBreakawayDenied = true } = {}
|
||||
) {
|
||||
const buildDir = join(projectDir, 'node_modules', 'node-pty', 'build', 'Release')
|
||||
mkdirSync(buildDir, { recursive: true })
|
||||
if (process.platform === 'win32') {
|
||||
writeFileSync(join(buildDir, 'conpty.node'), '')
|
||||
writeFileSync(
|
||||
join(buildDir, 'conpty.node'),
|
||||
cygwinBreakawayDenied ? CYGWIN_BREAKAWAY_MARKER : Buffer.alloc(0)
|
||||
)
|
||||
mkdirSync(join(buildDir, 'conpty'), { recursive: true })
|
||||
writeFileSync(join(buildDir, 'conpty', 'conpty.dll'), '')
|
||||
writeFileSync(join(buildDir, 'conpty', 'OpenConsole.exe'), '')
|
||||
|
||||
@@ -35,9 +35,12 @@ import {
|
||||
readFileSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { platform as osPlatform } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
const requireLocal = createRequire(import.meta.url)
|
||||
|
||||
const projectDir = process.cwd()
|
||||
let cliOptions
|
||||
try {
|
||||
@@ -79,11 +82,10 @@ const NATIVE_MODULES = [
|
||||
...(rebuildPlatform === 'win32' ? ['@orca/windows-registry', '@vscode/windows-process-tree'] : [])
|
||||
]
|
||||
const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m))
|
||||
/** Whether this rebuild targets something other than the machine running it. */
|
||||
const isCrossHostRebuild = rebuildPlatform !== osPlatform() || rebuildArch !== process.arch
|
||||
const forceRebuild =
|
||||
process.env.ORCA_FORCE_NATIVE_REBUILD === '1' ||
|
||||
cliOptions.force ||
|
||||
rebuildPlatform !== osPlatform() ||
|
||||
rebuildArch !== process.arch
|
||||
process.env.ORCA_FORCE_NATIVE_REBUILD === '1' || cliOptions.force || isCrossHostRebuild
|
||||
let modulesToRebuild = onlyModules
|
||||
|
||||
ensureElectronPackageInstalled()
|
||||
@@ -175,6 +177,7 @@ try {
|
||||
})
|
||||
restoreNodePtyWindowsConptyRuntime()
|
||||
assertWindowsProcessTreeAddonIsPatched()
|
||||
assertNodePtyConptyDeniesMsysBreakaway()
|
||||
} catch (/** @type {any} */ err) {
|
||||
console.error('[rebuild] Native module rebuild failed:', err?.message ?? err)
|
||||
if (isWindowsNativeLockError(err)) {
|
||||
@@ -228,6 +231,32 @@ function assertWindowsProcessTreeAddonIsPatched() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The other half of the same problem, for the addon this rebuild just produced.
|
||||
*
|
||||
* The Electron probe below carries the marker check too, but it is skipped
|
||||
* whenever the Electron package binary is unusable -- and "covered by another
|
||||
* path" is not "this path checks". Reading the binary needs neither a loadable
|
||||
* Electron nor an executable target arch, so it runs here regardless.
|
||||
*
|
||||
* Absent is fatal on the host that will run this install: loadNativeModule
|
||||
* falls through to prebuilds/win32-<arch>, and the published prebuild predates
|
||||
* the denial, so the app would load it with nothing said. A cross-host rebuild
|
||||
* does not necessarily leave a win32 addon on this disk, and that must not fail
|
||||
* an install that was working.
|
||||
*/
|
||||
function assertNodePtyConptyDeniesMsysBreakaway() {
|
||||
if (rebuildPlatform !== 'win32' || !modulesToRebuild.includes('node-pty')) {
|
||||
return
|
||||
}
|
||||
const { assertRebuiltConptyDeniesMsysBreakaway } = requireLocal('./node-pty-job-ownership.cjs')
|
||||
assertRebuiltConptyDeniesMsysBreakaway({
|
||||
nodePtyDir: resolve(projectDir, 'node_modules', 'node-pty'),
|
||||
rebuildArch,
|
||||
crossHost: isCrossHostRebuild
|
||||
})
|
||||
}
|
||||
|
||||
function restoreNodePtyWindowsConptyRuntime() {
|
||||
if (rebuildPlatform !== 'win32' || !onlyModules.includes('node-pty')) {
|
||||
return
|
||||
@@ -548,14 +577,22 @@ function loadNativeModule(moduleName) {
|
||||
}
|
||||
if (moduleName === 'node-pty') {
|
||||
projectRequire('node-pty')
|
||||
const { assertNodePtyJobOwnership } = projectRequire(
|
||||
const { assertNodePtyJobOwnership, nodePtyAddonPath } = projectRequire(
|
||||
'./config/scripts/node-pty-job-ownership.cjs'
|
||||
)
|
||||
const { loadNativeModule } = projectRequire('node-pty/lib/utils')
|
||||
const nativeName = getNodePtyNativeModuleName()
|
||||
const native = loadNativeModule(nativeName)
|
||||
assertNodePtyWindowsConptyRuntime(native.dir)
|
||||
assertNodePtyJobOwnership({ nativeName, native })
|
||||
assertNodePtyJobOwnership({
|
||||
nativeName,
|
||||
native,
|
||||
addonPath: nodePtyAddonPath(
|
||||
projectRequire.resolve('node-pty/lib/utils'),
|
||||
native,
|
||||
nativeName
|
||||
)
|
||||
})
|
||||
if (requirePatchedNodePtySourceBuild && !isNodePtyReleaseBuildDir(native.dir)) {
|
||||
throw new Error(
|
||||
'node-pty resolved to ' +
|
||||
|
||||
@@ -19,7 +19,17 @@ function collectScriptModules(scriptPath, seen = new Set()) {
|
||||
return seen
|
||||
}
|
||||
seen.add(scriptPath)
|
||||
for (const [, specifier] of readFileSync(scriptPath, 'utf8').matchAll(/from '(\.\/[^']+)'/g)) {
|
||||
// `from`, bare and dynamic `import`, and plain `require` -- the Windows gates
|
||||
// are .cjs, and a module reached only by require or by a side-effect import is
|
||||
// the one nobody notices is missing until a subprocess fails with a
|
||||
// resolution error instead. Deliberately not `projectRequire`/`requireLocal`
|
||||
// wrappers: those specifiers are resolved against the project root at runtime,
|
||||
// not against this file, so following them would stage the wrong path.
|
||||
const source = readFileSync(scriptPath, 'utf8')
|
||||
const specifiers = source.matchAll(
|
||||
/(?:\bfrom|\brequire\s*\(|\bimport\s*\(|\bimport)\s*'(\.\/[^']+)'/g
|
||||
)
|
||||
for (const [, specifier] of specifiers) {
|
||||
collectScriptModules(join(dirname(scriptPath), specifier), seen)
|
||||
}
|
||||
return seen
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { existsSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { copyScriptWithLocalModules } from './script-module-dependencies.mjs'
|
||||
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), 'script-module-dependencies-'))
|
||||
|
||||
function sourceTree(files) {
|
||||
const sourceDir = mkdtempSync(join(fixtureDir, 'source-'))
|
||||
for (const [name, contents] of Object.entries(files)) {
|
||||
writeFileSync(join(sourceDir, name), contents)
|
||||
}
|
||||
return sourceDir
|
||||
}
|
||||
|
||||
function copiedNames(files, entryName) {
|
||||
const sourceDir = sourceTree(files)
|
||||
const destinationDir = join(mkdtempSync(join(fixtureDir, 'dest-')), 'scripts')
|
||||
copyScriptWithLocalModules(join(sourceDir, entryName), destinationDir)
|
||||
return readdirSync(destinationDir).sort()
|
||||
}
|
||||
|
||||
describe('copyScriptWithLocalModules', () => {
|
||||
it('takes the entry script itself', () => {
|
||||
expect(copiedNames({ 'entry.mjs': 'export const a = 1\n' }, 'entry.mjs')).toEqual(['entry.mjs'])
|
||||
})
|
||||
|
||||
it('follows a co-located import', () => {
|
||||
expect(
|
||||
copiedNames(
|
||||
{ 'entry.mjs': "import { a } from './dep.mjs'\n", 'dep.mjs': 'export const a = 1\n' },
|
||||
'entry.mjs'
|
||||
)
|
||||
).toEqual(['dep.mjs', 'entry.mjs'])
|
||||
})
|
||||
|
||||
// The Windows addon gates are .cjs and reach each other by require. A module
|
||||
// pulled in only that way used to be left behind, and the subprocess then
|
||||
// failed with a resolution error that looks nothing like the defect it hides.
|
||||
it('follows a co-located require, not only an import', () => {
|
||||
expect(
|
||||
copiedNames(
|
||||
{
|
||||
'entry.cjs': "const { a } = require('./dep.cjs')\nmodule.exports = { a }\n",
|
||||
'dep.cjs': 'module.exports = { a: 1 }\n'
|
||||
},
|
||||
'entry.cjs'
|
||||
)
|
||||
).toEqual(['dep.cjs', 'entry.cjs'])
|
||||
})
|
||||
|
||||
it('follows a require reached only through an imported module', () => {
|
||||
const names = copiedNames(
|
||||
{
|
||||
'entry.mjs': "import './middle.cjs'\n",
|
||||
'middle.cjs': "require('./leaf.cjs')\n",
|
||||
'leaf.cjs': 'module.exports = {}\n'
|
||||
},
|
||||
'entry.mjs'
|
||||
)
|
||||
expect(names).toContain('leaf.cjs')
|
||||
})
|
||||
|
||||
it('leaves package and builtin specifiers alone', () => {
|
||||
const sourceDir = sourceTree({
|
||||
'entry.mjs': "import { join } from 'node:path'\nimport x from 'some-package'\n"
|
||||
})
|
||||
const destinationDir = join(mkdtempSync(join(fixtureDir, 'dest-')), 'scripts')
|
||||
copyScriptWithLocalModules(join(sourceDir, 'entry.mjs'), destinationDir)
|
||||
expect(readdirSync(destinationDir)).toEqual(['entry.mjs'])
|
||||
})
|
||||
|
||||
it('terminates on a cycle rather than recursing forever', () => {
|
||||
expect(
|
||||
copiedNames({ 'a.mjs': "import './b.mjs'\n", 'b.mjs': "import './a.mjs'\n" }, 'a.mjs')
|
||||
).toEqual(['a.mjs', 'b.mjs'])
|
||||
})
|
||||
|
||||
it('creates the destination directory it was handed', () => {
|
||||
const sourceDir = sourceTree({ 'entry.mjs': 'export const a = 1\n' })
|
||||
const destinationDir = join(mkdtempSync(join(fixtureDir, 'dest-')), 'nested', 'scripts')
|
||||
copyScriptWithLocalModules(join(sourceDir, 'entry.mjs'), destinationDir)
|
||||
expect(existsSync(join(destinationDir, 'entry.mjs'))).toBe(true)
|
||||
})
|
||||
|
||||
// The real tree this stages: the packaged-addon gate reaches its PE reader by
|
||||
// require, so a walker that missed it would break every rebuild fixture.
|
||||
it('stages the node-pty job-ownership gate with everything it requires', () => {
|
||||
const destinationDir = join(mkdtempSync(join(fixtureDir, 'dest-')), 'scripts')
|
||||
copyScriptWithLocalModules(
|
||||
fileURLToPath(new URL('./node-pty-job-ownership.cjs', import.meta.url)),
|
||||
destinationDir
|
||||
)
|
||||
expect(readdirSync(destinationDir).sort()).toEqual([
|
||||
'node-pty-job-ownership.cjs',
|
||||
'windows-pe-machine.cjs'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,52 @@
|
||||
const { existsSync } = require('node:fs')
|
||||
const { createRequire } = require('node:module')
|
||||
const { join } = require('node:path')
|
||||
const { assertNodePtyJobOwnership } = require('./node-pty-job-ownership.cjs')
|
||||
const {
|
||||
assertNodePtyJobOwnership,
|
||||
conptyDeniesCygwinBreakaway,
|
||||
nodePtyAddonPath,
|
||||
staleConptySourceBuildError
|
||||
} = require('./node-pty-job-ownership.cjs')
|
||||
const { normalizeNodePtyWindowsArch } = require('../packaged-runtime-node-modules.cjs')
|
||||
const { PE_MACHINE, describePeMachine, readPeMachine } = require('./windows-pe-machine.cjs')
|
||||
|
||||
/**
|
||||
* Every conpty.node the packaged tree can hand `loadNativeModule`, in its order.
|
||||
*
|
||||
* Why the order matters: the loader swallows each require failure and falls
|
||||
* through, so a wrong-arch or otherwise unloadable build hands the pane to the
|
||||
* next candidate. First loadable wins, and the published prebuild is always the
|
||||
* last one standing.
|
||||
*/
|
||||
function packagedConptyCandidates(resourcesDir, targetArch) {
|
||||
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
|
||||
const layouts = [
|
||||
{ segments: ['build', 'Release'], prebuilt: false },
|
||||
{ segments: ['build', 'Debug'], prebuilt: false },
|
||||
{ segments: ['prebuilds', `win32-${targetArch}`], prebuilt: true }
|
||||
]
|
||||
// Each layout is tried relative to node-pty's root, then to lib/, before the
|
||||
// next layout -- the unbundled then bundled pair node-pty's loader walks.
|
||||
return layouts.flatMap(({ segments, prebuilt }) =>
|
||||
[nodePtyDir, join(nodePtyDir, 'lib')].map((root) => ({
|
||||
path: join(root, ...segments, 'conpty.node'),
|
||||
prebuilt
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
function describeCandidates(candidates) {
|
||||
return candidates
|
||||
.map((candidate) => `${candidate.path} (${describePeMachine(candidate.machine)})`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
function loadPackagedConpty(resourcesDir) {
|
||||
const packagedRequire = createRequire(join(resourcesDir, 'package.json'))
|
||||
const { loadNativeModule } = packagedRequire('./node_modules/node-pty/lib/utils')
|
||||
return loadNativeModule('conpty')
|
||||
const utilsPath = packagedRequire.resolve('./node_modules/node-pty/lib/utils')
|
||||
const { loadNativeModule } = packagedRequire(utilsPath)
|
||||
const native = loadNativeModule('conpty')
|
||||
return { native, addonPath: nodePtyAddonPath(utilsPath, native, 'conpty') }
|
||||
}
|
||||
|
||||
function verifyPackagedNodePtyJobOwnership(resourcesDir, options = {}) {
|
||||
@@ -14,12 +55,145 @@ function verifyPackagedNodePtyJobOwnership(resourcesDir, options = {}) {
|
||||
return
|
||||
}
|
||||
|
||||
const native = (options.loadNative ?? loadPackagedConpty)(resourcesDir)
|
||||
assertNodePtyJobOwnership({ platform, nativeName: 'conpty', native })
|
||||
const { native, addonPath } = (options.loadNative ?? loadPackagedConpty)(resourcesDir)
|
||||
assertNodePtyJobOwnership({ platform, nativeName: 'conpty', native, addonPath })
|
||||
if (!native.dir.replace(/\\/g, '/').includes('build/Release/')) {
|
||||
throw new Error(`Packaged node-pty resolved to ${native.dir}; expected patched build/Release`)
|
||||
}
|
||||
console.log('[verify-packaged-node-pty] OK — packaged ConPTY owns process trees')
|
||||
}
|
||||
|
||||
module.exports = { verifyPackagedNodePtyJobOwnership }
|
||||
/**
|
||||
* The half of the packaged check that survives a cross-host build.
|
||||
*
|
||||
* The export check has to load the addon, so it cannot run when the packaging
|
||||
* host is not the target platform/arch -- and that skip is how a Windows
|
||||
* release built elsewhere could ship a node-pty that leaks every MSYS pane
|
||||
* child out of its job. Reading the binary needs neither.
|
||||
*
|
||||
* It resolves the addon the way the loader does rather than reading one path:
|
||||
* only the PE machine field separates a cross-arch package that built correctly
|
||||
* from one whose rebuild silently emitted the host's arch, and the first is a
|
||||
* correct package whose leftover prebuild is never reached. See the table in
|
||||
* docs/reference/windows-msys-job-breakaway.md.
|
||||
*
|
||||
* Nothing loadable is fatal, not skipped: that package has no ConPTY backend,
|
||||
* which a gate must not shrug at.
|
||||
*/
|
||||
function verifyPackagedConptyBreakawayMarker(resourcesDir, targetArch, options = {}) {
|
||||
// Deliberately no host-platform gate: the caller has already established that
|
||||
// the *target* is Windows, and gating on the host is the very skip this
|
||||
// closes.
|
||||
const architecture = normalizeNodePtyWindowsArch(targetArch)
|
||||
const candidates = packagedConptyCandidates(resourcesDir, architecture)
|
||||
const exists = options.exists ?? existsSync
|
||||
const present = candidates.filter((candidate) => exists(candidate.path))
|
||||
if (present.length === 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Packaged node-pty for win32-${architecture} has no conpty.node on any path its loader`,
|
||||
`tries (${candidates.map((c) => c.path).join(', ')}), so the packaged app has no`,
|
||||
'ConPTY backend at all.',
|
||||
'Nothing here can be checked for the Cygwin/MSYS job-breakaway denial, and a gate that',
|
||||
'cannot see its subject refuses rather than assume.'
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
// Read once: the same header answers "which one loads" and "what did we find".
|
||||
const inspected = present.map((candidate) => ({
|
||||
...candidate,
|
||||
machine: readPeMachine(candidate.path)
|
||||
}))
|
||||
const loaded = inspected.find((candidate) => candidate.machine === PE_MACHINE[architecture])
|
||||
if (!loaded) {
|
||||
throw new Error(
|
||||
[
|
||||
`Packaged node-pty for win32-${architecture} has conpty.node at`,
|
||||
`${describeCandidates(inspected)},`,
|
||||
'and the app can load none of them: a Windows process only loads a PE of its own',
|
||||
`machine, which for win32-${architecture} is`,
|
||||
`0x${PE_MACHINE[architecture].toString(16)}.`,
|
||||
'Rebuild node-pty for the target architecture and repackage.'
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
const addonPath = loaded.path
|
||||
if (conptyDeniesCygwinBreakaway(addonPath)) {
|
||||
console.log(
|
||||
`[verify-packaged-node-pty] OK — win32-${architecture} loads ${addonPath}, which denies ` +
|
||||
'MSYS job breakaway'
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!loaded.prebuilt) {
|
||||
throw staleConptySourceBuildError(addonPath)
|
||||
}
|
||||
// Past here the app falls back to the published prebuild, which never carries
|
||||
// the patch. Why it fell back decides the remedy, and the three are different
|
||||
// enough that naming the wrong one wastes the reader's build.
|
||||
const unusableSourceBuilds = inspected.filter((candidate) => !candidate.prebuilt)
|
||||
if (unusableSourceBuilds.some((candidate) => candidate.machine === null)) {
|
||||
throw new Error(
|
||||
[
|
||||
`Packaged node-pty for win32-${architecture} falls back to ${addonPath}, which predates`,
|
||||
'the Cygwin/MSYS job-breakaway denial, because the source build beside it is not a PE',
|
||||
`image at all: ${describeCandidates(unusableSourceBuilds)}.`,
|
||||
'A truncated, empty or quarantined build artifact looks like this. Rebuild node-pty and',
|
||||
'repackage. See docs/reference/windows-msys-job-breakaway.md.'
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
if (unusableSourceBuilds.length > 0) {
|
||||
throw new Error(
|
||||
[
|
||||
`Packaged node-pty for win32-${architecture} falls back to ${addonPath}, which predates`,
|
||||
'the Cygwin/MSYS job-breakaway denial, because the source build beside it is the wrong',
|
||||
`architecture: ${describeCandidates(unusableSourceBuilds)}.`,
|
||||
'A cross-arch rebuild that did not honour --arch looks exactly like this. Re-run',
|
||||
`config/scripts/rebuild-native-deps.mjs --platform=win32 --arch=${architecture},`,
|
||||
'confirm it emitted a conpty.node of that machine, and repackage.',
|
||||
'See docs/reference/windows-msys-job-breakaway.md.'
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
[
|
||||
`Packaged node-pty for win32-${architecture} loads ${addonPath}, the published prebuilt`,
|
||||
'fallback, which predates the Cygwin/MSYS job-breakaway denial: its per-PTY job still',
|
||||
'carries JOB_OBJECT_LIMIT_BREAKAWAY_OK, so every Git Bash pane child is created outside',
|
||||
'the job and survives terminatePtyJob.',
|
||||
'It is here because this package holds no node-pty source build at all for',
|
||||
'prunePackagedNodePty to have replaced it with, and only a host that can build node-pty',
|
||||
`for win32-${architecture} produces one.`,
|
||||
`If this IS a Windows ${architecture} host, the rebuild did not leave one -- check the`,
|
||||
'beforeBuild output above. Otherwise package this Windows slice on a host that can.',
|
||||
'See docs/reference/windows-msys-job-breakaway.md.'
|
||||
].join(' ')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole Windows verdict for one packaged slice.
|
||||
*
|
||||
* Both halves live here rather than in the afterPack hook so that "the marker
|
||||
* sweep runs even when the export check cannot" is a tested claim instead of
|
||||
* the shape of an if/else somebody could re-nest.
|
||||
*/
|
||||
function verifyPackagedWindowsNodePty(resourcesDir, targetArch, options = {}) {
|
||||
;(options.verifyMarker ?? verifyPackagedConptyBreakawayMarker)(resourcesDir, targetArch)
|
||||
const hostPlatform = options.hostPlatform ?? process.platform
|
||||
if (hostPlatform !== 'win32' || !options.canExecuteTargetArch) {
|
||||
console.log(
|
||||
'[verify-packaged-node-pty] skipped the export check on a cross-platform or cross-arch package'
|
||||
)
|
||||
return
|
||||
}
|
||||
;(options.verifyExports ?? verifyPackagedNodePtyJobOwnership)(resourcesDir)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
packagedConptyCandidates,
|
||||
verifyPackagedConptyBreakawayMarker,
|
||||
verifyPackagedNodePtyJobOwnership,
|
||||
verifyPackagedWindowsNodePty
|
||||
}
|
||||
|
||||
@@ -1,10 +1,53 @@
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { peImage } from './windows-pe-image-fixture.mjs'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const {
|
||||
verifyPackagedNodePtyJobOwnership
|
||||
packagedConptyCandidates,
|
||||
verifyPackagedConptyBreakawayMarker,
|
||||
verifyPackagedNodePtyJobOwnership,
|
||||
verifyPackagedWindowsNodePty
|
||||
} = require('./verify-packaged-node-pty-job-ownership.cjs')
|
||||
const { CYGWIN_BREAKAWAY_MARKER } = require('./node-pty-job-ownership.cjs')
|
||||
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), 'packaged-node-pty-job-'))
|
||||
const ELECTRON_BUILDER_CONFIG = readFileSync(
|
||||
new URL('../electron-builder.config.cjs', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
/** A real enough addon: a machine field the arch check reads, and the marker. */
|
||||
function conptyImage({ arch = 'x64', cygwinBreakawayDenied = true } = {}) {
|
||||
return Buffer.concat([
|
||||
peImage({ arch }),
|
||||
cygwinBreakawayDenied ? CYGWIN_BREAKAWAY_MARKER : Buffer.alloc(0)
|
||||
])
|
||||
}
|
||||
|
||||
function writeAddon(name, options) {
|
||||
const path = join(fixtureDir, name)
|
||||
writeFileSync(path, conptyImage(options))
|
||||
return path
|
||||
}
|
||||
|
||||
/** A packaged resources tree carrying exactly the conpty.node files named. */
|
||||
function packagedResources(addons) {
|
||||
const resourcesDir = mkdtempSync(join(fixtureDir, 'resources-'))
|
||||
const nodePtyDir = join(resourcesDir, 'node_modules', 'node-pty')
|
||||
for (const [relativePath, options] of Object.entries(addons)) {
|
||||
const addonPath = join(nodePtyDir, ...relativePath.split('/'))
|
||||
mkdirSync(dirname(addonPath), { recursive: true })
|
||||
writeFileSync(addonPath, conptyImage(options))
|
||||
}
|
||||
return resourcesDir
|
||||
}
|
||||
|
||||
const CURRENT_ADDON = writeAddon('current.node', { cygwinBreakawayDenied: true })
|
||||
const PRE_MSYS_ADDON = writeAddon('pre-msys.node', { cygwinBreakawayDenied: false })
|
||||
|
||||
const PATCHED = {
|
||||
dir: '../build/Release/',
|
||||
@@ -15,31 +58,39 @@ const PATCHED = {
|
||||
}
|
||||
}
|
||||
|
||||
const packaged = (native, addonPath = CURRENT_ADDON) => ({
|
||||
platform: 'win32',
|
||||
loadNative: () => ({ native, addonPath })
|
||||
})
|
||||
|
||||
describe('verifyPackagedNodePtyJobOwnership', () => {
|
||||
it('accepts the packaged patched ConPTY binding', () => {
|
||||
expect(() =>
|
||||
verifyPackagedNodePtyJobOwnership('resources', {
|
||||
platform: 'win32',
|
||||
loadNative: () => PATCHED
|
||||
})
|
||||
).not.toThrow()
|
||||
expect(() => verifyPackagedNodePtyJobOwnership('resources', packaged(PATCHED))).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a packaged upstream prebuild', () => {
|
||||
expect(() =>
|
||||
verifyPackagedNodePtyJobOwnership('resources', {
|
||||
platform: 'win32',
|
||||
loadNative: () => ({ dir: '../prebuilds/win32-x64/', module: {} })
|
||||
})
|
||||
verifyPackagedNodePtyJobOwnership(
|
||||
'resources',
|
||||
packaged({ dir: '../prebuilds/win32-x64/', module: {} })
|
||||
)
|
||||
).toThrow(/missing listJobProcessIds, terminateJob, assignCurrentProcessToJob/)
|
||||
})
|
||||
|
||||
// A release built against a stale native cache ships the MSYS orphan bug
|
||||
// while exporting every job function, so packaging has to read the binary.
|
||||
it('rejects a packaged build that predates the Cygwin/MSYS breakaway denial', () => {
|
||||
expect(() =>
|
||||
verifyPackagedNodePtyJobOwnership('resources', packaged(PATCHED, PRE_MSYS_ADDON))
|
||||
).toThrow(/predates the Cygwin\/MSYS job-breakaway denial/)
|
||||
})
|
||||
|
||||
it('requires the patched source-build directory', () => {
|
||||
expect(() =>
|
||||
verifyPackagedNodePtyJobOwnership('resources', {
|
||||
platform: 'win32',
|
||||
loadNative: () => ({ ...PATCHED, dir: '../prebuilds/win32-x64/' })
|
||||
})
|
||||
verifyPackagedNodePtyJobOwnership(
|
||||
'resources',
|
||||
packaged({ ...PATCHED, dir: '../prebuilds/win32-x64/' })
|
||||
)
|
||||
).toThrow(/expected patched build\/Release/)
|
||||
})
|
||||
|
||||
@@ -49,3 +100,331 @@ describe('verifyPackagedNodePtyJobOwnership', () => {
|
||||
expect(loadNative).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('packagedConptyCandidates', () => {
|
||||
// Pinned because the gate resolves the addon by walking this list in order:
|
||||
// a wrong order blesses a binary the app would never reach.
|
||||
it('walks the paths node-pty tries, in node-pty order', () => {
|
||||
expect(packagedConptyCandidates('RES', 'arm64').map((candidate) => candidate.path)).toEqual([
|
||||
join('RES', 'node_modules', 'node-pty', 'build', 'Release', 'conpty.node'),
|
||||
join('RES', 'node_modules', 'node-pty', 'lib', 'build', 'Release', 'conpty.node'),
|
||||
join('RES', 'node_modules', 'node-pty', 'build', 'Debug', 'conpty.node'),
|
||||
join('RES', 'node_modules', 'node-pty', 'lib', 'build', 'Debug', 'conpty.node'),
|
||||
join('RES', 'node_modules', 'node-pty', 'prebuilds', 'win32-arm64', 'conpty.node'),
|
||||
join('RES', 'node_modules', 'node-pty', 'lib', 'prebuilds', 'win32-arm64', 'conpty.node')
|
||||
])
|
||||
})
|
||||
|
||||
// Only the published prebuild gets the "no rebuild here can fix this" advice.
|
||||
it('knows which of them node-pty publishes prebuilt', () => {
|
||||
expect(packagedConptyCandidates('RES', 'x64').map((candidate) => candidate.prebuilt)).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyPackagedConptyBreakawayMarker', () => {
|
||||
// The release as built today: prunePackagedNodePty already dropped the
|
||||
// same-arch prebuild because a patched source build replaced it.
|
||||
it('passes a package whose only ConPTY load path carries the denial', () => {
|
||||
const resourcesDir = packagedResources({ 'build/Release/conpty.node': {} })
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).not.toThrow()
|
||||
})
|
||||
|
||||
// The cross-host package. No host but Windows can build conpty.node, so there
|
||||
// is no build/Release for prune to have replaced the prebuild with -- and the
|
||||
// published prebuild is exactly the binary that leaks every MSYS pane child.
|
||||
it('fails a cross-host package left holding the published prebuild', () => {
|
||||
const resourcesDir = packagedResources({
|
||||
'prebuilds/win32-x64/conpty.node': { cygwinBreakawayDenied: false }
|
||||
})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow(
|
||||
/predates the Cygwin\/MSYS job-breakaway denial/
|
||||
)
|
||||
})
|
||||
|
||||
it('tells that package how to fix it, which is not a rebuild it can run', () => {
|
||||
const resourcesDir = packagedResources({
|
||||
'prebuilds/win32-x64/conpty.node': { cygwinBreakawayDenied: false }
|
||||
})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow(
|
||||
/this package holds no node-pty source build at all[\s\S]*Otherwise package this Windows slice on a host that can/
|
||||
)
|
||||
})
|
||||
|
||||
// The same state reaches this from a capable host too, when the rebuild left
|
||||
// nothing: telling that packager to change hosts would send them nowhere.
|
||||
it('does not assume the packaging host is the wrong one', () => {
|
||||
const resourcesDir = packagedResources({
|
||||
'prebuilds/win32-x64/conpty.node': { cygwinBreakawayDenied: false }
|
||||
})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow(
|
||||
/If this IS a Windows x64 host, the rebuild did not leave one/
|
||||
)
|
||||
})
|
||||
|
||||
// The cross-arch package that worked: beforeBuild rebuilds node-pty for the
|
||||
// TARGET arch, so build/Release is patched and loadable and the prebuild
|
||||
// prune left behind is never reached. Failing this would be a false positive
|
||||
// whose advice -- change hosts -- is both wrong and impossible.
|
||||
it('passes a cross-arch package whose build/Release really is the target arch', () => {
|
||||
const resourcesDir = packagedResources({
|
||||
'build/Release/conpty.node': { arch: 'arm64' },
|
||||
'prebuilds/win32-arm64/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false }
|
||||
})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).not.toThrow()
|
||||
})
|
||||
|
||||
// The cross-arch package that silently did not: build/Release is the
|
||||
// packaging host's own arch, the target cannot load it, and the loader falls
|
||||
// through to the unpatched prebuild underneath.
|
||||
it('fails a cross-arch package whose build/Release is the packaging host arch', () => {
|
||||
const resourcesDir = packagedResources({
|
||||
'build/Release/conpty.node': { arch: 'x64' },
|
||||
'prebuilds/win32-arm64/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false }
|
||||
})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow(
|
||||
/prebuilds[\\/]win32-arm64/
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a package whose every conpty.node is the wrong architecture', () => {
|
||||
const resourcesDir = packagedResources({ 'build/Release/conpty.node': { arch: 'x64' } })
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow(
|
||||
/the app can load none of them/
|
||||
)
|
||||
})
|
||||
|
||||
// Naming the machine it found is what separates a cross-arch build from a
|
||||
// truncated download, which are the same "cannot load this" to the loader.
|
||||
it('names what it found rather than guessing why', () => {
|
||||
const resourcesDir = packagedResources({ 'build/Release/conpty.node': { arch: 'x64' } })
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow(
|
||||
/machine 0x8664[\s\S]*0xaa64/
|
||||
)
|
||||
})
|
||||
|
||||
it('calls a candidate that is not a PE image what it is', () => {
|
||||
const resourcesDir = packagedResources({})
|
||||
const addonPath = join(
|
||||
resourcesDir,
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
'build',
|
||||
'Release',
|
||||
'conpty.node'
|
||||
)
|
||||
mkdirSync(dirname(addonPath), { recursive: true })
|
||||
writeFileSync(addonPath, Buffer.alloc(0x200))
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow(/not a PE image/)
|
||||
})
|
||||
|
||||
// A truncated or quarantined artifact reaches the gate looking exactly like a
|
||||
// cross-arch build, and "re-run with --arch" is not the command that fixes it.
|
||||
it('does not blame --arch for a source build that is not a PE image', () => {
|
||||
const resourcesDir = packagedResources({
|
||||
'prebuilds/win32-x64/conpty.node': { cygwinBreakawayDenied: false }
|
||||
})
|
||||
const addonPath = join(
|
||||
resourcesDir,
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
'build',
|
||||
'Release',
|
||||
'conpty.node'
|
||||
)
|
||||
mkdirSync(dirname(addonPath), { recursive: true })
|
||||
writeFileSync(addonPath, Buffer.alloc(0x200))
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow(
|
||||
/is not a PE image at all[\s\S]*truncated, empty or quarantined/
|
||||
)
|
||||
})
|
||||
|
||||
// The remedy for this one is a rebuild, not a different host, and the
|
||||
// difference is a build somebody has to run twice to find out.
|
||||
it('blames the wrong-arch source build rather than the host, when there is one', () => {
|
||||
const resourcesDir = packagedResources({
|
||||
'build/Release/conpty.node': { arch: 'x64' },
|
||||
'prebuilds/win32-arm64/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false }
|
||||
})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow(
|
||||
/the source build beside it is the wrong architecture[\s\S]*machine 0x8664/
|
||||
)
|
||||
})
|
||||
|
||||
it('tells that build the command that would fix it', () => {
|
||||
const resourcesDir = packagedResources({
|
||||
'build/Release/conpty.node': { arch: 'x64' },
|
||||
'prebuilds/win32-arm64/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false }
|
||||
})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow(
|
||||
/rebuild-native-deps\.mjs --platform=win32 --arch=arm64/
|
||||
)
|
||||
})
|
||||
|
||||
it('does not tell it to change hosts, which would not help', () => {
|
||||
const resourcesDir = packagedResources({
|
||||
'build/Release/conpty.node': { arch: 'x64' },
|
||||
'prebuilds/win32-arm64/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false }
|
||||
})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow(
|
||||
/^(?![\s\S]*Package this Windows slice on such a host)[\s\S]*$/
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a prebuild for an arch this slice will never load', () => {
|
||||
const resourcesDir = packagedResources({
|
||||
'build/Release/conpty.node': {},
|
||||
'prebuilds/win32-arm64/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false }
|
||||
})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).not.toThrow()
|
||||
})
|
||||
|
||||
// build/Debug sits between Release and the prebuilds in the load order, and
|
||||
// nothing prunes it, so it wins whenever Release cannot be loaded.
|
||||
it('resolves past a Release build the target cannot load', () => {
|
||||
const resourcesDir = packagedResources({
|
||||
'build/Release/conpty.node': { arch: 'x64' },
|
||||
'build/Debug/conpty.node': { arch: 'arm64', cygwinBreakawayDenied: false }
|
||||
})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'arm64')).toThrow(
|
||||
/build[\\/]Debug/
|
||||
)
|
||||
})
|
||||
|
||||
// A stale source build is the packaging host's own to rebuild, so it gets the
|
||||
// advice that actually works rather than the cross-host one.
|
||||
it('tells a stale source build to rebuild, not to change hosts', () => {
|
||||
const resourcesDir = packagedResources({
|
||||
'build/Release/conpty.node': { cygwinBreakawayDenied: false }
|
||||
})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow(
|
||||
/Rebuild node-pty from source/
|
||||
)
|
||||
})
|
||||
|
||||
// Nothing to load is not "a layout we do not recognise", it is a package with
|
||||
// no ConPTY backend, and a gate that cannot see its subject is not a gate.
|
||||
it('refuses rather than skip a package with no conpty.node at all', () => {
|
||||
const resourcesDir = packagedResources({})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow(
|
||||
/no conpty\.node on any path its loader tries/
|
||||
)
|
||||
})
|
||||
|
||||
it('names every path it looked at when it finds none', () => {
|
||||
const resourcesDir = packagedResources({})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow(
|
||||
/build[\\/]Release[\s\S]*build[\\/]Debug[\s\S]*prebuilds[\\/]win32-x64/
|
||||
)
|
||||
})
|
||||
|
||||
// Present but unreadable is the state that used to pass, so it must not warn.
|
||||
// The read error itself is the message; the point is that it does not return.
|
||||
it('fails rather than pass a candidate it cannot read', () => {
|
||||
const resourcesDir = packagedResources({})
|
||||
mkdirSync(join(resourcesDir, 'node_modules', 'node-pty', 'build', 'Release', 'conpty.node'), {
|
||||
recursive: true
|
||||
})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'x64')).toThrow()
|
||||
})
|
||||
|
||||
it('accepts the electron-builder Arch enum the afterPack hook passes', () => {
|
||||
const resourcesDir = packagedResources({
|
||||
'prebuilds/win32-arm64/conpty.node': { arch: 'arm64' }
|
||||
})
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 3)).not.toThrow()
|
||||
})
|
||||
|
||||
it('refuses a target arch no Windows slice ships', () => {
|
||||
const resourcesDir = packagedResources({ 'build/Release/conpty.node': {} })
|
||||
expect(() => verifyPackagedConptyBreakawayMarker(resourcesDir, 'ia32')).toThrow(
|
||||
/Unsupported packaged node-pty Windows architecture/
|
||||
)
|
||||
})
|
||||
|
||||
it('looks where electron-builder actually lands the addon', () => {
|
||||
const exists = vi.fn().mockReturnValue(false)
|
||||
expect(() =>
|
||||
verifyPackagedConptyBreakawayMarker(join('out', 'win-unpacked', 'resources'), 'x64', {
|
||||
exists
|
||||
})
|
||||
).toThrow()
|
||||
expect(exists).toHaveBeenCalledWith(
|
||||
join(
|
||||
'out',
|
||||
'win-unpacked',
|
||||
'resources',
|
||||
'node_modules',
|
||||
'node-pty',
|
||||
'build',
|
||||
'Release',
|
||||
'conpty.node'
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('verifyPackagedWindowsNodePty', () => {
|
||||
const spies = () => ({ verifyMarker: vi.fn(), verifyExports: vi.fn() })
|
||||
|
||||
// The bug this replaced: the marker check sat in the else of the host gate, so
|
||||
// the cross-host package it exists for was the one package it never checked.
|
||||
it.each([
|
||||
['a cross-platform host', { hostPlatform: 'darwin', canExecuteTargetArch: true }],
|
||||
['a cross-arch slice', { hostPlatform: 'win32', canExecuteTargetArch: false }],
|
||||
['both', { hostPlatform: 'linux', canExecuteTargetArch: false }],
|
||||
['neither', { hostPlatform: 'win32', canExecuteTargetArch: true }]
|
||||
])('checks the marker on %s', (_case, host) => {
|
||||
const { verifyMarker, verifyExports } = spies()
|
||||
verifyPackagedWindowsNodePty('resources', 'x64', { ...host, verifyMarker, verifyExports })
|
||||
expect(verifyMarker).toHaveBeenCalledWith('resources', 'x64')
|
||||
})
|
||||
|
||||
it('loads the addon for the export check only where that can work', () => {
|
||||
const { verifyMarker, verifyExports } = spies()
|
||||
verifyPackagedWindowsNodePty('resources', 'x64', {
|
||||
hostPlatform: 'win32',
|
||||
canExecuteTargetArch: true,
|
||||
verifyMarker,
|
||||
verifyExports
|
||||
})
|
||||
expect(verifyExports).toHaveBeenCalledWith('resources')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a cross-platform host', { hostPlatform: 'darwin', canExecuteTargetArch: true }],
|
||||
['a cross-arch slice', { hostPlatform: 'win32', canExecuteTargetArch: false }]
|
||||
])('skips the export check on %s', (_case, host) => {
|
||||
const { verifyMarker, verifyExports } = spies()
|
||||
verifyPackagedWindowsNodePty('resources', 'x64', { ...host, verifyMarker, verifyExports })
|
||||
expect(verifyExports).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Swallowing the marker verdict would leave a gate that runs and decides
|
||||
// nothing, which is the failure mode this whole change is about.
|
||||
it('lets the marker verdict fail the package', () => {
|
||||
const verifyMarker = vi.fn(() => {
|
||||
throw new Error('predates the Cygwin/MSYS job-breakaway denial')
|
||||
})
|
||||
expect(() =>
|
||||
verifyPackagedWindowsNodePty('resources', 'x64', {
|
||||
hostPlatform: 'win32',
|
||||
canExecuteTargetArch: true,
|
||||
verifyMarker,
|
||||
verifyExports: vi.fn()
|
||||
})
|
||||
).toThrow(/predates the Cygwin\/MSYS job-breakaway denial/)
|
||||
})
|
||||
|
||||
it('is what the afterPack hook calls for a Windows slice', () => {
|
||||
expect(ELECTRON_BUILDER_CONFIG).toContain(
|
||||
'verifyPackagedWindowsNodePty(resourcesDir, context.arch, { canExecuteTargetArch })'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const { PE_MACHINE } = createRequire(import.meta.url)('./windows-pe-machine.cjs')
|
||||
|
||||
/**
|
||||
* A PE image with nothing in it but a readable `IMAGE_FILE_HEADER.Machine`.
|
||||
*
|
||||
* Fixtures need this because the Windows addon gates read the binary: one that
|
||||
* is not a PE cannot stand in for an addon whose architecture decides whether
|
||||
* the app loads it at all.
|
||||
*/
|
||||
export function peImage({ arch = 'x64', machine, peOffset = 0x80, signature = 'PE\0\0' } = {}) {
|
||||
if (machine === undefined && PE_MACHINE[arch] === undefined) {
|
||||
throw new Error(`No PE machine value for ${arch}; a fixture must not invent one.`)
|
||||
}
|
||||
const image = Buffer.alloc(peOffset + 8)
|
||||
image.write('MZ', 0, 'latin1')
|
||||
image.writeUInt32LE(peOffset, 0x3c)
|
||||
image.write(signature, peOffset, 'latin1')
|
||||
image.writeUInt16LE(machine ?? PE_MACHINE[arch], peOffset + 4)
|
||||
return image
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict'
|
||||
|
||||
const { closeSync, openSync, readSync } = require('node:fs')
|
||||
|
||||
/** PE `IMAGE_FILE_HEADER.Machine` values, so a cross-build cannot silently emit host arch. */
|
||||
const PE_MACHINE = { x64: 0x8664, arm64: 0xaa64 }
|
||||
|
||||
/**
|
||||
* `IMAGE_FILE_HEADER.Machine`, or null when the file is not a PE image.
|
||||
*
|
||||
* Null rather than a throw because callers ask this of files they did not
|
||||
* produce: a truncated or non-PE binary is a thing to decide about, not a crash.
|
||||
*/
|
||||
function readPeMachine(binaryPath) {
|
||||
const fd = openSync(binaryPath, 'r')
|
||||
try {
|
||||
const dosHeader = Buffer.alloc(0x40)
|
||||
if (readSync(fd, dosHeader, 0, 0x40, 0) < 0x40 || dosHeader.toString('latin1', 0, 2) !== 'MZ') {
|
||||
return null
|
||||
}
|
||||
const peOffset = dosHeader.readUInt32LE(0x3c)
|
||||
const peHeader = Buffer.alloc(6)
|
||||
if (
|
||||
readSync(fd, peHeader, 0, 6, peOffset) < 6 ||
|
||||
peHeader.toString('latin1', 0, 4) !== 'PE\0\0'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return peHeader.readUInt16LE(4)
|
||||
} finally {
|
||||
closeSync(fd)
|
||||
}
|
||||
}
|
||||
|
||||
/** How to name a machine field in an error, including the file that has none. */
|
||||
function describePeMachine(machine) {
|
||||
return machine === null ? 'not a PE image' : `machine 0x${machine.toString(16)}`
|
||||
}
|
||||
|
||||
module.exports = { PE_MACHINE, describePeMachine, readPeMachine }
|
||||
@@ -0,0 +1,85 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { peImage } from './windows-pe-image-fixture.mjs'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { PE_MACHINE, describePeMachine, readPeMachine } = require('./windows-pe-machine.cjs')
|
||||
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), 'windows-pe-machine-'))
|
||||
|
||||
function writeImage(name, build) {
|
||||
const path = join(fixtureDir, name)
|
||||
writeFileSync(path, build())
|
||||
return path
|
||||
}
|
||||
|
||||
const X64 = writeImage('x64.node', () => peImage({ machine: PE_MACHINE.x64 }))
|
||||
const ARM64 = writeImage('arm64.node', () => peImage({ machine: PE_MACHINE.arm64 }))
|
||||
|
||||
describe('PE_MACHINE', () => {
|
||||
// Spelled out rather than taken from the module: the fixtures below build
|
||||
// their headers from these, so a table that is wrong in both entries would
|
||||
// otherwise agree with itself.
|
||||
it('holds the IMAGE_FILE_MACHINE values Windows actually stamps', () => {
|
||||
expect(PE_MACHINE).toEqual({ x64: 0x8664, arm64: 0xaa64 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('readPeMachine', () => {
|
||||
it.each([
|
||||
['x64', X64, PE_MACHINE.x64],
|
||||
['arm64', ARM64, PE_MACHINE.arm64]
|
||||
])('reads the machine field of a %s image', (_case, path, expected) => {
|
||||
expect(readPeMachine(path)).toBe(expected)
|
||||
})
|
||||
|
||||
// Callers ask this of files they did not produce, so anything that is not a
|
||||
// PE has to be an answer rather than a crash.
|
||||
it.each([
|
||||
['a Mach-O or ELF binary', () => Buffer.alloc(0x200)],
|
||||
['a file too short to hold a DOS header', () => Buffer.from('MZ')],
|
||||
[
|
||||
'a DOS stub whose PE offset points nowhere',
|
||||
() => peImage({ machine: 0x8664, peOffset: 0x8000 }).subarray(0, 0x88)
|
||||
],
|
||||
['a file with no PE signature', () => peImage({ machine: 0x8664, signature: 'XX\0\0' })]
|
||||
])('returns null for %s', (_case, build) => {
|
||||
expect(readPeMachine(writeImage(`not-pe-${Math.random()}.bin`, build))).toBeNull()
|
||||
})
|
||||
|
||||
it('respects the DOS header pointer rather than a fixed offset', () => {
|
||||
const path = writeImage('shifted.node', () =>
|
||||
peImage({ machine: PE_MACHINE.arm64, peOffset: 0x120 })
|
||||
)
|
||||
expect(readPeMachine(path)).toBe(PE_MACHINE.arm64)
|
||||
})
|
||||
})
|
||||
|
||||
describe('peImage fixture', () => {
|
||||
// A fixture that quietly stamps machine 0x0000 for an arch it does not know
|
||||
// is the same species of silent lie these gates exist to catch.
|
||||
it('refuses to invent a machine value for an arch it has none for', () => {
|
||||
expect(() => peImage({ arch: 'ia32' })).toThrow(/must not invent one/)
|
||||
})
|
||||
|
||||
it('still takes an explicit machine, which is how the non-PE cases are built', () => {
|
||||
expect(readPeMachine(writeImage('explicit.node', () => peImage({ machine: 0x1234 })))).toBe(
|
||||
0x1234
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('describePeMachine', () => {
|
||||
// The callers put this straight into an error, and "not a PE image" is a
|
||||
// different problem from a cross-arch build.
|
||||
it('names a machine field it read', () => {
|
||||
expect(describePeMachine(PE_MACHINE.arm64)).toBe('machine 0xaa64')
|
||||
})
|
||||
|
||||
it('says so when there was none, rather than throwing on null', () => {
|
||||
expect(describePeMachine(null)).toBe('not a PE image')
|
||||
})
|
||||
})
|
||||
@@ -310,3 +310,34 @@ predicate. It is unobservable today — the host publishes neither field for a c
|
||||
all, so a mirror has nothing to take either way. If the capability-gated publish this section
|
||||
anticipates ever lands, narrow them the same way rather than by placement kind: a mirror should
|
||||
take a failure it cannot otherwise see, and only the hosting client should refuse it.
|
||||
|
||||
## Known hazard: on the mobile surface a scope refusal is not a missing method
|
||||
|
||||
The agent-session harness above asserts that a peer probing an unknown method is told
|
||||
`method_not_found`. That holds for the runtime-scoped surface and **not for the mobile one**.
|
||||
|
||||
`runtime-rpc-websocket-dispatch.ts` checks `MOBILE_RPC_METHOD_ALLOWLIST` and answers
|
||||
`forbidden` _before_ it calls the dispatcher. A method a desktop predates is on neither the
|
||||
allowlist nor the registry, and the gate answers first, so a phone never sees
|
||||
`method_not_found` for it. `method_not_found` would reach a mobile-scoped device only for a
|
||||
method that is allowlisted but unregistered, and `src/main/runtime/mobile-rpc-allowlist.test.ts`
|
||||
requires every method the phone calls to be both, so that combination cannot ship. The reverse
|
||||
— a registered method that no release has allowlisted yet — is the skew that does occur.
|
||||
|
||||
So a phone-side "is this host new enough to serve X?" probe must read **both** codes as
|
||||
absence. Keying it on `method_not_found` alone compiles and passes every same-version test
|
||||
while never firing. The Files and Git fallbacks have read both since they shipped
|
||||
(`isMobileMethodUnavailableError`, `isMobileGitUnavailable`); the Relay pairing probes were the
|
||||
outlier, and the cost was a write-once direct-relay upgrade journal, holding a pending resume
|
||||
secret, that an old desktop could never retire
|
||||
(`mobile/src/transport/pairing-relay-rpc-unavailable.ts`, with the gate pinned desktop-side by
|
||||
`src/main/runtime/runtime-rpc-mobile-unknown-method-scope-refusal.test.ts`).
|
||||
|
||||
Widening is safe only where `forbidden` cannot also mean a real authorization failure. Prove
|
||||
that per probe rather than globally: the pairing handlers cannot emit it (an unwired provider
|
||||
answers `runtime_error`), a bad or revoked token answers `unauthorized`, and the gate is one
|
||||
of only two places in `src/main` that emits the code at all. A probe whose handler _can_
|
||||
refuse by authorization must not be widened.
|
||||
|
||||
The cross-version harness dispatches as a mobile client but calls the dispatcher directly, so
|
||||
it never runs this gate and nothing reddens if any of the above is forgotten.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# Why an MSYS pane's children escape the per-PTY job
|
||||
|
||||
Every child started from a Git Bash / MSYS2 / Cygwin pane leaves the pane's job
|
||||
object unless the job is created **without** `JOB_OBJECT_LIMIT_BREAKAWAY_OK`.
|
||||
`terminatePtyJob` then reports `terminated` and leaves the child running — the
|
||||
orphan that holds a worktree directory open.
|
||||
|
||||
The denial is already in `config/patches/node-pty@1.1.0.patch`
|
||||
(`usesCygwinRuntime`, added in #19068). This page records the measurement
|
||||
behind it, because the failure mode it prevents is indistinguishable from a
|
||||
stale native addon and the gates of the day could not tell the two apart.
|
||||
|
||||
## The mechanism
|
||||
|
||||
The MSYS/Cygwin runtime asks for `CREATE_BREAKAWAY_FROM_JOB` on the
|
||||
`CreateProcessW` inside its `spawn`/`exec` path. A job that carries
|
||||
`JOB_OBJECT_LIMIT_BREAKAWAY_OK` grants it, so the child is created outside the
|
||||
job; a job without that limit denies it with `ERROR_ACCESS_DENIED`, and the
|
||||
runtime retries without the flag rather than failing the spawn. `fork` is not
|
||||
affected — forked Cygwin processes stay in the job either way.
|
||||
|
||||
Measured on Windows 11 `10.0.26200.9168`, Git `2.55.0.windows.3`,
|
||||
bash `5.3.15(1)-release`, node `v24.18.0`, `useConptyDll: true`, for
|
||||
`node-pty.spawn('C:\Program Files\Git\bin\bash.exe', ['--noprofile','--norc','-i'])`
|
||||
— `+J` / `-J` is membership of the per-PTY job, read with
|
||||
`QueryInformationJobObject(JobObjectBasicProcessIdList)`:
|
||||
|
||||
```
|
||||
bin\bash.exe +J ConPTY shell (assigned by node-pty)
|
||||
└ ..\usr\bin\bash.exe +J launcher hand-off, plain CreateProcess
|
||||
└ usr\bin\bash.exe +J Cygwin fork for the typed command
|
||||
└ node.exe -J Cygwin exec -- ESCAPES HERE
|
||||
```
|
||||
|
||||
`bin\bash.exe` is a 47 KB launcher, not an MSYS binary: `C:\Program Files\Git\bin`
|
||||
holds only `bash.exe`, `git.exe` and `sh.exe`, with no `msys-2.0.dll`. Its
|
||||
hand-off to `bin\..\usr\bin\bash.exe` is an ordinary `CreateProcess` and keeps
|
||||
job membership. Only the MSYS runtime's own spawn breaks away.
|
||||
|
||||
The shell-replacement shape (`bash -c 'exec "$BASH" --noprofile --norc -i'`)
|
||||
loses membership one step earlier, at the `exec`, and everything below inherits
|
||||
the loss:
|
||||
|
||||
```
|
||||
bin\bash.exe +J
|
||||
└ ..\usr\bin\bash.exe +J
|
||||
└ usr\bin\bash.exe -J Cygwin exec -- ESCAPES HERE
|
||||
└ usr\bin\bash -J
|
||||
└ node.exe -J
|
||||
```
|
||||
|
||||
Both shapes leak. The `exec` is not the cause; it only moves the escape earlier.
|
||||
|
||||
## The A/B that pins it
|
||||
|
||||
One source tree, one toolchain, one variable — `usesCygwinRuntime` forced to
|
||||
`false` so the per-PTY job keeps `JOB_OBJECT_LIMIT_BREAKAWAY_OK`:
|
||||
|
||||
| per-PTY job limit | `listPtyJobProcessIds` | child reaped by `terminatePtyJob` | runs |
|
||||
| ---------------------- | ---------------------- | --------------------------------- | ---- |
|
||||
| `BREAKAWAY_OK` set | 2 pids, child absent | no | 0/2 |
|
||||
| `BREAKAWAY_OK` cleared | 5 pids, child present | yes | 4/4 |
|
||||
|
||||
The job **is** the right boundary. With breakaway denied it holds the whole MSYS
|
||||
tree, including the child that detached from the console, and one
|
||||
`terminateJob` reaps all of it. No alternative tracking mechanism is needed.
|
||||
|
||||
Denying breakaway did not break ordinary launches from the pane: `git`,
|
||||
`cmd //c`, an absolute-path `node`, a `&`-backgrounded job with `disown`, and
|
||||
`where.exe` all returned 0 with no `Access is denied`, identically to the
|
||||
breakaway-allowed control. Untested: a **non-Cygwin** program that itself passes
|
||||
`CREATE_BREAKAWAY_FROM_JOB` (installers, updaters) and therefore has no runtime
|
||||
to retry for it. That needs a helper that calls `CreateProcess` with the flag;
|
||||
`start /b` does not exercise it (it uses `CREATE_NEW_CONSOLE`).
|
||||
|
||||
## A stale addon looks exactly like the bug
|
||||
|
||||
`config/scripts/node-pty-job-ownership.cjs` used to assert only that
|
||||
`terminateJob`, `listJobProcessIds` and `assignCurrentProcessToJob` are
|
||||
exported. All three predate #19068, so a `conpty.node` built before it passed
|
||||
every gate: `isPtyJobOwnershipAvailable()` returned true and
|
||||
`windows-pty-job.win32.test.ts` passed 6/6, while
|
||||
`windows-msys-job.win32.test.ts` failed with a two-pid job list that read as a
|
||||
source defect rather than a build-freshness one.
|
||||
|
||||
When that test fails, check the binary before the code:
|
||||
|
||||
```js
|
||||
// UTF-16LE, because usesCygwinRuntime holds the literals
|
||||
readFileSync(conptyNodePath).includes(Buffer.from('msys-2.0.dll', 'utf16le'))
|
||||
```
|
||||
|
||||
False means the addon predates the fix; rebuild node-pty from patched source.
|
||||
Note that a git worktree sharing `node_modules` with its main checkout shares
|
||||
that checkout's `build/Release/conpty.node`, so pinning the _source_ to a commit
|
||||
does not pin the _addon_.
|
||||
|
||||
The gate asserts that marker, the way `stagedRelayAddonIsUnpatched()` in
|
||||
`src/main/windows/windows-process-table.ts` already sniffs a patched addon by a
|
||||
binary import name. Symbol presence cannot distinguish patch revisions; a marker
|
||||
can.
|
||||
|
||||
Because the marker is a literal in `conpty.cc` and the gate's copy of it is a
|
||||
separate constant, `ensure-native-runtime-job-ownership.test.mjs` asserts the
|
||||
patch still adds `L"msys-2.0.dll"` to that file. Without that, editing the patch
|
||||
would turn the gate into a permanent false positive that fails every correctly
|
||||
rebuilt addon and tells the developer to do the one thing that cannot help.
|
||||
|
||||
## Every path the loader can fall through to
|
||||
|
||||
`loadNativeModule` tries `build/Release`, then `build/Debug`, then
|
||||
`prebuilds/win32-<arch>`, each relative to node-pty's root and then to `lib/`,
|
||||
swallowing every failure in between. A require of a wrong-architecture `.node`
|
||||
is one of those failures, so the candidate that runs is the first one the target
|
||||
arch can actually load. The published prebuild is always the last candidate and
|
||||
never carries the patch:
|
||||
|
||||
| package | `build/Release` | prebuild pruned? | what the app loads |
|
||||
| -------------------- | ----------------------------- | ---------------- | ------------------ |
|
||||
| same host, same arch | patched | yes | `build/Release` |
|
||||
| cross host | absent, cannot be cross-built | no | the prebuild |
|
||||
| cross arch, built | patched, target arch | no | `build/Release` |
|
||||
| cross arch, failed | the host's arch | no | the prebuild |
|
||||
|
||||
`beforeBuild` runs `rebuild-native-deps.mjs --platform=win32 --arch=<target>`, so
|
||||
a cross-arch slice normally does get a patched `build/Release` for the target —
|
||||
row three is a correct package whose leftover prebuild is never reached.
|
||||
`prunePackagedNodePty` keeps that prebuild anyway, because its guard is
|
||||
`electronArch === process.arch` rather than the arch of the binary.
|
||||
|
||||
So presence alone cannot separate row three from row four, and failing on any
|
||||
unmarked file present would reject a correct package with advice its builder
|
||||
could not act on. `verifyPackagedConptyBreakawayMarker` instead resolves the
|
||||
addon the way the loader does — first candidate whose PE `IMAGE_FILE_HEADER`
|
||||
machine matches the target — and checks the marker on that one. A package with
|
||||
no candidate at all, or none of the target's architecture, is refused: it has no
|
||||
ConPTY backend to load.
|
||||
@@ -241,6 +241,10 @@ export const electronViteConfig: UserConfig = {
|
||||
'port-scan-command-worker-entry': resolve(
|
||||
'src/main/ports/port-scan-command-worker-entry.ts'
|
||||
),
|
||||
// Why: the Claude/Codex/OpenCode usage scans walk whole history
|
||||
// corpora and read SQLite synchronously; a worker thread keeps that
|
||||
// off the main-process event loop.
|
||||
'usage-scan-worker-entry': resolve('src/main/usage/usage-scan-worker-entry.ts'),
|
||||
// Why: forked with ELECTRON_RUN_AS_NODE so @parcel/watcher faults
|
||||
// can't take down the main process (issue #7547).
|
||||
'parcel-watcher-process-entry': resolve('src/main/ipc/parcel-watcher-process-entry.ts'),
|
||||
|
||||
Generated
+1265
-1509
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,9 @@
|
||||
"family": "aiVault.history",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "7add46922ba5486e56c8acd99a53d083b605f5d98f3cc44ee3cb350ec0406080",
|
||||
"scenarioSha256": "0431ac82cbb8c60b16f4432fd0da7cff665485d668e512b20fc1168ec63db3fe",
|
||||
"platform": "darwin",
|
||||
@@ -13,51 +13,9 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"56c96fec6d08": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}",
|
||||
"sent": 2
|
||||
},
|
||||
"6e50957443ea": {
|
||||
"name": "status.get#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "status.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-1",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"capabilities": ["aiVault.v1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"852980e2efc0": {
|
||||
"name": "status.get#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}",
|
||||
"sent": 1
|
||||
},
|
||||
"b567072e5440": {
|
||||
"3719c54df702": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"ordinal": 3,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -98,6 +56,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"4a76a58cbdac": {
|
||||
"name": "status.get#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "status.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-1",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"capabilities": ["aiVault.v1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"97bd894604ee": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"ordinal": 4,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}"
|
||||
},
|
||||
"cd739a80b7a8": {
|
||||
"activeWorktreePath": "/repo/feature",
|
||||
"hostStatusResult": {
|
||||
@@ -117,6 +114,11 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"d08f74d65ee6": {
|
||||
"name": "status.get#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -132,8 +134,8 @@
|
||||
{
|
||||
"id": "ready",
|
||||
"observation": {
|
||||
"sender": ["6e50957443ea", "b567072e5440"],
|
||||
"payloads": ["852980e2efc0", "56c96fec6d08"],
|
||||
"sender": ["4a76a58cbdac", "3719c54df702"],
|
||||
"payloads": ["d08f74d65ee6", "97bd894604ee"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "aiVault.history",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "7add46922ba5486e56c8acd99a53d083b605f5d98f3cc44ee3cb350ec0406080",
|
||||
"scenarioSha256": "e97c6db772a70ee1912419ac67835b6074aaf9a341d4360389a11465f08092c5",
|
||||
"platform": "darwin",
|
||||
@@ -24,8 +24,9 @@
|
||||
"kind": "unsupported"
|
||||
}
|
||||
},
|
||||
"6f30f8b6f3d7": {
|
||||
"a3b75b607f63": {
|
||||
"name": "status.get#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -57,10 +58,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"852980e2efc0": {
|
||||
"d08f74d65ee6": {
|
||||
"name": "status.get#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}",
|
||||
"sent": 1
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
@@ -77,8 +78,8 @@
|
||||
{
|
||||
"id": "unsupported",
|
||||
"observation": {
|
||||
"sender": ["6f30f8b6f3d7"],
|
||||
"payloads": ["852980e2efc0"],
|
||||
"sender": ["a3b75b607f63"],
|
||||
"payloads": ["d08f74d65ee6"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "aiVault.history",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "7add46922ba5486e56c8acd99a53d083b605f5d98f3cc44ee3cb350ec0406080",
|
||||
"scenarioSha256": "10011c7c75e74d9c2e880c5458c9156264ae07e91956a80946ede1d4e684952a",
|
||||
"platform": "darwin",
|
||||
@@ -13,21 +13,9 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"33d053404f10": {
|
||||
"activeWorktreePath": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"hostStatusResult": {
|
||||
"capabilities": ["aiVault.v1"]
|
||||
},
|
||||
"refreshing": false,
|
||||
"scope": "workspace",
|
||||
"screenState": {
|
||||
"kind": "loading"
|
||||
}
|
||||
},
|
||||
"38364726a135": {
|
||||
"05215ca2ea35": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -68,13 +56,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"4f3bdb245d26": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}",
|
||||
"sent": 3
|
||||
"33d053404f10": {
|
||||
"activeWorktreePath": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"hostStatusResult": {
|
||||
"capabilities": ["aiVault.v1"]
|
||||
},
|
||||
"refreshing": false,
|
||||
"scope": "workspace",
|
||||
"screenState": {
|
||||
"kind": "loading"
|
||||
}
|
||||
},
|
||||
"6e50957443ea": {
|
||||
"4a76a58cbdac": {
|
||||
"name": "status.get#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -106,13 +103,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"852980e2efc0": {
|
||||
"name": "status.get#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}",
|
||||
"sent": 1
|
||||
},
|
||||
"9cac597c56da": {
|
||||
"4e35efd1ee45": {
|
||||
"name": "status.get#2",
|
||||
"ordinal": 3,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -144,10 +137,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"b33a14df0df6": {
|
||||
"name": "status.get#2",
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}",
|
||||
"sent": 2
|
||||
"72d9a278aca3": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"ordinal": 6,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}"
|
||||
},
|
||||
"cd739a80b7a8": {
|
||||
"activeWorktreePath": "/repo/feature",
|
||||
@@ -168,6 +161,11 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"d08f74d65ee6": {
|
||||
"name": "status.get#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -175,6 +173,11 @@
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"f1834177e593": {
|
||||
"name": "status.get#2",
|
||||
"ordinal": 4,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -183,8 +186,8 @@
|
||||
{
|
||||
"id": "held",
|
||||
"observation": {
|
||||
"sender": ["6e50957443ea"],
|
||||
"payloads": ["852980e2efc0"],
|
||||
"sender": ["4a76a58cbdac"],
|
||||
"payloads": ["d08f74d65ee6"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
@@ -195,8 +198,8 @@
|
||||
{
|
||||
"id": "ready",
|
||||
"observation": {
|
||||
"sender": ["6e50957443ea", "9cac597c56da", "38364726a135"],
|
||||
"payloads": ["852980e2efc0", "b33a14df0df6", "4f3bdb245d26"],
|
||||
"sender": ["4a76a58cbdac", "4e35efd1ee45", "05215ca2ea35"],
|
||||
"payloads": ["d08f74d65ee6", "f1834177e593", "72d9a278aca3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"worktrees-loaded": "eb79a9b3682a"
|
||||
|
||||
@@ -3,20 +3,41 @@
|
||||
"family": "aiVault.history-screen",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "694e6a331a7b0a742059ac6575ee8fd5cdeb3ba80abe58cbd216f6bc4a24e400",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3",
|
||||
"scenarioSha256": "d52d3c5858298a4a6a90bd9a8986b780004477de105fe93f6303d9c303ffea38",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"017d690f964b": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}",
|
||||
"sent": 4
|
||||
"03106dceb986": {
|
||||
"name": "status.get#1",
|
||||
"ordinal": 2,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "status.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"035edfd9a1b9": {
|
||||
"crash": {
|
||||
@@ -35,8 +56,107 @@
|
||||
"labels": ["Back", "Refresh agent sessions"],
|
||||
"text": ["Agent Session History", "orca-history", "Workspace", "Project", "All"]
|
||||
},
|
||||
"074d2293c010": {
|
||||
"3a91d2093c6e": {
|
||||
"name": "worktree.ps#1",
|
||||
"ordinal": 3,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}"
|
||||
},
|
||||
"3aaa7e04cda5": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "aiVault.listSessions"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"force": false,
|
||||
"limit": 500,
|
||||
"scopePaths": ["/repo/feature"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"4cebf6258ec1": {
|
||||
"name": "worktree.ps#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "worktree.ps"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"limit": 10000
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"4d676e55076c": {
|
||||
"name": "status.get#2",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "status.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-3",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"capabilities": ["aiVault.v1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"4d68bff46cff": {
|
||||
"name": "status.get#1",
|
||||
"ordinal": 4,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
|
||||
},
|
||||
"8d930b47bf2f": {
|
||||
"name": "worktree.ps#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -79,101 +199,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"29ba09534e96": {
|
||||
"name": "status.get#2",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "status.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-3",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"capabilities": ["aiVault.v1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"522b745543f1": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "aiVault.listSessions"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"force": false,
|
||||
"limit": 500,
|
||||
"scopePaths": ["/repo/feature"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"6ae619d3108a": {
|
||||
"name": "status.get#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "status.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"capabilities": ["aiVault.v1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"8c2a1dcb9598": {
|
||||
"a6bb0efe5318": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -235,8 +263,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ba9fd57319d3": {
|
||||
"acf501fa9958": {
|
||||
"name": "status.get#1",
|
||||
"ordinal": 2,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -256,35 +285,23 @@
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"bc1a8e138f82": {
|
||||
"name": "worktree.ps#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "worktree.ps"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"limit": 10000
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"capabilities": ["aiVault.v1"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"cee4e3edb0a1": {
|
||||
"name": "status.get#2",
|
||||
"ordinal": 6,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
|
||||
},
|
||||
"d22bb2f62cea": {
|
||||
"crash": {
|
||||
"$rpc": "null"
|
||||
@@ -301,15 +318,10 @@
|
||||
"labels": ["Back", "Refresh agent sessions"],
|
||||
"text": ["Agent Session History", "orca-history"]
|
||||
},
|
||||
"d4ef0569dbbc": {
|
||||
"name": "status.get#2",
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}",
|
||||
"sent": 3
|
||||
},
|
||||
"de79fb948454": {
|
||||
"name": "worktree.ps#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}",
|
||||
"sent": 2
|
||||
"d3c3ce66625d": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}"
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
@@ -318,11 +330,6 @@
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"ff0ffaddbbf7": {
|
||||
"name": "status.get#1",
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}",
|
||||
"sent": 2
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -331,8 +338,8 @@
|
||||
{
|
||||
"id": "worktrees-pending",
|
||||
"observation": {
|
||||
"sender": ["bc1a8e138f82", "ba9fd57319d3"],
|
||||
"payloads": ["de79fb948454", "ff0ffaddbbf7"],
|
||||
"sender": ["4cebf6258ec1", "03106dceb986"],
|
||||
"payloads": ["3a91d2093c6e", "4d68bff46cff"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
@@ -343,8 +350,8 @@
|
||||
{
|
||||
"id": "worktrees-listed",
|
||||
"observation": {
|
||||
"sender": ["074d2293c010", "6ae619d3108a", "29ba09534e96", "522b745543f1"],
|
||||
"payloads": ["de79fb948454", "ff0ffaddbbf7", "d4ef0569dbbc", "017d690f964b"],
|
||||
"sender": ["8d930b47bf2f", "acf501fa9958", "4d676e55076c", "3aaa7e04cda5"],
|
||||
"payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
@@ -355,8 +362,8 @@
|
||||
{
|
||||
"id": "ready",
|
||||
"observation": {
|
||||
"sender": ["074d2293c010", "6ae619d3108a", "29ba09534e96", "8c2a1dcb9598"],
|
||||
"payloads": ["de79fb948454", "ff0ffaddbbf7", "d4ef0569dbbc", "017d690f964b"],
|
||||
"sender": ["8d930b47bf2f", "acf501fa9958", "4d676e55076c", "a6bb0efe5318"],
|
||||
"payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
|
||||
@@ -3,23 +3,143 @@
|
||||
"family": "aiVault.history-screen",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"adapterSha256": "694e6a331a7b0a742059ac6575ee8fd5cdeb3ba80abe58cbd216f6bc4a24e400",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "c78ab47eea594b7e1988403321ff9ba135ca60c513bb9d5848bdde26c0ffe3c3",
|
||||
"scenarioSha256": "f50f63c4e2a69793b3d322ed16089c4241ff6169d8f9549106480230fb8dd5e7",
|
||||
"platform": "darwin",
|
||||
"scenarioVersion": 1,
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"017d690f964b": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}",
|
||||
"sent": 4
|
||||
"03106dceb986": {
|
||||
"name": "status.get#1",
|
||||
"ordinal": 2,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "status.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"074d2293c010": {
|
||||
"3a91d2093c6e": {
|
||||
"name": "worktree.ps#1",
|
||||
"ordinal": 3,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}"
|
||||
},
|
||||
"3aaa7e04cda5": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "aiVault.listSessions"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"force": false,
|
||||
"limit": 500,
|
||||
"scopePaths": ["/repo/feature"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"4cebf6258ec1": {
|
||||
"name": "worktree.ps#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "worktree.ps"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"limit": 10000
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"4d676e55076c": {
|
||||
"name": "status.get#2",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "status.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-3",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"capabilities": ["aiVault.v1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"4d68bff46cff": {
|
||||
"name": "status.get#1",
|
||||
"ordinal": 4,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
|
||||
},
|
||||
"8d930b47bf2f": {
|
||||
"name": "worktree.ps#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -62,68 +182,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"29ba09534e96": {
|
||||
"name": "status.get#2",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "status.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-3",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"capabilities": ["aiVault.v1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"522b745543f1": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "aiVault.listSessions"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"force": false,
|
||||
"limit": 500,
|
||||
"scopePaths": ["/repo/feature"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"6ae619d3108a": {
|
||||
"acf501fa9958": {
|
||||
"name": "status.get#1",
|
||||
"ordinal": 2,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -155,55 +216,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"ba9fd57319d3": {
|
||||
"name": "status.get#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "status.get"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"bc1a8e138f82": {
|
||||
"name": "worktree.ps#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "worktree.ps"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"limit": 10000
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
"cee4e3edb0a1": {
|
||||
"name": "status.get#2",
|
||||
"ordinal": 6,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}"
|
||||
},
|
||||
"d22bb2f62cea": {
|
||||
"crash": {
|
||||
@@ -221,15 +237,10 @@
|
||||
"labels": ["Back", "Refresh agent sessions"],
|
||||
"text": ["Agent Session History", "orca-history"]
|
||||
},
|
||||
"d4ef0569dbbc": {
|
||||
"name": "status.get#2",
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}",
|
||||
"sent": 3
|
||||
},
|
||||
"de79fb948454": {
|
||||
"name": "worktree.ps#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"worktree.ps\",\"params\":{\"limit\":10000,\"supportsWorktreeVisibilitySourceDefaults\":true}}",
|
||||
"sent": 2
|
||||
"d3c3ce66625d": {
|
||||
"name": "aiVault.listSessions#1",
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.listSessions\",\"params\":{\"limit\":500,\"force\":false,\"scopePaths\":[\"/repo/feature\"]}}"
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
@@ -238,11 +249,6 @@
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"ff0ffaddbbf7": {
|
||||
"name": "status.get#1",
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"status.get\"}",
|
||||
"sent": 2
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -251,8 +257,8 @@
|
||||
{
|
||||
"id": "worktrees-pending",
|
||||
"observation": {
|
||||
"sender": ["bc1a8e138f82", "ba9fd57319d3"],
|
||||
"payloads": ["de79fb948454", "ff0ffaddbbf7"],
|
||||
"sender": ["4cebf6258ec1", "03106dceb986"],
|
||||
"payloads": ["3a91d2093c6e", "4d68bff46cff"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
@@ -263,8 +269,8 @@
|
||||
{
|
||||
"id": "worktrees-listed",
|
||||
"observation": {
|
||||
"sender": ["074d2293c010", "6ae619d3108a", "29ba09534e96", "522b745543f1"],
|
||||
"payloads": ["de79fb948454", "ff0ffaddbbf7", "d4ef0569dbbc", "017d690f964b"],
|
||||
"sender": ["8d930b47bf2f", "acf501fa9958", "4d676e55076c", "3aaa7e04cda5"],
|
||||
"payloads": ["3a91d2093c6e", "4d68bff46cff", "cee4e3edb0a1", "d3c3ce66625d"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "aiVault.resume-launch",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb",
|
||||
"scenarioSha256": "ebf1bbc01ad7704fe79eff0969fcc2d4af8ebfa7c2410d2ed9d3ae8c6976b434",
|
||||
"platform": "darwin",
|
||||
@@ -13,22 +13,9 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"62643ed38326": {
|
||||
"failure": "Workspace is busy",
|
||||
"launched": "unlaunched"
|
||||
},
|
||||
"6e913cd7b306": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"error": {
|
||||
"category": "Error",
|
||||
"message": "Workspace is busy",
|
||||
"isRpcDeliveryUnknown": false
|
||||
}
|
||||
},
|
||||
"a7dec90e01a8": {
|
||||
"3e6273f4fe55": {
|
||||
"name": "session.tabs.createTerminal#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -64,10 +51,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"af416f104f9a": {
|
||||
"62643ed38326": {
|
||||
"failure": "Workspace is busy",
|
||||
"launched": "unlaunched"
|
||||
},
|
||||
"6e913cd7b306": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"error": {
|
||||
"category": "Error",
|
||||
"message": "Workspace is busy",
|
||||
"isRpcDeliveryUnknown": false
|
||||
}
|
||||
},
|
||||
"88cd0d2b1302": {
|
||||
"name": "session.tabs.createTerminal#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"session.tabs.createTerminal\",\"params\":{\"worktree\":\"id:workspace-1\",\"activate\":false,\"select\":true,\"navigation\":\"caller\"}}",
|
||||
"sent": 1
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"session.tabs.createTerminal\",\"params\":{\"worktree\":\"id:workspace-1\",\"activate\":false,\"select\":true,\"navigation\":\"caller\"}}"
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -76,8 +77,8 @@
|
||||
{
|
||||
"id": "create-refused",
|
||||
"observation": {
|
||||
"sender": ["a7dec90e01a8"],
|
||||
"payloads": ["af416f104f9a"],
|
||||
"sender": ["3e6273f4fe55"],
|
||||
"payloads": ["88cd0d2b1302"],
|
||||
"settlements": {
|
||||
"bare": "6e913cd7b306"
|
||||
},
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "aiVault.resume-launch",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb",
|
||||
"scenarioSha256": "281ccf5a082f3788ae8bf19f742ea4baf35c20c78bc91d7d91873845583e1a91",
|
||||
"platform": "darwin",
|
||||
@@ -13,27 +13,28 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"432332c9740e": {
|
||||
"failure": "Created terminal response was invalid",
|
||||
"launched": "unlaunched"
|
||||
},
|
||||
"495d8519301e": {
|
||||
"0bce25f5646d": {
|
||||
"name": "session.tabs.createTerminal#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"session.tabs.createTerminal\",\"params\":{\"worktree\":\"id:workspace-1\",\"env\":{\"ORCA_RESUME\":\"1\"},\"envToDelete\":[\"CODEX_HOME\"],\"launchAgent\":\"codex\",\"clientMutationId\":\"resume-mutation-1\",\"activate\":false,\"select\":true,\"navigation\":\"caller\"}}",
|
||||
"sent": 1
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"session.tabs.createTerminal\",\"params\":{\"worktree\":\"id:workspace-1\",\"env\":{\"ORCA_RESUME\":\"1\"},\"envToDelete\":[\"CODEX_HOME\"],\"launchAgent\":\"codex\",\"clientMutationId\":\"resume-mutation-1\",\"activate\":false,\"select\":true,\"navigation\":\"caller\"}}"
|
||||
},
|
||||
"681fc4d59b92": {
|
||||
"478ad4afe83e": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"error": {
|
||||
"category": "Error",
|
||||
"message": "Created terminal response was invalid",
|
||||
"message": "The host sent a reply this app could not read (session.tabs.createTerminal)",
|
||||
"isRpcDeliveryUnknown": false
|
||||
}
|
||||
},
|
||||
"af9961132c24": {
|
||||
"84a5c9365ee8": {
|
||||
"failure": "The host sent a reply this app could not read (session.tabs.createTerminal)",
|
||||
"launched": "unlaunched"
|
||||
},
|
||||
"8de69c55b96f": {
|
||||
"name": "session.tabs.createTerminal#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -83,12 +84,12 @@
|
||||
{
|
||||
"id": "invalid-tab",
|
||||
"observation": {
|
||||
"sender": ["af9961132c24"],
|
||||
"payloads": ["495d8519301e"],
|
||||
"sender": ["8de69c55b96f"],
|
||||
"payloads": ["0bce25f5646d"],
|
||||
"settlements": {
|
||||
"full": "681fc4d59b92"
|
||||
"full": "478ad4afe83e"
|
||||
},
|
||||
"state": "432332c9740e",
|
||||
"state": "84a5c9365ee8",
|
||||
"effects": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "aiVault.resume-launch",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb",
|
||||
"scenarioSha256": "941fcf202417c94ef546f5ee331983689d8b72397dac6f61dda944ca24abed9e",
|
||||
"platform": "darwin",
|
||||
@@ -13,6 +13,11 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"0bce25f5646d": {
|
||||
"name": "session.tabs.createTerminal#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"session.tabs.createTerminal\",\"params\":{\"worktree\":\"id:workspace-1\",\"env\":{\"ORCA_RESUME\":\"1\"},\"envToDelete\":[\"CODEX_HOME\"],\"launchAgent\":\"codex\",\"clientMutationId\":\"resume-mutation-1\",\"activate\":false,\"select\":true,\"navigation\":\"caller\"}}"
|
||||
},
|
||||
"0f026fafa7e1": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
@@ -23,55 +28,14 @@
|
||||
"isRpcDeliveryUnknown": false
|
||||
}
|
||||
},
|
||||
"39cabd8258a3": {
|
||||
"7d9e7036f3d0": {
|
||||
"name": "terminal.send#1",
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-9\",\"text\":\"codex resume rollout\",\"enter\":true}}",
|
||||
"sent": 2
|
||||
"ordinal": 4,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-9\",\"text\":\"codex resume rollout\",\"enter\":true}}"
|
||||
},
|
||||
"495d8519301e": {
|
||||
"name": "session.tabs.createTerminal#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"session.tabs.createTerminal\",\"params\":{\"worktree\":\"id:workspace-1\",\"env\":{\"ORCA_RESUME\":\"1\"},\"envToDelete\":[\"CODEX_HOME\"],\"launchAgent\":\"codex\",\"clientMutationId\":\"resume-mutation-1\",\"activate\":false,\"select\":true,\"navigation\":\"caller\"}}",
|
||||
"sent": 1
|
||||
},
|
||||
"5f84c02b1f7b": {
|
||||
"name": "terminal.send#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "terminal.send"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"enter": true,
|
||||
"terminal": "terminal-9",
|
||||
"text": "codex resume rollout"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"send": {
|
||||
"accepted": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"6b1e36abce6b": {
|
||||
"a67829619e64": {
|
||||
"name": "session.tabs.createTerminal#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -117,6 +81,44 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"cb651a7fb6ff": {
|
||||
"name": "terminal.send#1",
|
||||
"ordinal": 3,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "terminal.send"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"enter": true,
|
||||
"terminal": "terminal-9",
|
||||
"text": "codex resume rollout"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"send": {
|
||||
"accepted": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"da57c90b80d6": {
|
||||
"failure": "Terminal input is locked",
|
||||
"launched": "unlaunched"
|
||||
@@ -128,8 +130,8 @@
|
||||
{
|
||||
"id": "input-locked",
|
||||
"observation": {
|
||||
"sender": ["6b1e36abce6b", "5f84c02b1f7b"],
|
||||
"payloads": ["495d8519301e", "39cabd8258a3"],
|
||||
"sender": ["a67829619e64", "cb651a7fb6ff"],
|
||||
"payloads": ["0bce25f5646d", "7d9e7036f3d0"],
|
||||
"settlements": {
|
||||
"full": "0f026fafa7e1"
|
||||
},
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "aiVault.resume-launch",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb",
|
||||
"scenarioSha256": "f0ac1c996e5b1b4043e0a081978476c6c2dd8a5d517d5752857721205a588fb0",
|
||||
"platform": "darwin",
|
||||
@@ -13,10 +13,10 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"39cabd8258a3": {
|
||||
"name": "terminal.send#1",
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-9\",\"text\":\"codex resume rollout\",\"enter\":true}}",
|
||||
"sent": 2
|
||||
"0bce25f5646d": {
|
||||
"name": "session.tabs.createTerminal#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"session.tabs.createTerminal\",\"params\":{\"worktree\":\"id:workspace-1\",\"env\":{\"ORCA_RESUME\":\"1\"},\"envToDelete\":[\"CODEX_HOME\"],\"launchAgent\":\"codex\",\"clientMutationId\":\"resume-mutation-1\",\"activate\":false,\"select\":true,\"navigation\":\"caller\"}}"
|
||||
},
|
||||
"400d946a183d": {
|
||||
"failure": {
|
||||
@@ -28,13 +28,24 @@
|
||||
"title": "codex"
|
||||
}
|
||||
},
|
||||
"495d8519301e": {
|
||||
"name": "session.tabs.createTerminal#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"session.tabs.createTerminal\",\"params\":{\"worktree\":\"id:workspace-1\",\"env\":{\"ORCA_RESUME\":\"1\"},\"envToDelete\":[\"CODEX_HOME\"],\"launchAgent\":\"codex\",\"clientMutationId\":\"resume-mutation-1\",\"activate\":false,\"select\":true,\"navigation\":\"caller\"}}",
|
||||
"sent": 1
|
||||
"6e79da536ca9": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "tab-9",
|
||||
"terminal": "terminal-9",
|
||||
"title": "codex"
|
||||
}
|
||||
},
|
||||
"6b1e36abce6b": {
|
||||
"7d9e7036f3d0": {
|
||||
"name": "terminal.send#1",
|
||||
"ordinal": 4,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"terminal.send\",\"params\":{\"terminal\":\"terminal-9\",\"text\":\"codex resume rollout\",\"enter\":true}}"
|
||||
},
|
||||
"a67829619e64": {
|
||||
"name": "session.tabs.createTerminal#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -80,18 +91,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"6e79da536ca9": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "tab-9",
|
||||
"terminal": "terminal-9",
|
||||
"title": "codex"
|
||||
}
|
||||
},
|
||||
"a84f5d45a48b": {
|
||||
"cceac9bcb36d": {
|
||||
"name": "terminal.send#1",
|
||||
"ordinal": 3,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -134,8 +136,8 @@
|
||||
{
|
||||
"id": "resumed",
|
||||
"observation": {
|
||||
"sender": ["6b1e36abce6b", "a84f5d45a48b"],
|
||||
"payloads": ["495d8519301e", "39cabd8258a3"],
|
||||
"sender": ["a67829619e64", "cceac9bcb36d"],
|
||||
"payloads": ["0bce25f5646d", "7d9e7036f3d0"],
|
||||
"settlements": {
|
||||
"full": "6e79da536ca9"
|
||||
},
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "aiVault.resume-preparation",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb",
|
||||
"scenarioSha256": "dc9926f95475413627315e1f1c96740ececa697c6a0a5e3c42e18cfd4d58448a",
|
||||
"platform": "darwin",
|
||||
@@ -23,8 +23,9 @@
|
||||
"isRpcDeliveryUnknown": false
|
||||
}
|
||||
},
|
||||
"3a299ed75c3d": {
|
||||
"6f601b6d009a": {
|
||||
"name": "aiVault.prepareSessionResume#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -60,14 +61,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"770627dbd25d": {
|
||||
"name": "aiVault.prepareSessionResume#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.prepareSessionResume\",\"params\":{\"agent\":\"codex\",\"filePath\":\"/sessions/rollout.jsonl\",\"codexHome\":\"/hosts/codex-runtime-home/home\",\"executionHostId\":\"local\"}}",
|
||||
"sent": 1
|
||||
},
|
||||
"7e4864f4412b": {
|
||||
"failure": "codex home is locked",
|
||||
"prepared": "unprepared"
|
||||
},
|
||||
"88c8bb20eb6f": {
|
||||
"name": "aiVault.prepareSessionResume#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.prepareSessionResume\",\"params\":{\"agent\":\"codex\",\"filePath\":\"/sessions/rollout.jsonl\",\"codexHome\":\"/hosts/codex-runtime-home/home\",\"executionHostId\":\"local\"}}"
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -76,8 +77,8 @@
|
||||
{
|
||||
"id": "refused",
|
||||
"observation": {
|
||||
"sender": ["3a299ed75c3d"],
|
||||
"payloads": ["770627dbd25d"],
|
||||
"sender": ["6f601b6d009a"],
|
||||
"payloads": ["88c8bb20eb6f"],
|
||||
"settlements": {
|
||||
"prepare": "1c21b98bedb1"
|
||||
},
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "aiVault.resume-preparation",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb",
|
||||
"scenarioSha256": "6719aea706086a68709894546f9595264407db2df94fa56180cb3c68b08aaa69",
|
||||
"platform": "darwin",
|
||||
@@ -35,13 +35,9 @@
|
||||
"filePath": "/sessions/rollout.jsonl"
|
||||
}
|
||||
},
|
||||
"770627dbd25d": {
|
||||
"name": "aiVault.prepareSessionResume#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.prepareSessionResume\",\"params\":{\"agent\":\"codex\",\"filePath\":\"/sessions/rollout.jsonl\",\"codexHome\":\"/hosts/codex-runtime-home/home\",\"executionHostId\":\"local\"}}",
|
||||
"sent": 1
|
||||
},
|
||||
"d8803e70463f": {
|
||||
"66ff2c43e820": {
|
||||
"name": "aiVault.prepareSessionResume#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -76,6 +72,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"88c8bb20eb6f": {
|
||||
"name": "aiVault.prepareSessionResume#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.prepareSessionResume\",\"params\":{\"agent\":\"codex\",\"filePath\":\"/sessions/rollout.jsonl\",\"codexHome\":\"/hosts/codex-runtime-home/home\",\"executionHostId\":\"local\"}}"
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -84,8 +85,8 @@
|
||||
{
|
||||
"id": "repinned",
|
||||
"observation": {
|
||||
"sender": ["d8803e70463f"],
|
||||
"payloads": ["770627dbd25d"],
|
||||
"sender": ["66ff2c43e820"],
|
||||
"payloads": ["88c8bb20eb6f"],
|
||||
"settlements": {
|
||||
"prepare": "15e10cea84b9"
|
||||
},
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "aiVault.resume-preparation",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb",
|
||||
"scenarioSha256": "3afaf2807506f5bde2159b367f34c6b777739d6aad564796d54ac0da05b5bd02",
|
||||
"platform": "darwin",
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "aiVault.resume-preparation",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "2f43211e4084c0493bd02ec98868acf53a4c748f89a70cd9657c9c3ee87b12fb",
|
||||
"scenarioSha256": "a5eedea5f551e0ca0e143292f1d9aa8920dd7af62a02d8e8777666ab3779c019",
|
||||
"platform": "darwin",
|
||||
@@ -13,13 +13,25 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"770627dbd25d": {
|
||||
"88c8bb20eb6f": {
|
||||
"name": "aiVault.prepareSessionResume#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.prepareSessionResume\",\"params\":{\"agent\":\"codex\",\"filePath\":\"/sessions/rollout.jsonl\",\"codexHome\":\"/hosts/codex-runtime-home/home\",\"executionHostId\":\"local\"}}",
|
||||
"sent": 1
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"aiVault.prepareSessionResume\",\"params\":{\"agent\":\"codex\",\"filePath\":\"/sessions/rollout.jsonl\",\"codexHome\":\"/hosts/codex-runtime-home/home\",\"executionHostId\":\"local\"}}"
|
||||
},
|
||||
"a25c46d6c1db": {
|
||||
"e839ea279e77": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"agent": "codex",
|
||||
"codexHome": "/hosts/codex-runtime-home/home",
|
||||
"executionHostId": "local",
|
||||
"filePath": "/sessions/rollout.jsonl"
|
||||
}
|
||||
},
|
||||
"e8fdd9c76c0f": {
|
||||
"name": "aiVault.prepareSessionResume#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -55,17 +67,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"e839ea279e77": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"agent": "codex",
|
||||
"codexHome": "/hosts/codex-runtime-home/home",
|
||||
"executionHostId": "local",
|
||||
"filePath": "/sessions/rollout.jsonl"
|
||||
}
|
||||
},
|
||||
"f9dc8bff0bbd": {
|
||||
"failure": {
|
||||
"$rpc": "null"
|
||||
@@ -84,8 +85,8 @@
|
||||
{
|
||||
"id": "degraded-to-legacy",
|
||||
"observation": {
|
||||
"sender": ["a25c46d6c1db"],
|
||||
"payloads": ["770627dbd25d"],
|
||||
"sender": ["e8fdd9c76c0f"],
|
||||
"payloads": ["88c8bb20eb6f"],
|
||||
"settlements": {
|
||||
"prepare": "e839ea279e77"
|
||||
},
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "legacy-inventory",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "262eaad263a45aa13ec5b27c12b59946b12c202474229fff7a5727dba6d702ca",
|
||||
"scenarioSha256": "23ffc912a432dcd3ff70be1903a8d518cf85634f27a2be6d21585963e338e7e3",
|
||||
"platform": "darwin",
|
||||
@@ -13,160 +13,9 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"0d903486cbe8": {
|
||||
"name": "files.list#2",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "files.list"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"worktree": "id:A"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 240
|
||||
}
|
||||
},
|
||||
"1ba589a74085": {
|
||||
"name": "files.searchPaths#2",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "files.searchPaths"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"limit": 16,
|
||||
"query": "fresh",
|
||||
"worktree": "id:A"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 240,
|
||||
"settledAt": 240,
|
||||
"value": {
|
||||
"error": {
|
||||
"code": "method_not_found",
|
||||
"message": "Unknown method"
|
||||
},
|
||||
"id": "frame-3",
|
||||
"ok": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"387248eb3124": {
|
||||
"name": "files.searchPaths#2",
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"files.searchPaths\",\"params\":{\"worktree\":\"id:A\",\"query\":\"fresh\",\"limit\":16}}",
|
||||
"sent": 3
|
||||
},
|
||||
"3b6419fbab75": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 240,
|
||||
"settledAt": 240,
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"602e35a92eec": {
|
||||
"files": []
|
||||
},
|
||||
"603254c040fc": {
|
||||
"name": "files.list#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "files.list"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"worktree": "id:A"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 120,
|
||||
"settledAt": 240,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"files": [
|
||||
{
|
||||
"relativePath": "old.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"a129552fdc6e": {
|
||||
"files": ["third.ts"]
|
||||
},
|
||||
"a4643cbb0362": {
|
||||
"name": "files.list#2",
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"files.list\",\"params\":{\"worktree\":\"id:A\"}}",
|
||||
"sent": 4
|
||||
},
|
||||
"b2434f1de9f6": {
|
||||
"name": "files.list#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "files.list"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"worktree": "id:A"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 120
|
||||
}
|
||||
},
|
||||
"bb65fa195d1c": {
|
||||
"name": "files.list#1",
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"files.list\",\"params\":{\"worktree\":\"id:A\"}}",
|
||||
"sent": 2
|
||||
},
|
||||
"c0821dc354d7": {
|
||||
"32c9018052ba": {
|
||||
"name": "files.searchPaths#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -201,21 +50,54 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"cd59dd2431e0": {
|
||||
"name": "files.searchPaths#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"files.searchPaths\",\"params\":{\"worktree\":\"id:A\",\"query\":\"old\",\"limit\":16}}",
|
||||
"sent": 1
|
||||
},
|
||||
"d2ad71e601c4": {
|
||||
"3b6419fbab75": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 120,
|
||||
"settledAt": 120,
|
||||
"startedAt": 240,
|
||||
"settledAt": 240,
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"daf226a0261c": {
|
||||
"602e35a92eec": {
|
||||
"files": []
|
||||
},
|
||||
"82aa78cec383": {
|
||||
"name": "files.list#2",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "files.list"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"worktree": "id:A"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 240
|
||||
}
|
||||
},
|
||||
"8db0d1234c94": {
|
||||
"name": "files.searchPaths#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"files.searchPaths\",\"params\":{\"worktree\":\"id:A\",\"query\":\"old\",\"limit\":16}}"
|
||||
},
|
||||
"a129552fdc6e": {
|
||||
"files": ["third.ts"]
|
||||
},
|
||||
"a7dc0e022f15": {
|
||||
"name": "files.list#2",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -254,6 +136,93 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"b272f776e5de": {
|
||||
"name": "files.list#2",
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"files.list\",\"params\":{\"worktree\":\"id:A\"}}"
|
||||
},
|
||||
"c9b9d7a9b388": {
|
||||
"name": "files.list#1",
|
||||
"ordinal": 4,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"files.list\",\"params\":{\"worktree\":\"id:A\"}}"
|
||||
},
|
||||
"d2ad71e601c4": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 120,
|
||||
"settledAt": 120,
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"d9f32749aeb3": {
|
||||
"name": "files.list#1",
|
||||
"ordinal": 3,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "files.list"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"worktree": "id:A"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 120,
|
||||
"settledAt": 240,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"files": [
|
||||
{
|
||||
"relativePath": "old.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ea94d4508bba": {
|
||||
"name": "files.list#1",
|
||||
"ordinal": 3,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "files.list"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"worktree": "id:A"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 120
|
||||
}
|
||||
},
|
||||
"eae0ce09f0e8": {
|
||||
"name": "files.searchPaths#2",
|
||||
"ordinal": 6,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"files.searchPaths\",\"params\":{\"worktree\":\"id:A\",\"query\":\"fresh\",\"limit\":16}}"
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -261,6 +230,43 @@
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"f461501d556e": {
|
||||
"name": "files.searchPaths#2",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "files.searchPaths"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"limit": 16,
|
||||
"query": "fresh",
|
||||
"worktree": "id:A"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 240,
|
||||
"settledAt": 240,
|
||||
"value": {
|
||||
"error": {
|
||||
"code": "method_not_found",
|
||||
"message": "Unknown method"
|
||||
},
|
||||
"id": "frame-3",
|
||||
"ok": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -269,8 +275,8 @@
|
||||
{
|
||||
"id": "old-pending",
|
||||
"observation": {
|
||||
"sender": ["c0821dc354d7", "b2434f1de9f6"],
|
||||
"payloads": ["cd59dd2431e0", "bb65fa195d1c"],
|
||||
"sender": ["32c9018052ba", "ea94d4508bba"],
|
||||
"payloads": ["8db0d1234c94", "c9b9d7a9b388"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"old": "eb79a9b3682a"
|
||||
@@ -282,8 +288,8 @@
|
||||
{
|
||||
"id": "stale-arrived-fresh-pending",
|
||||
"observation": {
|
||||
"sender": ["c0821dc354d7", "603254c040fc", "1ba589a74085", "0d903486cbe8"],
|
||||
"payloads": ["cd59dd2431e0", "bb65fa195d1c", "387248eb3124", "a4643cbb0362"],
|
||||
"sender": ["32c9018052ba", "d9f32749aeb3", "f461501d556e", "82aa78cec383"],
|
||||
"payloads": ["8db0d1234c94", "c9b9d7a9b388", "eae0ce09f0e8", "b272f776e5de"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"old": "eb79a9b3682a",
|
||||
@@ -298,8 +304,8 @@
|
||||
{
|
||||
"id": "third-query",
|
||||
"observation": {
|
||||
"sender": ["c0821dc354d7", "603254c040fc", "1ba589a74085", "0d903486cbe8"],
|
||||
"payloads": ["cd59dd2431e0", "bb65fa195d1c", "387248eb3124", "a4643cbb0362"],
|
||||
"sender": ["32c9018052ba", "d9f32749aeb3", "f461501d556e", "82aa78cec383"],
|
||||
"payloads": ["8db0d1234c94", "c9b9d7a9b388", "eae0ce09f0e8", "b272f776e5de"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"old": "eb79a9b3682a",
|
||||
@@ -315,8 +321,8 @@
|
||||
{
|
||||
"id": "fresh-arrived",
|
||||
"observation": {
|
||||
"sender": ["c0821dc354d7", "603254c040fc", "1ba589a74085", "daf226a0261c"],
|
||||
"payloads": ["cd59dd2431e0", "bb65fa195d1c", "387248eb3124", "a4643cbb0362"],
|
||||
"sender": ["32c9018052ba", "d9f32749aeb3", "f461501d556e", "a7dc0e022f15"],
|
||||
"payloads": ["8db0d1234c94", "c9b9d7a9b388", "eae0ce09f0e8", "b272f776e5de"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"old": "eb79a9b3682a",
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "project-explicit-false",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab",
|
||||
"scenarioSha256": "b31992be2f91bd61fbe1b8a5400da3b7a56753564b0b0b2b38bc5d549812d693",
|
||||
"platform": "darwin",
|
||||
@@ -13,11 +13,6 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"02839f22d2db": {
|
||||
"name": "projectMutating",
|
||||
"value": false,
|
||||
"sent": 1
|
||||
},
|
||||
"0c4dced3e005": {
|
||||
"error": "",
|
||||
"mutating": true,
|
||||
@@ -32,27 +27,9 @@
|
||||
"itemType": "ISSUE"
|
||||
}
|
||||
},
|
||||
"204a5c5728c2": {
|
||||
"error": "Cannot read properties of null (reading 'ok')",
|
||||
"mutating": false,
|
||||
"row": {
|
||||
"content": {
|
||||
"assignees": [],
|
||||
"labels": [],
|
||||
"number": 1,
|
||||
"repository": "owner/repo"
|
||||
},
|
||||
"id": "item-1",
|
||||
"itemType": "ISSUE"
|
||||
}
|
||||
},
|
||||
"219dced97206": {
|
||||
"name": "github.project.updateIssueBySlug#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.project.updateIssueBySlug\",\"params\":{\"owner\":\"owner\",\"repo\":\"repo\",\"host\":\"github.enterprise.test\",\"number\":1,\"updates\":{\"addLabels\":[\"recorded\"]}}}",
|
||||
"sent": 1
|
||||
},
|
||||
"6f0142de3930": {
|
||||
"1158580f696d": {
|
||||
"name": "github.project.updateIssueBySlug#1",
|
||||
"ordinal": 2,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -90,22 +67,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"7b2465eedefe": {
|
||||
"204a5c5728c2": {
|
||||
"error": "Cannot read properties of null (reading 'ok')",
|
||||
"mutating": false,
|
||||
"row": {
|
||||
"content": {
|
||||
"assignees": [],
|
||||
"labels": [],
|
||||
"number": 1,
|
||||
"repository": "owner/repo"
|
||||
},
|
||||
"id": "item-1",
|
||||
"itemType": "ISSUE"
|
||||
}
|
||||
},
|
||||
"5d32aa29303c": {
|
||||
"name": "projectMutating",
|
||||
"value": true,
|
||||
"sent": 0
|
||||
"ordinal": 1,
|
||||
"value": true
|
||||
},
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
},
|
||||
"a7b76954b136": {
|
||||
"name": "projectRowDetailError",
|
||||
"value": "Cannot read properties of null (reading 'ok')",
|
||||
"sent": 1
|
||||
},
|
||||
"e5673036d45e": {
|
||||
"7ecf3a14081d": {
|
||||
"name": "github.project.updateIssueBySlug#1",
|
||||
"ordinal": 2,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -135,6 +118,20 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"9270aeb7d9c6": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
},
|
||||
"c4a308482e58": {
|
||||
"name": "github.project.updateIssueBySlug#1",
|
||||
"ordinal": 3,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"github.project.updateIssueBySlug\",\"params\":{\"owner\":\"owner\",\"repo\":\"repo\",\"host\":\"github.enterprise.test\",\"number\":1,\"updates\":{\"addLabels\":[\"recorded\"]}}}"
|
||||
},
|
||||
"e9296dde8e41": {
|
||||
"name": "projectMutating",
|
||||
"ordinal": 5,
|
||||
"value": false
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -142,6 +139,11 @@
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"f2f279e9fa21": {
|
||||
"name": "projectRowDetailError",
|
||||
"ordinal": 4,
|
||||
"value": "Cannot read properties of null (reading 'ok')"
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -150,27 +152,27 @@
|
||||
{
|
||||
"id": "pending",
|
||||
"observation": {
|
||||
"sender": ["e5673036d45e"],
|
||||
"payloads": ["219dced97206"],
|
||||
"sender": ["7ecf3a14081d"],
|
||||
"payloads": ["c4a308482e58"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"submit": "9270aeb7d9c6"
|
||||
},
|
||||
"state": "0c4dced3e005",
|
||||
"effects": ["7b2465eedefe"]
|
||||
"effects": ["5d32aa29303c"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "settled",
|
||||
"observation": {
|
||||
"sender": ["6f0142de3930"],
|
||||
"payloads": ["219dced97206"],
|
||||
"sender": ["1158580f696d"],
|
||||
"payloads": ["c4a308482e58"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"submit": "eb79a9b3682a"
|
||||
},
|
||||
"state": "204a5c5728c2",
|
||||
"effects": ["7b2465eedefe", "a7b76954b136", "02839f22d2db"]
|
||||
"effects": ["5d32aa29303c", "f2f279e9fa21", "e9296dde8e41"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "linear-detail-barrier",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "d3b7f33d810e1fa420ac41a628cde9fe4a9e65fd57f89fbca0a40fc7d74951ab",
|
||||
"scenarioSha256": "130e493fcd7765e037405f59e6cc78a0cc1793b1ae092cad933ff9d5a9df8b7a",
|
||||
"platform": "darwin",
|
||||
@@ -13,8 +13,120 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"034a83431f03": {
|
||||
"0056f47b204a": {
|
||||
"name": "linear.getIssue#1",
|
||||
"ordinal": 4,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "linear.getIssue"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"id": "issue-1",
|
||||
"workspaceId": "linear-workspace"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"1d3774209877": {
|
||||
"name": "linear.issueComments#1",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "linear.issueComments"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"issueId": "issue-1",
|
||||
"workspaceId": "linear-workspace"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"error": {
|
||||
"category": "Error",
|
||||
"message": "comments transport error",
|
||||
"isRpcDeliveryUnknown": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"1eaad537f925": {
|
||||
"name": "linear.getIssue#1",
|
||||
"ordinal": 6,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"linear.getIssue\",\"params\":{\"id\":\"issue-1\",\"workspaceId\":\"linear-workspace\"}}"
|
||||
},
|
||||
"264010756b38": {
|
||||
"name": "detailLoading",
|
||||
"ordinal": 3,
|
||||
"value": true
|
||||
},
|
||||
"33614c90b6eb": {
|
||||
"name": "detailPayload",
|
||||
"ordinal": 1,
|
||||
"value": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
},
|
||||
"42903545f0f8": {
|
||||
"error": "comments transport error",
|
||||
"loading": false,
|
||||
"payload": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
},
|
||||
"51ea77da7dbd": {
|
||||
"name": "detailError",
|
||||
"ordinal": 8,
|
||||
"value": "comments transport error"
|
||||
},
|
||||
"780aaf1d97be": {
|
||||
"error": "",
|
||||
"loading": true,
|
||||
"payload": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
},
|
||||
"7d8d46d494ff": {
|
||||
"name": "linear.issueComments#1",
|
||||
"ordinal": 7,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"linear.issueComments\",\"params\":{\"issueId\":\"issue-1\",\"workspaceId\":\"linear-workspace\"}}"
|
||||
},
|
||||
"8ccd4fa759a5": {
|
||||
"name": "detailLoading",
|
||||
"ordinal": 9,
|
||||
"value": false
|
||||
},
|
||||
"a4f1a8696a24": {
|
||||
"name": "detailError",
|
||||
"ordinal": 2,
|
||||
"value": ""
|
||||
},
|
||||
"d3dc79e5717b": {
|
||||
"name": "linear.getIssue#1",
|
||||
"ordinal": 4,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -48,13 +160,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"0e0abca05602": {
|
||||
"name": "detailError",
|
||||
"value": "comments transport error",
|
||||
"sent": 2
|
||||
},
|
||||
"3cb9a384ce0e": {
|
||||
"ea4a4f6523e7": {
|
||||
"name": "linear.issueComments#1",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -79,79 +187,6 @@
|
||||
"startedAt": 0
|
||||
}
|
||||
},
|
||||
"42903545f0f8": {
|
||||
"error": "comments transport error",
|
||||
"loading": false,
|
||||
"payload": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
},
|
||||
"46d0f1129c84": {
|
||||
"name": "linear.getIssue#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"linear.getIssue\",\"params\":{\"id\":\"issue-1\",\"workspaceId\":\"linear-workspace\"}}",
|
||||
"sent": 2
|
||||
},
|
||||
"4e7c4654b51d": {
|
||||
"name": "linear.issueComments#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "linear.issueComments"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"issueId": "issue-1",
|
||||
"workspaceId": "linear-workspace"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "rejected",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"error": {
|
||||
"category": "Error",
|
||||
"message": "comments transport error",
|
||||
"isRpcDeliveryUnknown": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"56d172ecd2fe": {
|
||||
"name": "detailLoading",
|
||||
"value": true,
|
||||
"sent": 0
|
||||
},
|
||||
"5b2b6dd0b30f": {
|
||||
"name": "linear.issueComments#1",
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"linear.issueComments\",\"params\":{\"issueId\":\"issue-1\",\"workspaceId\":\"linear-workspace\"}}",
|
||||
"sent": 2
|
||||
},
|
||||
"780aaf1d97be": {
|
||||
"error": "",
|
||||
"loading": true,
|
||||
"payload": {
|
||||
"$rpc": "null"
|
||||
}
|
||||
},
|
||||
"9bd1de5d9753": {
|
||||
"name": "detailPayload",
|
||||
"value": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"sent": 0
|
||||
},
|
||||
"9d6ce9f28401": {
|
||||
"name": "detailError",
|
||||
"value": "",
|
||||
"sent": 0
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -159,37 +194,6 @@
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"ee0c4638d266": {
|
||||
"name": "detailLoading",
|
||||
"value": false,
|
||||
"sent": 2
|
||||
},
|
||||
"fc4ce176400a": {
|
||||
"name": "linear.getIssue#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "linear.getIssue"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"id": "issue-1",
|
||||
"workspaceId": "linear-workspace"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"timeoutMs": 30000
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "pending",
|
||||
"startedAt": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -198,42 +202,42 @@
|
||||
{
|
||||
"id": "pending",
|
||||
"observation": {
|
||||
"sender": ["fc4ce176400a", "3cb9a384ce0e"],
|
||||
"payloads": ["46d0f1129c84", "5b2b6dd0b30f"],
|
||||
"sender": ["0056f47b204a", "ea4a4f6523e7"],
|
||||
"payloads": ["1eaad537f925", "7d8d46d494ff"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "780aaf1d97be",
|
||||
"effects": ["9bd1de5d9753", "9d6ce9f28401", "56d172ecd2fe"]
|
||||
"effects": ["33614c90b6eb", "a4f1a8696a24", "264010756b38"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "issue-refused-comments-pending",
|
||||
"observation": {
|
||||
"sender": ["034a83431f03", "3cb9a384ce0e"],
|
||||
"payloads": ["46d0f1129c84", "5b2b6dd0b30f"],
|
||||
"sender": ["d3dc79e5717b", "ea4a4f6523e7"],
|
||||
"payloads": ["1eaad537f925", "7d8d46d494ff"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "780aaf1d97be",
|
||||
"effects": ["9bd1de5d9753", "9d6ce9f28401", "56d172ecd2fe"]
|
||||
"effects": ["33614c90b6eb", "a4f1a8696a24", "264010756b38"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "settled",
|
||||
"observation": {
|
||||
"sender": ["034a83431f03", "4e7c4654b51d"],
|
||||
"payloads": ["46d0f1129c84", "5b2b6dd0b30f"],
|
||||
"sender": ["d3dc79e5717b", "1d3774209877"],
|
||||
"payloads": ["1eaad537f925", "7d8d46d494ff"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a"
|
||||
},
|
||||
"state": "42903545f0f8",
|
||||
"effects": [
|
||||
"9bd1de5d9753",
|
||||
"9d6ce9f28401",
|
||||
"56d172ecd2fe",
|
||||
"0e0abca05602",
|
||||
"ee0c4638d266"
|
||||
"33614c90b6eb",
|
||||
"a4f1a8696a24",
|
||||
"264010756b38",
|
||||
"51ea77da7dbd",
|
||||
"8ccd4fa759a5"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "browser.dialog",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658",
|
||||
"scenarioSha256": "95f377bc4d8bf1248cafed26b2e9f425ad37e8d3c99e354bd6a8c67eb9bd9b1b",
|
||||
"platform": "darwin",
|
||||
@@ -13,6 +13,11 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"93f4e9f3ead2": {
|
||||
"name": "browser.dialogAccept#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"browser.dialogAccept\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\"}}"
|
||||
},
|
||||
"9855d4ec3415": {
|
||||
"busy": false,
|
||||
"dialog": {
|
||||
@@ -24,21 +29,9 @@
|
||||
"keyboardValue": "hello",
|
||||
"pointerModifiers": []
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"f11babba920d": {
|
||||
"name": "browser.dialogAccept#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"browser.dialogAccept\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\"}}",
|
||||
"sent": 1
|
||||
},
|
||||
"f23289a40300": {
|
||||
"cd44f29d755e": {
|
||||
"name": "browser.dialogAccept#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -70,6 +63,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -78,8 +79,8 @@
|
||||
{
|
||||
"id": "dismissed",
|
||||
"observation": {
|
||||
"sender": ["f23289a40300"],
|
||||
"payloads": ["f11babba920d"],
|
||||
"sender": ["cd44f29d755e"],
|
||||
"payloads": ["93f4e9f3ead2"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"dialog": "eb79a9b3682a"
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "browser.dialog",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658",
|
||||
"scenarioSha256": "5071188ee492a4ced5be793b2dab32baa9750ee6535128635f274fd79198e2f1",
|
||||
"platform": "darwin",
|
||||
@@ -13,24 +13,9 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"786423f11435": {
|
||||
"name": "browser.dialogDismiss#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"browser.dialogDismiss\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\"}}",
|
||||
"sent": 1
|
||||
},
|
||||
"9855d4ec3415": {
|
||||
"busy": false,
|
||||
"dialog": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"keyboardValue": "hello",
|
||||
"pointerModifiers": []
|
||||
},
|
||||
"be3e7ad116a5": {
|
||||
"44cc0151aab3": {
|
||||
"name": "browser.dialogDismiss#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -63,6 +48,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"9855d4ec3415": {
|
||||
"busy": false,
|
||||
"dialog": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"keyboardValue": "hello",
|
||||
"pointerModifiers": []
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -70,6 +66,11 @@
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"f27b7eb2da31": {
|
||||
"name": "browser.dialogDismiss#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"browser.dialogDismiss\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\"}}"
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -78,8 +79,8 @@
|
||||
{
|
||||
"id": "dismissed",
|
||||
"observation": {
|
||||
"sender": ["be3e7ad116a5"],
|
||||
"payloads": ["786423f11435"],
|
||||
"sender": ["44cc0151aab3"],
|
||||
"payloads": ["f27b7eb2da31"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"dialog": "eb79a9b3682a"
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "browser.keyboard",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658",
|
||||
"scenarioSha256": "eb2294c30af70ebc2bac092dc5105249ae8cf1941f750406622f7ff6dd70cc1b",
|
||||
"platform": "darwin",
|
||||
@@ -13,25 +13,37 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"1bc6d9688999": {
|
||||
"name": "browser.keyboardInsertText#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"browser.keyboardInsertText\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"text\":\"hello\"}}",
|
||||
"sent": 1
|
||||
},
|
||||
"2f4d80d09d24": {
|
||||
"6104536ec606": {
|
||||
"name": "browser.keypress#1",
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"browser.keypress\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"key\":\"Enter\"}}",
|
||||
"sent": 2
|
||||
"ordinal": 5,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"browser.keypress\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"key\":\"Enter\"}}"
|
||||
},
|
||||
"5fe64ef6c1f3": {
|
||||
"8160e8872519": {
|
||||
"busy": false,
|
||||
"dialog": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"keyboardValue": "",
|
||||
"pointerModifiers": []
|
||||
},
|
||||
"86b725378b3b": {
|
||||
"name": "browser.keyboardInsertText#1",
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"browser.keyboardInsertText\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"text\":\"hello\"}}"
|
||||
},
|
||||
"a5e12fd12d8a": {
|
||||
"name": "toast",
|
||||
"ordinal": 3,
|
||||
"value": {
|
||||
"message": "Sent"
|
||||
},
|
||||
"sent": 1
|
||||
}
|
||||
},
|
||||
"770254847b6a": {
|
||||
"d1075b8afeb6": {
|
||||
"name": "browser.keyboardInsertText#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -65,19 +77,9 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"8160e8872519": {
|
||||
"busy": false,
|
||||
"dialog": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"keyboardValue": "",
|
||||
"pointerModifiers": []
|
||||
},
|
||||
"c532c7fdcc69": {
|
||||
"e6c97e728698": {
|
||||
"name": "browser.keypress#1",
|
||||
"ordinal": 4,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -126,15 +128,15 @@
|
||||
{
|
||||
"id": "typed",
|
||||
"observation": {
|
||||
"sender": ["770254847b6a", "c532c7fdcc69"],
|
||||
"payloads": ["1bc6d9688999", "2f4d80d09d24"],
|
||||
"sender": ["d1075b8afeb6", "e6c97e728698"],
|
||||
"payloads": ["86b725378b3b", "6104536ec606"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"text": "eb79a9b3682a",
|
||||
"keypress": "eb79a9b3682a"
|
||||
},
|
||||
"state": "8160e8872519",
|
||||
"effects": ["5fe64ef6c1f3"]
|
||||
"effects": ["a5e12fd12d8a"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "browser.pointer-click",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658",
|
||||
"scenarioSha256": "73faa87b359a11959543542016295bb6b36a10934dd99ad5c1a77a4290a608cd",
|
||||
"platform": "darwin",
|
||||
@@ -13,13 +13,33 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"0da10b838d9e": {
|
||||
"002a5bcbde42": {
|
||||
"name": "browser.mouseClick#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"browser.mouseClick\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"x\":40,\"y\":80,\"button\":\"left\",\"modifiers\":[],\"radius\":14}}",
|
||||
"sent": 1
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"browser.mouseClick\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"x\":40,\"y\":80,\"button\":\"left\",\"modifiers\":[],\"radius\":14}}"
|
||||
},
|
||||
"5b621e308200": {
|
||||
"9855d4ec3415": {
|
||||
"busy": false,
|
||||
"dialog": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"keyboardValue": "hello",
|
||||
"pointerModifiers": []
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
},
|
||||
"f14b569391f9": {
|
||||
"name": "browser.mouseClick#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -56,25 +76,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"9855d4ec3415": {
|
||||
"busy": false,
|
||||
"dialog": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"error": {
|
||||
"$rpc": "null"
|
||||
},
|
||||
"keyboardValue": "hello",
|
||||
"pointerModifiers": []
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"$rpc": "undefined"
|
||||
}
|
||||
}
|
||||
},
|
||||
"recording": {
|
||||
@@ -83,8 +84,8 @@
|
||||
{
|
||||
"id": "clicked",
|
||||
"observation": {
|
||||
"sender": ["5b621e308200"],
|
||||
"payloads": ["0da10b838d9e"],
|
||||
"sender": ["f14b569391f9"],
|
||||
"payloads": ["002a5bcbde42"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"click": "eb79a9b3682a"
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
"family": "browser.pointer-click",
|
||||
"namedDeltas": [],
|
||||
"runnerVersion": 1,
|
||||
"baseline": "a28085adbfb65d3f27393cc7a0d78cfa2b4ff07e",
|
||||
"lockfileSha256": "788c1234b38a61fc882fca292341fce6690deee9cc6695673b0575cf2c4cf571",
|
||||
"recorderSha256": "d08bf9cd596ee449531f079c9f4f83efcb03fbc5e8d8658d15e0407d3464ec0e",
|
||||
"baseline": "01a1b6b024f6664c110e9ae50717ef4880fd8d82",
|
||||
"lockfileSha256": "4568633356187895ccda8e2ff83245c37d5621832c69e268733733b6947971e3",
|
||||
"recorderSha256": "d4fa125899cd863c03dbf96657d46b8c7d6c0ccf522b342bc4f164ed1e3becfe",
|
||||
"adapterSha256": "55971752f963202d30160851197a301089b0f3ebd0c46725af1a461d8310d658",
|
||||
"scenarioSha256": "20779e28880cf62340bc34cef8f40a49311e11262ac069df909743da9b5500ff",
|
||||
"platform": "darwin",
|
||||
@@ -13,58 +13,29 @@
|
||||
"projectionVersion": 2,
|
||||
"goldenFormatVersion": 5,
|
||||
"values": {
|
||||
"0da10b838d9e": {
|
||||
"002a5bcbde42": {
|
||||
"name": "browser.mouseClick#1",
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"browser.mouseClick\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"x\":40,\"y\":80,\"button\":\"left\",\"modifiers\":[],\"radius\":14}}",
|
||||
"sent": 1
|
||||
"ordinal": 2,
|
||||
"json": "{\"id\":\"frame-1\",\"deviceToken\":\"recording-device\",\"method\":\"browser.mouseClick\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"x\":40,\"y\":80,\"button\":\"left\",\"modifiers\":[],\"radius\":14}}"
|
||||
},
|
||||
"11a7e8034273": {
|
||||
"08224c934905": {
|
||||
"name": "browser.mouseDown#1",
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"browser.mouseDown\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"button\":\"left\"}}",
|
||||
"sent": 3
|
||||
"ordinal": 6,
|
||||
"json": "{\"id\":\"frame-3\",\"deviceToken\":\"recording-device\",\"method\":\"browser.mouseDown\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"button\":\"left\"}}"
|
||||
},
|
||||
"1961908d1da1": {
|
||||
"name": "browser.mouseDown#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "browser.mouseDown"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"button": "left",
|
||||
"page": "page-1",
|
||||
"worktree": "id:worktree-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-3",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"down": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"25ad8c6e489e": {
|
||||
"125a546c8a77": {
|
||||
"name": "browser.mouseMove#1",
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"browser.mouseMove\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"x\":40,\"y\":80}}",
|
||||
"sent": 2
|
||||
"ordinal": 4,
|
||||
"json": "{\"id\":\"frame-2\",\"deviceToken\":\"recording-device\",\"method\":\"browser.mouseMove\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"x\":40,\"y\":80}}"
|
||||
},
|
||||
"41b41cc39e88": {
|
||||
"71b83de9e4d3": {
|
||||
"name": "browser.mouseUp#1",
|
||||
"ordinal": 8,
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"browser.mouseUp\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"button\":\"left\"}}"
|
||||
},
|
||||
"82ccdef1369f": {
|
||||
"name": "browser.mouseUp#1",
|
||||
"ordinal": 7,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -109,49 +80,9 @@
|
||||
"keyboardValue": "hello",
|
||||
"pointerModifiers": []
|
||||
},
|
||||
"aa160e9b114e": {
|
||||
"name": "browser.mouseUp#1",
|
||||
"json": "{\"id\":\"frame-4\",\"deviceToken\":\"recording-device\",\"method\":\"browser.mouseUp\",\"params\":{\"worktree\":\"id:worktree-1\",\"page\":\"page-1\",\"button\":\"left\"}}",
|
||||
"sent": 4
|
||||
},
|
||||
"b3273afd3ec2": {
|
||||
"name": "browser.mouseMove#1",
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "browser.mouseMove"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"page": "page-1",
|
||||
"worktree": "id:worktree-1",
|
||||
"x": 40,
|
||||
"y": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"moved": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cf9ea7b52a57": {
|
||||
"d333a2a9fd72": {
|
||||
"name": "browser.mouseClick#1",
|
||||
"ordinal": 1,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
@@ -190,6 +121,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dbfbacf8be29": {
|
||||
"name": "browser.mouseMove#1",
|
||||
"ordinal": 3,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "browser.mouseMove"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"page": "page-1",
|
||||
"worktree": "id:worktree-1",
|
||||
"x": 40,
|
||||
"y": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-2",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"moved": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"e3ae5f4e0dc4": {
|
||||
"name": "browser.mouseDown#1",
|
||||
"ordinal": 5,
|
||||
"args": [
|
||||
{
|
||||
"name": "method",
|
||||
"value": "browser.mouseDown"
|
||||
},
|
||||
{
|
||||
"name": "params",
|
||||
"value": {
|
||||
"button": "left",
|
||||
"page": "page-1",
|
||||
"worktree": "id:worktree-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "options",
|
||||
"value": {
|
||||
"$rpc": "absent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"settlement": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
"settledAt": 0,
|
||||
"value": {
|
||||
"id": "frame-3",
|
||||
"ok": true,
|
||||
"result": {
|
||||
"down": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"eb79a9b3682a": {
|
||||
"status": "fulfilled",
|
||||
"startedAt": 0,
|
||||
@@ -205,8 +209,8 @@
|
||||
{
|
||||
"id": "clicked-by-fallback",
|
||||
"observation": {
|
||||
"sender": ["cf9ea7b52a57", "b3273afd3ec2", "1961908d1da1", "41b41cc39e88"],
|
||||
"payloads": ["0da10b838d9e", "25ad8c6e489e", "11a7e8034273", "aa160e9b114e"],
|
||||
"sender": ["d333a2a9fd72", "dbfbacf8be29", "e3ae5f4e0dc4", "82ccdef1369f"],
|
||||
"payloads": ["002a5bcbde42", "125a546c8a77", "08224c934905", "71b83de9e4d3"],
|
||||
"settlements": {
|
||||
"mount": "eb79a9b3682a",
|
||||
"click": "eb79a9b3682a"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user