Repository navigation
Conversation
Introduce core type definitions for the bot tool system: - ToolResult: result returned from tool execution - RequesterInfo: user attribution info for PRs - ToolExecutionContext: context passed to tool execution - BotTool: main interface for tools with dynamic loading support
Add global tool registry for managing bot tools: - registerTool: register a tool at module load time - getTool: look up a tool by name - getAllTools: get all registered tools - hasRegisteredTool: check if a tool exists - getRegisteredToolCount: get count for debugging
Add tool loader that dynamically loads tools based on owner's integrations: - getToolsForOwner: returns tools available to an owner - getToolDefinitionsForOwner: returns OpenAI tool definitions - Tools with requiredIntegration only available if owner has that integration
Move spawn_cloud_agent tool from slack-bot.ts to dedicated implementation: - Extracted tool definition and execution logic - Added BotTool interface implementation - Set requiredIntegration to 'github' for dynamic loading - Returns ToolResult with sessionId in metadata - Renamed logging prefix from [SlackBot] to [SpawnCloudAgent]
Create index.ts that: - Re-exports all types, registry, and loader functions - Auto-registers spawn_cloud_agent tool on module import - Provides usage documentation in JSDoc
Update slack-bot.ts to use the new tool system: - Import tools from @/lib/bot/tools instead of inline definitions - Dynamically load tools based on owner's integrations - Use getTool() from registry to execute tool calls - Build ToolExecutionContext with requester info for tools - Remove ~180 lines of tool-specific code (moved to tools module) This enables: - Dynamic tool availability based on owner's integrations - Easy addition of new tools without modifying slack-bot.ts - Consistent tool interface across different tools
Contributor
|
This Pull Request has been inactive for 30 days and will be closed in 7 days if no further activity occurs. Please update or close this PR if it is no longer relevant. |
This was referenced Mar 25, 2026
Closed
42 tasks
jrf0110
added a commit
that referenced
this pull request
Apr 3, 2026
…review fixes - Fix #1: Add WHERE clause to updateConfig (config.ts accepts wastelandId param) - Fix #2: Update router.ts and ownership.ts imports from stub to Wasteland.do - Fix #3: initializeWasteland/storeCredential/updateConfig now return records - Fix #4: Implement all 6 missing RPC methods (updateMember, connectTown, disconnectTown, listConnectedTowns, getWantedBoard, refreshWantedBoard) - Fix #5: Synchronous initializeDatabase(), no ensureInitialized/initPromise - Fix #7: Delete WastelandDO.stub.ts New files: - 5 table definitions under db/tables/ (config, members, credentials, connected-towns, wanted-cache) - 5 sub-modules under dos/wasteland/ with business logic - Wasteland.do.ts: thin DO class delegating to sub-modules
markijbema
pushed a commit
that referenced
this pull request
May 19, 2026
…raffic Review fix #1. `skipBalanceCheck` only skipped the preflight zero-balance rejection; it did not mark the usage as free, so `processTokenData` would still record full upstream cost into `microdollar_usage.cost` and the insert CTE would increment `kilocode_users.microdollars_used`. A zero-balance user could be charged real upstream cost on a preview experiment and pushed negative. Adds an explicit `provider_funded` flag to `MicrodollarUsageContext` and treats it the same as `user_byok` in the cost-zeroing branch (zeroes `cost_mUsd` and `cacheDiscount_mUsd` while preserving `market_cost` for reporting). The route handler sets the flag for experimented requests, alongside the existing `skipBalanceCheck`.
markijbema
added a commit
that referenced
this pull request
May 25, 2026
* feat(gateway): capture x-kilo-request and x-kilo-session headers
Phase 2 of the model-experiments rollout. Captures the per-message
client request id (used as the join key for PostHog feedback and for
`model_experiment_request.client_request_id` on experimented traffic)
and accepts `x-kilo-session` as a fallback session id when
`x-kilocode-taskid` is absent.
Also extends MicrodollarUsageContext with the experiment routing
fields (`modelExperimentVariantVersionId`,
`modelExperimentAllocationSubject`, `experimentPromptCapture`,
`clientRequestId`) so later phases can plumb them through without
touching every usage-context construction site.
* refactor(gateway): extract buildDirectProvider used by custom_llm path
Phase 3a of the model-experiments rollout. Pulls the kilo-internal/...
(custom_llm2) Provider construction into a shared
`buildDirectProvider` helper under
`apps/web/src/lib/ai-gateway/experiments/`. The experiment routing
path added in Phase 3b will consume the same helper, so direct-to-
upstream traffic for custom LLMs and experiments share one
implementation. Behavior is unchanged for custom_llm callers.
* feat(gateway): add experiment variant picker and routing-cache loader
Phase 3b of the model-experiments rollout. Adds
`apps/web/src/lib/ai-gateway/experiments/pick-variant.ts` with:
- `isPublicIdExperimented(publicId)` — fast Redis-backed membership
check with Postgres fallback, returning `unavailable` when both
cache and DB are inaccessible so the gateway can fail closed.
- `getRoutingExperimentForPublicId(publicId)` — Redis-cached
resolved experiment for a public id, including a single Postgres
`DISTINCT ON (variant_id)` query that picks each variant's current
`model_experiment_variant_version` and decrypts `encrypted_api_key`
once at cache-build time. Returns `none` (no routing-relevant
experiment), `experiment` (active or paused), or `unavailable`
(config/decrypt/validation failure or BYOK_ENCRYPTION_KEY missing).
- `pickModelExperimentVariant(input)` — deterministic picker using
`getRandomNumber`. Allocation subject precedence is user → machine
→ ip; missing all subjects returns `unavailable`. Variants are
walked by id-asc cumulative weight so live label edits never
rebucket users.
The `upstream` JSONB blob is validated by ExperimentUpstreamSchema
before use; partner-issued api keys live only in the encrypted column
and the cached payload, never in tRPC responses.
* feat(gateway): route experimented public ids to direct upstream
Phase 3c + 3d of the model-experiments rollout.
`getProvider` now returns a discriminated `GetProviderResult`
union (`provider` | `not-found` | `unavailable`) and adds a
new branch for experimented public ids that runs after the BYOK
checks and before kilo-internal/custom_llm and the
kiloExclusiveModels lookup. Active experiments produce a direct
provider built via `buildDirectProvider` plus experiment routing
metadata; paused experiments map to a local model-unavailable
response so traffic doesn't silently fall through to default
routing; cache/DB/config failures map to a 503
temporarily-unavailable response.
Experiment traffic uses three new flags on the provider result:
`skipBalanceCheck` (preview traffic is provider-funded),
`skipProviderPin` (the partner endpoint is selected by the
variant version, not by the org config), and
`skipKiloExclusiveModelSettings` (which
`applyProviderSpecificLogic` honors so the registry does not
override the variant's `internal_id`). Server-side organization
model/data-collection policy still runs against the public id.
Also excludes experimented preview public ids from `kilo-auto/free`
candidate sets so they are never silently selected on a user's
behalf — users only ever reach the experiment path by opting in
to the dedicated preview model id.
The route handler captures `x-kilocode-machineid` once into
`machineIdHeader` and threads it (plus the resolved client IP)
into `getProvider` so the variant picker can fall back from
userId to machineId to clientIp.
* feat(r2): add experiment-prompts content-addressed storage helper
Phase 4a of the model-experiments rollout. New helper module
`apps/web/src/lib/r2/experiment-prompts.ts` exposes
`putPromptIfAbsent(content)` (HEAD-then-PUT under a sha256 hex key
for automatic dedup) and `getPromptByHash(sha)` (admin out-of-band
reads). The bucket comes from a new env var
`R2_EXPERIMENT_PROMPTS_BUCKET_NAME` exported from the existing R2
client module; per-environment buckets are
`kilo-experiment-prompts-dev` and `kilo-experiment-prompts-prod`.
The helper is wired up but not yet called from the request hot
path — Phase 4b will add the bounded prompt capture and
attribution writer that consumes it.
* feat(gateway): persist experiment attribution after microdollar write
Phase 4b + 4c of the model-experiments rollout.
Adds `apps/web/src/lib/ai-gateway/experiments/persist.ts` with two
exports:
- `buildExperimentPromptCapture(request)` produces a bounded
capture of the canonical post-`transformRequest` upstream body,
splitting out the leading `role: 'system'` message for
chat_completions and tail-truncating either side at 4 MB. The
capture is stored on `MicrodollarUsageContext` so the async
`after()` path never retains a reference to the full uncapped
request body.
- `persistExperimentAttribution(input)` writes one row into
`model_experiment_request` after running two parallel
content-addressed R2 puts via `putPromptIfAbsent`, using
`__absent__` / `__failed__` sentinels for missing/failed
sides. Errors are reported to Sentry and swallowed — the billing
write must succeed independently of analytics.
`processUsage.ts` now surfaces the inserted `{ usageId, createdAt }`
out of `logMicrodollarUsage` and `processTokenData` so the
`accountForMicrodollarUsage` after-hook can chain
`persistExperimentAttribution` on the same row. The route handler
calls `buildExperimentPromptCapture` after provider-specific
transforms and stashes the result on the usage context only for
experimented requests.
* chore(experiments): drop unused MicrodollarUsageContext import
* docs(plan): update experimental-models-1 status for PR #3325
Marks Phases 2 + 3 as merged-pending against PR #3325 and Phase 4
as in-progress with 4a/4b/4c on the same PR. 4d (the
`model_experiment_request_stats` view) is now explicitly deferred
until a real report consumer needs it. Also records the concrete
output of each phase as it landed.
* fix(experiments): zero billable cost for provider-funded experiment traffic
Review fix #1. `skipBalanceCheck` only skipped the preflight
zero-balance rejection; it did not mark the usage as free, so
`processTokenData` would still record full upstream cost into
`microdollar_usage.cost` and the insert CTE would increment
`kilocode_users.microdollars_used`. A zero-balance user could be
charged real upstream cost on a preview experiment and pushed
negative.
Adds an explicit `provider_funded` flag to
`MicrodollarUsageContext` and treats it the same as `user_byok`
in the cost-zeroing branch (zeroes `cost_mUsd` and
`cacheDiscount_mUsd` while preserving `market_cost` for
reporting). The route handler sets the flag for experimented
requests, alongside the existing `skipBalanceCheck`.
* docs(plan): record stale membership cache as next-step follow-up
Captures the review-#4 finding from PR #3325: no TTL on the
EXPERIMENTED_PUBLIC_IDS_REDIS_KEY plus best-effort/swallowed
invalidation can leave a stale list indefinitely. Documents the
agreed fix (add a TTL so the cache self-heals via the existing
DB fallback path) and defers it to a follow-up PR.
* fix(experiments): refuse experiment routing when org disables data collection
Review fix #2. Direct experiment upstreams bypass the
gateway-pinned `data_collection` setting — that setting only
applies when the request is forwarded through OpenRouter or Vercel
with a pinned provider config. For experimented public ids the
upstream is selected by the variant version, so the org's
preference would be silently ignored while we still capture
prompts to R2 for partner evaluation.
When an org sets `data_collection: 'deny'` and the request
resolves to an experiment, return the existing
`dataCollectionRequiredResponse()` instead of routing. The
existing model deny list still runs first via
`checkOrganizationModelRestrictions`. Anonymous users and
non-org authenticated users have no settings and continue to
route normally.
* fix(experiments): cache encrypted upstream; decrypt only on pick
Review fix #3. The Redis per-public-id experiment cache previously
held decrypted partner-issued api keys for up to 10 minutes. That
defeated the BYOK_ENCRYPTION_KEY fail-closed behavior: removing or
rotating the env var did nothing for warm caches because
`getRoutingExperimentForPublicId` returned the cached payload
straight to the picker. The PR verification claim ('disabling
BYOK_ENCRYPTION_KEY returns 503') only held on cold caches.
Refactors the cache to hold CIPHERTEXT exclusively. Decryption +
the missing-key check now run inside
`pickModelExperimentVariant`, on the chosen variant only, on
every request. Three knock-on benefits:
1. Key rotation is effective immediately on the next request;
warm caches no longer serve stale plaintext keys.
2. We decrypt one variant per request instead of N at cache-build
time.
3. A Redis dump no longer leaks usable api keys.
Per-request cost is one AES-256-GCM decrypt (microseconds), which
is negligible against an LLM round-trip.
* fix(experiments): cap prompt capture by UTF-8 bytes, not UTF-16 units
Review fix #6. SYSTEM_PROMPT_CAP_BYTES / REQUEST_BODY_CAP_BYTES
were checked with `string.length` and trimmed with
`string.slice`, which both operate on UTF-16 code units. For
non-ASCII content the actual UTF-8 byte payload uploaded to R2
could be up to ~3× the intended cap (a single CJK char is 1 UTF-16
unit but 3 UTF-8 bytes; an emoji is 2 UTF-16 units but 4 UTF-8
bytes), so the cap was not bounding what the plan claimed it
bounded. Slicing UTF-16 code units could also split a surrogate
pair, leading the next UTF-8 encode to emit U+FFFD and breaking
the deterministic-prefix promise (truncated content would hash
differently across runtimes).
Adds `truncateToUtf8Bytes(s, maxBytes)` which encodes once, walks
back to a UTF-8 codepoint boundary using the 0b10xxxxxx
continuation-byte rule, and returns valid UTF-8 with bytes
<= maxBytes. Replaces the three call sites in
`buildExperimentPromptCapture` to measure with
`Buffer.byteLength` and truncate via the new helper.
* test(experiments): cover picker, fail-closed paths, and UTF-8 truncation
Adds two test files covering the new experiment routing surface:
- `persist.test.ts` (14 cases) — `truncateToUtf8Bytes` boundary
cases (multi-byte CJK, surrogate-pair emoji, exact alignment,
zero cap, determinism); `buildExperimentPromptCapture`
extraction (leading vs mid-conversation system message,
serialization of structured content, UTF-8-byte cap enforcement
for both system and body sides).
- `pick-variant.test.ts` (13 cases) — `isPublicIdExperimented`
positive/negative + paused-also-counted; picker determinism for
the same userId; api-key decryption returns the right partner
secret per variant; allocation-subject precedence
user > machine > ip; `unavailable` when no allocation subject;
`not-found` for paused so traffic doesn't silently fall
through; hot-swap preserves the variant_id slot while serving
the new variant_version_id; weighted-distribution sanity (1:3
split lands ~25%); historical attribution via the old version
row survives a hot-swap; completed experiments stop routing.
Full suite: 373 passed (up from 371).
* fix(experiments): honor request-level data-collection opt-out
Follow-up to review fix #2. The previous commit only refused
experiment routing on the org-level setting
(`settings.data_collection === 'deny'`). A caller can also opt a
single request out of training/data-retention via OpenRouter's
request-body fields `provider.data_collection: 'deny'` or
`provider.zdr: true`. Direct experiment upstreams ignore those
fields entirely (we don't go through OpenRouter), but the
prompt-capture path was still uploading to R2 for partner
evaluation, violating the caller's stated intent.
Refuses experiment routing with `dataCollectionRequiredResponse()`
when `!isFreePromptTrainingAllowed(body.provider)` regardless of
org/anon/BYOK status. The check runs unconditionally right after
`getProvider` returns so it covers anonymous and non-org
authenticated users too.
Adds a unit test for `isFreePromptTrainingAllowed` covering all
opt-out shapes (`data_collection: 'deny'`, `zdr: true`,
`zdr: false`, both, neither, undefined) so a regression cannot
silently allow capture for users who explicitly opted out at the
request scope. Also tightens the `GatewayRequest` cast in
`persist.test.ts` per the test-file as-cast convention.
* chore(experiments): drop plan references from code comments
Plans are ephemeral and get removed when the work is done. Comments
that pointed readers at `.plans/experimental-models-1.md` or
referenced specific plan sections now stand on their own — the
relevant context (sentinel meanings, why the api key column is
separate, scope of preview routing) is inlined where it matters
instead of forwarded to a doc that won't exist long-term.
Touched:
- apps/web/src/lib/r2/experiment-prompts.ts (header)
- apps/web/src/lib/ai-gateway/experiments/persist.ts (sentinel docs)
- apps/web/src/lib/ai-gateway/experiments/upstream-schema.ts (api_key)
- apps/web/src/app/admin/model-experiments/ModelExperimentsContent.tsx
(admin UI subtitle: dropped trailing 'See .plans/...' sentence)
- packages/db/src/schema.ts (three model_experiment* table headers)
* chore(experiments): leave shared-with-main files for separate chore PR
The previous commit (b6b1ebc) cleaned plan references from five
files, three of which already exist on main from earlier merged
PRs (Phase 1 schema, Phase 5 admin UI). Touching cross-package
files unrelated to the gateway work blew up this PR's diff and
risked merge churn against PR #3329, which carries those same
three edits independently.
Restore the on-main copies of:
- packages/db/src/schema.ts
- apps/web/src/lib/ai-gateway/experiments/upstream-schema.ts
- apps/web/src/app/admin/model-experiments/ModelExperimentsContent.tsx
The branch-only files (persist.ts, experiment-prompts.ts) keep
their cleanups since they were authored on this branch and
land/ship together with the gateway work.
* revert(gateway): drop kilo-auto/free experimented-id safeguard
Reverts the `filterOutExperimentedPublicIds` filter added in
`72dd51171` for `kilo-auto/free` candidate selection.
The safeguard added a per-candidate `isPublicIdExperimented`
Redis check on every `kilo-auto/free` request — material extra
latency on a hot path — to defend against an invariant that holds
by construction: `autoFreeModels` and the frontier preset list
are hand-curated and never include dedicated preview experiment
ids. The explicit-opt-in property of preview models is preserved
without any runtime guard.
`getProvider`'s experiment-membership check is unchanged —
explicit requests to a preview public id still resolve through the
experiment routing path.
Plan updated to reflect the no-guard decision and rationale.
* chore(experiments): drop unused barrel index.ts
Every consumer in the repo deep-imports the specific file
(`./pick-variant`, `./persist`, `./upstream-schema`,
`./build-direct-provider`); nothing imported from the barrel.
Most `index.ts` files under `ai-gateway/` are real module
implementations, not pure re-exports — drop this one to match.
* refactor(experiments): drop system-prompt split, plumb request_kind through capture
Adapts the gateway path to the simpler model_experiment_request schema
landed in #3358. Addresses comments #2 + #3 on #3325.
- ExperimentPromptCapture: remove `systemPromptContent`, add
`requestKind`. The full post-`transformRequest` body is now stored as
a single content-addressed blob; the upstream API shape is recorded
alongside it for downstream reporting.
- buildExperimentPromptCapture: collapse the chat_completions-only
system-extraction branch; one body capture path for all kinds.
- persistExperimentAttribution: one R2 PUT instead of two, one hash
written, no `__absent__` sentinel. `PROMPT_HASH_ABSENT` removed;
`PROMPT_HASH_FAILED` and `PROMPT_HASH_DELETED` retained.
- Tests: drop the system-extraction cases (no longer applicable), add
coverage for the three request kinds and a determinism check (content
addressing relies on byte-stable serialization).
The dedup-on-shared-system-prompt micro-optimization had no measured
benefit. If we ever want it back, content-addressing the full body is
the better starting point anyway.
* refactor(gateway): make balance <= 0 the primary condition on the usage-limit guard
Reorders the conjuncts on the usage-limit guard so the salient check
reads first and `skipBalanceCheck` is clearly an override flag, not
the gating condition. Pure reorder — boolean evaluation is unchanged.
Addresses comment #1 on #3325.
* fix(experiments): backfill the experimented-public-ids redis cache on miss
Previously a Redis miss / corrupt cache / Redis error in
`isPublicIdExperimented` fell through to Postgres for a single-id
lookup and returned the answer without populating Redis. Result: every
request on a cold cache hammered Postgres until something else
happened to write the key (admin mutation, etc.).
On the cache-miss path we now rebuild the full routing-eligible public
id set from `model_experiment` (matching what
`recomputeExperimentedPublicIds` writes from the admin router) and
SET it back to Redis with the same TTL. Subsequent requests hit Redis
again. The backfill is fire-and-forget — if Redis is genuinely down
we still return the correct answer for this request.
Also drops the per-id-only DB query in favor of the full-set query
because the cache contract is the complete list, not per-id presence.
Addresses comment #4 on #3325.
* refactor(experiments): rename subjectPick → allocationSubject, fix lying 'unreachable' comment
Two small clarity fixes from #3325 review:
- Rename the local variable bound from `pickAllocationSubject(input)`
from `subjectPick` to `allocationSubject`. Matches the field name
on the result and answers the reviewer's 'is this the user?' question
inline. Pure rename.
- Replace the 'Unreachable: getRandomNumber returns [0, totalWeight).
Fail closed.' comment with an honest one. The branch is defensive
fail-closed insurance for the case where `getRandomNumber`'s
contract breaks (NaN, negative, weight rounding), not actually
unreachable code. The captureMessage + 'unavailable' return is the
right behavior; only the comment was wrong.
Addresses comments #5 and #6 on #3325.
* fix(gateway): return usage identity for zero-cost inserts
* fix(gateway): use JS-side identity values to avoid Postgres timestamp format
* refactor(gateway): extract experiment variant types
* refactor(gateway): narrow experiment attribution catch
* refactor(gateway): move prompt storage fallback
* refactor(gateway): simplify prompt hash fallback
* refactor(gateway): inline experiment attribution insert
* refactor(gateway): remove prompt truncation breadcrumb
* refactor(gateway): inline prompt capture serialization
* refactor(gateway): simplify experiment attribution guard
* fix(web): require BYOK encryption key
* refactor(gateway): remove missing BYOK key branch
* refactor(gateway): cache experimented ids from Redis only
* refactor(gateway): load experiment routing from Postgres
* test(gateway): avoid silent Redis membership skips
* feat(gateway): expose active experiment models
* feat(admin): show experiment request log
* fix(gateway): allow anonymous experiment models
* refactor(gateway): treat experiment models as free models
Resolve isPublicIdExperimented once per request and OR the result into the existing free-model gates: rate limit, anonymous-access, and balance check. Drop the now-redundant skipBalanceCheck flag from GetProviderProviderResult.
* chore(gateway): remove unrelated review changes
* refactor(gateway): fold experiment check into isFreeModel
Now that isFreeModel is async (PR #3393), let it answer for experimented public ids directly. Drops the route's local isExperimentedModel boolean from the auth and balance gates and removes the provider_funded flag — cost zeroing is handled by the isFreeModel check in processUsage.ts.
* chore(gateway): revert unnecessary comment edits
* fix(gateway): refuse experiments under enterprise allow-list
Enterprise provider allow-list is enforced via OpenRouter's body.provider.only field, which doesn't reach a direct partner upstream. Refuse experiment routing rather than silently bypassing the org's allow-list. Note Option 3 (unify direct-upstream routing abstraction) in the plan as the proper followup.
* fix(gateway): remove duplicate unfiltered experiment models
Experiment models were already added to restrictionCandidates and folded into the org-restrictions filter, then appended again unfiltered at the end. The duplicate append created two issues: catalog entries appeared twice, and Enterprise model_deny_list entries were silently re-exposed. Drop the duplicate append; the filter at line 32-42 covers experiments and matches the gateway's behavior (experiments do not set bypassAccessCheck and go through checkOrganizationModelRestrictions on the request path).
* fix(gateway): split membership and isFreeModel out of models.ts
models.ts is reachable from client bundles via constants like PRIMARY_DEFAULT_MODEL and preferredModels. Folding the experiment-membership check into isFreeModel inside models.ts dragged in the Redis client (and via pick-variant.ts the Postgres client), breaking the production build with 'Module not found' errors for net/tls/dns/promises and pg.
Split along two seams that already existed: pick-variant.ts now contains only the drizzle-using routing (getRoutingExperimentForPublicId, pickModelExperimentVariant), while a new membership.ts holds the Redis-only isPublicIdExperimented. The async isFreeModel itself moves to a new is-free-model.ts that imports the sync helpers from models.ts and the membership check from membership.ts. Net result: models.ts has no I/O dependencies, the fold is preserved for server callers, and the experiment-membership check still lives once in the codebase.
* docs(experiments): note response model-id leak as v1 followup
This was referenced Jul 16, 2026
2 tasks done
2 tasks done
This was referenced Jul 31, 2026
This was referenced Aug 27, 2026
pandemicsyn
added a commit
that referenced
this pull request
Sep 14, 2026
Each of the four CREATE UNIQUE INDEX CONCURRENTLY statements in 0245_calm_lockheed.sql had no idempotency guard. CONCURRENTLY can leave an INVALID index under its name if a build fails partway or is interrupted; a blind retry of CREATE UNIQUE INDEX CONCURRENTLY on the same name then fails with 'already exists' instead of rebuilding it - the exact non-retryable strand Finding #1 addressed, just moved down to the index-build step. Precede each build with DROP INDEX CONCURRENTLY IF EXISTS on its exact name: a no-op on the happy path (index doesn't exist yet), and a clean removal of a leftover invalid index otherwise, so every retry converges to the same end state. Per packages/db/AGENTS.md, deliberately not using CREATE ... CONCURRENTLY IF NOT EXISTS, since that would leave a leftover invalid index in place unrebuilt instead of recovering it; DROP INDEX CONCURRENTLY is the exact recovery mechanism the skill names, just applied proactively rather than only after manual diagnosis. Both DROP CONCURRENTLY and CREATE CONCURRENTLY remain in the existing post-COMMIT/pre-BEGIN section, outside any transaction block, as required. No change to the final index set, names, or schema.ts/snapshots. Verified: drizzle-kit check passes. Full chain applies cleanly from scratch. Directly simulated the failure mode: seeded a duplicate-data conflict, forced one of the four builds to fail and leave an INVALID index (confirmed indisvalid=false), confirmed a blind retry without this guard fails with 'already exists' (reproducing the bug), then repaired the data and confirmed the guarded migration now retries cleanly to a fully valid final index set. packages/db/src/schema.test.ts (103 tests) passes.
This was referenced Sep 15, 2026
pandemicsyn
added a commit
that referenced
this pull request
Sep 16, 2026
* feat(github): enable shared web agent installations * fix(github): harden shared installation races * fix(github): serialize shared callback cleanup * fix(integrations): fence provider state mutations * fix(integrations): serialize provider state sessions * fix(integrations): restore provider session consistency * Revert "fix(integrations): restore provider session consistency" This reverts commit ee85e37. * Revert "fix(integrations): serialize provider state sessions" This reverts commit bbecb73. * Revert "fix(integrations): fence provider state mutations" This reverts commit 25817c7. * feat(integrations): reserve provider OAuth attempts * fix(integrations): lock OAuth reservations by owner row * fix(integrations): fence provider OAuth state purpose * test(integrations): harden OAuth reservation races * test(integrations): prove OAuth owner-row blocking * fix(github): serialize verified owner cardinality * test(integrations): include OAuth state issue time * chore(db): regenerate shared GitHub migration * fix(github): close shared runtime isolation gaps * fix(github): fence exclusive lifecycle retries * fix(github): close legacy authorization edges * fix(github): fence direct token legacy paths * fix(github): retry in-progress lifecycle receipts * refactor(github): simplify lifecycle cleanup * fix(github): align repository refresh lock order * test(github): prove lifecycle lock overlap * test(github): match exact advisory lock query * fix(github): bind bot links to tenant association * fix(github): constrain legacy bot-link fallback * fix(db): reconcile orphans before validating github FK Split 0243 into a pre-flight reconciliation migration and the FK-adding migration: - 0243_reconcile_orphaned_github_installation_id.sql nulls out any platform_integrations.github_installation_id that no longer references an existing github_app_installations row, so the FK added below cannot fail its full-table validation on drift. - 0244_free_thena.sql (renamed from 0243_exotic_triton.sql) now declares the FK with an explicit ON DELETE restrict, matching the repo convention for blocking FKs and the lifecycle already enforced in code: github_app_installations rows are soft-deleted (lifecycle_state/deleted_at), and the only hard-delete path (anonymizeCloudUserData) already guards with a NOT EXISTS check against remaining associations. schema.ts documents the ON DELETE restrict choice inline. * fix(github): only record webhook receipt on 2xx dispatch dispatchSharedOnce recorded a completed delivery receipt whenever the shared-installation receipt lookup wasn't missing_canonical, regardless of the dispatched handler's response status. A non-2xx dispatch was therefore marked completed, so a later GitHub redelivery of the same event was short-circuited as a duplicate and could never retry. Only record the receipt when response.ok, leaving non-2xx dispatches unrecorded so GitHub's redelivery can retry them. missing_canonical behavior is unchanged. * fix(integrations): supersede unexpired pending OAuth attempts beginProviderOAuthAttempt only expired pending/consumed rows whose expires_at was already in the past before inserting the new attempt. An unexpired pending row for the same owner+provider (e.g. an abandoned flow) still collided with the partial unique index on (owner, provider, status = 'pending'), blocking a fresh /connect attempt for up to ATTEMPT_TTL_MS (10 minutes) and surfacing oauth_init_failed. Drop the expires_at filter for pending rows so a new attempt always supersedes an existing pending one for that owner+provider, within the same tx and owner-row lock already held. Consumed rows are still only expired once actually past their TTL, since they don't hold that unique slot. consumeProviderOAuthAttempt's pre-step doesn't insert and matches the row to consume by exact state_hash, so it doesn't share this collision pattern and is unchanged. * fix(cloud-agent): dedupe exact-duplicate repository entries results.flatMap(r => r.repositories) in fetchRepositoriesForIntegrations had no dedup after dedupeRepositories was removed, so a corrupted or duplicated cached repositories array on a single installation would surface the same repo twice for that installation. Deliberately keep one entry per (platformIntegrationId, id) rather than deduping by repo id alone: NewSessionPanel already keys and selects repository rows by platform + platformIntegrationId + id, and the existing 'preserves both association choices when two installations expose one repository' test documents that two different installations granting the same repo must both remain selectable, since session start resolves the token/permissions from the chosen installation. Deduping by repo id would silently drop one of those legitimate, functionally distinct entries. Added a test covering the same-installation duplicate case and confirmed the existing cross-installation test is unaffected. * fix(cloud-agent): repair personal GitHub session start and stale refresh Three must-fix issues found in code review of the GitHub connections work: 1. NewSessionPanel's GitHub-integration guard was unconditional on selectedGitHubIntegrationId, but that field is only ever populated for organization-scoped repos. Every personal (non-org) user selecting a GitHub repo hit 'Select the GitHub repository again' and could never start a session. Scope the guard to organizationId, matching the sibling conditional already used for creationIntent. 2. Both listGitHubRepositories tRPC output schemas silently stripped githubAppType (and the personal router also stripped platformIntegrationId/platformAccountLogin), making the new 'Lite' badge dead code and contributing to (1). Added the missing fields to both schemas. 3. refreshInstallation stopped syncing account/permissions/scopes/ repository_access/installed_at onto the platform_integrations association row after the shared-installation refactor removed the legacy upsertPlatformIntegrationForOwner call. getInstallation/ listIntegrations read those columns directly off platform_integrations, so GitHubIntegrationDetails kept showing stale data after a refresh. Added syncIntegrationInstallationDetails, scoped by integration id only (safe for shared/multi-owner installations, unlike the removed legacy writer), and wired it into refreshInstallation. Added regression tests for all three: both listGitHubRepositories routers now assert a full repo object round-trips unchanged through the real tRPC output validation, and refreshInstallation's tests assert syncIntegrationInstallationDetails is called with fresh data (and not called on the early-exit failure path). * fix(db): make github FK migration crash-safe and guard orphan reconciliation Split 0244 into three independently-tracked migrations so a failed or interrupted deploy can never strand durably-committed DDL behind an untracked migration, and never leaves a window with neither the old nor the new uniqueness protection in place: - 0244_salty_cyclops.sql: plain transactional additions only (new tables, new columns, all FK constraints including the validating platform_integrations FK). No explicit COMMIT/BEGIN, so Postgres rolls the whole file back atomically on any failure; a retry is always clean. - 0245_calm_lockheed.sql: only the four CREATE UNIQUE INDEX CONCURRENTLY replacement indexes, fenced with the repo's approved COMMIT/BEGIN pattern. Does NOT drop the old two unique indexes. - 0246_clean_the_renegades.sql: drops the two old unique indexes and adds the sharing_mode CHECK constraint, only after 0245 has been independently tracked as applied, so the replacements are always confirmed built before the originals are removed. Verified empirically: applying only through 0245 leaves both the old and new indexes coexisting (all valid) - there is no point in the sequence where the uniqueness invariant is unprotected. 0246 then applies cleanly as its own later migration. Per packages/db/AGENTS.md, deliberately did not add IF NOT EXISTS or a pre-emptive DROP INDEX IF EXISTS around the CONCURRENTLY builds: the skill explicitly prohibits automatically masking a failed/invalid concurrent index build that way and requires explicit operator inspection and recovery (DROP INDEX CONCURRENTLY / REINDEX INDEX CONCURRENTLY) instead. The file split already achieves retry-safety at the migration-tracking level without needing that. Also hardens 0243: replace the silent UPDATE that nulled any dangling platform_integrations.github_installation_id with a DO 12662 guard that RAISEs if any orphan exists. Nulling was a behavior change in disguise - for a row with github_app_type IS NULL, clearing the reference makes it newly eligible for the legacy bot-link fallback lookup (which treats github_app_type = 'standard' OR IS NULL as equivalent). Since production orphan_count is confirmed 0, assert the invariant instead of silently mutating data/eligibility. Verified: drizzle-kit check passes; the full chain (including a staged partial-apply through 0245 only, then 0246 separately) applies cleanly from scratch against a fresh local Postgres; schema.ts is unchanged (same end-state schema, just staged across more granular migrations); packages/db/src/schema.test.ts (103 tests) passes. * fix(db): make concurrent index builds in 0245 retry-safe Each of the four CREATE UNIQUE INDEX CONCURRENTLY statements in 0245_calm_lockheed.sql had no idempotency guard. CONCURRENTLY can leave an INVALID index under its name if a build fails partway or is interrupted; a blind retry of CREATE UNIQUE INDEX CONCURRENTLY on the same name then fails with 'already exists' instead of rebuilding it - the exact non-retryable strand Finding #1 addressed, just moved down to the index-build step. Precede each build with DROP INDEX CONCURRENTLY IF EXISTS on its exact name: a no-op on the happy path (index doesn't exist yet), and a clean removal of a leftover invalid index otherwise, so every retry converges to the same end state. Per packages/db/AGENTS.md, deliberately not using CREATE ... CONCURRENTLY IF NOT EXISTS, since that would leave a leftover invalid index in place unrebuilt instead of recovering it; DROP INDEX CONCURRENTLY is the exact recovery mechanism the skill names, just applied proactively rather than only after manual diagnosis. Both DROP CONCURRENTLY and CREATE CONCURRENTLY remain in the existing post-COMMIT/pre-BEGIN section, outside any transaction block, as required. No change to the final index set, names, or schema.ts/snapshots. Verified: drizzle-kit check passes. Full chain applies cleanly from scratch. Directly simulated the failure mode: seeded a duplicate-data conflict, forced one of the four builds to fail and leave an INVALID index (confirmed indisvalid=false), confirmed a blind retry without this guard fails with 'already exists' (reproducing the bug), then repaired the data and confirmed the guarded migration now retries cleanly to a fully valid final index set. packages/db/src/schema.test.ts (103 tests) passes. * fix(github): restore exact-id shared runtime authorization lost in the main merge The main #6178/#6179 reconciliation merge (81628ee) kept sharing_mode gating unconditionally exclusive-only, which regressed a shared (web_cloud_agent) association's ability to ever authorize, even with an exact association id pinned. Note: this carve-out never actually existed on feat/github-connections-pr2 before that merge (verified via git history: it only exists on feat/github-connections-pr4's commits 15ae070 and f53e068) — but it is a real, needed capability for shared installations to read/refresh their own repository inventory, so re-applying it here now, adapted to main's reason-returning shape: 1. getGitHubRuntimeAssociationRejectionReason(association, options?) takes { allowShared?: boolean }; sharing_mode is only permitted to be 'web_cloud_agent' when allowShared is explicitly true, otherwise it must be 'exclusive'. isGitHubRuntimeAssociationAuthorized forwards the same options (defaulting to exclusive-only). 2. assertGitHubInstallationRuntimeAuthorized normalizes expectedIntegrationId once into exactAssociationId (empty string -> undefined) and derives allowShared = exactAssociationId !== undefined, so the id predicate, the row limit, and the sharing-mode carve-out can never diverge (the F3 invariant). 3. The SQL's canonical-installation OR-branch permits sharing_mode IN (exclusive, web_cloud_agent) only when allowShared; otherwise exclusive only. 4. The post-query reason check passes { allowShared } through so it agrees with what the SQL already filtered for. Every other check (ownership, local health, canonical installation health, ambiguous_association for the generic path) is unchanged. Ported the PR4 test coverage for this onto PR2's reason-based API: - runtime-authorization.test.ts: allowShared true/false/unset on the pure reason function and boolean wrapper, plus mock-level exact-id vs generic-path authorization of a shared association. - github-installations.test.ts: a real end-to-end integration test (two orgs sharing one installation) covering exact-id shared authorization, generic-path rejection, an added empty-string-id F3 case, unknown-id rejection, and local/owner health enforcement on the exact-id path; plus canonical-installation-unhealthy rejection. - New review-shared-repositories.test.ts (ported from PR4): an end-to-end adapter-level regression proving fetchGitHubRepositories succeeds for an exact healthy shared association. Did not port PR4's separate connection-service.ts repository-caching- at-attach fix (15ae070) — that is an unrelated bug in a different function, out of scope for this regression fix. Verified: pnpm --filter web typecheck clean; oxlint clean on all 4 changed files; pnpm format (line-wrap only); git diff --check clean; no schema.ts or migration changes. 182 tests pass across the full required suite (runtime-authorization, review-shared-repositories, webhook-handler + webhook-handler-dispatch.integration, installation-handler, connection-service, github-integration-helpers, prepare-review-payload, github-installations).
pandemicsyn
added a commit
that referenced
this pull request
Sep 16, 2026
* feat(github): enable shared web agent installations * fix(github): harden shared installation races * fix(github): serialize shared callback cleanup * fix(integrations): fence provider state mutations * fix(integrations): serialize provider state sessions * fix(integrations): restore provider session consistency * Revert "fix(integrations): restore provider session consistency" This reverts commit ee85e37. * Revert "fix(integrations): serialize provider state sessions" This reverts commit bbecb73. * Revert "fix(integrations): fence provider state mutations" This reverts commit 25817c7. * feat(integrations): reserve provider OAuth attempts * fix(integrations): lock OAuth reservations by owner row * fix(integrations): fence provider OAuth state purpose * test(integrations): harden OAuth reservation races * test(integrations): prove OAuth owner-row blocking * fix(github): serialize verified owner cardinality * test(integrations): include OAuth state issue time * chore(db): regenerate shared GitHub migration * fix(github): close shared runtime isolation gaps * fix(github): fence exclusive lifecycle retries * fix(github): close legacy authorization edges * fix(github): fence direct token legacy paths * fix(github): retry in-progress lifecycle receipts * refactor(github): simplify lifecycle cleanup * fix(github): align repository refresh lock order * test(github): prove lifecycle lock overlap * test(github): match exact advisory lock query * fix(github): bind bot links to tenant association * fix(github): constrain legacy bot-link fallback * feat(slack): fence shared installation activation * feat(slack): delegate with exact GitHub connection * fix(slack): make installation activation recoverable * fix(slack): resolve all-access GitHub repositories * fix(slack): retire expired installation generations * test(slack): reject unhealthy all-access GitHub links * fix(slack): preserve cleanup reservations until revoke * fix(slack): clean verified legacy token metadata * fix(slack): retain cleanup identity barriers * fix(slack): bridge Enterprise workspace identities * fix(slack): order Enterprise identity aliases * fix(slack): fence alias and cleanup ordering * fix(slack): persist cleanup and alias progress * fix(slack): defer shared Enterprise Grid support * fix(slack): isolate shared workspace activation * fix(slack): fence workspace sharing conversion * fix(slack): validate workspace conversion state * fix(slack): route shared and cleanup states exactly * fix(slack): preserve unshared runtime boundaries * fix(slack): enforce runtime health boundaries * fix(slack): reserve successful unshared workspaces * fix(slack): finalize SDK effects with generation CAS * fix(slack): separate workspace and Enterprise cleanup * fix(slack): terminalize legacy cleanup safely * fix(slack): release terminal cleanup tombstones * fix(slack): isolate webhook maintenance failures * fix(slack): exclude Enterprise installs from backfill * fix(slack): validate Enterprise IDs and reset health * fix(slack): make Enterprise activation success-only * fix(slack): reconstruct Enterprise runtime state * Revert "fix(slack): reconstruct Enterprise runtime state" This reverts commit 0d98e23. * Revert "fix(slack): make Enterprise activation success-only" This reverts commit 0440374. * Revert "fix(slack): validate Enterprise IDs and reset health" This reverts commit 06f0792. * Revert "fix(slack): exclude Enterprise installs from backfill" This reverts commit bf4f256. * Revert "fix(slack): isolate webhook maintenance failures" This reverts commit 9bfcd8d. * Revert "fix(slack): release terminal cleanup tombstones" This reverts commit 6d67a1e. * Revert "fix(slack): terminalize legacy cleanup safely" This reverts commit bfe544e. * Revert "fix(slack): separate workspace and Enterprise cleanup" This reverts commit 8ac1656. * Revert "fix(slack): finalize SDK effects with generation CAS" This reverts commit 31e4964. * Revert "fix(slack): reserve successful unshared workspaces" This reverts commit d8eecc6. * Revert "fix(slack): enforce runtime health boundaries" This reverts commit fd6c560. * Revert "fix(slack): preserve unshared runtime boundaries" This reverts commit f386ae0. * Revert "fix(slack): route shared and cleanup states exactly" This reverts commit 185270d. * Revert "fix(slack): validate workspace conversion state" This reverts commit b567d09. * Revert "fix(slack): fence workspace sharing conversion" This reverts commit 3bf72f0. * Revert "fix(slack): isolate shared workspace activation" This reverts commit 31ac813. * Revert "fix(slack): defer shared Enterprise Grid support" This reverts commit 4eab6e0. * Revert "fix(slack): persist cleanup and alias progress" This reverts commit 89ac167. * Revert "fix(slack): fence alias and cleanup ordering" This reverts commit 41538c3. * Revert "fix(slack): order Enterprise identity aliases" This reverts commit 439013f. * Revert "fix(slack): bridge Enterprise workspace identities" This reverts commit 8db09b0. * Revert "fix(slack): retain cleanup identity barriers" This reverts commit 4357a03. * Revert "fix(slack): clean verified legacy token metadata" This reverts commit 0636b8f. * Revert "fix(slack): preserve cleanup reservations until revoke" This reverts commit 1d9b194. * Revert "test(slack): reject unhealthy all-access GitHub links" This reverts commit 296df67. * Revert "fix(slack): retire expired installation generations" This reverts commit 300d02a. * Revert "fix(slack): resolve all-access GitHub repositories" This reverts commit 27d5944. * Revert "fix(slack): make installation activation recoverable" This reverts commit b9aeab1. * Revert "feat(slack): fence shared installation activation" This reverts commit 57f5e26. * test(slack): verify minimal shared delegation * test(slack): preserve unsupported provider fences * test(slack): tighten minimal sharing fixtures * fix(slack): validate cached GitHub repositories * fix(db): reconcile orphans before validating github FK Split 0243 into a pre-flight reconciliation migration and the FK-adding migration: - 0243_reconcile_orphaned_github_installation_id.sql nulls out any platform_integrations.github_installation_id that no longer references an existing github_app_installations row, so the FK added below cannot fail its full-table validation on drift. - 0244_free_thena.sql (renamed from 0243_exotic_triton.sql) now declares the FK with an explicit ON DELETE restrict, matching the repo convention for blocking FKs and the lifecycle already enforced in code: github_app_installations rows are soft-deleted (lifecycle_state/deleted_at), and the only hard-delete path (anonymizeCloudUserData) already guards with a NOT EXISTS check against remaining associations. schema.ts documents the ON DELETE restrict choice inline. * fix(github): only record webhook receipt on 2xx dispatch dispatchSharedOnce recorded a completed delivery receipt whenever the shared-installation receipt lookup wasn't missing_canonical, regardless of the dispatched handler's response status. A non-2xx dispatch was therefore marked completed, so a later GitHub redelivery of the same event was short-circuited as a duplicate and could never retry. Only record the receipt when response.ok, leaving non-2xx dispatches unrecorded so GitHub's redelivery can retry them. missing_canonical behavior is unchanged. * fix(integrations): supersede unexpired pending OAuth attempts beginProviderOAuthAttempt only expired pending/consumed rows whose expires_at was already in the past before inserting the new attempt. An unexpired pending row for the same owner+provider (e.g. an abandoned flow) still collided with the partial unique index on (owner, provider, status = 'pending'), blocking a fresh /connect attempt for up to ATTEMPT_TTL_MS (10 minutes) and surfacing oauth_init_failed. Drop the expires_at filter for pending rows so a new attempt always supersedes an existing pending one for that owner+provider, within the same tx and owner-row lock already held. Consumed rows are still only expired once actually past their TTL, since they don't hold that unique slot. consumeProviderOAuthAttempt's pre-step doesn't insert and matches the row to consume by exact state_hash, so it doesn't share this collision pattern and is unchanged. * fix(cloud-agent): dedupe exact-duplicate repository entries results.flatMap(r => r.repositories) in fetchRepositoriesForIntegrations had no dedup after dedupeRepositories was removed, so a corrupted or duplicated cached repositories array on a single installation would surface the same repo twice for that installation. Deliberately keep one entry per (platformIntegrationId, id) rather than deduping by repo id alone: NewSessionPanel already keys and selects repository rows by platform + platformIntegrationId + id, and the existing 'preserves both association choices when two installations expose one repository' test documents that two different installations granting the same repo must both remain selectable, since session start resolves the token/permissions from the chosen installation. Deduping by repo id would silently drop one of those legitimate, functionally distinct entries. Added a test covering the same-installation duplicate case and confirmed the existing cross-installation test is unaffected. * fix(slack): accept adapter-written repository cache * fix(github): harden shared delivery and repository validation * fix(cloud-agent): repair personal GitHub session start and stale refresh Three must-fix issues found in code review of the GitHub connections work: 1. NewSessionPanel's GitHub-integration guard was unconditional on selectedGitHubIntegrationId, but that field is only ever populated for organization-scoped repos. Every personal (non-org) user selecting a GitHub repo hit 'Select the GitHub repository again' and could never start a session. Scope the guard to organizationId, matching the sibling conditional already used for creationIntent. 2. Both listGitHubRepositories tRPC output schemas silently stripped githubAppType (and the personal router also stripped platformIntegrationId/platformAccountLogin), making the new 'Lite' badge dead code and contributing to (1). Added the missing fields to both schemas. 3. refreshInstallation stopped syncing account/permissions/scopes/ repository_access/installed_at onto the platform_integrations association row after the shared-installation refactor removed the legacy upsertPlatformIntegrationForOwner call. getInstallation/ listIntegrations read those columns directly off platform_integrations, so GitHubIntegrationDetails kept showing stale data after a refresh. Added syncIntegrationInstallationDetails, scoped by integration id only (safe for shared/multi-owner installations, unlike the removed legacy writer), and wired it into refreshInstallation. Added regression tests for all three: both listGitHubRepositories routers now assert a full repo object round-trips unchanged through the real tRPC output validation, and refreshInstallation's tests assert syncIntegrationInstallationDetails is called with fresh data (and not called on the early-exit failure path). * fix(github): reclaim stale installation delivery claims * fix(github): enforce webhook delivery-claim window * fix(db): make github FK migration crash-safe and guard orphan reconciliation Split 0244 into three independently-tracked migrations so a failed or interrupted deploy can never strand durably-committed DDL behind an untracked migration, and never leaves a window with neither the old nor the new uniqueness protection in place: - 0244_salty_cyclops.sql: plain transactional additions only (new tables, new columns, all FK constraints including the validating platform_integrations FK). No explicit COMMIT/BEGIN, so Postgres rolls the whole file back atomically on any failure; a retry is always clean. - 0245_calm_lockheed.sql: only the four CREATE UNIQUE INDEX CONCURRENTLY replacement indexes, fenced with the repo's approved COMMIT/BEGIN pattern. Does NOT drop the old two unique indexes. - 0246_clean_the_renegades.sql: drops the two old unique indexes and adds the sharing_mode CHECK constraint, only after 0245 has been independently tracked as applied, so the replacements are always confirmed built before the originals are removed. Verified empirically: applying only through 0245 leaves both the old and new indexes coexisting (all valid) - there is no point in the sequence where the uniqueness invariant is unprotected. 0246 then applies cleanly as its own later migration. Per packages/db/AGENTS.md, deliberately did not add IF NOT EXISTS or a pre-emptive DROP INDEX IF EXISTS around the CONCURRENTLY builds: the skill explicitly prohibits automatically masking a failed/invalid concurrent index build that way and requires explicit operator inspection and recovery (DROP INDEX CONCURRENTLY / REINDEX INDEX CONCURRENTLY) instead. The file split already achieves retry-safety at the migration-tracking level without needing that. Also hardens 0243: replace the silent UPDATE that nulled any dangling platform_integrations.github_installation_id with a DO 12662 guard that RAISEs if any orphan exists. Nulling was a behavior change in disguise - for a row with github_app_type IS NULL, clearing the reference makes it newly eligible for the legacy bot-link fallback lookup (which treats github_app_type = 'standard' OR IS NULL as equivalent). Since production orphan_count is confirmed 0, assert the invariant instead of silently mutating data/eligibility. Verified: drizzle-kit check passes; the full chain (including a staged partial-apply through 0245 only, then 0246 separately) applies cleanly from scratch against a fresh local Postgres; schema.ts is unchanged (same end-state schema, just staged across more granular migrations); packages/db/src/schema.test.ts (103 tests) passes. * fix(db): make concurrent index builds in 0245 retry-safe Each of the four CREATE UNIQUE INDEX CONCURRENTLY statements in 0245_calm_lockheed.sql had no idempotency guard. CONCURRENTLY can leave an INVALID index under its name if a build fails partway or is interrupted; a blind retry of CREATE UNIQUE INDEX CONCURRENTLY on the same name then fails with 'already exists' instead of rebuilding it - the exact non-retryable strand Finding #1 addressed, just moved down to the index-build step. Precede each build with DROP INDEX CONCURRENTLY IF EXISTS on its exact name: a no-op on the happy path (index doesn't exist yet), and a clean removal of a leftover invalid index otherwise, so every retry converges to the same end state. Per packages/db/AGENTS.md, deliberately not using CREATE ... CONCURRENTLY IF NOT EXISTS, since that would leave a leftover invalid index in place unrebuilt instead of recovering it; DROP INDEX CONCURRENTLY is the exact recovery mechanism the skill names, just applied proactively rather than only after manual diagnosis. Both DROP CONCURRENTLY and CREATE CONCURRENTLY remain in the existing post-COMMIT/pre-BEGIN section, outside any transaction block, as required. No change to the final index set, names, or schema.ts/snapshots. Verified: drizzle-kit check passes. Full chain applies cleanly from scratch. Directly simulated the failure mode: seeded a duplicate-data conflict, forced one of the four builds to fail and leave an INVALID index (confirmed indisvalid=false), confirmed a blind retry without this guard fails with 'already exists' (reproducing the bug), then repaired the data and confirmed the guarded migration now retries cleanly to a fully valid final index set. packages/db/src/schema.test.ts (103 tests) passes. * fix(github): restore exact-id shared runtime authorization lost in the main merge The main #6178/#6179 reconciliation merge (81628ee) kept sharing_mode gating unconditionally exclusive-only, which regressed a shared (web_cloud_agent) association's ability to ever authorize, even with an exact association id pinned. Note: this carve-out never actually existed on feat/github-connections-pr2 before that merge (verified via git history: it only exists on feat/github-connections-pr4's commits 15ae070 and f53e068) — but it is a real, needed capability for shared installations to read/refresh their own repository inventory, so re-applying it here now, adapted to main's reason-returning shape: 1. getGitHubRuntimeAssociationRejectionReason(association, options?) takes { allowShared?: boolean }; sharing_mode is only permitted to be 'web_cloud_agent' when allowShared is explicitly true, otherwise it must be 'exclusive'. isGitHubRuntimeAssociationAuthorized forwards the same options (defaulting to exclusive-only). 2. assertGitHubInstallationRuntimeAuthorized normalizes expectedIntegrationId once into exactAssociationId (empty string -> undefined) and derives allowShared = exactAssociationId !== undefined, so the id predicate, the row limit, and the sharing-mode carve-out can never diverge (the F3 invariant). 3. The SQL's canonical-installation OR-branch permits sharing_mode IN (exclusive, web_cloud_agent) only when allowShared; otherwise exclusive only. 4. The post-query reason check passes { allowShared } through so it agrees with what the SQL already filtered for. Every other check (ownership, local health, canonical installation health, ambiguous_association for the generic path) is unchanged. Ported the PR4 test coverage for this onto PR2's reason-based API: - runtime-authorization.test.ts: allowShared true/false/unset on the pure reason function and boolean wrapper, plus mock-level exact-id vs generic-path authorization of a shared association. - github-installations.test.ts: a real end-to-end integration test (two orgs sharing one installation) covering exact-id shared authorization, generic-path rejection, an added empty-string-id F3 case, unknown-id rejection, and local/owner health enforcement on the exact-id path; plus canonical-installation-unhealthy rejection. - New review-shared-repositories.test.ts (ported from PR4): an end-to-end adapter-level regression proving fetchGitHubRepositories succeeds for an exact healthy shared association. Did not port PR4's separate connection-service.ts repository-caching- at-attach fix (15ae070) — that is an unrelated bug in a different function, out of scope for this regression fix. Verified: pnpm --filter web typecheck clean; oxlint clean on all 4 changed files; pnpm format (line-wrap only); git diff --check clean; no schema.ts or migration changes. 182 tests pass across the full required suite (runtime-authorization, review-shared-repositories, webhook-handler + webhook-handler-dispatch.integration, installation-handler, connection-service, github-integration-helpers, prepare-review-payload, github-installations).
pandemicsyn
added a commit
that referenced
this pull request
Sep 16, 2026
…testing (#6100) * feat(github): enable shared web agent installations * fix(github): harden shared installation races * fix(github): serialize shared callback cleanup * fix(integrations): fence provider state mutations * fix(integrations): serialize provider state sessions * fix(integrations): restore provider session consistency * Revert "fix(integrations): restore provider session consistency" This reverts commit ee85e37126e6cb898da4033490f53fb03526e586. * Revert "fix(integrations): serialize provider state sessions" This reverts commit bbecb7325122c5d77047992074a86f250f79e8a3. * Revert "fix(integrations): fence provider state mutations" This reverts commit 25817c7c03bedfd2226ac430668f56492c4ab19b. * feat(integrations): reserve provider OAuth attempts * fix(integrations): lock OAuth reservations by owner row * fix(integrations): fence provider OAuth state purpose * test(integrations): harden OAuth reservation races * test(integrations): prove OAuth owner-row blocking * fix(github): serialize verified owner cardinality * test(integrations): include OAuth state issue time * chore(db): regenerate shared GitHub migration * fix(github): close shared runtime isolation gaps * fix(github): fence exclusive lifecycle retries * fix(github): close legacy authorization edges * fix(github): fence direct token legacy paths * fix(github): retry in-progress lifecycle receipts * refactor(github): simplify lifecycle cleanup * fix(github): align repository refresh lock order * test(github): prove lifecycle lock overlap * test(github): match exact advisory lock query * fix(github): bind bot links to tenant association * fix(github): constrain legacy bot-link fallback * feat(slack): fence shared installation activation * feat(slack): delegate with exact GitHub connection * fix(slack): make installation activation recoverable * fix(slack): resolve all-access GitHub repositories * fix(slack): retire expired installation generations * test(slack): reject unhealthy all-access GitHub links * fix(slack): preserve cleanup reservations until revoke * fix(slack): clean verified legacy token metadata * fix(slack): retain cleanup identity barriers * fix(slack): bridge Enterprise workspace identities * fix(slack): order Enterprise identity aliases * fix(slack): fence alias and cleanup ordering * fix(slack): persist cleanup and alias progress * fix(slack): defer shared Enterprise Grid support * fix(slack): isolate shared workspace activation * fix(slack): fence workspace sharing conversion * fix(slack): validate workspace conversion state * fix(slack): route shared and cleanup states exactly * fix(slack): preserve unshared runtime boundaries * fix(slack): enforce runtime health boundaries * fix(slack): reserve successful unshared workspaces * fix(slack): finalize SDK effects with generation CAS * fix(slack): separate workspace and Enterprise cleanup * fix(slack): terminalize legacy cleanup safely * fix(slack): release terminal cleanup tombstones * fix(slack): isolate webhook maintenance failures * fix(slack): exclude Enterprise installs from backfill * fix(slack): validate Enterprise IDs and reset health * fix(slack): make Enterprise activation success-only * fix(slack): reconstruct Enterprise runtime state * Revert "fix(slack): reconstruct Enterprise runtime state" This reverts commit 0d98e23901e2bf5f0eab1808cd933d8d04f4a44d. * Revert "fix(slack): make Enterprise activation success-only" This reverts commit 044037412d82dfacf43ae6096eb4a6b11d9f3e58. * Revert "fix(slack): validate Enterprise IDs and reset health" This reverts commit 06f0792ade52271eb9787f5b4379cb9e760caea9. * Revert "fix(slack): exclude Enterprise installs from backfill" This reverts commit bf4f2569591b9849677439d7bb23d0dd174d3d5e. * Revert "fix(slack): isolate webhook maintenance failures" This reverts commit 9bfcd8d922f5db7670f2e1335e5714a9346151b7. * Revert "fix(slack): release terminal cleanup tombstones" This reverts commit 6d67a1ee2e6e0f22bdd0b6fa7789dccc04aa50f3. * Revert "fix(slack): terminalize legacy cleanup safely" This reverts commit bfe544e479c5ffbe394c6b54b46d2b08d1062e70. * Revert "fix(slack): separate workspace and Enterprise cleanup" This reverts commit 8ac165699e3851a48df175bfd299739453645e1d. * Revert "fix(slack): finalize SDK effects with generation CAS" This reverts commit 31e4964945b55982399415304f8f090534d5d9a2. * Revert "fix(slack): reserve successful unshared workspaces" This reverts commit d8eecc65f677b302d373faca1a4a1343bc341e51. * Revert "fix(slack): enforce runtime health boundaries" This reverts commit fd6c560f7bdbb72afb4c98d9cceb3ad5fd3750b1. * Revert "fix(slack): preserve unshared runtime boundaries" This reverts commit f386ae078fb09435d612a65f21e2893b0852f8dc. * Revert "fix(slack): route shared and cleanup states exactly" This reverts commit 185270d903eb3a9621908e15f8db0be7e42c2c37. * Revert "fix(slack): validate workspace conversion state" This reverts commit b567d09d9d3a4a082ff2c4e1438986f986d8a524. * Revert "fix(slack): fence workspace sharing conversion" This reverts commit 3bf72f03b2a7c23d07981720a6d35ddfb9a0ce3f. * Revert "fix(slack): isolate shared workspace activation" This reverts commit 31ac81376339c423e256696f47d57a5cf4d5c44b. * Revert "fix(slack): defer shared Enterprise Grid support" This reverts commit 4eab6e07537054d35481c7a6798f0bd144fb6a59. * Revert "fix(slack): persist cleanup and alias progress" This reverts commit 89ac1672a8382e8daaf1bb40fa3d6fa2367594bd. * Revert "fix(slack): fence alias and cleanup ordering" This reverts commit 41538c3b411ca91530bd40bcd19a094fc2a61df1. * Revert "fix(slack): order Enterprise identity aliases" This reverts commit 439013f6fbac6eea73befddbabdf8fbd5e265b8b. * Revert "fix(slack): bridge Enterprise workspace identities" This reverts commit 8db09b0d2908043e9322da6dc86a2ffad7d65996. * Revert "fix(slack): retain cleanup identity barriers" This reverts commit 4357a03a2d58770443ccbff3e08a9771e65a20ed. * Revert "fix(slack): clean verified legacy token metadata" This reverts commit 0636b8f71bc85fec094d18bf26a621d299f049b0. * Revert "fix(slack): preserve cleanup reservations until revoke" This reverts commit 1d9b19497717dc08e67d95376c07a83e70fbe1b6. * Revert "test(slack): reject unhealthy all-access GitHub links" This reverts commit 296df672a973e6d576df7c138c979eaae1c32c34. * Revert "fix(slack): retire expired installation generations" This reverts commit 300d02a4d1069b0579518b120b8f9831fb772a54. * Revert "fix(slack): resolve all-access GitHub repositories" This reverts commit 27d5944fa715d0008018ee64a7723b5260603bb5. * Revert "fix(slack): make installation activation recoverable" This reverts commit b9aeab17f9b307f257bc555f994d09fc3ae30a56. * Revert "feat(slack): fence shared installation activation" This reverts commit 57f5e26032452695fa9a41d9d8ab8d67f54159dd. * test(slack): verify minimal shared delegation * test(slack): preserve unsupported provider fences * test(slack): tighten minimal sharing fixtures * fix(slack): validate cached GitHub repositories * fix(github): stop a locally disconnected connection from blocking new ones After local disconnect, the platform_integrations row stayed included in every "does this org already have an installation" cardinality check, permanently occupying the org's single-installation slot and hiding both the fresh-install and connect-existing entry points. It also could never be hard-uninstalled afterward, because uninstallExclusiveGitHubInstallation required the target association itself to still be locally connected. - Exclude locally disconnected rows from the fresh-install (mintInstallState) and connect-existing (listOrganizationInstallations canAdd/ existingConnectionAdmission) cardinality checks, and from connectVerifiedGitHubInstallation's owner-cardinality guard. - Fix uninstallExclusiveGitHubInstallation to key off whether any OTHER tenant association is still connected, not whether the target association itself is; a disconnected sole association can now be hard-uninstalled. - Update the settings dropdown to offer real removal (upstream uninstall) instead of a no-op repeat "Disconnect" once a connection is already disconnected locally. No new lifecycle machine; reuses the existing disconnect/uninstall writers and their advisory-lock ordering. * fix(organizations): keep onboarding Source Control in sync with GitHub getOrganizationOnboardingState already used the same health predicate as the integration list (platformIntegrationHealthSql), so the reported '0/3 Source Control incomplete despite an active connection' symptom traced to a UI staleness gap rather than the status source itself: a GitHub connection made from the organization's Settings/Integrations page (fresh install or Connect existing) has no way to notify an onboarding wizard tab left open from before that change. Add the same window-focus/visibilitychange refetch the wizard's own embedded GitHub setup UI already uses for its returning-tab case, applied to the checklist query generally, so Source Control reflects the current connection state when the tab regains focus. Add DB-level regression coverage locking in that the checklist already correctly reflects: an active connection created through the canonical connectVerifiedGitHubInstallation writer, its clearing on local disconnect, and suspended/auth-invalid/disconnected/pending GitHub integrations as incomplete (previously untested negative paths). * fix(github): record authorization provenance on every connect path github_authorized_by_user_id/github_authorized_user_id/github_authorized_at were only ever set by connectVerifiedGitHubInstallation, leaving them null on two other paths that do have an available identity to record: - The main install callback's legacy branch (connection management disabled, or the weaker access-list assertUserAdministersInstallation check) still exchanges a real authenticated GitHub identity via exchangeGitHubOAuthCode; it just never carried that identity, or the signed-in Kilo user, into upsertPlatformIntegrationForOwner. - autoCompleteInstallation, which finishes a pending admin-approval request from the installation.created webhook, had the original requester's already-OAuth-verified Kilo and GitHub identity sitting on the pending row's metadata but never copied it into the provenance columns. Extend upsertPlatformIntegrationForOwner with optional kiloUserId/ githubUserId, thread the callback's exchanged identity through, and have autoCompleteInstallation record the requester's identity as the authorizer. Documented in code: the pending-approval path can misattribute authorship if a different GitHub org admin approves someone else's request, since no stronger identity is available at webhook time; this is the best available provenance on that path, not a fully independent verification. * fix(github): also exclude disconnected rows from the legacy cardinality guard connectVerifiedGitHubInstallation's owner-cardinality guard was fixed to ignore locally disconnected rows, but upsertPlatformIntegrationForOwner's equivalent guard (the legacy writer used when connection management is disabled) still counted them, so a disconnected standard installation kept blocking a fresh install with multiple_installations_disabled even though the UI/API gate reported the slot as available. Add the same isNull(github_disconnected_at) condition to the legacy writer's ownerIntegrations query, and add a DB-backed regression test: legacy connect -> blocked second install -> local disconnect -> fresh install now succeeds. * fix(github): terminalize active review work on disconnect and uninstall Disconnect and exclusive uninstall never touched the association's own review work. A queued/running review kept its dispatch reservation and non-terminal status after disconnect, and uninstallExclusiveGitHubInstallation deleted the association out from under it (platform_integration_id is ON DELETE SET NULL, not cascade), orphaning it forever. Worse, evaluateGitHubSharingCompatibility inspects every canonical association including disconnected ones, so that stale active review then rejected a later connect-existing attempt from a different, legitimately approved organization with incompatible_workflow. - Generalize cancelActiveCodeReviewsForIntegration (previously org-only, with zero production callers) to take an Owner so it also covers user-owned integrations, accept an optional transaction so it can run under disconnect/uninstall's own advisory-lock ordering, and null dispatch_reservation_id on cancel to match the terminalization convention already used by the stale-review reaper. - Call it from disconnectGitHubInstallation and uninstallExclusiveGitHubInstallation, before the delete in the uninstall case. - Defense in depth: exclude locally disconnected associations from evaluateGitHubSharingCompatibility's association set entirely, so a disconnected tenant's other stale resources (automation, bot requests, deployments) also stop counting as incumbent work. - Update disableBitbucketCodeReviewerForIntegration, the one existing caller, for the generalized signature. DB tests: dispatch reservation clearing, user-owned integration coverage, and an end-to-end disconnect scenario asserting the association's own review/attempt reach a terminal state, an unrelated integration's review is untouched, and a newly approved organization can then connect-existing to the same canonical installation. * test(github): add remaining P2 coverage from PR4 review - connection-service.test: assert a completed connection attempt records authorization provenance, and add an end-to-end create-attempt -> disconnect -> new-attempt -> complete flow proving connection-attempt completion succeeds after a prior local disconnect. - callback route test: assert that a management-enabled install routes through connectVerifiedGitHubInstallation with both the Kilo user id and the verified GitHub user id, not just the legacy writer. - devAddInstallation (dev-only E2E seeding shortcut): document in code why it intentionally omits authorization provenance (no live OAuth identity exists on this path), and add tests asserting it is rejected outside NODE_ENV=development and that it does not pass kiloUserId/ githubUserId when it does run. * fix(github): settle the review ledger only after disconnect/uninstall commits cancelActiveCodeReviewsForIntegration settled the operation ledger through the default db connection immediately after the cancellation query, even when the caller supplied its own transaction. If that caller transaction later rolled back (for example, uninstallExclusiveGitHubInstallation's deleteUpstream throwing), the review cancellation itself was undone but the ledger had already gone terminal (no_op) on a separate connection, leaving the ledger/outbox permanently out of sync with the review's real state. Follow the pattern already used by disableBitbucketCodeReviewerForIntegration: when a transaction is supplied, skip settling inside cancelActiveCodeReviewsForIntegration and let the caller settle (settleCancelledReviews, now exported) only after its own transaction has committed. disconnectGitHubInstallation and uninstallExclusiveGitHubInstallation now return the cancelled rows from their transaction and settle right after. Standalone callers without a transaction keep the exact same best-effort, immediate-settle behavior as before. DB tests: a successful uninstall with an active review/attempt/reservation terminalizes both, clears the reservation, settles the ledger to no_op, and the FK's ON DELETE SET NULL leaves the now-unlinked review terminal; a deleteUpstream that throws rolls back the cancellation, the attempt, and the association delete together, and never settles the ledger (status stays admitted, settled_at stays null). * feat(github): converge Connect GitHub and Connect existing into one flow A user has no way to know ahead of time whether the Kilo GitHub App is already installed somewhere they can access, so they had no way to know whether to click 'Connect GitHub'/'Add organization' (fresh install, skips OAuth) or 'Connect existing' (OAuth-first, discover, pick) — two separate top-level buttons for what is really one intent. Converge on a single primary action. When connection management is enabled, it always begins the existing purpose-bound OAuth attempt (unchanged beginConnection); the resulting picker (GitHubConnectionAttemptState) now shows both the discovered attachable installations AND an explicit 'install on a different GitHub organization' option side by side, so a first-time org still gets a clear path to install even with zero discovered candidates. Selecting an installation still goes through the exact same connect-existing confirm path (selectConnectionInstallation -> connectVerifiedGitHubInstallation); install-new still goes through the exact same mint+redirect path (mintInstallState). Neither path, nor its authorization, changes. When connection management is disabled, the primary action still calls the direct-to-installer mint flow unchanged: beginConnection is gated behind connection management and would reject an OAuth-first attempt in that mode, so today's behavior is preserved exactly. Hide the top-level primary action while a connection attempt's picker is showing, so the surface has exactly one primary decision point at a time. Extend GitHubConnectionAttemptState's existing render tests for the new install-new option in both the empty and non-empty candidate states, and gated by whether a fresh install is currently permitted. * fix(github): distinguish a stale connection attempt from a transient error GitHubConnectionAttemptState always claimed the connection attempt 'expired or is no longer available' on any query error, including a transient network failure or server error where the attempt itself may still be perfectly valid. Branch on the tRPC error code: only a confirmed NOT_FOUND shows the expired/restart copy and the full-restart action; any other error shows a neutral 'could not load' message with a retry that just refetches the same attempt instead of forcing a brand new OAuth round trip. Also give each candidate button in the picker an explicit accessible name (its bare visible text was only the account login, with the raw installation ID as a second, unlabeled text node): 'Connect {login}, installation {id}', with the raw ID marked aria-hidden so it isn't duplicated in the accessible name. No authorization, router, ledger, or data-model change. * fix(github): actually send code_verifier through the OAuth token exchange Live two-org demo testing found the connect/attach flow (and by extension the unified picker's discover stage) failing with connection_failed. GitHub's token endpoint was rejecting the exchange with invalid_grant: 'A code_verifier was not included, but the authorization request included a code_challenge. Codes requested with a code_challenge MUST be redeemed with a code_verifier.' Root cause: @octokit/oauth-methods' exchangeWebFlowCode never forwards a supplied codeVerifier to GitHub for clientType: 'github-app' — confirmed against the installed package (@octokit/oauth-methods@6.0.2): there is no code_verifier/codeVerifier reference anywhere in it. beginConnection requests a PKCE-bound code (code_challenge/code_challenge_method=S256) and connection-state stores the matching codeVerifier; the connection callback correctly passed it into exchangeGitHubOAuthCode, but that verifier was then silently dropped before reaching GitHub. Exchange directly with GitHub's token endpoint instead, mirroring exchangeGitHubUserAuthorizationCode in user-authorization.ts, which already does this correctly and was unaffected (it never went through exchangeWebFlowCode). Also surface GitHub's actual error body (error/error_description) in the thrown error instead of a generic message — that's what let this get diagnosed. Non-PKCE callers (the install callback's setup_action=request/install admin-proof paths, which call exchangeGitHubOAuthCode with no codeVerifier) are unaffected: the field is simply omitted from the request when absent, as it always was. Added apps/web/src/lib/integrations/platforms/github/adapter.test.ts (new file — no prior unit tests existed for this adapter): asserts the outbound request includes code_verifier when provided and omits it when not, and that GitHub's error body is surfaced. A full OAuth round trip isn't unit-testable; this request-shape assertion is what prevents this exact regression. * fix(github): do not require sharing admission for a disconnected prior tenant connectVerifiedGitHubInstallation's otherOwnerAssociations query was not filtered by github_disconnected_at, unlike the equivalent guards introduced elsewhere in this stack (sharing-compatibility.ts, platform-integrations.ts, github-apps-router.ts). A fully disconnected prior tenant's stale row still forced requiresSharingAdmission=true for a new tenant, requiring that new tenant to be on the shared-installation allowlist even though there is no actual concurrent tenant anymore. Split the check: a personal (non-org) owner is still rejected unconditionally whenever *any* other owner has ever claimed this installation, connected or not (claimed_by_other_owner) — that protection must not weaken based on the other owner's connection state. Only the *sharing-admission* requirement (allowlist + compatibility check) is now scoped to *actively connected* other-owner associations, since a disconnected one is no longer real concurrent incumbent work. DB test: a new, non-allowlisted organization can attach to an installation as an ordinary (non-shared) first tenant once the prior owner has disconnected, and the canonical row's sharing_mode stays 'exclusive' (proving the admission path was skipped, not merely approved). * refactor(github): reduce duplication in provenance and settle-contract docs - Extract buildGitHubAuthorizationProvenance, shared by autoCompleteInstallation and upsertPlatformIntegrationForOwner, which previously built an identical-shaped provenance object independently in each. - Name the inline owner: { type: org|user; id } shape on IntegrationReviewCancellationInput as ReviewOwnerRef, so it reads as distinct from this same file's own (differently-shaped) Owner and the unrelated Owner in @/lib/integrations/core/types at a glance. - Keep the authoritative 'settle only after commit' explanation on settleCancelledReviews and have the three other sites that restated it near-verbatim reference it instead, so the invariant only needs updating in one place if it ever changes. * fix(github): picker action races, error-state polish, and effect churn - Disable every action in the connection-attempt picker (candidate select and install-new) while any one of them is pending, not just its own action. Before this, confirming a candidate left install-new clickable while selectConnection was still in flight (the confirm modal already closed, before the redirect actually navigated away), letting the two flows race. - Derive isNotFound as isError && <confirmed NOT_FOUND> at the call site so the two props can never disagree, instead of trusting the caller to keep them in sync independently. - Give the generic-error retry action a pending state (isRetrying, wired from the query's isFetching) and rename it from the generic 'Try again' to 'Reload connection attempt', consistent with this component's other verb+object labels. - Give both of this PR's newly added buttons (install-new, the retry action) the same touch-target class the primary button already uses, and make both of them variant=default when they are the sole action in their state, per the one-primary-action-per-surface convention this component already follows for install-new-as-sole-candidate. - OrganizationSetupWizard's focus/visibilitychange effect now reads checklistQuery.refetch through a ref updated every render and depends on [] instead of the whole query result object, avoiding needless listener churn on every render. Extends GitHubConnectionAttemptState's render tests for the renamed/ pending retry state and the cross-action disable behavior. * fix(github): reject a non-JSON OAuth token response with a clear error If GitHub (or an intermediate proxy) ever returned a 2xx response with a non-JSON body, await response.json() threw a raw, unattributed SyntaxError instead of a message identifying what actually failed. Still caught by the callback route's outer try/catch, so not a crash, but a confusing entry in logs/Sentry. Wrap the parse and surface a clear, attributed error; the normal JSON success path and the existing error/error_description surfacing are unchanged. Add the missing test case alongside the existing include/omit/error-body coverage. * docs(github): correct inaccurate rationale in disconnect review-cancellation comment The comment claimed cancelling reviews on disconnect was needed because evaluateGitHubSharingCompatibility would otherwise treat the disconnected association as still-active incumbent work. That's no longer true (and wasn't when this comment was written): sharing-compatibility.ts already excludes disconnected associations from its own association query entirely, independent of review status. The actual reason to cancel here is to avoid an orphaned queued/running review and a dangling dispatch reservation, matching the accurate rationale already given for the uninstall path. No behavior change. * refactor(github): make the picker's error state a discriminated union isError/isNotFound were two independent booleans where only 3 of 4 combinations were valid, enforced solely by a doc comment asking callers to derive isNotFound as isError && <confirmed NOT_FOUND>. Replace both with a single error: null | { kind: 'not_found' } | { kind: 'other' } prop, making the invalid 'not found but not an error' combination unrepresentable instead of relying on caller discipline. Behavior is unchanged; the one caller (OrganizationGitHubInstallations.tsx) now builds the discriminated value directly from the same tRPC error-code check it used before. Also give the candidate-select buttons the same min-h-11 sm:min-h-0 touch-target class already applied to their sibling install-new/retry buttons in this same picker, so tap targets are consistent within one button group instead of the candidates falling back to the smaller default. Add the missing test for the install-new button's own loading label ('Opening GitHub…') while isInstalling, alongside the existing tests for disabling every other action while it's in flight. * refactor(github): move latest-ref update into an effect, not the render body checklistRefetchRef.current was reassigned directly in the render body, which is a commonly-used but technically-out-of-spec pattern (React documents refs as write-during-effect/event-handler, not write-during-render). Move the assignment into its own deps-less effect that runs after every render; the listener effect below still depends on [] and still reads the ref only from its event listeners, so the focus/ visibilitychange refetch behavior is unchanged. * fix(db): reconcile orphans before validating github FK Split 0243 into a pre-flight reconciliation migration and the FK-adding migration: - 0243_reconcile_orphaned_github_installation_id.sql nulls out any platform_integrations.github_installation_id that no longer references an existing github_app_installations row, so the FK added below cannot fail its full-table validation on drift. - 0244_free_thena.sql (renamed from 0243_exotic_triton.sql) now declares the FK with an explicit ON DELETE restrict, matching the repo convention for blocking FKs and the lifecycle already enforced in code: github_app_installations rows are soft-deleted (lifecycle_state/deleted_at), and the only hard-delete path (anonymizeCloudUserData) already guards with a NOT EXISTS check against remaining associations. schema.ts documents the ON DELETE restrict choice inline. * fix(github): only record webhook receipt on 2xx dispatch dispatchSharedOnce recorded a completed delivery receipt whenever the shared-installation receipt lookup wasn't missing_canonical, regardless of the dispatched handler's response status. A non-2xx dispatch was therefore marked completed, so a later GitHub redelivery of the same event was short-circuited as a duplicate and could never retry. Only record the receipt when response.ok, leaving non-2xx dispatches unrecorded so GitHub's redelivery can retry them. missing_canonical behavior is unchanged. * fix(integrations): supersede unexpired pending OAuth attempts beginProviderOAuthAttempt only expired pending/consumed rows whose expires_at was already in the past before inserting the new attempt. An unexpired pending row for the same owner+provider (e.g. an abandoned flow) still collided with the partial unique index on (owner, provider, status = 'pending'), blocking a fresh /connect attempt for up to ATTEMPT_TTL_MS (10 minutes) and surfacing oauth_init_failed. Drop the expires_at filter for pending rows so a new attempt always supersedes an existing pending one for that owner+provider, within the same tx and owner-row lock already held. Consumed rows are still only expired once actually past their TTL, since they don't hold that unique slot. consumeProviderOAuthAttempt's pre-step doesn't insert and matches the row to consume by exact state_hash, so it doesn't share this collision pattern and is unchanged. * fix(cloud-agent): dedupe exact-duplicate repository entries results.flatMap(r => r.repositories) in fetchRepositoriesForIntegrations had no dedup after dedupeRepositories was removed, so a corrupted or duplicated cached repositories array on a single installation would surface the same repo twice for that installation. Deliberately keep one entry per (platformIntegrationId, id) rather than deduping by repo id alone: NewSessionPanel already keys and selects repository rows by platform + platformIntegrationId + id, and the existing 'preserves both association choices when two installations expose one repository' test documents that two different installations granting the same repo must both remain selectable, since session start resolves the token/permissions from the chosen installation. Deduping by repo id would silently drop one of those legitimate, functionally distinct entries. Added a test covering the same-installation duplicate case and confirmed the existing cross-installation test is unaffected. * fix(github): stop locking an incumbent personal owner out of their own installation The personal (non-org) cross-owner rejection in connectVerifiedGitHubInstallation fired whenever *any* other owner had an association for the installation, regardless of that owner's connection state and regardless of whether the caller already had their own association. So a personal owner who was the original tenant of their own installation got rejected with claimed_by_other_owner the moment an allowlisted org attached as a second tenant (running sharing admission and setting sharing_mode=web_cloud_agent), locking the incumbent out of their own connection. The 'cross-owner protection unchanged' claim held for new claimants but not for incumbent reconnects. Gate the rejection on the caller having no association of their own (!existing) AND at least one *actively connected* other-owner association. A caller reconnecting/refreshing their own existing row is not a new claimant, and a locally disconnected other-owner row is not an active incumbent (matching the disconnected-relinquish semantics the org path already uses). This does not reintroduce the sharing-admission issue the active/disconnected split originally fixed: requiresSharingAdmission is untouched and still keys off active other-owner associations only, so an org attaching as a genuine new second tenant still goes through sharing admission unchanged. Tests (github-installations.test.ts): - regression: personal incumbent -> allowlisted org attaches (sharing_mode=web_cloud_agent) -> incumbent reconnects -> ok, same association. - protection intact: a new personal claimant is still rejected claimed_by_other_owner while another personal owner, or an organization, is actively connected. - new personal claimant with only a disconnected other-owner row is no longer force-rejected and attaches as an ordinary first tenant. - an org attaching as a new second tenant still fails the unchanged sharing-admission gate. Both new behavior tests were confirmed to fail against the pre-fix code with reason claimed_by_other_owner. * fix(slack): accept adapter-written repository cache * fix(github): stop an unbound legacy association from uninstalling a shared installation upstream uninstallExclusiveGitHubInstallation resolved both the canonical sharing mode and the set of sibling tenants through the *target row's own* github_installation_id binding. For an unbound target (github_installation_id IS NULL) that made: - the shared-installation guard a no-op (canonicalId was null), and - the sibling query fall into its isNull(github_installation_id) branch, which cannot see a sibling that IS bound to the canonical. Legacy rows can be unbound: the canonical backfill in migration 0239 deliberately skipped associations that were already disconnected or suspended. So a tenant holding such a row could call uninstall and have deleteUpstream delete the GitHub App installation while another tenant was actively using it — observeGitHubInstallationLifecycle then suspends every non-disconnected association for that installation, taking the other tenant's live integration (webhooks, runtime, code reviews, bots) down with it. The pre-PR check (connected set must equal exactly the target) refused this case, so this was a regression from the earlier relinquish-aware uninstall change. Resolve the canonical by stable installation identity (github_app_type, installation_id) and match sibling tenants by the same identity (platform + platform_installation_id + effective app type, non-disconnected, excluding the target), regardless of how either row is bound. The target row is still locked owner-scoped first. Tests: an unbound legacy association can no longer uninstall while a bound sibling tenant is actively connected (upstream delete not called, sibling stays active, no suspension cascade); the shared-canonical guard now also applies to unbound targets; and an unbound legacy association with no connected sibling still uninstalls as before. Reverting the fix locally makes the two cross-tenant tests resolve instead of reject. * fix(github): harden shared delivery and repository validation * fix(cloud-agent): repair personal GitHub session start and stale refresh Three must-fix issues found in code review of the GitHub connections work: 1. NewSessionPanel's GitHub-integration guard was unconditional on selectedGitHubIntegrationId, but that field is only ever populated for organization-scoped repos. Every personal (non-org) user selecting a GitHub repo hit 'Select the GitHub repository again' and could never start a session. Scope the guard to organizationId, matching the sibling conditional already used for creationIntent. 2. Both listGitHubRepositories tRPC output schemas silently stripped githubAppType (and the personal router also stripped platformIntegrationId/platformAccountLogin), making the new 'Lite' badge dead code and contributing to (1). Added the missing fields to both schemas. 3. refreshInstallation stopped syncing account/permissions/scopes/ repository_access/installed_at onto the platform_integrations association row after the shared-installation refactor removed the legacy upsertPlatformIntegrationForOwner call. getInstallation/ listIntegrations read those columns directly off platform_integrations, so GitHubIntegrationDetails kept showing stale data after a refresh. Added syncIntegrationInstallationDetails, scoped by integration id only (safe for shared/multi-owner installations, unlike the removed legacy writer), and wired it into refreshInstallation. Added regression tests for all three: both listGitHubRepositories routers now assert a full repo object round-trips unchanged through the real tRPC output validation, and refreshInstallation's tests assert syncIntegrationInstallationDetails is called with fresh data (and not called on the early-exit failure path). * fix(github): reclaim stale installation delivery claims * fix(github): enforce webhook delivery-claim window * fix(db): make github FK migration crash-safe and guard orphan reconciliation Split 0244 into three independently-tracked migrations so a failed or interrupted deploy can never strand durably-committed DDL behind an untracked migration, and never leaves a window with neither the old nor the new uniqueness protection in place: - 0244_salty_cyclops.sql: plain transactional additions only (new tables, new columns, all FK constraints including the validating platform_integrations FK). No explicit COMMIT/BEGIN, so Postgres rolls the whole file back atomically on any failure; a retry is always clean. - 0245_calm_lockheed.sql: only the four CREATE UNIQUE INDEX CONCURRENTLY replacement indexes, fenced with the repo's approved COMMIT/BEGIN pattern. Does NOT drop the old two unique indexes. - 0246_clean_the_renegades.sql: drops the two old unique indexes and adds the sharing_mode CHECK constraint, only after 0245 has been independently tracked as applied, so the replacements are always confirmed built before the originals are removed. Verified empirically: applying only through 0245 leaves both the old and new indexes coexisting (all valid) - there is no point in the sequence where the uniqueness invariant is unprotected. 0246 then applies cleanly as its own later migration. Per packages/db/AGENTS.md, deliberately did not add IF NOT EXISTS or a pre-emptive DROP INDEX IF EXISTS around the CONCURRENTLY builds: the skill explicitly prohibits automatically masking a failed/invalid concurrent index build that way and requires explicit operator inspection and recovery (DROP INDEX CONCURRENTLY / REINDEX INDEX CONCURRENTLY) instead. The file split already achieves retry-safety at the migration-tracking level without needing that. Also hardens 0243: replace the silent UPDATE that nulled any dangling platform_integrations.github_installation_id with a DO 12662 guard that RAISEs if any orphan exists. Nulling was a behavior change in disguise - for a row with github_app_type IS NULL, clearing the reference makes it newly eligible for the legacy bot-link fallback lookup (which treats github_app_type = 'standard' OR IS NULL as equivalent). Since production orphan_count is confirmed 0, assert the invariant instead of silently mutating data/eligibility. Verified: drizzle-kit check passes; the full chain (including a staged partial-apply through 0245 only, then 0246 separately) applies cleanly from scratch against a fresh local Postgres; schema.ts is unchanged (same end-state schema, just staged across more granular migrations); packages/db/src/schema.test.ts (103 tests) passes. * fix(db): make concurrent index builds in 0245 retry-safe Each of the four CREATE UNIQUE INDEX CONCURRENTLY statements in 0245_calm_lockheed.sql had no idempotency guard. CONCURRENTLY can leave an INVALID index under its name if a build fails partway or is interrupted; a blind retry of CREATE UNIQUE INDEX CONCURRENTLY on the same name then fails with 'already exists' instead of rebuilding it - the exact non-retryable strand Finding #1 addressed, just moved down to the index-build step. Precede each build with DROP INDEX CONCURRENTLY IF EXISTS on its exact name: a no-op on the happy path (index doesn't exist yet), and a clean removal of a leftover invalid index otherwise, so every retry converges to the same end state. Per packages/db/AGENTS.md, deliberately not using CREATE ... CONCURRENTLY IF NOT EXISTS, since that would leave a leftover invalid index in place unrebuilt instead of recovering it; DROP INDEX CONCURRENTLY is the exact recovery mechanism the skill names, just applied proactively rather than only after manual diagnosis. Both DROP CONCURRENTLY and CREATE CONCURRENTLY remain in the existing post-COMMIT/pre-BEGIN section, outside any transaction block, as required. No change to the final index set, names, or schema.ts/snapshots. Verified: drizzle-kit check passes. Full chain applies cleanly from scratch. Directly simulated the failure mode: seeded a duplicate-data conflict, forced one of the four builds to fail and leave an INVALID index (confirmed indisvalid=false), confirmed a blind retry without this guard fails with 'already exists' (reproducing the bug), then repaired the data and confirmed the guarded migration now retries cleanly to a fully valid final index set. packages/db/src/schema.test.ts (103 tests) passes. * fix(github): serialize pending-install completion with sharing admission The installation.created handler read the existing-association exclusivity state, auto-completed the pending row, and bound it to the canonical installation as three separate un-serialized operations. A competing verified connection could commit between the check and the bind, leaving two active tenant associations on one canonical installation while sharing_mode stayed exclusive — the pending request became a second tenant without ever passing sharing admission, compatibility, or a fresh authorization check. Hold the installation advisory lock (the same identity lock connectVerifiedGitHubInstallation and bindGitHubIntegrationToCanonicalInstallation take) across the whole decision: re-check exclusivity, complete the pending row, and bind, all inside one transaction. If another tenant already owns the installation, the request is left PENDING and the existing 'requires verified connection confirmation' response is returned, so it must go through the verified connect-existing path. Supporting changes: exported lockGitHubInstallationIdentity so callers can join the same lock, and gave autoCompleteInstallation and bindGitHubIntegrationToCanonicalInstallation optional transaction parameters (existing callers keep their current behavior). Tests: installation-handler.test.ts updated for the new call shape; new DB-backed installation-handler.db.test.ts covers (a) a pending request staying pending when another tenant already owns the installation, (b) a winning pending completion still forcing the loser through sharing admission, and (c) a controlled-concurrency case that pauses the pending completion inside its transaction and asserts a competing verified connection cannot commit while it is in flight (verified to fail against the pre-fix handler). * fix(github): apply the pending-completion transaction to the right query The previous commit's transaction plumbing landed on the non-GitHub tail of upsertPlatformIntegrationForOwner instead of autoCompleteInstallation, which both failed typecheck (undeclared 'transaction' in that scope) and left the pending-install completion running outside the handler's transaction — so a later bind failure would still have committed the row. Point the update in autoCompleteInstallation at the supplied transaction and restore the untouched writer, and add a rollback test: when the canonical binding throws, the pending request must remain pending with no installation id applied. That test fails against the non-transactional variant. * fix(github): never route a webhook through a disconnected tenant association After two tenants share an installation, locally disconnecting one keeps its row and flips the canonical back to exclusive (only the other tenant remains connected). findIntegrationByInstallationId selects an arbitrary row for the installation with no health filter, so it can return the disconnected tenant's row. The deferred-dispatch check then authorized on installation id and app type alone, which passed on the healthy sibling — and the caller went on to log and dispatch the payload under the revoked tenant, retaining private webhook data and routing PR/cache/workflow updates to it. Validate the selected association before it is used as the delivery's owner: a retained row that is disconnected, suspended, auth-invalid, or not active is never routed (200, no logging, no dispatch) at all five routing sites. Also pass that exact association id into assertGitHubInstallationRuntimeAuthorized via isAvailableForDeferredGitHubDispatch, so installation-wide authorization can never authorize deferred work that carries a different association's identity. Tests: webhook-handler fixtures now carry a realistic health state; added the review's regression (a disconnected selected association plus a healthy sibling that authorizes must produce no logging and no dispatch — fails without the routing guard) and a check that deferred dispatch validates the exact routed association id. * fix(github): let an exact shared association read its repository inventory A newly attached shared all-repositories association starts with repositories=null, and discovery/Slack read the cached list. Two things blocked populating it: completeGitHubConnectionAttempt only fetched repositories for selected-repository installations, and both the runtime SQL and the in-memory predicate required sharing_mode=exclusive even when the caller supplied one exact association id — so a legitimately shared association could never refresh its own inventory (web discovery failed, Slack saw no repositories). Cache the inventory for both selections at attach, and allow a shared canonical only on the exact-id path. The carve-out keeps every other check: ownership (org membership / user ownership), local health (active, not suspended, not auth-invalid, not disconnected), and canonical health (active, not suspended, not deleted, not auth-invalid). The generic, installation-wide path (no exact id) still requires exclusive, so the runtime-token guard is unchanged for callers that did not resolve a tenant. Tests: added the review's shared-repository regression (real adapter and runtime predicate with a healthy shared association and exact id), exact-id shared authorization vs the still-rejecting generic path, enforcement of local and canonical health and owner validity on the exact-id path, and an attach-path test proving an all-repositories attach caches the fetched inventory. * chore(github): apply formatter output to the security-fix files * fix(github): treat an empty exact-association id as the exclusive-only generic path assertGitHubInstallationRuntimeAuthorized enabled the shared-installation carve-out from the PRESENCE of expectedIntegrationId (expectedIntegrationId !== undefined) while the exact-id query predicate and the row limit used its TRUTHINESS. An empty-string id therefore enabled shared mode without constraining the query to an exact association, so a shared canonical installation could be authorized installation-wide through one healthy association — breaking the invariant that shared mode is permitted only with a genuine exact id. Normalize once into exactAssociationId and drive the predicate, the limit, and the carve-out from that single value, so an empty string is the generic exclusive-only path. No other behavior changes: the non-empty exact-id carve-out and the ownership/local/canonical health checks are untouched. Test: 'treats an empty exact-association id as the generic exclusive-only path' — a shared installation with one healthy association and a suspended sibling rejects an empty-string id (verified it resolved before this fix) while the real exact id still resolves. * fix(github): restore exact-id shared runtime authorization lost in the main merge The main #6178/#6179 reconciliation merge (81628eea1) kept sharing_mode gating unconditionally exclusive-only, which regressed a shared (web_cloud_agent) association's ability to ever authorize, even with an exact association id pinned. Note: this carve-out never actually existed on feat/github-connections-pr2 before that merge (verified via git history: it only exists on feat/github-connections-pr4's commits 15ae07032 and f53e068d2) — but it is a real, needed capability for shared installations to read/refresh their own repository inventory, so re-applying it here now, adapted to main's reason-returning shape: 1. getGitHubRuntimeAssociationRejectionReason(association, options?) takes { allowShared?: boolean }; sharing_mode is only permitted to be 'web_cloud_agent' when allowShared is explicitly true, otherwise it must be 'exclusive'. isGitHubRuntimeAssociationAuthorized forwards the same options (defaulting to exclusive-only). 2. assertGitHubInstallationRuntimeAuthorized normalizes expectedIntegrationId once into exactAssociationId (empty string -> undefined) and derives allowShared = exactAssociationId !== undefined, so the id predicate, the row limit, and the sharing-mode carve-out can never diverge (the F3 invariant). 3. The SQL's canonical-installation OR-branch permits sharing_mode IN (exclusive, web_cloud_agent) only when allowShared; otherwise exclusive only. 4. The post-query reason check passes { allowShared } through so it agrees with what the SQL already filtered for. Every other check (ownership, local health, canonical installation health, ambiguous_association for the generic path) is unchanged. Ported the PR4 test coverage for this onto PR2's reason-based API: - runtime-authorization.test.ts: allowShared true/false/unset on the pure reason function and boolean wrapper, plus mock-level exact-id vs generic-path authorization of a shared association. - github-installations.test.ts: a real end-to-end integration test (two orgs sharing one installation) covering exact-id shared authorization, generic-path rejection, an added empty-string-id F3 case, unknown-id rejection, and local/owner health enforcement on the exact-id path; plus canonical-installation-unhealthy rejection. - New review-shared-repositories.test.ts (ported from PR4): an end-to-end adapter-level regression proving fetchGitHubRepositories succeeds for an exact healthy shared association. Did not port PR4's separate connection-service.ts repository-caching- at-attach fix (15ae07032) — that is an unrelated bug in a different function, out of scope for this regression fix. Verified: pnpm --filter web typecheck clean; oxlint clean on all 4 changed files; pnpm format (line-wrap only); git diff --check clean; no schema.ts or migration changes. 182 tests pass across the full required suite (runtime-authorization, review-shared-repositories, webhook-handler + webhook-handler-dispatch.integration, installation-handler, connection-service, github-integration-helpers, prepare-review-payload, github-installations). * fix(github): let installation.unsuspend clear a suspended association The F2 routability guard was applied to every routing branch, including installation.unsuspend. isRoutableGitHubIntegration rejects unhealthy rows and a suspended row is unhealthy, so the guard dropped the one event that can clear the suspension: with connection management disabled (the default) the suspend webhook marks the association suspended, and the later unsuspend delivery was then refused with 'Integration unavailable', leaving the association and its canonical installation suspended until a manual reconnect. Unsuspend is a recovery event, so gate that branch on disconnected-ness only (isRecoverableGitHubIntegration): a suspended row stays routable while a locally disconnected former tenant is still refused, preserving the F2 protection. The other branches keep the full health guard. handleInstallationUnsuspend also resolved its owner through an unfiltered lookup, so a disconnected former tenant's row could still be the association an unsuspend was applied to; it now ignores a locally disconnected row while keeping a suspended row eligible. Tests: webhook-handler 'processes installation.unsuspend for a suspended association instead of dropping it' (fails with 'Integration unavailable' under the old guard) and 'still refuses to route installation.unsuspend for a locally disconnected association'; DB-backed installation-handler tests 'recovers a suspended association when GitHub delivers installation.unsuspend' (asserts it becomes active with suspended_at cleared) and 'does not unsuspend a locally disconnected former tenant'. * fix(github): keep the all-repositories inventory sync best-effort The F3 change awaited fetchGitHubRepositoriesForMaintenance unconditionally before the connection transaction, so a transient listing failure (rate limit, 5xx, timeout) aborted completeGitHubConnectionAttempt and the connection was never created — previously an all-repositories attach never depended on that call at all. Restore best-effort semantics for the all-repositories case: the fetch still runs (so the cache is populated when it succeeds, which is F3's intent), but a failure is captured and the attach proceeds with repositories left null for a later refresh. The selected-repositories case keeps surfacing the failure, because that listing is the user's own selection. This matches how the pending-install completion path already treats the same call. Test: 'a failing inventory listing does not block an all-repositories attach' — the attach completes with an active association and null repositories (aborts before this fix); the existing caching test still asserts the inventory is populated on success. * fix(github): serialize the legacy install bind against pending completion A pending-install completion could still attach a second tenant to an installation the legacy (connection-management-disabled) callback was already claiming. That path commits the association unbound and binds it in a separate step, and the webhook's locked existence check only counted CANONICALLY BOUND rows — so a peer that was committed but not yet bound was invisible, and a second organization could be auto-completed and bound onto the same canonical installation with sharing_mode left exclusive. - Broaden the locked check in handleInstallationCreated to count every other live association for this installation (pending or active, bound or not, excluding the pending row and locally disconnected rows), so an unbound legacy peer blocks the completion instead of being skipped. - Serialize the legacy leg: the install callback's create+bind now runs under the same installation advisory lock with the same competing-tenant check, and refuses with 'installation_already_claimed' rather than double-binding. - Share one app-type predicate (effectiveAppTypeCondition, now exported) instead of a third copy. Tests: installation-handler.db 'does not auto-attach a second tenant while legacy associations are committed but unbound' (the pending row became active before this fix) and the callback route suite updated for the locked bind. * fix(github): apply lifecycle events to the connected tenant, not an arbitrary row Canonical lifecycle handlers resolved their tenant with findIntegrationByInstallationId, which returns an arbitrary association for the installation, and unsuspend then discarded the result when it happened to be locally disconnected. After one tenant disconnects and another stays connected, the lookup can return the retained disconnected row, so the event was dropped and the connected tenant's suspension never cleared — the association and its canonical installation stayed suspended until a manual reconnect. Suspend had the same arbitrary-row selection. Resolve the tenant through a new findLifecycleIntegration: keep using the existing lookup first (so behaviour is unchanged when it already returns a connected row), and when it returns a locally disconnected former tenant fall back to an explicit connected-association query for the installation and app type. suspend and unsuspend both use it; canonical state (observeGitHubInstallationLifecycle) still runs unconditionally. A locally disconnected tenant is never the association acted on, so the earlier never-act-on-a-disconnected-tenant fix is not reopened. Test: installation-handler.db 'recovers the connected tenant when a disconnected former tenant is also present' — with a disconnected row inserted first, the connected suspended tenant stayed suspended before this fix and now becomes active. * chore(github): apply formatter output to the lifecycle regression test * fix(github): resolve the connected association for installation.unsuspend findIntegrationByInstallationId has no health filter and no ordering, so for an installation with a retained locally disconnected row it can return that row instead of the tenant that still owns the installation. The route guard then dropped the unsuspend delivery before the handler ran, and the handler discarded the disconnected row, so the live tenant's suspension was never cleared — the same stuck state, still reachable. Add findConnectedIntegrationByInstallationId (shared condition builder with findIntegrationByInstallationId, plus github_disconnected_at IS NULL) and use it for the unsuspend path in both places: the webhook route resolves the connected tenant for logging and dispatch, and the handler resolves the connected tenant for the tenant-facing unsuspend, instead of resolving an arbitrary row and discarding it. Suspend uses the same connected resolution. Canonical state (observeGitHubInstallationLifecycle) still runs unconditionally, and a locally disconnected tenant is never acted on. Tests: webhook-handler 'does not log installation.unsuspend when only a disconnected former tenant remains' replaces the drop-the-event assertion, and the suspended/connected cases assert the connected lookup is used; installation-handler mocks updated to the new lookup. * fix(github): gate the legacy bind guard and compare owners NULL-safely Two defects in the competing-tenant guard added for the legacy install path: - It ran unconditionally, so a connection-management-ENABLED install was rejected with installation_already_claimed even though connectVerifiedGitHubInstallation had already admitted and committed the shared association. The guard now only runs on the legacy (management-disabled) writer path, where the unbound-commit-then-bind window exists; the verified path binds directly as before. - The owner comparison used ne() against nullable columns, and is NULL, so a competitor owned by the other owner type (for example an org-owned row when the caller is user-owned) was invisible and the guard would bind anyway, reopening the two-tenants race. NULL is now treated as a different owner explicitly. Tests (callback route suite, DB-backed): 'does not block a management-enabled install when a shared tenant already owns the installation' (returned installation_already_claimed before the gating fix) and 'refuses the legacy bind when a competitor of the other owner type owns the installation' (bound before the owner-comparison fix). * fix(github): make the legacy install write, guard, and bind one transaction The legacy (management-disabled) callback committed the association through upsertPlatformIntegrationForOwner in its own transaction and only then ran the competing-tenant guard and the canonical bind in a second transaction. The advisory lock serialized admission, but a bind failure or an interrupted request left a committed active-but-unbound association behind even though an error was returned, and a concurrent pending-install completion could then attach a second tenant. The legacy path now runs lockGitHubInstallationIdentity, the NULL-safe cross-owner competitor check, the write, observeGitHubInstallationLifecycle, the owner-scoped read, and the bind inside ONE db.transaction, so any failure rolls the whole thing back and the caller still gets today's installation_already_claimed / error redirect. upsertPlatformIntegrationForOwner and findIntegrationByInstallationIdForOwner gained optional transaction parameters (the same pattern autoCompleteInstallation and the bind already use) so the writer joins the caller's transaction instead of opening its own. The verified-writer path is untouched: it still uses its own writer and binds directly. Tests (callback route suite, DB-backed): 'rolls back the legacy write when the canonical bind fails' (the real writer runs inside the caller transaction; the failed bind leaves no association row) and the competitor test now also asserts nothing was written for the caller. * fix(github): stop the suspend route from dropping a lifecycle delivery installation.suspend in the webhook route still resolved an arbitrary association with findIntegrationByInstallationId and early-returned Integration unavailable through isRoutableGitHubIntegration when that row happened to be a retained disconnected former tenant. handleInstallationSuspend therefore never ran, so the canonical installation state was never reconciled — the same class already fixed for unsuspend, but only in the handler. Mirror the unsuspend fix: the suspend route resolves the connected association with findConnectedIntegrationByInstallationId and no longer applies the generic routability early-return, so the handler always runs and reconciles canonical state even when only a disconnected tenant remains. The handler still restricts the tenant-facing suspend to the connected, non-disconnected association, so never-act-on-a-disconnected-tenant is preserved. Tests: 'dispatches installation.suspend for the connected tenant when a disconnected former tenant exists' (asserts the unfiltered lookup is not used, the delivery is logged, and the handler runs) and 'still dispatches installation.suspend when only a disconnected former tenant remains' (handler runs for canonical state, nothing logged against the disconnected tenant). Both fail against the pre-fix route.
iscekic
added a commit
that referenced
this pull request
Sep 18, 2026
… (part 1/1) (#6282) ## Changelog for users - Google Play purchases now record the amount actually charged, the currency, and the tax withheld in `kilo_pass_store_purchases`. - Warehouse revenue can be calculated per Google Play purchase from the recorded amount and currency. - New purchases take the three values from the Google Play order data the platform already receives. - A one-off run fills the three values for existing Google Play purchases from their stored order ids. - A purchase whose Play order carries no money, or money that cannot be read, still completes and records nothing rather than a wrong amount. ## Changelog for maintainers - `kilo_pass_store_purchases` gains nullable `amount_charged_minor_units` (integer), `currency` (text), and `tax_minor_units` (integer); migration 0250 is additive and adds check constraints for non-negative amounts, `^[A-Z]{3}$` currency, and a currency whenever an amount is present. - The Play order request now asks for order- and line-item-level `total` and `tax`; the same call feeds verification, notifications, and the backfill. - New `store-purchase-money.ts` converts Play `Money { currencyCode, units, nanos }` to the currency's smallest unit using its ISO exponent; it prefers the matched line item, then the only line item, then order totals, and maps anything unusable to nulls. - The Google Play verifier attaches the money to the mapped purchase, and the single insert in `completeStoreKiloPassPurchase` persists it; App Store rows stay NULL. - New `backfillGooglePlayPurchaseAmounts` re-reads each stored order id, updates only rows with both money columns NULL, and is idempotent and resumable via `limit`; failures carry row id, order id, and message. - The backfill script supports `--limit`, `--dry-run`, and `--help`, and validates the Play service account before the first request so a bad credential exits non-zero instead of reporting only a failure count. - Review first: the currency-exponent fallback for well-formed but unknown codes (treated as two decimals), and the rule that an item carrying unreadable money yields nulls instead of borrowing order totals. ## E2E proof **[e10] ux-check: Kilo Pass purchase/checkout and account surfaces regression** — e10-surfaces.log records '/subscriptions' rendering 'Kilo Pass / KiloClaw / Coding Plans' and '/profile' rendering the Kilo Pass plans ('Starter', 'Pro', 'Expert', 'Buy now'), and e10-checkout.log records 'after buy url=https://checkout.stripe.com/c/pay/cs_test_' with 'Subscribe to Kilo Pass (Starter)' (screenshots e10-subscriptions.png, e10-profile.png, e10-checkout.png); the only error state seen, a 500 on /api/auto-routing/settings (e10-autorouting.log), is tagged [pre-existing] — no auto-routing file appears in e10-diffstat.log — and presents a 'Try again' action. [e10] ux-check: Kilo Pass purchase/checkout and account surfaces regression — e2e-web/e10-profile.png (capture not retained) **[e10] ux-check: Kilo Pass purchase/checkout and account surfaces regression** [e10] ux-check: Kilo Pass purchase/checkout and account surfaces regression — e2e-web/e10-checkout.png (capture not retained) **[e10] ux-check: Kilo Pass purchase/checkout and account surfaces regression** ![[e10] ux-check: Kilo Pass purchase/checkout and account surfaces regression — prior/e10-profile.png](https://github.com/user-attachments/assets/3719d1d1-1a52-431b-8c30-3718765788ae) **[e10] ux-check: Kilo Pass purchase/checkout and account surfaces regression** ![[e10] ux-check: Kilo Pass purchase/checkout and account surfaces regression — prior/e10-checkout.png](https://github.com/user-attachments/assets/2661bfe5-6c5b-4dd6-868b-ad8c4b343700) ## E2E proof — log excerpts ``` [e2] ux-check: Kilo Pass purchase/checkout and account surfaces regression -> pass :: Live playwright run against the dev stack at localhost:4800 signed in as e2e-verifier@kilo.test: /subscriptions renders 'Manage your subscriptions and billing in one place.' with the Kilo Pass, KiloClaw ('New KiloClaw instances are unavailable') and Coding Plans ('MiniMax Token Plan Plus' ... 'Sold out') tabs, /profile renders 'Unlock up to 50% free credits. Checkout securely with Stripe.' and the purchase entry is present ('buynow count=3' in e2-surfaces.log), the first Buy now reached the hosted checkout shown as 'Subscribe to Kilo Pass (Starter)' (e2-surfaces.log), no visible element is new or changed because the diff touches no UI file (e2-diffstat.log), and no layout shift was measured: ``` <details><summary><code>/home/igor_kilocode_ai/.local/share/kwf/sections/req-20260917-192220-8435/e2e-web/e2-surfaces.log</code></summary> ``` Maldives Mali Malta Martinique Mauritania Mauritius Mayotte Mexico Moldova Monaco Mongolia Montenegro Montserrat Morocco Mozambique Myanmar (Burma) Namibia Nauru Nepal Netherlands New consoleErrors: ["Failed to load resource: the server responded with a status of 401 (Unauthorized)","Failed to load resource: the server responded with a status of 404 (Not Found)","Failed to load res 5xx/4xx responses: ["401 http://localhost:4800/api/user","404 http://localhost:4800/ingest/array/fake-token/config.js","404 http://localhost:4800/ingest/array/fake-token/config?ip=0&_=1789672743291&ve DONE ``` </details> <details><summary><code>/home/igor_kilocode_ai/.local/share/kwf/sections/req-20260917-192220-8435/e2e-web/e2-diffstat.log</code></summary> ``` apps/web/src/lib/kilo-pass/store-subscription-completion.ts apps/web/src/scripts/d2026-09-17_backfill-store-purchase-amounts.ts packages/db/src/migrations/0250_kilo_pass_store_purchase_amounts.sql packages/db/src/migrations/meta/0250_snapshot.json packages/db/src/migrations/meta/_journal.json packages/db/src/schema.ts $ git diff --stat 4238fe9..HEAD .../kilo-pass/google-play-notifications.test.ts | 70 + apps/web/src/lib/kilo-pass/google-play-sdk.test.ts | 29 +- apps/web/src/lib/kilo-pass/google-play-sdk.ts | 36 +- .../src/lib/kilo-pass/google-play-verifier.test.ts | 39 + apps/web/src/lib/kilo-pass/google-play-verifier.ts | 2 + .../store-purchase-money-backfill.test.ts | 355 + .../lib/kilo-pass/store-purchase-money-backfill.ts | 184 + .../src/lib/kilo-pass/store-purchase-money.test.ts | 185 + apps/web/src/lib/kilo-pass/store-purchase-money.ts | 108 + .../store-subscription-completion.test.ts | 138 + .../lib/kilo-pass/store-subscription-completion.ts | 6 + .../d2026-09-17_backfill-store-purchase-amounts.ts | 123 + .../0250_kilo_pass_store_purchase_amounts.sql | 7 + packages/db/src/migrations/meta/0250_snapshot.json | 41877 +++++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema.ts | 19 + 16 files changed, 43180 insertions(+), 5 deletions(-) ``` </details> <details><summary><code>/home/igor_kilocode_ai/.local/share/kwf/sections/req-20260917-192220-8435/e2e-web/e2-layoutshift.log</code></summary> ``` /subscriptions cls=0.0009420437282986111 shiftEntries=2 firstShifts=[0.0001,0.0008] /profile cls=0.00007263183593750001 shiftEntries=1 firstShifts=[0.0001] ``` </details> <details><summary><code>/home/igor_kilocode_ai/.local/share/kwf/sections/req-20260917-192220-8435/e2e-web/e2-autorouting.log</code></summary> ``` status=500 body(head 1200)= ``` </details> <details><summary><code>/home/igor_kilocode_ai/.local/share/kwf/sections/req-20260917-192220-8435/e2e-web/web-setup-smoke.log</code></summary> ``` ✘ 1 [chromium] › tests/setup-smoke/profile.spec.ts:10:7 › local setup smoke › signs in with fake auth and renders the profile page (30.2s) 1) [chromium] › tests/setup-smoke/profile.spec.ts:10:7 › local setup smoke › signs in with fake auth and renders the profile page Test timeout of 30000ms exceeded. Error: expect(locator).toBeVisible() failed Locator: getByRole('region', { name: 'User profile' }) Expected: visible Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 30000ms - waiting for getByRole('region', { name: 'User profile' }) 58 | 59 | const profileCard = page.getByRole('region', { name: 'User profile' }); > 60 | await expect(profileCard).toBeVisible({ timeout: 30_000 }); | ^ 61 | await expect(page.getByRole('link', { name: 'Your Profile' })).toBeVisible(); 62 | await expect(profileCard.getByRole('button', { name: 'Edit profile' })).toBeVisible(); 63 | await expect(profileCard.getByText(testEmail, { exact: true })).toBeVisible(); at /home/igor_kilocode_ai/.local/share/kwf/wt/req-20260917-192220-8435/apps/web/tests/setup-smoke/profile.spec.ts:60:31 attachment #1: screenshot (image/png) ────────────────────────────────────────────────────────── test-results/setup-smoke/profile-local-setup-smoke--f6e7e-nd-renders-the-profile-page-chromium/test-failed-1.png ──────────────────────────────────────────────────────────────────────────────────────────────── Error Context: test-results/setup-smoke/profile-local-setup-smoke--f6e7e-nd-renders-the-profile-page-chromium/error-context.md 1 failed ``` </details> <details> <summary>Owner request for the stack</summary> > Add the actual amount charged, the currency, and the tax to our Google Play purchase records in kilo_pass_store_purchases. Today those records cannot tell the warehouse what a purchase really earned: we need the real amount charged, the currency it was charged in, and the tax withheld, recorded per Google Play purchase alongside what is already stored, so revenue can be calculated in the warehouse. Find the existing table and the Google Play purchase write path, extend the record with the three values, populate them for new purchases from the Google Play purchase data we already receive, and backfill what can be backfilled from the stored receipts. Prove it end to end with a real Google Play purchase record written and read back showing the amount, the currency and the tax. </details> ## PR stack (merge bottom to top) - part 1/1: #6282 (`kwf/req-20260917-192220-8435-l1`) ← this PR
This was referenced Sep 18, 2026
iscekic
added a commit
that referenced
this pull request
Sep 21, 2026
Explorer finding: pr-open-by-url: The uppercase repository slug wraps to two lines and leaves the PR number '#1' orphaned on a third line, crowding the share and Submit review controls. The user-agent explorer found this while using the app like a user. One finding per item; the explorer never edits product code. Flow: pr-open-by-url Found on revision: f2181ae Repro: 1. set this state first: credits 25; reviews 4; org 2. open the app on 1F6F1503-9C26-4120-82D4-5F8768CBE42F 3. reach pr-open-by-url 4. the capture shows the defect named below Observed: The uppercase repository slug wraps to two lines and leaves the PR number '#1' orphaned on a third line, crowding the share and Submit review controls. Expected: the screen renders without this defect Evidence (from the device run): - ~/.local/share/kwf/findings/explorer-pr-open-by-url-the-uppercase-repository-slug-wra-afba1609/pr-open-by-url.png
iscekic
added a commit
that referenced
this pull request
Sep 22, 2026
Explorer finding: pr-open-by-url: The uppercase repository slug wraps to two lines and leaves the PR number '#1' orphaned on a third line, crowding the share and Submit review controls. The user-agent explorer found this while using the app like a user. One finding per item; the explorer never edits product code. Flow: pr-open-by-url Found on revision: f2181ae Repro: 1. set this state first: credits 25; reviews 4; org 2. open the app on 1F6F1503-9C26-4120-82D4-5F8768CBE42F 3. reach pr-open-by-url 4. the capture shows the defect named below Observed: The uppercase repository slug wraps to two lines and leaves the PR number '#1' orphaned on a third line, crowding the share and Submit review controls. Expected: the screen renders without this defect Evidence (from the device run): - ~/.local/share/kwf/findings/explorer-pr-open-by-url-the-uppercase-repository-slug-wra-afba1609/pr-open-by-url.png
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR introduces a tool registry system that enables dynamic tool loading based on the owner's integrations, laying the groundwork for:
Changes
New Tool System (
/src/lib/bot/tools/)types.ts: Core type definitions (BotTool,ToolResult,ToolExecutionContext,RequesterInfo)registry.ts: Global tool registry withregisterTool,getTool,getAllToolstool-loader.ts: Dynamic tool loading based on owner's active integrationsimplementations/spawn-cloud-agent.ts: Extractedspawn_cloud_agenttoolindex.ts: Barrel export with auto-registrationUpdated
slack-bot.tsgetToolDefinitionsForOwner()to dynamically load toolsgetTool()from registry to execute tool callsgithub)Architecture
Testing
spawn_cloud_agenttoolPR Stack
This is PR 1 of 7 in the Slack bot tech debt reduction initiative: