Skip to content

Merge upstream/main into main (sync fork, 67 commits) - #16

Merged
keegan-companycam merged 68 commits into
mainfrom
merge-20260709-upstream
Jul 10, 2026
Merged

keegan-companycam merged 68 commits into
mainfrom
merge-20260709-upstream

Conversation

@colemurray-31531

Copy link
Copy Markdown

Sync fork with upstream ColeMurray/background-agents

Merges 67 upstream commits (through 07bc46c) into our main via a merge commit. The merge is conflict-free and the fork-only user-defined PR labels feature (#15) is preserved intact.

Verification

  • ✅ Clean merge — zero conflicts
  • ✅ npm install clean (no lockfile drift)
  • ✅ @open-inspect/shared builds
  • ✅ npm run typecheck passes across all packages

Notable upstream changes

Merge details

motatoes and others added 30 commits June 30, 2026 15:30
I noticed a strange behaviour when testing the opencomputer provider
with openinspect - the sandboxes would hibernate (automatically) after a
few mins of inactive. After 10 minutes it seems that they get killed by
openinspect, seems to be detecting that box as stale. Subsequent
messages lead to a failed start

Upon digging i noticed that openinspect attempts to checkpoint the
(automatically) hibernated sandbox and that attempt fails, which leads
to subsequent reconnects failing.

In order to keep things simpler, in this change we don't set an
automatic timeout when the sandbox starts instead we delegate explicit
hibernation to the durable object session to perform the hibernation and
waking up. On this test it seems more stable.

This change removes the implicit OpenComputer timeout for normal runtime
sessions. We now only pass/set a timeout when one is explicitly
supplied, while leaving repo-image build timeouts unchanged. The intent
is to let the Durable Object own normal session hibernation and wake
behavior, which has been more stable in testing

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Sandbox actions now only apply a timeout when one is explicitly
provided.
* Default sandbox creation, restore, and resume flows no longer force a
timeout.
  * Explicit timeout values are still honored when set.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Mohamed Habib <mohamedhabib@Mohameds-MacBook-Pro.local>
These documentation guides were missing in the opencomputer provider
support. I have instructed an agent to generate them and verified
correctness manually

Also bundled in this change: I removed the arguments
opencomputer_project_id abd opencomputer_target which are hullociations
from the agent (they don't really exist)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support and setup guidance for using OpenComputer as a sandbox
provider.
* Expanded the getting-started docs with updated provider and
prerequisites information.

* **Bug Fixes**
* Improved sandbox timeout handling so timeouts are only applied when
explicitly provided.
* Simplified OpenComputer setup by removing unnecessary scoping settings
from configuration and deployment examples.

* **Documentation**
  * Updated the README credits and package table formatting.
* Added a new OpenComputer provider guide and refreshed production setup
instructions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Mohamed Habib <mohamedhabib@Mohameds-MacBook-Pro.local>
## Summary

- tighten repo-image provider boundaries around provider policy/model
objects and adapter creation
- keep route-level HTTP concerns at the route boundary while workflow
methods operate on typed commands and domain outcomes
- replace planner/workflow error result bags with typed RepoImageError
exceptions that routes map to HTTP responses
- expand repo-image unit and integration coverage across Modal, Vercel,
and OpenComputer callback/build flows
- resolve latest main conflicts and fix shared create-session schema
composition so the merged tree typechecks

## Validation

- npm run build -w @open-inspect/shared
- npm run lint -w @open-inspect/shared
- npm run typecheck -w @open-inspect/shared
- npm run typecheck -w @open-inspect/control-plane
- npm run lint -w @open-inspect/control-plane
- npm test -w @open-inspect/control-plane --
src/repo-images/workflow.test.ts src/routes/repo-images.trigger.test.ts
src/routes/repo-images.test.ts --reporter=dot
- npm run test:integration -w @open-inspect/control-plane --
repo-images.test.ts --reporter=dot
- npm run build -w @open-inspect/control-plane
- git diff --check


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Extended repo-image workflow callback support, including returning a
direct `buildId` on successful triggers and streamlined
completion/failure payloads.
* Added stronger, more consistent callback handling for build
finalization, including authorization header support.

* **Bug Fixes**
* Improved request validation for build-complete/build-failed callbacks,
including payload-size limits and stricter “accept/fail” eligibility
checks.
* More consistent error responses and status codes for unavailable
providers, unconfigured providers, rejected/invalid callbacks, and build
readiness/finalization failures.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

- Add source-level architecture comments for the repo image domain
model, typed build plans, workflow, planner, store, provider
policy/factory, and provider adapters.
- Clarify the difference between provider-image callbacks and
provider-session callbacks without changing runtime behavior.
- Document that repo image artifact IDs are provider-opaque and should
stay interpreted inside provider adapters.

## Validation

- `git diff --check`
- `npm run lint -w @open-inspect/control-plane`
- `npm run build -w @open-inspect/shared`
- `npm run typecheck -w @open-inspect/shared`
- `npm run typecheck -w @open-inspect/control-plane`
- `npm run build -w @open-inspect/control-plane`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Expanded developer-facing JSDoc documentation across repo image build
planning, provider policy, adapter-provider boundaries, and
callback-mode semantics.
* Clarified build lifecycle behavior, including temporary sandbox usage,
checkpointing/snapshotting artifacts on success, and best-effort
cleanup.
* Added clearer explanations of workflow sequencing and repo image store
transitions, including safe handling of duplicate callbacks and racing
builds.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
- Pass credential-helper clone auth through OpenComputer repo-image
builds, matching the Vercel provider-session path while leaving normal
sessions broker-based.
- Preserve repo/user LLM secret precedence over provider defaults and
scrub reserved repo-image callback env keys from OpenComputer build
env/secrets.
- Clear inherited repo-image build clone tokens from runtime forks
unless the session explicitly supplies one.
- Clean up allocated OpenComputer sandboxes on startup/bind failures.

## Tests
- npm run typecheck -w @open-inspect/control-plane
- npm test -w @open-inspect/control-plane

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* OpenComputer builds now support repository clone authentication,
improving private repo setup.
* Repo-image workflows now handle provider-specific clone auth more
consistently across supported providers.

* **Bug Fixes**
* Fixed environment handling so user-provided secrets and callback
values are preserved or cleared appropriately.
* Improved cleanup when sandbox startup fails, preventing leftover
resources.
* Added safer behavior for repo-image forks, including clearer handling
of clone tokens and build-mode markers.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
- Add OpenComputer to repo image prebuild availability and backend
architecture docs
- Use provider-neutral image artifact wording for Modal images, Vercel
snapshots, and OpenComputer checkpoints
- Update repo image debugging log references from Vercel-specific
snapshot events to current provider-neutral/finalization events

## Validation
- `git diff --check`
- `npm exec --yes --package prettier@3.8.4 -- prettier --check
docs/IMAGE_PREBUILD.md docs/HOW_IT_WORKS.md docs/DEBUGGING_PLAYBOOK.md`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Expanded “How it works” sandbox backend guidance with a clearer
provider-by-provider view of repo-image builds and snapshot/restore
behavior (Modal, Daytona, Vercel Sandboxes, OpenComputer).
* Updated terminology to “repo image artifact” (and “checkpoint” where
applicable) and clarified how prebuilt environment state enables faster
subsequent sessions.
* Refreshed the debugging event catalog by replacing Vercel-specific
snapshot events with provider-general finalize lifecycle events and
updated the listed key fields for build completion and finalize
outcomes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…oleMurray#868)

Slack requires action_id to be unique within an actions block and
rejects the message with invalid_blocks otherwise. When the
repo-clarification picker offers two or more quick-picks, it doesn't
post and the user gets no response.

    `action_id` "select_repo_quick_pick" already exists
    [json-pointer:/blocks/0/elements/1/action_id]

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved repo selection handling in Slack so quick-pick buttons route
correctly.
* Each quick-pick button now uses a unique interaction ID, reducing
collisions and making button clicks more reliable.
* Repo selection options continue to behave the same for users, but
interactions are now handled more consistently.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
## Summary
- Render repo clarification pickers as inline `static_select` menus when
the full repo list fits Slack's 100-option limit
- Keep `external_select` for larger repo lists that need dynamic option
loading
- Pass the already-fetched repo list into the clarification block
builder and cover both picker paths in tests

## Why
The repo clarification dropdown became dependent on Slack's Select Menus
Options Load URL after moving to `external_select`. If that URL is not
configured, Slack shows `no result` and the worker never receives a
`/interactions` request, so it cannot show a better runtime error. Small
installs can avoid that dependency by embedding the repo options
directly in the message.

## Validation
- `npm test -w @open-inspect/slack-bot`
- `npm run typecheck -w @open-inspect/slack-bot`
- `npm run lint -w @open-inspect/slack-bot`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Added a smarter repository chooser in Slack clarification messages.
* When the repository list is small enough, users now see inline
selectable repo options; larger lists switch to search-based selection.
* Updated the prompt text to match whether users should choose or search
for another repository.

* **Tests**
* Expanded coverage for the new repo selection behavior, including
inline selection and search-based fallback.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
- Document Slack's separate Select Menus Options Load URL for external
select menus
- Point the Options Load URL at the existing `/interactions` endpoint
- Clarify that searchable repository pickers require this configuration

## Why
Slack quick-pick buttons are rendered inline, but the searchable repo
dropdown uses an `external_select`. Slack only requests those dynamic
options when the app has a Select Menus Options Load URL configured, so
the dropdown can show `no result` even though repos are available.

## Validation
- `npx prettier --check docs/GETTING_STARTED.md`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Updated the Slack setup guide to add a required **Select Menus →
Options Load URL** configuration for searchable repository pickers.
* Clarified that the Options Load URL should match the same
`/interactions` endpoint used for the Request URL.
* Renumbered/reformatted the subsequent setup steps to reflect the added
configuration.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
- Add an explicit `No repository` option to the homepage session target
selector.
- Create repo-less sessions from the web composer with `repoOwner: null`
and `repoName: null`.
- Hide branch selection and omit branch context for repo-less sessions.
- Default to `No repository` when the user has no accessible
repositories.

## Testing
- `npm test -w @open-inspect/web -- src/app/(app)/page.test.tsx`
- `npm run typecheck -w @open-inspect/web`
- `npm run lint -w @open-inspect/web`
- `npm test -w @open-inspect/web`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added an explicit “start without a repository” option for session
creation.
* Updated the repository picker to reflect this choice and only show
branch selection when a repository is selected.
* **Bug Fixes**
* Improved session startup when no repositories are available, including
clearer guidance in the empty state.
* **Tests**
* Added UI flow coverage to ensure sessions can start with a “no
repository” target and send the expected session creation payload.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This is an automated nightly unsafe-cast remediation. It replaces
selected high-risk TypeScript assertions at OAuth/API trust boundaries
with Zod `safeParse` validation, following the TypeScript Coding
Standards unsafe-cast / parse-don't-assert guidance and the Zod
boundary-validation pattern established in PR ColeMurray#807.

| Finding | Risk | Cast removed | Fix |
| --- | --- | --- | --- |
| `packages/control-plane/src/auth/github.ts:48` | HIGH | `(await
response.json()) as GitHubTokenResponse & { error?: string;
error_description?: string }` for GitHub OAuth code exchange | Added
`githubTokenResponseSchema` as the `GitHubTokenResponse` source of truth
and parse through `parseGitHubTokenResponse` with preserved OAuth error
handling |
| `packages/control-plane/src/auth/github.ts:81` | HIGH | `(await
response.json()) as GitHubTokenResponse & { error?: string;
error_description?: string }` for GitHub OAuth refresh | Reused
`githubTokenResponseSchema.safeParse` at the refresh boundary |
| `packages/control-plane/src/auth/openai.ts:50` | HIGH |
`response.json() as Promise<OpenAITokenResponse>` for OpenAI OAuth
refresh | Replaced the hand-written interface with
`openAITokenResponseSchema` / `z.infer` and validate the parsed response
before returning |

Verification:

| Command | Result |
| --- | --- |
| `npm test -w @open-inspect/control-plane -- src/auth/github.test.ts
src/auth/openai.test.ts` | Passed |
| `npm run build -w @open-inspect/shared` | Passed |
| `npm run build -w @open-inspect/control-plane` | Passed |
| `npm run typecheck` | Passed |
| `npm run lint` | Passed after temporarily moving the pre-existing
untracked `.opencode/` tooling directory out of the workspace so the
root lint command evaluated tracked repository files |
| `npm run format` | Passed |
| `npm test -w @open-inspect/control-plane` | Passed |

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/c0eee7014d656c67ad338b45f014cd85)*

Co-authored-by: OpenInspect <open-inspect@noreply.github.com>
This is an automated nightly unsafe-cast remediation. It replaces three
high-risk TypeScript assertions at request/internal-session/API trust
boundaries with Zod `safeParse` validation, following the TypeScript
Coding Standards unsafe-cast / parse-don't-assert guidance and the Zod
boundary-validation pattern established in PR ColeMurray#807.

| Finding | Risk | Cast removed | Fix |
| --- | --- | --- | --- |
| `packages/control-plane/src/routes/session-child-spawn.ts:36` | HIGH |
`(await request.json()) as SpawnChildSessionRequest` for child session
creation input | Added shared `spawnChildSessionRequestSchema` with
`z.infer` as the type source and validate the request body with
`safeParse`, preserving the existing 400 `title and prompt are required`
path |
| `packages/control-plane/src/routes/session-child-spawn.ts:82` | HIGH |
`(await spawnContextRes.json()) as SpawnContext` for parent session
state used to initialize child sessions | Added shared
`spawnContextSchema` with nullable fields modeled explicitly and
validate the internal response with `safeParse`, preserving the existing
500 `Failed to get parent session context` path |
| `packages/github-bot/src/github-auth.ts:132` | HIGH | `(await
response.json()) as { permission: string }` for GitHub collaborator
permission authorization response | Added a local Zod response schema
and validate with `safeParse`, preserving `{ hasPermission: false,
error: true }` on malformed API responses |

Verification:

| Command | Result |
| --- | --- |
| `npm test -w @open-inspect/shared --
src/types/boundary-schemas.test.ts` | Passed |
| `npm test -w @open-inspect/github-bot -- test/github-auth.test.ts` |
Passed |
| `npm test -w @open-inspect/control-plane --
src/router.spawn-child.test.ts` | Passed after `@open-inspect/shared`
rebuild refreshed the new exports |
| `npm run build -w @open-inspect/shared` | Passed |
| `npm run build -w @open-inspect/control-plane` | Passed |
| `npm run build -w @open-inspect/github-bot` | Passed |
| `npm test -w @open-inspect/shared` | Passed |
| `npm test -w @open-inspect/control-plane` | Passed |
| `npm test -w @open-inspect/github-bot` | Passed |
| `npm run typecheck` | Passed |
| `npm run lint` | Passed after temporarily moving the pre-existing
untracked `.opencode/` tooling directory out of the workspace so the
root lint command evaluated tracked repository files |
| `npm run format` | Passed |
| `git diff --check` | Passed |

References: TypeScript Coding Standards unsafe-cast / parse-don't-assert
guidance; Zod boundary-validation pattern established in PR ColeMurray#807.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/e71a3545b1132e0bd0e5132142bf3fc3)*

Co-authored-by: OpenInspect <open-inspect@noreply.github.com>
Co-authored-by: Cole Murray <colemurray.cs@gmail.com>
This is an automated nightly unsafe-cast remediation sweep. It replaces
selected high-risk response-boundary assertions with Zod validation
following the TypeScript Coding Standards unsafe-cast /
parse-don't-assert guidance and the Zod boundary-validation pattern
established in PR ColeMurray#807. This PR is marked DRAFT in the title/body
because two exact verification gates are blocked in this workspace, as
detailed below.

Requested label: `automation:unsafe-cast`

## Findings Fixed
| file:line | risk | cast removed | how it was fixed |
| --- | --- | --- | --- |
| `packages/web/src/lib/auth.ts:77` | HIGH | `(await response.json()) as
GithubEmail[]` from GitHub's `/user/emails` API, feeding auth allowlist
email resolution | Package-local Zod schema in `github-email-schema.ts`;
validates consumed fields and accepts `visibility: null`; malformed
responses fail closed with `[]` |
| `packages/github-bot/src/handlers.ts:69` | HIGH | `(await
response.json()) as { sessionId: string }`, feeding GitHub bot session
state | Package-local Zod schema in `control-plane-responses.ts`;
invalid success responses throw through the existing session creation
error path |
| `packages/github-bot/src/handlers.ts:91` | HIGH | `(await
response.json()) as { messageId: string }`, feeding GitHub bot message
state | Package-local Zod schema in `control-plane-responses.ts`;
invalid success responses throw through the existing prompt delivery
error path |

## Verification
| command | result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | PASS |
| `npm run build -w @open-inspect/github-bot` | PASS |
| `npm run build -w @open-inspect/web` | FAIL: TypeScript passes, then
prerender of `/automations/new` fails with `TypeError: Cannot read
properties of null (reading 'useContext')`; this page was not touched by
this change |
| `npm run typecheck` | PASS |
| `npm run lint` | FAIL: root lint scans pre-existing untracked
`.opencode/` tooling files and reports globals such as `process`,
`fetch`, `Headers`; committed files are clean |
| `npm run lint -- --ignore-pattern '.opencode/**'` | PASS |
| `npm run lint -w @open-inspect/shared` | PASS |
| `npm run lint -w @open-inspect/github-bot` | PASS |
| `npm run lint -w @open-inspect/web` | PASS |
| `npm test -w @open-inspect/shared` | PASS |
| `npm test -w @open-inspect/github-bot` | PASS |
| `npm test -w @open-inspect/web` | PASS |

## Dependency Check
Added `zod@^4.4.3` to `@open-inspect/web` for a genuinely package-local
auth boundary. `package-lock.json` changed only to add the corresponding
`packages/web` dependency entry; no unrelated lockfile churn remains.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/662960e2a119deb77df0849505476f36)*

Co-authored-by: OpenInspect <open-inspect@noreply.github.com>
Co-authored-by: Cole Murray <colemurray.cs@gmail.com>
## Summary
- remove the exact `total` count from the sessions list API
- derive `hasMore` by fetching one extra session row
- update archived-session pagination to use the returned `hasMore` flag

## Why
The frontend only consumes `sessions` and `hasMore`, so the D1
`COUNT(*)` query was doing unnecessary work on a hot session-list path.
Removing it avoids the growing table scan for `status != archived`
requests.

## Validation
- `npm test -w @open-inspect/control-plane -- session-index`
- `npm test -w @open-inspect/web -- session-list control-plane-query
data-controls-settings session-sidebar`
- `npm run test:integration -w @open-inspect/control-plane --
d1-session-index auth`
- `npm run typecheck`
- `npm run lint -w @open-inspect/control-plane`
- `npm run lint -w @open-inspect/web`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Session lists now use server-provided pagination info, making “Load
more” behavior more reliable.
* Archived sessions now load more accurately without relying on page
size checks.

* **Improvements**
* Session list responses no longer include a total count and now return
a clearer `hasMore` indicator.
* Pagination handling was updated across the app to match the new
session list response format.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…y#854)

## Problem

`scripts/d1-migrate.sh` tracks applied migrations by their **numeric
prefix** — `VERSION=$(echo "$FILENAME" | grep -oE '^[0-9]+')`, stored as
the `_schema_migrations.version` primary key, and the skip check is
`grep -qxF "$VERSION"`.

If two migration files ever share a prefix, only one is recorded and the
other is **silently skipped — permanently**. A re-run never recovers it,
because the number is already marked applied. The result is schema drift
that doesn't surface at migrate time; it shows up later as a confusing
runtime error (e.g. `table X has no column named Y`).

This is easy to hit without anyone doing anything wrong: two PRs in
flight each grab the next sequential number (`00NN_...`), both pass CI
independently, and after both merge the directory has two `00NN_*.sql`
files. One migration is then dropped on the next deploy.

## Fix

Detect colliding numeric prefixes at the start of the run and fail with
a clear, actionable message instead of silently skipping:

```
ERROR: duplicate migration version prefixes detected:
  0022
Renumber the colliding files so each prefix is unique before deploying.
```

No behavior change when prefixes are unique. One-file change, no new
dependencies (`sort | uniq -d` over the basenames' prefixes).

## Why a guard rather than reworking the tracking

Keying on the full filename instead of the prefix would also avoid the
silent skip, but it changes the `_schema_migrations` PK semantics and
the INSERT path for existing deployments. The guard is the minimal,
behavior-preserving fix; it just turns a silent data-loss case into a
loud, self-explaining failure.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Added a pre-flight validation for migration files to stop the
migration process when two SQL files share the same version prefix.
* Migrations now fail fast with clear error output when duplicate
migration numbers are detected (and require filenames with numeric
prefixes).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This is an automated nightly unsafe-cast remediation sweep. It replaces
selected scheduler boundary assertions with Zod parsing so untrusted
internal request bodies are validated before they drive automation
matching, run creation, or run completion state. This follows the
TypeScript Coding Standards guidance for unsafe casts /
parse-don't-assert and the Zod boundary-validation pattern established
in PR ColeMurray#807.

| Finding | Risk | Cast removed | Fix |
| --- | --- | --- | --- |
| `packages/control-plane/src/scheduler/durable-object.ts:427` | HIGH |
`(await request.json()) as AutomationEvent` for scheduler automation
events feeding matching, dedup, and run creation | Shared
`automationEventSchema` Zod discriminated union in
`@open-inspect/shared`, parsed with `safeParse` and 400 on invalid
payload |
| `packages/control-plane/src/scheduler/durable-object.ts:598` | HIGH |
`(await request.json()) as { automationId: string }` for manual trigger
run creation | Package-local Zod `manualTriggerBodySchema`, parsed with
`safeParse` and existing 400 `automationId required` response |
| `packages/control-plane/src/scheduler/durable-object.ts:686` | HIGH |
`(await request.json()) as { automationId; runId; sessionId; messageId?;
success; error? }` for run-complete state transitions | Package-local
Zod `runCompleteBodySchema`, parsed with `safeParse` and 400 on invalid
callback |

Verification commands run in a clean worktree for this branch:

| Command | Result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | Passed |
| `npm run build -w @open-inspect/control-plane` | Passed |
| `npm run typecheck` | Passed |
| `npm run lint` | Passed |
| `npm run format` | Passed |
| `npm test -w @open-inspect/shared` | Passed, 22 files / 275 tests |
| `npm test -w @open-inspect/control-plane` | Passed, 89 files / 1415
tests |

Note: in the original workspace, `npm run lint` was also attempted and
failed only because of a pre-existing untracked `.opencode/` directory
outside this PR; the exact command passed in the clean worktree
containing only committed branch files.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/e7ba177d5123895cea9e49c69689c0e1)*

---------

Co-authored-by: OpenInspect <open-inspect@noreply.github.com>
Co-authored-by: Cole Murray <colemurray.cs@gmail.com>
This is an automated nightly unsafe-cast remediation sweep. It replaces
selected high-risk TypeScript boundary assertions with
parse-don't-assert validation, following the TypeScript Coding Standards
for unsafe casts and the Zod boundary-validation pattern established in
PR ColeMurray#807.

| Finding | Risk | Cast removed | Fix |
| --- | --- | --- | --- |
| `packages/web/src/hooks/use-session-socket.ts:139` | HIGH | WebSocket
message `raw as WsMessage` after only checking for a `type` key | Added
shared `serverMessageSchema` in `@open-inspect/shared` and now
`safeParse`s WebSocket messages before handling them. |
| `packages/web/src/app/api/sessions/[id]/title/route.ts:27` | HIGH |
`await request.json() as { title?: string }` on an API request body |
Added a minimal inline parser for the request body that preserves the
existing 400 invalid-body path. |

## Verification

| Command | Result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | Passed |
| `env -u NODE_ENV npm run build -w @open-inspect/web` | Passed; the
plain inherited shell environment had a non-standard `NODE_ENV` that
caused Next prerendering to fail before this retry. |
| `npm run typecheck` | Passed |
| `npm run lint` | Passed after temporarily moving the untracked local
`.opencode/` tool directory aside and restoring it; the tracked repo
files lint clean. |
| `npm run format` | Passed |
| `npm test -w @open-inspect/shared` | Passed: 21 files, 274 tests |
| `npm test -w @open-inspect/web` | Passed: 48 files, 513 tests |

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/bc3972ace378acf23238764498c05c1f)*

Co-authored-by: OpenInspect <open-inspect@noreply.github.com>
Co-authored-by: Cole Murray <colemurray.cs@gmail.com>
## Summary

Linear agent-session auth failures now preserve the concrete reason they
failed instead of collapsing every OAuth problem into a bare
missing-client return.

When a workspace token is missing, malformed, expired without a refresh
token, rejected by Linear during refresh, or blocked by a KV/read
failure, the token loader raises a `LinearAuthError` with the failure
reason and any available OAuth response details. Agent-session webhooks
catch that error, log `agent_session.no_oauth_token` with the org,
issue, trace ID, mode, and auth failure reason, then stop before
creating or prompting an Open-Inspect session.

The final behavior is intentionally log-only for these agent-session
paths. Earlier fallback issue-comment handling was removed after review
because a failed OAuth refresh usually means operators should inspect
the structured logs rather than trying to notify Linear through the
static API-key fallback.

## Validation

- npm run lint
- npm run typecheck
- npm test

## Post-Deploy Monitoring & Validation

- Watch Linear bot logs for `agent_session.no_oauth_token`,
`oauth.refresh_failed`, `oauth.refresh_error`, and
`oauth.token_read_error`.
- Healthy signal: auth failures include `auth_failure_reason`, issue
context, and trace ID, with no control-plane session or prompt created
after the auth failure.
- Failure signal: repeated `token_read_error` / `refresh_error` spikes,
missing issue context in `agent_session.no_oauth_token`, or unexpected
Linear agent-session requests continuing after auth failure.
- Validation window: first deploy after merge; owner: operator handling
the Linear bot deploy.

Related: ColeMurray#863

---

[![Compound
Engineering](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin)
![GPT-5](https://img.shields.io/badge/GPT--5-000000)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved handling of expired or invalid Linear OAuth tokens, reducing
failed agent session starts and follow-ups.
* When authentication can’t be restored, the system now skips the action
safely and records clearer failure details.
* Refresh failures are now classified more consistently, helping
diagnose issues like invalid grants and token read errors.

* **Tests**
* Added coverage for token expiry, refresh success/failure, and agent
session flows.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Support a custom domain for the Cloudflare web Worker

Adds the ability to serve the web app on a custom domain (e.g.
`app.example.com`)
instead of the default `*.workers.dev` URL when `web_platform =
"cloudflare"`.

There was an existing `cloudflare_zone_id` terraform variable, but it
was unused.

### Changes

- **`variables.tf`** — new optional `cloudflare_custom_domain` variable
(the hostname to attach), alongside the existing `cloudflare_zone_id`.
Defaults to `null`.
- **`web-cloudflare.tf`** — new
`cloudflare_workers_custom_domain.web_app` resource that attaches the
hostname to the web Worker. Cloudflare provisions the DNS record and
edge certificate. Gated on `local.web_custom_domain_enabled` and depends
on the Worker deploy.
- **`locals.tf`** — added `web_custom_domain_enabled` flag and a third
case to `web_app_url`. When a custom domain is set, the app URL (used
for `NEXTAUTH_URL` and cross-service config) becomes
`https://<custom-domain>` instead of the `workers.dev` URL.
- **`terraform.tfvars.example`** — documented the new
`cloudflare_custom_domain` input under the Cloudflare zone section.

### Behavior

The custom domain only activates when **all** of the following are true:

- `web_platform = "cloudflare"`
- `cloudflare_zone_id` is set
- `cloudflare_custom_domain` is non-empty

Otherwise the deployment falls back to the existing `workers.dev` URL —
no change for existing deployments.

### Notes

- Requires a Cloudflare zone you control (set `cloudflare_zone_id`).
- The Cloudflare API token needs **Workers Routes: Edit** to manage the
custom domain.
- `terraform validate` passes cleanly (dropped the deprecated
`environment` attribute on the resource).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added optional Cloudflare custom-domain support for production web
workers, including automatic URL switching to the custom hostname when
configured.
* **Safety Checks**
* Added validation to prevent misconfigured custom-domain/zone settings;
falls back to the default workers subdomain when required inputs are
missing.
* **Documentation**
* Expanded production Cloudflare setup guidance and examples for the
custom-domain option.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…oleMurray#882)

Follow-ups to ColeMurray#747 (custom domain support for the Cloudflare web
Worker).

## Bug: `terraform plan` fails for every Cloudflare-platform deployment
without a custom domain

Terraform's `coalesce()` errors when **all** arguments are null or empty
strings — `coalesce(null, "")` is a hard error, not `""`:

```
Call to function "coalesce" failed: no non-null, non-empty-string arguments.
```

The `web_custom_domain_enabled` local from ColeMurray#747 evaluates
`trimspace(coalesce(var.cloudflare_custom_domain, ""))` whenever
`web_platform == "cloudflare"` (`&&` short-circuits, so Vercel
deployments escape). Both `cloudflare_custom_domain` and
`cloudflare_zone_id` default to `null`, so any existing `web_platform =
"cloudflare"` deployment that hasn't opted into a custom domain now
fails at plan time. Setting `cloudflare_custom_domain = ""` explicitly
errors the same way.

This slipped past `terraform validate` because validate treats variables
as unknown values — the error only fires on plan/apply with concrete
values.

**Fix**: null-safe normalization locals (`var.x == null ? "" :
trimspace(var.x)`) consumed by the gate, the host, and the
`cloudflare_workers_custom_domain` resource. This also fixes the
inconsistency where the gate trimmed the domain but the resource/URL
consumed the raw value.

## Hard validation instead of an advisory warning

The `check` block from ColeMurray#747 had the same `coalesce` problem (it would
report an evaluation error rather than its intended message), and as a
warning it let a misconfigured domain silently fall back to workers.dev
— leaving `NEXTAUTH_URL` and OAuth callbacks pointing somewhere the
operator didn't intend. Replaced with two hard failures:

- a `validation` block on `cloudflare_custom_domain` for hostname shape:
must be a bare hostname — rejects scheme, port, path, trailing dot,
whitespace, wildcards
- a `terraform_data.cloudflare_custom_domain_gate` precondition in
`checks.tf` for the cross-input policy (same pattern as the existing
`access_control_gate`), expressed against the normalized locals as
`local.web_custom_domain == "" || local.web_custom_domain_enabled`: a
set domain requires `web_platform = "cloudflare"` and a non-empty
`cloudflare_zone_id` (also catches the previously-silent
domain-set-on-Vercel case)

`null`, `""`, and whitespace-only all still mean "not configured".

## Single canonical origin

Attaching a custom domain doesn't disable the workers.dev route, so the
app was reachable on two origins with `NEXTAUTH_URL` pointing at only
one. The generated `wrangler.production.toml` now sets `workers_dev =
false` when the custom domain is enabled (`true` otherwise — existing
behavior, now explicit).

> **Note for deployers already running ColeMurray#747 with a custom domain**:
after the next apply, the workers.dev URL for the web app stops serving;
the custom domain is the single origin.

## Docs

- `GETTING_STARTED.md`: custom-domain subsection under Step 8 (vars,
token permission, OAuth-callback reminder), custom-domain entries in the
callback-URL list and redirect-URI troubleshooting, commented vars in
the tfvars sample.
- `terraform.tfvars.example`: notes that workers.dev is disabled and
GitHub/Google OAuth callback URLs must be updated to the new hostname.

## Verification

`terraform fmt -check` and `terraform validate` pass. Validation and
locals exercised end-to-end with `terraform plan` on a minimal harness
under Terraform 1.14.6 (the repo's required floor):

| Scenario | Result |
| --- | --- |
| all unset (defaults) | plans cleanly, `enabled = false` — previously
the coalesce error |
| domain set, `web_platform = "vercel"` | hard error (precondition gate)
|
| domain set, zone id unset | hard error (precondition gate) |
| domain + zone, `web_platform = "cloudflare"` | `enabled = true`, host
= custom domain |
| `https://app.example.com` | hard error (bare-hostname rule) |
| domain `""` | plans cleanly, `enabled = false` |

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Added support for serving the web app on a Cloudflare custom domain.
* Expanded setup docs with Cloudflare-specific DNS, certificate, and
OAuth callback guidance.

* **Bug Fixes**
* Improved handling of custom-domain settings by normalizing empty
values and whitespace.
* Tightened validation so custom domains must be valid hostnames and
require the related Cloudflare zone setting when used.
* Updated production routing to disable the default `workers.dev` route
when a custom domain is enabled.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
- Take over ColeMurray#734 and resolve conflicts with current `main`, preserving
archived-project filtering.
- Use GitLab `namespace.full_path` for repository owner mapping so
nested groups round-trip correctly.
- Preserve nested group separators in manual MR URLs while still
encoding individual path segments.

## Test plan
- `npm test -w @open-inspect/control-plane -- gitlab-provider.test.ts`
- `npm run typecheck -w @open-inspect/control-plane`

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/8b33d071aa6249421aeed1640d5ca6f7)*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Fixed repository ownership and listing for projects inside nested
GitLab groups.
* Improved manually generated merge request links so nested group paths
open correctly.
* Preserved archived repository filtering while updating project path
handling.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Mikhail Dubov <mikhail@chattermill.io>
## Summary
- retain the secret store for the active OpenComputer repo image
- carry provider secret-store ids through repo-image replacement cleanup
- delete OpenComputer secret stores only for failed builds or superseded
images

## Verification
- npm test -w @open-inspect/control-plane -- repo-images
opencomputer-adapter
- npm run typecheck -w @open-inspect/control-plane

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Sandbox cleanup now also removes the associated OpenComputer secret
store when a provider session sandbox is deleted.
* Deleting a sandbox now supports an optional setting to include
secret-store removal in the request.
* Existing behavior for missing or already-deleted sandboxes remains
unchanged.
* **Tests**
* Updated and added unit tests to verify secret-store deletion options
are forwarded correctly.
* **Documentation**
  * Public sandbox options type is now re-exported for easier use.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Mohamed Habib <mohamedhabib@Mohameds-MacBook-Pro.local>
Co-authored-by: Cole Murray <colemurray.cs@gmail.com>
## Summary
- allow sandbox `step_finish.tokens` to be either the legacy numeric
count or a structured usage object
- add shared schema coverage for OpenCode-style token usage details
- add a workerd sandbox WebSocket integration test for the
invalid-message regression

## Root Cause
The sandbox bridge forwards OpenCode `step-finish` parts verbatim.
OpenCode can send `tokens` as an object with usage details such as
`total`, `input`, `output`, `reasoning`, and `cache`, but the
control-plane boundary schema only accepted a number, so the Durable
Object rejected the entire sandbox message.

## Validation
- `npm run build -w @open-inspect/shared`
- `npm run typecheck -w @open-inspect/shared`
- `npm test -w @open-inspect/shared --
src/types/boundary-schemas.test.ts`
- `npm run test:integration -w @open-inspect/control-plane --
test/integration/websocket-sandbox.test.ts`
- `npx prettier --check packages/shared/src/types/index.ts
packages/shared/src/types/boundary-schemas.test.ts
packages/control-plane/test/integration/websocket-sandbox.test.ts`

## Known Existing Issue
- `npm run typecheck -w @open-inspect/control-plane` still fails on an
unrelated scheduler webhook typing issue at
`src/scheduler/durable-object.ts(522,49)`: webhook automation `body` is
optional where `WebhookAutomationEvent` requires it.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Sandbox step completion events now support richer token usage details,
including total, input, output, reasoning, and cache counts.
* Token usage can still be provided as a simple total value for
compatibility.

* **Bug Fixes**
* Improved validation and handling of sandbox event token data so
structured usage is accepted and preserved consistently.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
- add `zai-coding-plan/glm-5.2` as an opt-in model category
- document `ZHIPU_API_KEY` setup through Open-Inspect web app secrets
- update model utility tests for the new provider group

## Notes
- no Terraform, Modal secret, or control-plane provider-key plumbing was
added; customers configure `ZHIPU_API_KEY` via Settings > Secrets
- verified the OpenCode catalog exposes provider `zai-coding-plan` with
model `glm-5.2` and env key `ZHIPU_API_KEY`

## Tests
- `npm test -w @open-inspect/shared`
- `npm run typecheck -w @open-inspect/shared`
- `npm run lint -w @open-inspect/shared`
- `npx prettier --check README.md docs/AVAILABLE_MODELS.md
docs/GETTING_STARTED.md docs/HOW_IT_WORKS.md
docs/OPENCOMPUTER_PROVIDER.md docs/SECRETS.md
packages/shared/src/models.ts packages/shared/src/models.test.ts`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for the **Z.AI Coding Plan** model in model selection
and validation.
* The new provider appears in the available models list and related
model groupings.

* **Documentation**
* Updated setup and troubleshooting docs to explain when
provider-specific secrets are needed.
* Added guidance for enabling Z.AI Coding Plan and its required API key.
* Refreshed model availability tables and secret examples across the
docs.

* **Tests**
* Expanded model coverage to include the new provider and its
default-enable behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…ray#886)

Supersedes ColeMurray#850. Huge thanks to @rubenlangeweg for ColeMurray#850 — the product
design there (and its decision doc) is what ships here. This PR keeps
that design and rebuilds the execution/storage layer on a single unified
pipeline so the multi-repo and single-repo paths cannot drift apart.
Design record:
[`docs/MULTI_REPO_AUTOMATIONS.md`](docs/MULTI_REPO_AUTOMATIONS.md); full
review findings that motivated the rebuild are in the repo's review
notes for ColeMurray#850.

## What this does

An automation now selects **0–10 repositories** (`repositories:
[{repoOwner, repoName, baseBranch?}]`). Every firing records one
**invocation**; a non-skipped invocation fans out into one **run per
repository**, each an ordinary session with its own branch/PR flow.
Multi-repo selections are schedule/manual-only in v1 (product scope cut
— event fan-out semantics are undefined, not technically blocked).

```
automation ── repositories (live selection, editable any time)
    └── invocation (one per firing; skips are childless invocations)
          └── runs (one per repo, firing-time repo snapshot) ── session
```

- New history endpoint `GET /automations/:id/invocations`; the web UI
renders one row per firing (fan-outs expand into repository rows; UI
copy keeps "Run History" per the API↔UI noun split).
- Invocation status is **derived from children, never stored**
(`starting/running/completed/failed/partial_failed/skipped`) — the
stored-aggregate wedge class from ColeMurray#850's review is unrepresentable.
- Runs snapshot `repo_owner/repo_name/repo_id/base_branch` at firing
time, so **repository edits are always allowed** (no cardinality freeze,
no edit-while-active guard): history and in-flight children never depend
on the live selection.
- Auto-pause: an invocation with ≥1 failed child counts **one** strike,
stamped via a `failure_counted_at` CAS (exactly-once across callbacks,
launch failures, and recovery sweeps); only fully-completed invocations
reset the counter; childless skips count neither way. A bounded
finalization sweep closes crash windows.
- Scheduler: one `startInvocation` path for tick/manual/event;
per-source overlap (whole-automation for schedule/manual,
per-concurrency-key for events); atomic skip+advance batches so cron
slots can't wedge; a per-tick child-launch budget (~50) keeps 25×10-repo
ticks under the Workers subrequest cap.

## Kept from ColeMurray#850 vs. rebuilt

| Kept | Rebuilt |
| --- | --- |
| Product model: fan-out, cap 10, one session/PR per repo, shared
instructions | Pipeline: one `startInvocation` path, no
single-repo/multi-repo fork |
| Decision-doc Q&A (overlap, auto-pause counting partial failures, skips
don't count, manual-while-paused, duplicate rejection) | Storage: thin
`automation_invocations` + normalized `automation_repositories`; status
derived, not stored |
| Failure-count CAS idea (exactly-once strike) | State machine: guarded
child updates, born-terminal finalization, recovery/finalization sweeps
|
| Sessions linkage (`automation_run_id`) | Migrations: additive `0030`
(two idempotent backfills) + `0031` (drops the legacy columns) instead
of the 0030–0033 sequence |
| Web form multi-select UX | History API: single unified invocations
endpoint, instead of a parallel groups shape |

Naming follows the review outcome: `automation / repository / invocation
/ run / session`.

## Deploy & rollback

Standard pipeline order (Terraform applies D1 migrations before the
worker). `0030` adds the repositories/invocations tables and backfills
them (replay-safe — rerunning the file is the recovery for a partial
apply); `0031` then drops the deprecated `automations.repo_*` mirror (a
`PRAGMA defer_foreign_keys` table rebuild, since those columns carry
CHECK constraints from 0029) and the frozen
`automation_runs.trigger_key/concurrency_key/trigger_run_metadata`
columns. They apply in order, so an instance upgrading from before this
feature migrates its data (0030) before the drop (0031) in the same
deploy.

**Not code-revertible without a database restore.** `0031` drops columns
the previous worker reads (`automations.repo_*`), so reverting the code
alone leaves it querying columns that no longer exist. Snapshot D1
before applying if you want a rollback path. This is deliberate — see
"Cleanup folded in" below.

**Migration reconciliation for ColeMurray#850 deployments**: any D1 that applied
ColeMurray#850's branch migrations (its 0030–0033: run groups table, `target_*`
columns, repair/rebuild steps) has schema objects and
`_schema_migrations` markers this chain doesn't know. Reconcile manually
before applying this PR's `0030`/`0031` — restore a pre-ColeMurray#850 state, or
drop ColeMurray#850's objects/markers first.

## Cleanup folded into this PR (no deprecation window)

The single-release compatibility window the plan called for was **folded
into this PR** rather than shipped as a follow-up. Rationale: this is a
self-hosted, internal-only system that deploys control-plane + web
together, and prod never runs this PR in isolation before the cleanup —
so the compat shims' only beneficiaries (a stale browser tab during the
brief deploy skew; a code-revert rollback) are ones we chose not to
spend a release protecting. Stale tabs recover on refresh (a scalar-only
edit from an old bundle now lands as "repositories unchanged", so it
can't clobber the selection); rollback is a D1 restore (above). Removed
here instead of one release later:

1. `GET /automations/:id/runs` list alias (+ its web proxy) — the UI
uses `/invocations`.
2. `Automation.repoOwner/repoName/repoId/baseBranch` response mirrors of
`repositories[0]`.
3. Scalar-trio request sugar (`repoOwner/repoName/baseBranch` on
create/update).
4. `run` field in the `POST …/trigger` response.
5. The `automations.repo_*` dual-write + scalar mirror, and the frozen
`automation_runs.trigger_key/concurrency_key/trigger_run_metadata`
columns with their indexes (`idx_runs_trigger_key`,
`idx_runs_concurrency`, `idx_runs_thread_continuity`,
`idx_automations_repo`, `idx_automations_event_match`). Firing keys now
live only on the invocation; overlap / dedup / Slack thread-continuity
reach them via a join to runs.

### Note on CHANGELOG

The plan called for a `CHANGELOG.md` entry, but the repo has no tracked
changelog; release notes live in this description and
`docs/MULTI_REPO_AUTOMATIONS.md` instead.

## Behavioral notes (intentional deltas from main)

- Event-source overlap skips are now recorded for **all** event sources
as childless skipped invocations (previously only Slack skips were
recorded) — uniform history.
- Skip invocations never carry the event `trigger_key`, so a skip can
never consume the dedup slot of the real delivery.
- `POST …/trigger` returns `201 {invocationId, runs}` and `500` only
when every child failed to launch.

## Validation

Full matrix, green on the branch tip:

- `npm run build` (shared first), `npm run typecheck`, `npm run lint` —
clean
- Unit: shared 302, control-plane 1517, web 542, github-bot 110,
slack-bot 170, linear-bot 125
- Integration (workerd + real D1): 439 — the `0030` invocation-link
backfill, the derived-status truth table (incl. childless skips), the
F1/F2/F3/F7 regression races (concurrent last-child completes,
crash-before-finalize sweeps, mid-fan-out launch failure, manual-vs-cron
guarded insert), the overlap/auto-pause matrix, per-key event
concurrency + dedup, and a mid-flight repo-set edit
- Python: modal-infra 179, sandbox-runtime 364 — green

Commits are incremental by layer (contracts → schema/store → routes →
scheduler → web → docs → deprecation cleanup); migration `0031` applies
cleanly under the integration harness's real D1.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added multi-repository automation support (up to 10) with
per-invocation history and a new invocation list API/page.
* Automation UI now shows repository lists, repository counts, and
per-repository session details; run history is invocation-based.
* Updated the automation form to submit a canonical `repositories`
selection (including repo-less support where allowed).

* **Bug Fixes**
* Improved reporting for fan-out statuses, including partial failures,
and refined auto-pause consecutive-failure behavior.
* Hardened concurrency/overlap handling and trigger “active invocation”
semantics.
  * Fixed no-repository sandbox/snapshot environment scoping.

* **Documentation**
* Updated automations and multi-repo execution documentation and limits.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…oleMurray#888)

## Summary

Session detail pages can hang on "loading session" and never render.

## Root cause

`sandboxEventSchema` — the `SandboxEvent` discriminated union in
`packages/shared/src/types/index.ts` — does not include the `ready`
event type. The sandbox bridge emits a `ready` event on every connect
(`packages/sandbox-runtime/src/sandbox_runtime/bridge.py`), so `ready`
is present in essentially every session's replay history.

The client validates the `subscribed` hydration message with
`serverMessageSchema.safeParse(...)` and silently drops the message when
validation fails (`packages/web/src/hooks/use-session-socket.ts`).
Because `replay.events` was `z.array(sandboxEventSchema)`, a single
unrecognized `ready` event failed the entire message — so the client
never received the session state and the page stayed on "loading
session." Any session whose history contains a `ready` event is
affected.

## Fix (`packages/shared/src/types/index.ts`)

1. Add the `ready` branch to `sandboxEventSchema` (`{ type, sandboxId,
timestamp, opencodeSessionId }`).
2. Validate replay events individually and drop unrecognized ones
instead of failing the whole `subscribed` message, so a single unknown
or legacy event can never wedge session hydration.

## Verification

- Added regression tests: a `ready` event parses; the `subscribed`
replay keeps recognized events and drops unknown ones without failing.
- `@open-inspect/shared` test suite passes (304 tests); `typecheck` is
clean for `shared`, `web`, and `control-plane`.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Added support for a new `"ready"` sandbox event.
* Replay and history results now handle mixed event/item data more
gracefully.

* **Bug Fixes**
* Parsing no longer fails when replay or history payloads include
unknown entries.
* Valid events and items are preserved while unsupported ones are
skipped automatically.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
- Adds a bounded local worker pool for automation child-session launches
with concurrency 4.
- Keeps per-child failure handling inside each launch task and counts
launched children from final running statuses.
- Adds a regression test proving a later child init begins while an
earlier child is still pending.

## Concurrency Notes
- `createSessionForAutomationRun` is per-child: each call generates its
own `sessionId`, uses the run repo snapshot, and initializes its own
session.
- `sendPromptToSession` enqueues work per session, so prompt delivery
remains isolated.
- `AutomationStore.updateRun` prepares SQL and params per call;
concurrent updates target distinct run ids and do not reuse a shared
mutable statement buffer.
- Each child object is only mutated by its own launch task; the pool
only shares a cursor.

## Verification
- `npm test -w @open-inspect/control-plane`
- `npm run typecheck -w @open-inspect/control-plane`
- `npm run lint:fix`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added bounded concurrency for child automation run launches (capped at
4 per start invocation) to smooth multi-repo fan-out spikes.

* **Bug Fixes**
* Updated scheduler orchestration so later child sessions begin
initialization while earlier ones are still blocked, improving parallel
startup behavior and reliability.

* **Tests**
* Added a unit test covering scheduler launch concurrency and confirmed
both init requests and run updates occur as expected under blocked
initialization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
- Updates the fork clone command in `CONTRIBUTING.md` to use
`background-agents.git`.
- Fixes the broken `open-inspect.git` repository path reported in ColeMurray#890.

## Testing
- Not run; documentation-only change.

Closes ColeMurray#890

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/c0fe2862611b9d09cf3331cfec519eb6)*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Documentation**
* Updated the getting started instructions to use the current repository
URL for cloning the project.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
- Add `HttpError` and map it once in the central router dispatch catch
while preserving fall-through logging and headers.
- Convert repo resolution helpers to return success types and throw on
failures, including ordered `Promise.allSettled` handling for automation
repository selection.
- Remove the seven direct resolver `instanceof Response` checks and add
router coverage for 404 body plus CORS/trace headers.

## Validation
- `npm run build -w @open-inspect/shared`
- `npm run typecheck -w @open-inspect/control-plane`
- `npm test -w @open-inspect/control-plane`
- `npm run lint:fix -w @open-inspect/control-plane`
- `npm run lint:fix` fails because root ESLint descends into
`worktrees/multi-repo-automations/` and reports 139 unrelated
pre-existing errors there.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Repository resolution failures now map to the correct HTTP status and
JSON error message (including not-found), with CORS and
tracing/request-id headers preserved.
* Session, automation, and secret endpoints no longer stop prematurely
on repository lookup results and now handle failures consistently.
* Automation repository selection now runs lookups concurrently while
keeping input-order behavior for determining failures.
* **Tests**
* Added coverage for error mapping behavior, ensuring expected status,
message, and headers are returned and that downstream fetch is not
called.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
- Remove the unused Modal warm-sandbox HTTP endpoint and matching
control-plane client method/types.
- Remove the stale repo-keyed warm-pool implementation and provider
`supportsWarm` capability.
- Update docs/comments that still described warm pools, while preserving
typing-triggered sandbox start behavior.

## Behavior
Typing-start warming still works through the existing WebSocket `typing`
message, `PresenceService.handleTyping()`, and lifecycle `warmSandbox()`
path. That path uses the normal session spawn flow, including repo image
lookup when available.

## Validation
- `npm test -w @open-inspect/control-plane --
src/sandbox/providers/modal-provider.test.ts
src/sandbox/providers/vercel/provider.test.ts
src/sandbox/providers/daytona-provider.test.ts
src/sandbox/providers/opencomputer-provider.test.ts
src/sandbox/lifecycle/manager.test.ts
src/session/presence-service.test.ts
src/session/http/handlers/session-lifecycle.handler.test.ts`
- `cd packages/modal-infra && python -m pytest tests/ -q`
- `npm run typecheck`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Removed the pre-warm sandbox flow from sandbox APIs and provider
capabilities, so supported features now reflect snapshot/restore-based
behavior more accurately.
* Updated sandbox lifecycle and scheduler notes to match the current
behavior.

* **Documentation**
* Revised sandbox guidance and architecture docs to remove references to
warm pools and the removed endpoint.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
ColeMurray and others added 26 commits July 8, 2026 00:21
## What

PR-7 of the multi-repo sessions plan (§PR-7): the web read-only surface
for multi-repo sessions. Renders the member list, per-repo PR chips,
non-fatal boot warnings, and `+N` session cards. No picker changes
(that's Phase 2 / PR-12) — this is the sole owner of the v1 session UI
and is a hard prerequisite of PR-12, so a customer can't create a
multi-repo session whose page shows one repo.

## Changes

**`use-session-socket.ts` — repo-aware `session_branch`**
- `state.repositories` already flows through the `subscribed` handler
(wholesale state spread); no change needed there.
- New `applySessionBranchUpdate` helper: a repo-scoped `session_branch`
(message carries optional `repoOwner`/`repoName`, added in PR-3) updates
the matching member's `branchName` in `state.repositories`, and mirrors
to the scalar `branchName` **only when it names the primary** (position
0). An absent identity names the session's sole repo (the primary) and
mirrors too. Sessions without a hydrated member list stay scalar-only,
exactly as before. A branch update for an unknown member leaves state
untouched rather than clobbering the scalar.
- `toUiArtifact` now carries `repoOwner`/`repoName` through PR-artifact
metadata (needed for per-repo chip matching).

**`metadata-section.tsx` — member list, per-repo PR chips, warnings**
- Multi-member sessions (`repositories.length > 1`) render a
"Repositories" block: each member links to its repo, the primary is
tagged, and each shows its own working branch + PR chip (`#number` +
state badge). PR chips are matched by artifact metadata repo;
identity-less artifacts (pre-multi-repo) attribute to the primary —
mirrors the control-plane `findPrArtifactForRepo`.
- Single-repo / scalar-era sessions render **exactly as before** (the
scalar PR badge / base branch / working branch / repo tag are untouched,
just gated behind `!isMultiRepo`).
- Non-fatal `warning` sandbox events
(`sync`/`setup`/`start`/`assembly`/`secrets` scopes) are now surfaced in
the sidebar with repo attribution — previously defined in the shared
schema but rendered nowhere.

**Session cards (`session-sidebar.tsx`)**
- New `formatSessionRepositoriesLabel` shows the primary repo with a
`+N` suffix (`acme/web +1`) reading the hydrated `Session.repositories`.
Scalar-only sessions render exactly as today. Kept the existing
`formatRepositoriesLabel` (automation cards) unchanged rather than
overloading it.

**Contracts (shared + control-plane)**
- Added `SessionListRepository` to shared and threaded `repositories?`
onto the web-facing `Session` type. The `/sessions` list route already
serializes this (PR-4 hydration), so the card is web-only.
- Re-pointed control-plane's `SessionIndexRepository` to alias
`SessionListRepository` so the wire shape has a single home (no behavior
change).

## Gating

No feature flag. The multi-repo UI is gated by data presence
(`repositories.length > 1` / warning events present), and `repositories`
is only populated for multi-repo sessions, which can't be created until
the PR-12 picker ships. Single-repo/scalar sessions are byte-identical
to today. This avoids the flag-removal coupling the plan flags ("removal
of the flag is not [optional] — PR-12 requires it live").

## Tests

- `repo-label.test.ts` (new): `formatSessionRepositoriesLabel` — scalar,
single-member, `+N`, hydrated-primary-over-scalar.
- `metadata-section.test.tsx`: per-repo member list + chips matched by
artifact metadata, identity-less→primary attribution, warning rendering.
(Added `afterEach(cleanup)` — this suite renders into a shared body
without auto-cleanup.)
- `use-session-socket.test.tsx`: repo-scoped `session_branch` routes to
the matching member and mirrors the scalar only for the primary.
- Full suite: 551 web + 331 shared pass; typecheck + lint clean across
packages.

## Notes for reviewers

- Deviation from the plan's literal wording: the plan said "`+N` via
`formatRepositoriesLabel`", but that helper (shared with automation
cards) renders "N repositories". To get the plan's stated "primary +N"
UX without regressing automations, I added a session-specific
`formatSessionRepositoriesLabel` rather than overloading the existing
one.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added multi-repository session support across the sidebar and
metadata: per-repository PR/branch/primary-member details and improved
repository labels (including “+N” for additional members).
* Surfaced non-fatal session warnings in the session details, with
optional repository attribution.
* **Bug Fixes**
* Fixed multi-repo `session_branch` updates to apply to the correct
repository member without overwriting unrelated members.
* Ensured PR artifact attribution follows the correct repository,
falling back to the primary member when repo identity is missing.
* **Tests**
* Expanded coverage for multi-repo session routing, metadata rendering
(including warnings), and repository label formatting.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…ray#913)

## What

Flips the combined-secrets cap default from `warn` to **`enforce`**,
completing
the staged rollout landed in PR-6 (ColeMurray#909). `parseSecretsCapMode` is now
**fail-closed**: only the literal `SECRETS_CAP_ENFORCEMENT=warn` opts
out — unset
or garbled values enforce.

This is **behavior change #2** from the multi-repo-sessions plan
(D§6.4's
ship-enforced spec). A merged secrets payload that exceeds
`MAX_COMBINED_SECRETS_BYTES` (128 KiB, after the global + member fold)
now
**rejects the spawn/build** with a value-free `SecretsCapExceededError`
that
attributes bytes/keys per source, instead of only emitting a
`secrets.cap_exceeded`
warn log and proceeding. Applies identically on the spawn path
(`durable-object.getUserEnvVars`) and the repo-image build planner —
both route
through the shared `auditSecretsMerge`.

## Why now

PR-7 (ColeMurray#912) merged, so all Phase-1 substrate PRs (PR-1…PR-7) are landed.
The plan
gates the **Phase-1 verification checklist** on this flip:

> the `enforce` flip is the still-owed follow-up commit **owned by the
PR-6 author
> and gated before the Phase-1 verification checklist runs — the phase
gate cannot
> pass in warn mode.**

## Rollback

No code revert needed — set `SECRETS_CAP_ENFORCEMENT=warn` in the
control-plane
worker vars
(`terraform/environments/production/workers-control-plane.tf`) to fall
back to warn-only logging.

## ⚠️ Pre-deploy check

The warn window existed precisely to surface any launch unit already
over the cap.
**Before this deploys, confirm no `secrets.cap_exceeded` warn logs are
firing in
prod** — otherwise an existing oversized setup will start failing
spawns. (Plan
risk-table mitigation for behavior change #2.)

## Tests

- `parseSecretsCapMode` unit assertions flipped to the fail-closed
contract.
- Full Phase-1 regression green locally with the flip applied: all 9 TS
suites
(shared, control-plane unit + integration, web, slack/github/linear
bots,
  typecheck) + pytest (435 sandbox-runtime, 186 modal-infra).

No test seeds a combined payload anywhere near the 128 KiB cap (largest
is 20 KiB),
so the flip is behavior-neutral for the suite outside the
`parseSecretsCapMode`
assertions.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Tightened secret-size handling so the safer enforcement mode is now
the default.
* Only an exact `warn` setting keeps the previous logging-only behavior;
unset or unrecognized values now use enforcement.
  * Updated related guidance to match the new default behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…oleMurray#915)

## What

PR-8 of the multi-repo sessions & environments plan — the Phase-2
kickoff.
Introduces the **environment** entity: a named, prebuildable set of
repositories
with its own secrets. Everything here is **additive and dark** until the
web
picker lands (PR-12); the create-a-session-from-an-environment path is
PR-9.

## Changes

- **Migration `0033_environments.sql`** (DDL from design §7.2):
`environments`,
`environment_repositories` (position-ordered), `environment_secrets`
(mirrors
  `repo_secrets`), `environment_images` (mirrors `repo_images`), plus
  `sessions.environment_id` (written by PR-9) and a reserved
  `automations.environment_id` (§13.3).
- **shared**: `Environment` / `EnvironmentRepository` types and
`create`/`updateEnvironmentInputSchema`. Repositories reuse the session
list
  contract (non-empty, deduped by owner/name and repoName, capped at
  `MAX_TARGET_REPOSITORIES`).
- **`db/environments.ts`** — `EnvironmentStore`: CRUD, case-insensitive
name
uniqueness, atomic scalar + repository updates, and an application-level
DELETE
cascade (repository + secret rows in one batch, live images superseded
so the
  PR-10 reaper reclaims provider artifacts).
- **`db/environment-secrets.ts`** — `EnvironmentSecretsStore`:
repo-secrets
parity keyed by `environment_id` (same `REPO_SECRETS_ENCRYPTION_KEY`),
plus a
per-key **import** that copies ciphertext **verbatim** from a member
repo (no
  decrypt/re-encrypt — plaintext never transits the control plane).
- **`routes/environments.ts`** — `POST/GET/PUT/DELETE
/environments[...]`,
  `GET/PUT/DELETE /environments/:id/secrets[...]`, and
`POST /environments/:id/secrets/import`. Internal-HMAC only (web BFF
proxied).
Import authorization: the source repo must be a current member (else
403), and
  responses carry **key names only**.
- **web BFF** — proxy routes under `app/api/environments/**`.
- **hygiene** — rename the Modal client's `environment?` param to
  `modalEnvironment` to disambiguate from the new entity (§7.1).

## Naming

The repository list is `repositories` (the design prose said `members`):
no
surveyed product uses "members" (Cursor `repos[]`, Codespaces
`repositories`,
Gitpod `additionalRepositories`), the design's own API-shape reference
is
Cursor's `repos[] XOR env`, and automations/sessions already use
`repositories`.
"member" survives only in prose and the mirrored DB columns
(`members_fingerprint`, `member_shas`).

## Tests

- shared: environment schema validation (name bounds, non-empty/deduped
repos).
- control-plane integration (real D1 + workerd): 0033 applies; store
CRUD +
cascade; secrets CRUD; verbatim-ciphertext import (byte-equality +
decrypt),
non-member 403, value-free responses; route auth / validation / 409 /
404.

The happy-path `POST /environments` resolves repositories through the
SCM
provider, which is unconfigured in the test env — so environments are
seeded via
`EnvironmentStore` and the routes are tested for
auth/validation/secrets/import
(mirrors PR-4's DO/store-vs-route split).

## Follow-up (not in this PR)

The `repo` / `global` / `environment` secrets stores are now three
near-identical implementations. A `refactor: unify scoped secrets
stores` PR
should extract a shared scoped-secrets base — deferred here to keep this
PR
additive and avoid churning the spawn-critical `repo-secrets` path.

## Validation

`typecheck` (control-plane incl. test config + web route types +
shared),
`eslint`, `prettier` clean. Full suites green: shared (343),
control-plane unit
(1609) + integration (481), web (552).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added environment management endpoints in the control plane and web
app (create, list, retrieve, update, delete) with ordered repository-set
handling.
* Added environment-scoped secrets APIs (list, replace, delete) and
secret import of selected repo secrets.
* Introduced new environments and environment secrets persistence plus
corresponding API route groups.
* **Bug Fixes**
* Updated Modal sandbox dashboard URL parameter naming and aligned
related session/URL usage.
* **Tests**
* Added integration coverage for environments and environment secrets,
including import behavior and authorization.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## PR-9 · sessions from environments

Phase-2 continuation of the multi-repo-sessions plan (follows ColeMurray#915).
Wires the environment entity (PR-8) into session creation, filling the
null seams PR-6 (ColeMurray#909) left. **Additive; no D1 migration file change** —
`0033` already added `sessions.environment_id`; the DO gets it via a new
SQLite schema migration (id 32).

### What it does
- **Three-way exclusivity** (shared):
`createSessionRequestSchema`/`createSessionInputSchema` accept
`environmentId`, mutually exclusive with `repositories` and the scalar
`repoOwner/repoName/branch`. Replaced `hasExclusiveRepositoryTarget`
with a presence-counting `hasExclusiveSessionTarget` (at most one mode
active).
- **Environment resolution**: new `resolveEnvironmentTarget(store, id)`
loads the environment (404 if missing) and returns its members as
resolution inputs in position order. The create handler feeds those
through the **same** `resolveSessionRepositories` all-or-nothing SCM
check an ad-hoc list gets — a member whose access was revoked after the
environment was created fails the create cleanly rather than booting a
partial workspace — and records `environment_id` provenance. Scalar/list
paths are untouched (keeps scalar's `404` semantics off the hot
single-repo path).
- **Provenance storage**: `environment_id` threaded through
`initialize.ts` → D1 `sessions` INSERT + DO init → `session` table
(migration 32, `SessionRow`, `upsertSession`).
`SessionEntry.environmentId` hydrates on the session list (PR-12 renders
it).
- **Environment-scoped secrets** (fills PR-6's seam): the
secret-sourcing builder's environment branch now loads real environment
secrets — **global + environment only; member repos never inherit**
(§6.4/§7.4). Also fixed a placeholder `environmentId: number` → `string`
(environment ids are strings).
- **Token-refresh environment source** (re-adds what ColeMurray#909 reverted, now
with a real source): `OpenAITokenRefreshService` resolves a
`TokenSecretSource` (environment | repo | global) carrying everything
needed to read *and* write. Environment sessions rotate against
environment secrets, never the repo. The union makes the old `repoId ===
null` guard unrepresentable, so it (and the `session` params it needed)
are deleted.
- **`SessionState.environmentId` + live `environmentName`**:
`getSessionState` resolves the name live so a deleted environment
surfaces as `null` (§7.6 "environment deleted"), with the snapshot left
intact.

### Tests
All green locally: shared 347 (+4 exclusivity), control-plane unit 1612,
control-plane integration 488 (+6 new
`session-from-environment.test.ts`: resolver order/404,
member+provenance snapshot, live name + deleted→null, and the key
**member-repo-secrets-absent** assertion), web 552; typecheck + lint +
prettier clean.

### Deliberate deviations
- Named the new function `resolveEnvironmentTarget` rather than the
design's unified `resolveSessionTarget`. Scalar/list normalization
already lives inline in the handler, and a 3-mode resolver with a single
active caller would be a thin wrapper; unifying scalar into the list
path would change the single-repo `404`→`400` error. The genuinely
reusable piece (env → member inputs) is what got extracted — a unified
funnel can come with the Slack/Linear surface PRs that actually need it.
- `environmentName` is resolved via a per-`getSessionState` D1 lookup
for environment sessions — intentional, since deleted-env-shows-null
requires live resolution, not a snapshot.
- **Naming**: new code deliberately avoids the `LaunchUnit` term (the
token-refresh source resolver is `resolveSessionSecretSource`). The
pre-existing secret-sourcing builder from PR-6 keeps its name in this PR
— renaming it (and its file/type) is a standalone follow-up so we don't
fold a broad rename into this change.

### Not covered (consistent with PR-4/PR-8)
The full route happy path (`POST /sessions {environmentId}` → SCM
resolve → create) isn't integration-tested because the control-plane
integration harness has no `GITHUB_APP_*` bindings, so the SCM provider
raises `GitHub App not configured` (→ 500) before the environment
snapshot logic runs — a suite-wide limitation for **any** repo-resolving
create path, not specific to this PR. Instead, environments are seeded
via the store and the SCM-free pieces are exercised directly (resolver,
DO storage/provenance, `getSessionState`, secret scoping). Live e2e
remains owed on deployed infra.

**Follow-up (tracked):** make the create route testable without live SCM
— inject a `SourceControlProvider` into `handleCreateSession` (the seam
already exists on `resolveSessionRepositories`), or add fake App
bindings + a mocked GitHub API to the harness — so the full `POST
/sessions` path (scalar, list, and environment) can be covered
end-to-end.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Sessions can now be created from an environment via a new
`environmentId` option.
  * Session state now exposes `environmentId` and `environmentName`.
* Environment-scoped secrets are used for sandbox launches and OpenAI
token refreshes.
* **Bug Fixes**
* Session environment provenance is persisted and returned consistently,
including after environment deletion.
  * OpenAI token refresh now reads/writes secrets to the correct scope.
* **Tests**
* Added/updated unit and integration coverage for environment-targeted
session creation, state, secret scoping, and refresh behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## PR-10 · environment image builds

Phase-2 continuation of the multi-repo-sessions plan (follows ColeMurray#916). The
environment prebuild pipeline (design §7.3/§7.4): environments get built
as a unit — every repository cloned at its base branch, setup hooks run
sequentially-fatally, filesystem snapshotted — and the resulting images
are registered, invalidated, and reclaimed so PR-11 can select them at
spawn. One migration: `0034` renames the `environment_images` provenance
columns shipped in `0033` (PR-8) to repository nomenclature
(`repositories_fingerprint`, `repository_shas`) — safe as a plain rename
since nothing writes to the table until this PR lands.

Staged commits, as the plan sequences it: **Modal end-to-end first**,
then the **Vercel/OpenComputer parity step**, followed by a review-fix
pass and the member→repository nomenclature scrub.

### What it does

**Runtime (provider-neutral):** build mode now reports what only the
build knows — per-repository clone provenance `repository_shas`
(`[{repoOwner, repoName, baseSha}]`, one cross-language document shape
used unchanged from runtime → callback → D1 → cron) and the baked
`runtime_version` (read from the image's own `SANDBOX_VERSION`, so
orchestrators never guess it from their code). Emitted via log events
for Modal's orchestrator and via `report_success` for provider-session
builds.

**Modal:** `build_environment_image` generalizes the repo-image worker
(`SESSION_CONFIG.repositories` through the existing
`create_build_sandbox` seam; redaction covers the clone token plus every
build secret value; a completed build missing
`repository_shas`/`runtime_version` fails with an actionable message
instead of an opaque callback rejection). The 30-min cron gains an
isolated **environments pass**: GET enabled environments + status →
rebuild triggers, cheapest first — (1) no ready image matching the
current repositories fingerprint, (3) runtime below the compatibility
floor (or unparseable — fail closed), (2) per-repository `git ls-remote
refs/heads/<branch>` drift (honors non-default branches, an improvement
over repo images) — with a **per-tick trigger cap of 4** and
per-environment concurrency 1.

**Control plane:** `EnvironmentImageStore` mirrors the repo-image state
machine (conditional transitions, newer-ready races, `(created_at, id)`
tiebreaks) with two model-owed differences: supersede scope is
`(environment_id, provider)` — the fingerprint covers branches, so one
live image per environment/provider — and rows are also superseded
**out-of-band** (environment delete, secret change), so the cleanup pass
gains a **superseded-artifact reaper** (artifact deleted first, row only
after, failures retried next pass). The planner assembles build secrets
as **global + environment** — the same set the environment's sessions
get (§6.4 build/session parity) — through the PR-6 cap enforcement, and
honors the primary repository's `buildTimeoutSeconds`. The workflow
enforces **concurrency-1 at the trigger** (cron, save-hooks, and future
"Rebuild now" all converge there) and **fails registration closed** on
missing/unparseable `runtime_version` or `repository_shas` for *all*
providers — an unversioned image must never pass PR-11's floor check.
Routes:
`/environment-images/{build-complete,build-failed,trigger/:id,status,enabled,mark-stale,cleanup}`
mirroring the repo-image surface; `enabled` also serves each
environment's fingerprint and the floor so the fingerprint algorithm
never leaves the control plane.

**Save-hooks (§7.3/§7.4):** environment create/update triggers an
immediate build (skipped when a ready image already matches the current
repository set — trigger-1 evaluated eagerly); a secret change
(PUT/DELETE/import) **supersedes every live image in the same request**
— including in-flight builds, which are baking the outdated values —
then kicks a rebuild. The supersede is awaited and fail-visible; the
rebuild is detached and best-effort.

**Vercel/OpenComputer parity (commit 2):** both providers' hand-rolled
branch-only build `SESSION_CONFIG`s are generalized so environment
builds serialize the repositories-bearing payload; environment builds
get the full provider-session lifecycle — bearer callback tokens (same
token scheme as repo images), trigger-time session binding with teardown
on failure, deferred snapshot/checkpoint finalization behind
`waitUntil`, and build-sandbox cleanup hooks. Daytona untouched (base
image), as designed.

### Tests
All green locally: control-plane unit 1656 (+44: workflow
trigger/completion/failure/cleanup incl. provider-session paths,
fingerprint/runtime-version parsing), control-plane integration 508 (+20
new `environment-images.test.ts`: store state machine vs real D1,
cron-facing routes, callback auth + fail-closed registration matrix,
secret-change supersede through the real PUT/DELETE routes, reaper
leaves artifact-bearing rows for retry when the provider is
unconfigured), sandbox-runtime 436 (+1 multi-repo repository_shas
emission), modal-infra 195 (BuildLogResult + both cron passes);
typecheck + eslint + prettier + ruff clean.

### Deliberate deviations from the plan doc
- **No `ttl=None` at snapshot time.** The Modal TTL change (ColeMurray#897) was
rolled back and Modal is pinned at 1.4.3, where `snapshot_filesystem`
has no `ttl` parameter and snapshots don't auto-expire — so **trigger 4
(provider-side expiry) is inert** and documented as the seam that
activates with the snapshot-TTL follow-up. Nothing else in this PR
depends on it.
- **`repositories_fingerprint` is computed control-plane-side at
registration**, not echoed by the build callback. The same algorithm
must run at spawn matching (PR-11); putting it in the callback would
duplicate the hash across two languages. The callback carries only what
the build alone knows: `repository_shas` + `runtime_version`.
- **`markRestoreFailed` is deferred to PR-11** with its caller (the
spawn restore-failure fallback). It was specced here as "PR-0's twin",
but PR-0 was reverted — repo-images has no such method today, and per
the ColeMurray#909 precedent seams land with the code that uses them.
- **The concurrency-1 guard lives in the control-plane trigger path**,
not cron-side like repo images — environment builds are triggered from
three places (cron, save-hooks, later "Rebuild now"), so the guard sits
where they converge. The cron still checks first to save the HTTP
round-trip.
- **Mirror, not generalize.** The subsystem parallels repo-images
(store/planner/workflow/adapters/errors) rather than parameterizing the
merged, spawn-critical repo-image module; the genuinely shared leaves
(callback-token scheme, provider policy, secrets cap enforcement,
bearer-token extraction) are imported from their existing homes. Folding
both into one image-build subsystem is a tracked follow-up refactor
alongside the scoped-secrets-store unification.

### Not covered
- Live end-to-end (trigger → Modal build → callback → spawn selection)
needs deployed infra — owed with the standing live-e2e pass; spawn
selection itself is PR-11.
- Triggering a build via the route isn't integration-tested end-to-end
(the harness has no Modal/Vercel credentials — the same suite-wide
limitation as the SCM gap noted on ColeMurray#916); trigger orchestration is
covered at the workflow layer with mocked adapters, and everything from
the callback onward runs against real D1 through the real routes.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced environment image builds across supported providers,
including trigger, completion, failure, and cleanup workflows.
* Added callback routes, status/enabled views, and maintenance endpoints
for stale builds and cleanup.
* Implemented multi-repository “repositories fingerprint” and
environment-image build scheduling on save and after secrets changes.
* **Bug Fixes**
* Enforced single in-flight builds and token-gated, fail-closed
completion handling.
* Improved superseding, late completion reporting, and reaping behavior
(including artifact-retention edge cases).
* **Tests**
* Added unit and integration coverage for fingerprints, workflow
behavior, and scheduler rebuild decisions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
PR-11 of the multi-repo sessions plan (design §7.3 "Spawn-time
matching", §6.5). Environment sessions now boot from their environment's
pre-built image when it matches, with base-image fallback on any miss
and on provider restore failure — sessions are never blocked on builds.

## What

**Spawn-time matching**
(`sandbox/lifecycle/environment-image-selection.ts`, new): an
environment session uses the latest ready environment image iff

1. `status = 'ready'` on the active provider (latest by `created_at`),
2. `runtime_version` passes `MIN_COMPATIBLE_RUNTIME_VERSION`
(fail-closed on an unparseable version), and
3. `repositories_fingerprint` equals the fingerprint of the **session's
own repository snapshot** — not the environment's current repositories,
so an environment edited after the session was created can never hand it
a mismatched image.

Any miss → base image, with the miss reason logged
(`sandbox.environment_image_miss`: `no_ready_image` / `missing_artifact`
/ `runtime_below_floor` / `fingerprint_mismatch` / `lookup_failed`) —
these are the observability numbers the plan wants for judging the
prebuild fast-follows.

**Store** (`db/environment-images.ts`):
- `getLatestReadyForSpawn(environmentId, provider)` — mirrors the
repo-images `repo_metadata` join: an `environments` INNER JOIN so a
lingering row can never serve a deleted environment, plus
`prebuild_enabled = 1` mirroring repo images' `image_build_enabled` gate
(a disabled environment's frozen image never rebuilds, so serving it
would drift unboundedly).
- `markRestoreFailed(environmentImageId, error)` — the deferred PR-0
twin: flips a `ready` row to `failed` by id so the rebuild cron sees no
matching ready image and rebuilds next tick. Scoped to `ready` (building
rows belong to the build workflow, superseded rows to the reaper).

**Restore-failure fallback** (design §7.3 remedy (b), lifecycle
manager): if `createSandbox` with an environment image fails, the
manager marks the row restore-failed (best-effort — a D1 outage must not
block the retry) and retries once from the base image. Unrelated create
failures (quota, network) can false-positive into a mark; the cost is
one rebuild, and the base retry surfaces them through the normal failure
path anyway.

**Repo images never apply to environment sessions.** Previously a
single-repo environment session would fall into the repo-image lookup
for its member repo — an image baked with that *repository's* setup and
secrets, not the environment's (§7.4 launch-unit scoping). The
repo-image branch is now gated on `!session.environment_id`.

**Boot-mode flags flow through all providers with zero data-plane
change**: the environment image travels through the existing
`repoImageId`/`repoImageSha` plumbing, which all three providers already
convert to boot flags (Modal `repo_image_id` → `FROM_REPO_IMAGE`, Vercel
snapshot id, OpenComputer checkpoint id). The runtime's `repo_image`
boot mode has exactly the semantics an environment-image boot needs —
skip setup hooks (ran at build time), per-repo sync with lenient failure
policy, start hooks per member — so the floor stays at 53 and no
runtime/CACHE_BUSTER change is needed. `repoImageSha` carries the
primary repository's baked SHA (the usual scalar mirror; it is
informational — boot logging only).

## Deviations from the plan

- **The plan said the fallback "depends on PR-0's `Image.from_id` wrap …
verify, don't assume." Verified: it is gone.** ColeMurray#897 (PR-0) was reverted
on main — `manager.py:439` is a bare `modal.Image.from_id()` with no
probe, no `image_restore_failed` flag, no CP `markRestoreFailed`
plumbing for repo images. The fallback here is therefore implemented
**provider-generically in the control plane** (catch → mark → retry from
base), which is what the design's remedy (b) prescribes ("implemented
provider-generically") and avoids the eager-hydrate probe that caused
ColeMurray#897's rollback. The repo-image twin of this fallback (plus
`RepoImageStore.markRestoreFailed`) belongs to the owed ColeMurray#897
fix-forward, not this PR — single-repo spawn behavior is untouched.
- No new boot-mode env flag / runtime change (plan: "Boot-mode env flags
flow through all providers"): a distinct `FROM_ENVIRONMENT_IMAGE` flag
would be a breaking runtime change requiring a floor bump to 54 and buys
only cosmetic log naming — the `repo_image` boot mode's policy is
exactly correct for environment images. Documented on
`CreateSandboxConfig.repoImageId`.

## Tests

- `environment-image-selection.test.ts` (new, 8): matching matrix —
match, case-insensitive identity / case-sensitive branches, no ready
image, missing artifact, below-floor + unparseable version fail-closed,
edited-environment fingerprint mismatch, reorder mismatch, malformed
provenance still selects with null SHA.
- `manager.test.ts` (+9): env session boots from matching image (and
repo lookup untouched); fingerprint miss → base; repo-image lookup never
consulted for env sessions (even single-repo); lookup failure → base; no
lookup bound → base; restore failure → `markRestoreFailed` + base retry
succeeds; retry also fails → normal failure path; mark failure doesn't
block the retry; repo-image spawn failures are NOT retried (scope note
above).
- Integration (+4, real D1): `getLatestReadyForSpawn`
latest-ready-per-provider selection across
statuses/providers/environments; deleted environment's lingering ready
row never served (raw `DELETE FROM environments` bypassing the store's
supersede batch); prebuild-disabled environment not served;
`markRestoreFailed` ready→failed exactly once, building rows untouched.
- Full suites green: control-plane 1675 unit / 514 integration,
typecheck, lint, prettier.

## Not covered

- End-to-end spawn through the DO with a live provider (same
SCM-less/provider-less harness split as PR-4/8/9/10); the wiring is the
same one-line-per-method lookup binding pattern as `repoImageLookup`.
- Restore-only paths: session snapshot restores (`restoreFromSnapshot`)
are unrelated to environment images by design — the image applies to
fresh spawns only.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Environment sessions can now select a matching prebuilt environment
image at spawn time for faster startup.
* If the selected image fails to restore, the system retries
automatically using the base image.
* **Bug Fixes**
* Selection is now stricter: only the latest “ready” image is used,
fingerprint mismatches won’t load, and runtime versions below the
minimum (or unparseable) won’t be considered.
* Restore failure tracking now only marks eligible “ready” images and
won’t interfere with other states.
* **Changes**
* Renamed image inputs from `repoImageId`/`repoImageSha` to
`prebuiltImageId`/`prebuiltImageSha`.
* **Tests**
* Expanded unit/integration coverage for spawn-time selection and
restore retry behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…ay#919)

PR-12 of the multi-repo sessions plan (design §5.1, §5.2, §7.5 web row).
The last web-facing feature PR of Phase 2: environments become creatable
and launchable from the UI.

## Settings › Environments

New settings tab (`environments-settings.tsx`) with the full lifecycle:

- **List** — name, description, repository summary (`primary +N`),
prebuild toggle, build status (ready/building/failed dot + last build
duration + error tooltip, mirroring Settings › Images), **Rebuild now**,
edit, and inline-confirm delete.
- **Create/edit form** (`environment-form.tsx`) — name, description, and
the ordered repository list: a searchable multi-select popover (the
automation form's selector pattern, extracted into a shared
`repository-multi-select.tsx`) plus one row per selected repository with
a per-repository base-branch picker, reorder controls (`repositories[0]`
is the primary and gets the badge), and remove. Client-side guards
mirror the shared schema: `MAX_TARGET_REPOSITORIES` cap and no duplicate
repository *name* across owners (checkout paths are
`/workspace/{repoName}`).
- **Secrets tab** — reuses `secrets-editor.tsx` via a new `environment`
scope (`/api/environments/{id}/secrets`), with the inherited-global
rendering intact, plus the per-key **import from a repository** flow
(`environment-secrets-import.tsx`): pick a source repository, check
keys, values copied server-side (PR-8's endpoint enforces that the
source is a current repository of the environment and never returns
values). Creating an environment lands on the Secrets tab so setup can
finish in one pass.

Prebuild-specific UI (toggle, status, rebuild) is gated on
`supportsRepoImages()`; the tab itself works on any provider.

## Unified new-session picker

`SessionTarget` grows from `none | repo` to `none | repo | environment |
repos`, moved out of `page.tsx` into `lib/session-target.ts` together
with the option-value round-trip and `buildSessionTargetRequestFields`,
which emits **exactly one** of the three mutually exclusive create modes
(scalar / `environmentId` / `repositories`) so the picker can never trip
`createSessionRequestSchema`'s exclusivity refinement.

- The repo combobox becomes one unified searchable list: an
**Environments** group (name + "N repositories · prebuilt/prebuild
building/prebuilds on", driven by one `GET /api/environment-images`
call) above the **Repositories** group. Instances with no environments
see the flat repo list as today.
- **Ad-hoc multi-select**: a "Multiple repositories" option switches to
the `repos` mode, seeded with the currently selected repo, edited
through the same multi-select popover. Order is primary order.
- Secrets-disclosure copy per §7.4 under the prompt box: environment
sessions use global + environment secrets; ad-hoc sessions use global +
the selected repositories' secrets — with the "save this set as an
environment" nudge (ad-hoc sets don't get prebuilds).
- The sandbox-warming config key now distinguishes environment ids and
ad-hoc lists, so changing the selection invalidates a warmed session
exactly like repo/branch changes do. Single-repo behavior (branch
dropdown, localStorage restore, warm-on-type) is byte-identical.

## BFF

- `POST /api/sessions` allowlist forwards `environmentId` and
`repositories`; identity fields stay server-derived and everything else
is still stripped (tested).
- New proxies: `GET /api/environments/[id]/images` (per-environment
status incl. failed rows, for settings), `POST
/api/environments/[id]/images/trigger` (Rebuild now), `GET
/api/environment-images` (cross-environment ready/building, for the
picker) — all 501-gated like the repo-images routes.

## Session page

`MetadataSection` gains the environment provenance row (threaded from
`SessionState.environmentId/environmentName`, which PR-9 already
hydrates through the socket): the environment name when it resolves, and
**"Environment deleted"** when the name resolves null (§7.6 contract).

## Tests

571 web tests pass (19 new):

- `session-target.test.ts` — per-mode request-field exclusivity,
option-value round trip, multi-mode seeding, warm-config identity,
launchability.
- `page.test.tsx` — environment launch sends only `environmentId`;
ad-hoc launch sends only `repositories` (seeded from the selected repo,
edited through the popover); existing no-repository flows unchanged.
- `environment-form.test.tsx` — primary marking + reorder changes
submitted order, cap disables further selection, name-collision guard,
submit gating.
- `sessions/route.test.ts` — forwards `environmentId` / `repositories`,
still strips `userId`/`spawnSource`/`scmToken`/`authUserId`.
- `metadata-section.test.tsx` — environment name, deleted state, absent
for repo-launched sessions.

## Not in this PR

- Environment names on session-list cards (`Session` carries
`environmentId` but not a resolved name; list rendering is a fast follow
if wanted).
- Slack/Linear/GitHub-bot environment targets (Phase 3, §7.5 — targets
unify, nothing migrates).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added an **Environments** settings tab with create/edit/list, delete,
and prebuilt image management (status, rebuild, enable/disable).
* Enhanced the launch flow to support **environment targets** and
**ordered multi-repo selection**.
* Added **environment-scoped secrets** and secret import from a selected
repository.
* **Bug Fixes**
* Improved session creation so environment and multi-repo launches send
the correct fields, and invalid/extra fields are stripped.
* Strengthened authentication gating for environment/repo image actions.
* **Documentation**
* Updated launch help/disclosures to reflect environment vs repository
launch modes.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…ay#920)

## Summary

Fast-follow committed on the ColeMurray#919 review thread
([discussion](ColeMurray#919)):
extract the session-target picker from the home page now that the
unified environment/repository picker landed.

- **`useSessionTargetPicker`** (new hook) owns the `SessionTarget`
state, the unified environment/repository option list (including
prebuild-status subtitles), display labels, branch and multi-repo
selection handling, and request-field construction
(`buildRequestFields()`, which returns `null` when the selection isn't
launchable).
- **`SessionTargetPicker`** (new component) owns the three target
controls: the unified target combobox, the ad-hoc repository
multi-select, and the branch combobox.
- **`page.tsx`** keeps exactly what the review discussion scoped to it —
model selection, prompt state, and sandbox warming — and shrinks from
733 to 437 lines. The 24-prop `HomeContent` boundary collapses to 13
props (one of which is the picker state object).

The pure pieces were already in `lib/session-target.ts`, so this is the
mechanical half: no behavior change, no new API calls, no markup
changes.

## Testing

- `npm test -w @open-inspect/web` — 589 tests pass, including the four
`Home` tests that drive the real picker end-to-end (no-repository,
environment-only body, ad-hoc seeded multi-select).
- `npm run typecheck -w @open-inspect/web`, `npm run lint` — clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added a unified session target picker to select environments,
repositories, and branches in a single interface.
* Automatically selects a default repository, restores the last-used
choice when available, and persists repository selection for future
sessions.
* Launch eligibility and request payloads are now driven by the picker’s
selection state.

* **Bug Fixes**
* Improved warm-session behavior by ensuring the returned warmed session
matches the currently selected target configuration before proceeding.
* Submit and prompt-triggered actions are disabled until the selected
target is ready to launch.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…Murray#921)

## Summary

PR-13 of the multi-repo sessions plan: user-facing and package
documentation for multi-repository sessions, environments, and
environment prebuilds (shipped across ColeMurray#915–ColeMurray#919).

- **docs/HOW_IT_WORKS.md** — session targets (single repo / ad-hoc set /
environment / none), the `/workspace/<repo-name>` checkout layout and
primary-repository rule, a new **Environments** section
(snapshot-at-creation semantics, "Environment deleted" state,
save-as-environment nudge), multi-repository notes in the fresh-start
and prebuilt-image lifecycles, and the launch-unit secrets rule in the
security model.
- **docs/SECRETS.md** — scope table gains **Environment**; new "Which
secrets a session receives" section (global + launch unit; environment
sessions never inherit repository secrets; primary-wins for ad-hoc
sets), the per-key import flow, the 128 KB combined cap with per-scope
attribution, and the §8.6 guidance: don't persist long-lived secrets to
disk in `setup.sh` (they get baked into prebuilt images;
environment-secret changes supersede + rebuild, repo/global rotation
does not).
- **docs/IMAGE_PREBUILD.md** — new **Environment Images** section:
enabling, how N-wide builds run (sequential per-repository setup, fail
names the repository, build/session secret parity), rebuild triggers
(fingerprint / new commits / runtime floor / secret-change supersede),
spawn matching with base-image fallback, and the explicit "ad-hoc
sessions never get prebuilds" tradeoff.
- **READMEs** — root README gains a Multi-Repository Sessions &
Environments feature blurb; control-plane README gains the
`/environments*` + `/environment-images*` endpoint tables and the new D1
tables (`session_repositories`, `environments`,
`environment_repositories`, `environment_secrets`,
`environment_images`); modal-infra README's stale "scheduling currently
disabled" notes replaced with the actual 30-minute two-pass cron;
AGENTS.md D1/data-plane lines updated.

Docs-only — no code changes.

## E2E verification checklist (execution pending deployment)

The plan's Phase-2 checklist, to run against a deployed instance once
the prod sync (Modal ColeMurray#900 first) lands; recorded here so it's tracked:

- [x] Create environment (2 repos, one heavy setup) → save triggers
build → completes
- [x] Session from the environment boots in seconds, **no setup.sh**
- [x] Agent edits both repositories → two PRs
- [x] Edit an environment repository's branch → next session boots base
until rebuild
- [x] Environment-secret change triggers rebuild (and supersedes the
ready image)
- [x] Resume from snapshot works for a multi-repository session
- [x] Ad-hoc 2-repo session boots base with full setup (documented
tradeoff)
- [x] Single-repo + bot (Slack/GitHub/Linear) regression pass


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Expanded setup and architecture materials to describe environments,
multi-repository session workspaces (including ordering and sandbox
startup flow), and how sessions target repositories.
* Overhauled prebuilt image docs to differentiate repository vs
environment image artifacts, explain rebuild triggers/timing, and
clarify when images are used (including ad-hoc multi-repo restrictions).
* Reworked secret documentation with a new secret-scope model
(global/repository/environment), environment secret import behavior, and
updated troubleshooting/limits.
* Added environment-focused API and infrastructure notes, including
environment endpoints and scheduled image rebuilds.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…rray#923)

## Summary

Fixes the bug where picking an **environment** in the new-session picker
is forgotten: any return to the home page — the sidebar "+", the
`Cmd/Ctrl+Shift+O` shortcut, the command-menu "New session", or the
header logo (all of which `router.push("/")` and remount `Home`) — fell
back to the previously stored repository.

**Root cause** (in `hooks/use-session-target-picker.ts`): the
persistence effect early-returned unless `sessionTarget.kind ===
"repo"`, so environment selections were never written to `localStorage`;
and the restore effect only validated the stored value against the repo
list and could only ever produce a repo (or "none") target.

**Fix**:

- **Persist the picker's select value** — a repo fullName or `env:<id>`
— under the existing storage key. Stored legacy repo fullNames keep
working unchanged (an `env:` value can never collide with a fullName),
so no key migration is needed. Ad-hoc "Multiple repositories" sets and
"No repository" stay unpersisted, matching today's behavior.
- **Restore branches on the parsed stored value.** A stored environment
waits for the environments fetch to settle before deciding — the restore
effect commits exactly once (the `if (sessionTarget) return` guard
blocks later correction), so deciding while environments were still
loading would flash the repo default and never recover. A stored
environment that no longer exists falls back to the repo default.
- **Guard against the session-loading window**: both `useRepos` and
`useEnvironments` gate their SWR fetch on the auth session and report
`loading: false` while it resolves, so an unguarded restore would read
the empty unloaded lists as authoritative (classifying a stored
environment as deleted, or locking in "no repository"). The restore now
waits for `useSession().status !== "loading"`, which also hardens the
pre-existing repo path.

## Testing

`npm test -w @open-inspect/web` — 593 tests (4 new in `page.test.tsx`):

- persists an environment selection (`env:env-1` in storage) and
restores it on a fresh mount, launching with only `environmentId`
- does **not** commit the repo default while a stored environment's
fetch is pending, then resolves to the environment
- falls back to the repo default when the stored environment was deleted
- still restores a stored legacy repository fullName

Typecheck and lint clean.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* The app now remembers your last selected repo or environment and
restores it on return, including after sign-in and reloads.
* **Bug Fixes**
* Improved restoration timing so saved environment selections are
applied only after environments finish loading.
* If a saved environment is missing or the stored value is malformed,
the app now falls back to a valid default repository.
  * Legacy saved repo selections are restored correctly.
* Environments/repos loading states now respect session resolution to
avoid premature empty results.
* **Tests**
* Expanded coverage for loading-state–dependent restoration and legacy
fallback behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

Phase 3 (reach) of the multi-repo sessions plan, first slice: **Slack
routing rules can now target an environment**. A keyword rule pointing
at a saved environment launches the full multi-repository workspace
(prebuilt image included) straight from Slack — the first bot surface to
gain environment targets, per the design's "targets unify instead of
migrate" principle (D§7.5, Phase A).

This is the deterministic path first. Channel-association environment
targets (Phase A2) and environments in the LLM classifier candidate set
(Phase B) are follow-up PRs.

## What changed

**shared**
- `SlackRoutingRule` activates the reserved `targetType?: "repository" |
"environment"` field. Environment rules store the stable `env_…` id
(never the rename-able display name).
- `normalizeRoutingRules` canonicalizes per kind: repository targets
stay trimmed + lowercased; environment ids are opaque and only trimmed.
Repository rules normalize to the bare `{ keyword, target }` shape,
keeping every pre-environment rule byte-identical. De-dupe key includes
the target type.

**control-plane**
- `validateRoutingRules` accepts the new field: environment rules must
carry an `env_…` id; repository rules keep the `owner/name` form check.
Target *existence* still isn't checked at this layer — the bot skips
stale targets at match time, same as stale repos.

**slack-bot**
- New `SlackSessionTarget` union (`repository` | `environment`) + value
encoding (`env:<id>`, mirroring the web picker; bare values keep parsing
as repo ids so in-flight clarification messages posted before this
deploy stay clickable).
- New `classifier/environments.ts`: `GET /environments` with the same
in-memory → KV cache and **fail-open-to-empty** posture as repos/rules.
Fetched lazily — only when a matched rule actually targets an
environment.
- The routing-rule stage resolves environment targets and now runs
**before the single-repo shortcut** (otherwise environment rules could
never fire in one-repo workspaces). Repo-rule behavior in single-repo
workspaces is unchanged: any accessible repo rule there resolves to the
same lone repo.
- `ClassificationResult` is now slack-local and target-aware
(`target`/`alternatives` as unions). The shared repo-only
`ClassificationResult` remains for the Linear bot.
- Clarification quick-picks render either kind (environments
disambiguate as `name (environment)` on label collisions); the selection
handler resolves `env:<id>` values; `createSession` sends `{
environmentId }` for environment targets — mutually exclusive with the
scalar repo fields and `branch` by the create schema (the environment
defines its own branches, so per-user branch preferences don't apply).
- Thread sessions and completion callbacks label environment sessions
with the environment name.

**web**
- Settings › Integrations › Slack routing rules: the target select
becomes a unified list (Environments group + Repositories group,
appearing only when environments exist — same convention as the session
picker). Drafts encode environments as `env:<id>` and split into
`target` + `targetType` on save. Stale-environment rules render with a
warning, like stale repos.

**docs**
- `HOW_IT_WORKS.md`: the "bots are single-repository" line now carves
out Slack routing rules.

## Deploy-order safety

Rules with `targetType: "environment"` read by an **old** bot normalize
to a bare `env_…` target that matches no repo → the rule is skipped
(fail-safe). New bot + old stored rules behave exactly as today.

## Testing

- shared: normalize preserves/canonicalizes env rules, mixed-kind
dedupe, repository-shape stability (`npm test -w @open-inspect/shared`,
351 ✓)
- control-plane: env-rule round-trip + rejection cases (unit 1678 ✓,
integration 514 ✓)
- slack-bot: env rule routes without LLM; no env fetch when no env rule
matches; env rule wins over the single-repo shortcut; mixed repo+env
clarification; deleted-env rule falls through to LLM; quick-pick `env:`
values + collision disambiguation; environments fetch cache/fail-open
(186 ✓)
- web: env-targeted rule add/save with `targetType`, stored-env rule
renders by name, stale-env warning (592 ✓)
- lint + typecheck clean across packages

## Plan/design refs

- docs/multi-repo-sessions-design.md §7.5 (Slack Phase A), decision 14
(additive bot targets)
- docs/multi-repo-sessions-implementation-plan.md — Phase 3 slice 1


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Slack session launches can now target either a repository or an
environment.
* Routing rules and clarification quick-picks support environment-based
destinations.
* Slack integration settings now let you configure and manage
environment routing rules alongside repositories.
* **Bug Fixes**
* Improved handling for missing, stale, or unresolved targets with
clearer warnings and graceful fallback.
* Enhanced reliability of environment lookup and routing-rule resolution
(including fail-open behavior).
* Slack mrkdwn output now escapes sensitive characters in completion
footers.
* **Documentation**
* Updated “how it works” guidance for bot-created single-repo behavior
and environment/workspace routing options.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary
- Replace the deprecated sandbox file-handle write path for tunnel env
files with the Modal filesystem writer.
- Update tunnel-port tests to mock the filesystem writer while
preserving graceful failure behavior.

## Why
Modal deprecates the sandbox file-handle API. Writing through the
filesystem API avoids deprecation warnings during sandbox setup.

## Validation
- `uv run ruff check src/sandbox/manager.py tests/test_tunnel_ports.py`
- `uv run ruff format --check src/sandbox/manager.py
tests/test_tunnel_ports.py`
- `uv run pytest tests`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
  * Improved reliability when saving tunnel environment data.
* Tunnel configuration files are now written through a more robust
file-writing path, with the same warning and error handling behavior.
* Updated validation coverage to ensure tunnel settings are written in
the correct order and format, and that write failures do not interrupt
tunnel setup.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This is an automated nightly unsafe-cast remediation sweep. It replaces
selected high/medium-risk TypeScript assertions at trust boundaries with
parse-don't-assert validation, following the TypeScript Coding Standards
for unsafe casts and the Zod boundary-validation pattern established in
PR ColeMurray#807.

## Findings Fixed

| file:line | risk | cast removed | fix |
| --- | --- | --- | --- |
| `packages/slack-bot/src/callbacks.ts:242` | HIGH | `payload as
CompletionCallback` after `request.json()` | Added a Zod
`completionCallbackSchema` and route-local `safeParse`; HMAC
verification still uses the original payload object so signed unknown
fields are preserved. |
| `packages/slack-bot/src/callbacks.ts:294` | HIGH | `payload as
ToolCallCallback` after `request.json()` | Added a Zod
`toolCallCallbackSchema` and route-local `safeParse`; malformed Slack
context continues to return the existing 400 response. |
| `packages/control-plane/src/scheduler/slack-completion.ts:35` | MEDIUM
| `JSON.parse(raw) as SlackRunMetadata` from persisted trigger metadata
| Added a Zod `slackRunMetadataSchema`, made `SlackRunMetadata` a
`z.infer` type, and return `null` on parse/shape failure to preserve the
existing skip contract. |

## Verification

| command | result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | passed |
| `npm run build -w @open-inspect/control-plane` | passed |
| `npm run build -w @open-inspect/slack-bot` | passed |
| `npm run typecheck` | passed |
| `npm run format` | passed |
| `npm run lint` | passed in a clean temporary worktree with this patch
applied; the main worktree has a pre-existing untracked `.opencode/`
directory that causes root lint to scan non-repo tool files |
| `npm run lint -w @open-inspect/control-plane` | passed |
| `npm run lint -w @open-inspect/slack-bot` | passed |
| `npm test -w @open-inspect/control-plane` | passed |
| `npm test -w @open-inspect/slack-bot` | passed |

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/a20ac15ca97c48a0ccb3c1d3ea968bf9)*

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved validation for Slack callback requests using stricter schema
checks, ensuring malformed or partially populated signed payloads fail
cleanly.
* Hardened completion and tool-call handling to reject invalid payloads
early while preserving unknown signed fields on valid requests.
* Added safer Slack run metadata parsing with schema validation,
returning `null` for malformed or incomplete metadata.
* **Tests**
* Added coverage for callback validation and completion handling,
including malformed/partial payload rejection and correct message
routing for signed completions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: OpenInspect <open-inspect@noreply.github.com>
## Summary

Slack Phase A2 of the multi-repo environments work (follows ColeMurray#924):
environments can now be associated with Slack channels, the same way
repositories can via repo metadata. A message in an associated channel
routes deterministically to the environment — no keyword needed.

### Control plane

- **Migration 0035**: additive `channel_associations TEXT` column on
`environments` (JSON string array, mirroring
`repo_metadata.channel_associations` from 0002). Rollback = stop reading
the column.
- `Environment` gains optional `channelAssociations`;
`create/updateEnvironmentInputSchema` accept it (trimmed, non-empty ids,
capped at 50). Update semantics match the rest of the patch shape: field
absent → untouched, array → wholesale replace, `[]` → cleared (column
collapses to NULL). The route dedupes before storing.
- Extracted the JSON-array column parser shared with `repo_metadata`
into `db/json-columns.ts` instead of duplicating it.
- API-only, like repository channel associations — no web UI in this
slice.

### Slack bot

- The classifier's channel-association stage is now target-aware:
`resolveChannelTargets` (in `classifier/routing.ts`, beside its
routing-rule sibling) returns environments and repositories whose
association lists name the channel.
- **The stage moves ahead of the single-repo shortcut** — same
reachability argument as the routing-rule stage in ColeMurray#924: without this,
an environment-associated channel in a one-repo workspace could never
route to the environment. For repository associations this is
behavior-preserving (in a one-repo workspace a channel can only ever
match that one repo).
- Semantics:
- exactly one associated target → high-confidence route (reasoning
escapes the environment name for mrkdwn, per the escape-at-render policy
from ColeMurray#924);
- several targets including an environment → clarification with those
targets as quick picks (the LLM can't select environments until Phase B,
so silently falling through would drop them);
- several repositories only → falls through to the LLM as today (channel
associations are already a prompt signal it can arbitrate with).
- The environments fetch stays the ColeMurray#924 cached resource (60s memory /
5min KV, **fail-open to `[]`**), so an environments outage degrades to
today's repository-only matching. It is consulted only for messages that
reach the channel stage inside a channel context.
- `getReposByChannel` folded into `resolveChannelTargets` (its only
caller was the classifier).

## Deploy-order safety

The column is additive and dark: an old bot ignores
`channelAssociations` on the API response; a new bot against an
un-migrated DB simply sees no environment associations. No coordination
needed.

## Testing

- shared: schema accept/reject cases for `channelAssociations` (360 ✓)
- control-plane: store JSON-column round-trip; route tests for
set/dedupe/omit-untouched/clear and 400s on malformed payloads (1679
unit + 518 integration ✓)
- slack-bot: repo association routing, environment association routing,
mrkdwn escaping of hostile environment names,
environment-beats-single-repo-shortcut, mixed repo+environment
clarification, repo-only multi-match LLM fallthrough, no environments
fetch outside channel context (205 ✓)
- web/github-bot/linear-bot suites unaffected and green (599/115/125 ✓)

Part of the multi-repo sessions Phase 3 plan (design §7.5, Slack Phase
A). Next slice: Phase B — environments in the LLM classifier candidate
set + unified clarification picker.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Environments can now be linked to Slack channels, allowing messages in
those channels to route directly to the right environment.
* Create and update environment endpoints now support channel
associations, including automatic de-duplication.
* Routing recognizes both repository and environment channel links for
more reliable direct session targeting.
* **Bug Fixes**
* Clarified behavior for empty vs omitted channel associations: existing
associations remain when omitted, and can be cleared when an empty list
is provided.
* **Documentation**
* Updated “Session Targets” documentation to reflect channel-association
routing without requiring a keyword.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This is an automated nightly unsafe-cast remediation sweep. It replaces
three high-risk Slack bot trust-boundary assertions with Zod `safeParse`
validation, following the TypeScript Coding Standards unsafe-cast /
parse-don't-assert guidance and the Zod boundary-validation pattern
established in PR ColeMurray#807.

| file:line | risk | cast removed | how it was fixed |
| --- | --- | --- | --- |
| `packages/slack-bot/src/activity-status.ts:201` | HIGH | `(await
response.json()) as SlackEnvelope & { detail?: unknown;
response_metadata?: unknown; warning?: unknown; warnings?: unknown }`
for Slack Web API status responses | Added a package-local Zod envelope
schema and `safeParse`; malformed bodies preserve the existing `{ ok:
false, error: "invalid_response" }` path. |
| `packages/slack-bot/src/channel-trigger.ts:148` | HIGH | `(await
response.json()) as { triggered?: number; skipped?: number; steered?:
number }` for control-plane Slack trigger forwarding results that decide
whether to react to a message | Added a package-local Zod response
schema and `safeParse`; malformed bodies log `invalid_response` and skip
reaction, matching the existing best-effort failure behavior. |
| `packages/slack-bot/src/classifier/repos.ts:301,330` | HIGH | `cached
as string[]` and `(await response.json()) as { channels?: string[] }`
for watched-channel cache/control-plane payloads used to pre-filter
Slack automation triggers | Added package-local Zod schemas for
watched-channel arrays/responses and `safeParse`; malformed payloads
fail closed to an empty watched-channel set. |

## Verification

| command | result |
| --- | --- |
| `npm run build -w @open-inspect/shared` | Passed |
| `npm run build -w @open-inspect/slack-bot` | Passed |
| `npm run typecheck` | Passed |
| `npm run lint -w @open-inspect/slack-bot` | Passed |
| `npm run lint` | Passed after temporarily moving the unrelated
untracked local `.opencode/` tooling directory out of the workspace and
restoring it immediately; the command otherwise lints that untracked
tooling. |
| `npm run format` | Passed |
| `npm test -w @open-inspect/slack-bot` | Passed: 14 files, 174 tests |

References: TypeScript Coding Standards unsafe-cast / parse-don't-assert
guidance; Zod boundary-validation pattern established in PR ColeMurray#807.

---
*Created with
[Open-Inspect](https://open-inspect-prod.vercel.app/session/93ea99ef0272013a1a6b1c6d38376786)*

Co-authored-by: OpenInspect <open-inspect@noreply.github.com>
…oleMurray#930)

## Problem

Since ColeMurray#926 deployed, sessions with `tunnelPorts` boot without
`/workspace/.tunnels.env`: the supervisor logs
`tunnel.stale_file_cleared` followed 30s later by
`tunnel.env_file_wait_timeout`, delaying every boot by the full wait and
leaving `start.sh` without tunnel URLs — even though the manager logs
`tunnel.urls_written`.

## Root cause

The supervisor's boot-time stale-file cleanup and the manager's tunnel
env write have **no ordering guarantee** — the design (from ColeMurray#663) only
works when the write lands *after* the clear:

| ts (from prod logs) | who | event |
|---|---|---|
| 1783614343447 | manager | `tunnel.urls_written` — write completes |
| 1783614343542 | supervisor | `supervisor.start` (+95ms) |
| 1783614343543 | supervisor | `tunnel.stale_file_cleared` — **deletes
the fresh file** |
| 1783614374781 | supervisor | `tunnel.env_file_wait_timeout` (30s
later) |

ColeMurray#926 switched the write from the deprecated file-handle API (3–4
sequential gRPC round trips after container start — always slow enough
to land after the clear) to `sandbox.filesystem.write_text`, which is
implemented as a single exec of a fs-tools helper. That exec completes
while the entrypoint is still importing Python modules, so the write now
lands *before* `run()` begins and the clear deletes it on essentially
every boot. The manager writes exactly once, so nothing recovers.

Compounding the confusion: the old cleanup logged
`tunnel.stale_file_cleared` unconditionally (`unlink(missing_ok=True)`),
so a boot that deleted the fresh file was indistinguishable from a
healthy no-op.

## Fix

Remove the ordering dependency instead of re-tuning the race. Writers
now tag the file's first line with the sandbox it was written for:

```dotenv
TUNNEL_SANDBOX_ID=sandbox-acme-app-1783614336426
TUNNEL_3000=https://...
```

The supervisor's cleanup keeps a file tagged with its own `SANDBOX_ID`
(the manager won the race — the URLs are for this sandbox and valid for
its lifetime) and clears everything else: files tagged with another
sandbox's ID (snapshot/image leftovers with dead URLs), untagged files,
and anything it can't read. A supervisor without a `SANDBOX_ID` identity
never trusts a pre-existing file. Write ordering no longer matters on
either side of the race, on both fresh-create and snapshot-restore
paths.

The Vercel provider writes the same file via exec and had the same
latent race, so it tags too. Cleanup logging is now truthful:
`tunnel.fresh_file_kept` / `tunnel.stale_file_cleared` only when a file
actually existed.

## Mixed-version rollout

Safe in both directions, no version gate needed:
- **Old runtime image + new writer**: the tag line is inert (the wait
loop matches `TUNNEL_<port>=` prefixes); the old cleanup still clears
unconditionally — status quo until images rebuild.
- **New runtime + untagged writer**: untagged files are treated as stale
— exactly today's behavior.

The consumer-facing format is unchanged apart from the extra `KEY=value`
line, which any dotenv consumer tolerates.

## Validation

- `uv run --extra dev pytest tests/` — sandbox-runtime **439 passed**
(new cases: keep-own-tag, clear-other-tag, clear-untagged,
clear-when-identity-unknown), modal-infra **194 passed**
- `npm test -w @open-inspect/control-plane` — **1686 passed**
- `npm run typecheck -w @open-inspect/control-plane`, `ruff check` /
`ruff format --check`, `eslint`, `prettier --check` — clean

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Documentation**
* Updated tunnel env-file documentation to reflect how stale files are
cleared and how current-sandbox files are preserved.
* **New Features**
* Added sandbox ID tagging to tunnel environment files to make the
owning sandbox identifiable.
* Ensured tunnel env-file writing includes the sandbox ID marker
followed by sorted port URL entries.
* **Bug Fixes**
* Improved boot cleanup to retain a correctly tagged tunnel file and
remove only truly stale/untagged or mismatched ones.
* **Tests**
* Updated unit/integration tests to validate the new sandbox ID marker
and content ordering/cleanup behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…oleMurray#928)

## Summary

Slack Phase B of the multi-repo environments work (design §7.5; follows
ColeMurray#924 routing rules and ColeMurray#927 channel associations): **environments join
the LLM classifier's candidate set**, and the clarification picker
offers both repositories and environments. This closes the gap where an
ambient message ("work on the full-stack app") could never reach an
environment — only explicit keywords or channel associations could.

Slack-bot only; no control-plane, shared, or migration changes.

### LLM classifier (`classifier/index.ts`)

- The LLM stage now fetches environments through the ColeMurray#924 cached
resource (fail-open to `[]` — an outage degrades to today's
repository-only classification). Deterministic stages (routing rules,
channel associations) still run first and unchanged.
- **Single-repo shortcut yields to environments**: it now fires only
when there is one repository *and no environments* — otherwise a
one-repo workspace could never classify into an environment.
- The prompt gains an "Available Environments" section (id, name,
description, member repositories) with guidance to prefer an environment
when the work spans several of its repositories. Omitted entirely when
none exist.
- Tool contract renamed `classify_repository`/`repoId` →
`classify_target`/`targetId`. This is self-contained (the same worker
builds the tool and parses the response), so no compatibility shim is
needed.
- Resolution ladder for `targetId` and alternatives (`matchTargetId` in
`classifier/routing.ts`): `env_…` id → environment by id; else
repository by id/fullName (the pre-environment behavior); else
environment by its unique case-insensitive name — so a model that echoes
the name instead of the id still resolves. Alternatives dedupe by target
value and never repeat the match.
- LLM `reasoning` is now escaped for mrkdwn at composition (it can quote
environment names or message text), matching the deterministic stages'
escape-at-composition policy.
- Per-user branch preferences already don't apply to environment targets
(the environment defines branches — ColeMurray#924).

### Unified clarification picker (`repo-clarification.ts` →
`target-clarification.ts`)

- The inline `static_select` uses **`option_groups`** (Environments
above Repositories, mirroring the web picker's grouping) once
environments exist; flat repository options otherwise. Environment
options carry the existing `env:<id>` value encoding, so the ColeMurray#924
selection handler resolves them with zero changes.
- The `external_select` suggestion path (`block_suggestion`) serves both
kinds: environments filter on name, repositories on fullName, combined
and capped at Slack's 100-option limit (environments first — the list is
short). The response is `{options}` or `{option_groups}` per Slack's
contract.
- Copy follows the offer: placeholder/headline say "repository or
environment" only when environments are actually on offer.
- This lands the naming rework deferred from ColeMurray#924: module renamed,
`buildTargetClarificationBlocks` / `getTargetClarificationOptions` /
`SELECT_TARGET_*` / `MAX_TARGET_QUICK_PICKS`. **Wire `action_id` values
are kept byte-identical** (`select_repo`, `select_repo_quick_pick`) so
pickers and quick-picks on already-posted messages keep working.
- `slack-blocks.ts`: `SlackStaticSelectElement` becomes `options XOR
option_groups` (Slack accepts exactly one), with a new
`SlackSelectOptionGroup` primitive.

## Deploy-order safety

Nothing to coordinate: all changes are bot-internal, wire ids are stable
for old messages, and the environments fetch fails open.

## Testing

- classifier: environment id/name resolution, prompt section
presence/absence, shortcut suppressed with environments and kept
without, mixed alternatives deduped, reasoning escaping (28 tests)
- target-clarification: grouped vs flat options, query filtering both
kinds, 100-option cap split, grouped inline picker blocks, both-kinds
copy, quick-pick env values, stable wire ids (28 tests)
- Full matrix green: slack-bot 225, shared 360, control-plane 1686 unit
+ 518 integration, web 599, github-bot 115, linear-bot 125;
lint/typecheck clean.

Part of the multi-repo sessions Phase 3 plan. Remaining Phase 3 slices:
Linear KV `env:<envId>` targets, GitHub bot `default_environment_id`,
automations shared-workspace mode, repo-filter joins.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Expanded Slack clarification to select **either repositories or
environments** via a unified target picker.
* Added grouped target suggestions and capped quick-pick buttons for
multi-candidate matches.
* Updated AI-assisted routing to classify and resolve **targets** using
a combined catalog of repositories + environments.
* **Bug Fixes**
* Improved robustness of Slack static select handling/validation to
better tolerate missing or malformed option data.
* **Documentation**
* Clarified “Session Targets” behavior for bot-created sessions,
including Slack environment targeting and classifier-driven selection.
* **Tests**
* Added/updated coverage for environment-aware classification and
target-clarification UI flows.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…urray#931)

## Summary

Linear-bot slice of the environments Phase 3 rollout (design §7.5): the
team and project KV mappings can now target a saved environment
alongside repositories, so a Linear issue can launch a full multi-repo
workspace deterministically.

- **Mapping entries accept `{ "environmentId": "env_…" }`** (the stable
id, not the rename-able display name) alongside `{ "owner", "name" }`,
in both `config:team-repos` (with the existing optional `label` filter)
and `config:project-repos`. Targets unify instead of migrate —
repository entries never stop working.
- **New `target-resolution.ts`** extracts the 4-stage ladder (project
mapping → team mapping → Linear repo suggestions → LLM classifier) out
of `webhook-handler.ts` and owns the target-kind policy (`SessionTarget`
union, labels, create-request fields). The suggestion and classifier
stages remain repository-only for now (parity with the design's staged
rollout).
- **Unknown environments fail open**: an entry whose environment was
deleted (or when the environments fetch fails) is skipped with a warning
and resolution falls through to the next stage — mirroring how the Slack
classifier skips rules targeting inaccessible targets.
- **New `environments.ts`** fetch helper mirroring `classifier/repos.ts`
(in-memory 60s → control plane → KV 300s, fail open to `[]`).
- **Session creation** sends `{ environmentId }` XOR `{ repoOwner,
repoName }` (the create schema makes the two mutually exclusive). The
issue→session KV record stores the environment id for environment
sessions.
- **Integration settings** (enabled-repos gate, model config) resolve
from the environment's **primary repository** until environment-level
integration settings exist (design §13.5);
`callbackContext.repoFullName` carries the primary repo (the field is
echoed back and unread today).
- `resolveStaticRepo` → `resolveStaticTarget` (it no longer resolves
only repos).

## Behavior notes

- Deterministic paths only: ambient issues with no mapping still
classify over repositories.
- Repository mappings, label filtering, and the elicitation flow are
byte-for-byte unchanged in behavior.
- The previously unreachable final "couldn't determine repository" guard
in `handleNewSession` is gone — the extracted resolver always returns a
full target or elicits.

## Testing

- `resolveStaticTarget`: environment entries match by label and as
label-less fallback.
- Handler tests (new): project→environment mapping creates a session
with `environmentId` and no repo scalars, settings resolve from the
primary repo, KV issue record stores the environment id; label-matched
team→environment mapping; unknown environment falls through without
creating a session; repository mappings still produce repo sessions.
- Full suite: 131 linear-bot tests green; repo-wide lint, typecheck,
format clean.

Part of the multi-repo sessions / environments Phase 3 rollout (after
ColeMurray#924, ColeMurray#927, ColeMurray#928).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Linear sessions can now target either a GitHub repository or a saved
environment.
* Team and project mappings now support environment-based routing,
including label-aware matching and fallback behavior.

* **Bug Fixes**
* Session setup now skips missing environments instead of failing the
flow.
* Improved target selection so the app falls back more reliably when a
direct match isn’t found.

* **Documentation**
* Updated Linear and session behavior docs to reflect repository vs.
environment targeting.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## What changed

- added a dedicated formatter for future relative timestamps (`in 5m`,
`in 2h`, `in 1d`)
- defined sub-minute scheduled runs as `in <1m`
- updated automation list metadata to use the future formatter
- added formatter boundary tests and rendered list coverage

## Why

Scheduled automation timestamps are future values, but the list rendered
them with a formatter designed for past activity. As a result, every
future `nextRunAt` value fell through to `just now`.

## Root cause

`formatRelativeTime` subtracts the supplied timestamp from the current
time and only handles positive past-time differences. Passing a future
timestamp produces negative minute, hour, and day values, so the
formatter returns its recent-past fallback.

## Checks

- `npm run build -w @open-inspect/shared`
- `npm test -w @open-inspect/web` (606 tests)
- `npm run typecheck -w @open-inspect/web`
- `npm run lint -w @open-inspect/web`
- `npx prettier --check packages/web/src/lib/time.ts
packages/web/src/lib/time.test.ts
packages/web/src/components/automations/automations-list.tsx
packages/web/src/components/automations/automations-list.test.tsx`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added clearer “Next” timing labels for scheduled automations, showing
future run times like “in 2h” or “in 1d”.
* Introduced improved future-time formatting for consistent
countdown-style display.

* **Tests**
* Expanded automated coverage for time formatting and scheduled
automation timing display.
* Added deterministic timer-based tests to verify past and future
relative time outputs.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## What changed

- preserve caller-supplied `event` fields in structured JSON log output
- keep logger-owned envelope fields protected from context and per-call
data
- document precedence across base context, child context, and per-call
data
- add focused coverage for emitted events, protected fields, precedence,
and child loggers

## Why

Control-plane callers attach stable event names such as
`automation.created` for log indexing and queries, but those fields were
silently missing from emitted JSON.

## Root cause

The shared logger included `event` in its reserved-key set, so it
stripped the field from both context and per-call data. Unlike the other
reserved keys, the logger never added an `event` value back to the
output envelope.

## Checks

- `npm test -w @open-inspect/shared` (25 files, 364 tests)
- `npm run build -w @open-inspect/shared`
- `npm run typecheck -w @open-inspect/shared`
- `npm run lint -w @open-inspect/shared`
- `npx prettier --check packages/shared/src/logger.ts
packages/shared/src/logger.test.ts`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Structured logs now preserve user-provided `event` fields instead of
stripping them.
* Logging output more reliably keeps custom context values while still
protecting core log fields from being overwritten.
* Child loggers now correctly inherit parent context and allow later
values to override matching fields.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
…Murray#935)

## Summary

Phase 3 slice: GitHub bot environment targets (design §13.2). A
repository can name a default environment in its repo metadata
(`repo_metadata.default_environment_id`); PR reviews and @mention
sessions triggered from that repo then open the environment's full
multi-repository workspace — a PR comment on `backend` can spin up the
whole full-stack workspace.

Follows the Phase 3 pattern established by the Slack (ColeMurray#924/ColeMurray#927/ColeMurray#928)
and Linear (ColeMurray#931) slices: **targets unify instead of migrate** —
repo-bound sessions stay the default and the fallback.

## Control plane

- **Migration 0036**: `repo_metadata.default_environment_id TEXT`
(nullable opt-in).
- `RepoMetadata.defaultEnvironmentId` (shared type) +
`RepoMetadataStore` read/write + accepted by `PUT
/repos/:owner/:name/metadata` — configured the same way
`channelAssociations` is today (API; no web UI for repo metadata exists
yet).

## GitHub bot

- New `session-target.ts` owns launch-target resolution: fetch the
trigger repo's metadata → fetch the environment → require the
environment to **contain the trigger repo** (the session must be able to
check out the PR under review) → launch with `{environmentId}`; every
failure path fails open to the repo-bound session with a `target.*`
warning log (deleted environment, membership lost, fetch/parse errors).
- `createSession` takes mutually exclusive target fields (`{repoOwner,
repoName} | {environmentId}`), spread into the create body — same
contract the Linear bot uses.
- Integration settings, enabled-repos gating, and permission checks are
untouched: they always resolve from the trigger repository.

## Testing

- github-bot: 5 new tests (environment launch, deleted-environment
fallback, non-member fallback, metadata-failure fallback,
case-insensitive membership); existing assertions moved from call-index
to URL-based lookup since handlers now make a metadata lookup before
session create. 120 pass.
- control-plane: store round-trip + clear-on-omit tests; unit 1687 pass,
integration 518 pass (migration 0036 applied).
- Full repo typecheck + lint clean; slack-bot 227, linear-bot 131, web
599 pass.

## Docs

- github-bot README: new "Session Launch Target" section.
- docs/HOW_IT_WORKS.md: bots paragraph updated.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* GitHub-bot sessions can now launch a repository’s configured default
environment (opt-in via repo metadata), instead of always starting in
the repository.
* If the environment is missing, no longer includes the trigger
repository, or the sender lacks required access, sessions automatically
fall back to repo-bound targeting.
* **Bug Fixes**
* Improved session targeting so review and comment-triggered sessions
consistently use the correct launch location.
* **Documentation**
* Updated GitHub-bot documentation to describe the new session launch
target behavior and fallback rules.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## What

- Show `No matching sessions` when a non-empty sidebar search has no
local matches.
- Restore the session list when the query is cleared.
- Keep genuine empty-session and fetch-failure messages distinct.
- Add public-UI regression coverage for all three states.

## Why

Searching for a session with no matches left the session panel blank,
giving users no indication that the search completed successfully.

## Root cause

The sidebar filtered sessions into its active and inactive lists, but
the empty-state render branch checked the unfiltered `sessions.length`.
When sessions existed but none matched the query, neither the normal
empty state nor any session rows rendered.

## Checks

- `npm run build -w @open-inspect/shared`
- `npm test -w @open-inspect/web --
src/components/session-sidebar.test.tsx` (12 passed)
- `npm test -w @open-inspect/web` (602 passed)
- `npm run typecheck -w @open-inspect/web`
- `npm run lint -w @open-inspect/web`
- `npx prettier --check packages/web/src/components/session-sidebar.tsx
packages/web/src/components/session-sidebar.test.tsx`

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved the sessions sidebar so an active search with no results
shows “No matching sessions”.
* Kept “No sessions yet” for the genuine empty state (when there are no
sessions).
* Preserved the “Unable to load sessions” message when sessions fail to
load, even after entering a search.
* **Tests**
* Added coverage for search/no-results, true empty state, and load-error
scenarios in the sessions sidebar.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

- add GPT-5.6 Sol, Terra, and Luna to the shared model registry,
default-enabled OpenAI models, and model picker
- allow all three models through the OpenAI OAuth proxy and support
matching Linear `model:*` labels
- document the new models and cover validation, reasoning, label
routing, and OAuth allowlisting

## Compatibility notes

- models.dev added all three OpenAI models on 2026-07-09 with 1.05M
context windows and 128K output limits
- OpenCode loads the model catalog dynamically from models.dev, so no
hard-coded OpenCode catalog change is required
- Open-Inspect's OAuth proxy has its own model allowlist, which must be
updated so the new catalog entries are retained in ChatGPT-OAuth
sandboxes
- reasoning options stop at `xhigh` because both the repository's pinned
OpenCode 1.14.41 runtime and current OpenCode 1.17.18 expose GPT-5.2+
through `xhigh`; this avoids offering the models.dev `max` option before
OpenCode supports it
- the OpenCode dependency remains pinned at 1.14.41 because newer
releases break the legacy SSE event stream used by Open-Inspect (tracked
in ColeMurray#567)

## Validation

- `npm run build -w @open-inspect/shared`
- `npm test -w @open-inspect/shared` (364 tests)
- `npm test -w @open-inspect/linear-bot --
src/__tests__/pure-functions.test.ts` (33 tests)
- `uv run --project packages/sandbox-runtime --extra dev pytest
packages/sandbox-runtime/tests/test_codex_auth_plugin_setup.py -q` (4
tests)
- `npm run lint`
- `npm run typecheck`


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added support for additional OpenAI GPT 5.6 variants across model
selection, label resolution, and authentication checks.
* These new models now appear in the model picker and are available by
default for new setups.

* **Bug Fixes**
* Improved handling of GPT 5.6 model labels so they resolve correctly in
related workflows.
* Updated supported-model validation and reasoning settings to better
match the new model variants.

* **Documentation**
* Refreshed the available models reference to include the latest GPT 5.6
options and updated descriptions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

- remove GPT-5.2 and GPT-5.2 Codex from shared model metadata and
default-enabled models
- remove both variants from the Codex OAuth allowlist and Linear model
labels
- refresh model examples, fixtures, and documentation to supported
OpenAI models
- verify legacy GPT-5.2 values are rejected and fall back safely

## Why

The Codex backend no longer supports the GPT-5.2 variants. Keeping them
selectable caused new sessions and integration overrides to target
unavailable models.

## Impact

GPT-5.2 models no longer appear in model selectors or integration
aliases. Existing stored preferences that reference them are treated as
unsupported and resolve through the existing default-model fallback.

## Validation

- `npm run build -w @open-inspect/shared`
- focused Vitest suites for shared, Linear, Slack, and web (84 tests)
- sandbox-runtime Codex auth plugin tests (5 tests)
- type checks for shared, Linear, Slack, web, and control plane
- ESLint for shared, Linear, Slack, web, and control plane
- Ruff and Prettier checks for changed files


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Updated supported model lists to include newer GPT 5.4/5.5/5.6
variants and GPT 5.3 Codex options.
* **Bug Fixes**
* Removed outdated GPT 5.2 model references so unsupported models are no
longer shown or selected by default.
* Improved model fallback behavior when a saved model is no longer
available.
* **Documentation**
* Refreshed setup and usage docs to match the current supported model
set.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
## Summary

Automations gain **environment targets** (multi-repo sessions design
§13.3, extended to fan-out): an automation's selection may now name
environments (`environmentIds`) alongside repositories, up to 10 targets
combined. Each firing fans out **one run per target** — a repository run
works that repository in its own session (unchanged), while an
environment run launches one session opening that environment's full
workspace. So an automation can run on Env A, Env B, and repo C in a
single firing.

> Scope note: this PR originally shipped a scalar single-environment
binding (`environmentId`, mutually exclusive with repositories). It was
reworked in place to the list/fan-out model in d3d35a6 after a
requirements clarification — fan-out across environments (Env A, Env B,
…) is expected, and mixing repository and environment targets falls out
of the same model.

## How it works

**Storage** — migration `0037`: new `automation_environments` table (the
live environment selection, twin of `automation_repositories`) and
`automation_runs.environment_id` (the firing-time target snapshot, twin
of the `repo_*` snapshot columns, with a per-invocation uniqueness
index). The scalar `automations.environment_id` reserved in 0033 goes
back to being inert — never read or written.

**API** — `POST/PUT /automations` accept `environmentIds: string[]`
(update replaces wholesale; empty clears). Validation: id shape +
existence (missing environments named in the 400), duplicate rejection,
and combined target-count rules — repo-scoped
`github_event`/`linear_event` triggers take exactly one repository and
no environments; more than one target requires a schedule trigger; 10
targets combined max. On update, count rules validate the final state
(replacement plus the other side's existing rows) only when a selection
is touched, so stored selections predating a rule can never brick
unrelated edits.

**Firing pipeline** — `startInvocation` builds one child per resolved
repository (unchanged, including per-repo pre-fail) plus one child per
selected environment, snapshotting the environment id on the run row. No
targets → one repo-less child, as before. Invocation status/accounting
derive from the children with no new code; the tick's child-budget
estimate counts both selections.

**Launch** — `automation/session-target.ts` resolves what a run's
session opens from the run row alone: environment runs resolve their
environment exactly like the session-create route (all-or-nothing at
launch; the session snapshots the member list per §7.6, primary mirrored
to scalars), repository and repo-less runs pass their snapshot through.
Environment resolution failures (deleted environment, revoked member
access) fail that child through the existing launch-failure path —
sibling targets are unaffected. Settings resolve via the canonical
`resolveSessionScopedSettings` (primary member, design §6.2).

**Web** — the automation form's target popover gains an "Environments"
section (hidden for repo-scoped triggers): single-select mode picks one
target of either kind; multi-select mode mixes repository and
environment checkboxes under the combined cap. Cards and the detail page
label targets via a shared `formatAutomationTargetsLabel` ("2
repositories + 1 environment"). Run history renders environment runs
with the environment's name; a fan-out row says "N targets" when
environments are involved.

## Known limitation (noted, not addressed)

`GET /automations?repoOwner=…` filters via `automation_repositories`, so
environment-targeted automations don't match repo filters even when an
environment contains that repo. The filter currently has no consumer;
extending it is a follow-up if one appears.

## Validation

- shared 364, control-plane unit 1708 + integration 519 (real-D1
environment-selection round-trip, migration applied), web 610, slack-bot
227, github-bot 123, linear-bot 131
- lint + typecheck clean across all packages

Design refs: docs/multi-repo-sessions-design.md §13.3 (extended per
updated requirements), §7.6 (snapshot semantics);
docs/MULTI_REPO_AUTOMATIONS.md decision updated.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Automations can now target environments alongside repositories,
including mixed selections.
* Environment-aware sessions/workspaces are created per selected
environment, and run history displays environment-target outcomes.
* Automation details, lists, forms, and editing now render
environment-inclusive target labels.

* **Bug Fixes**
* Improved create/update validation for combined repository+environment
target limits and trigger compatibility.
* Environment selections are now persisted, correctly mapped into
automation data, and reliably preserved/cleared across create/edit
flows.

* **Documentation**
* Updated multi-repo automation and automations documentation to reflect
environment fan-out behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Syncs the CompanyCam fork with ColeMurray/background-agents upstream
(67 commits, upstream through 07bc46c). Preserves the fork-only
user-defined PR labels feature (#15).
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Terraform Validation Results

Step Status
Format ✅
Init ✅
Validate ✅

Pushed by: @colemurray-31531, Action: pull_request

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Terraform Plan Results

Status: ✅ Success

Show Plan
terraform_data.access_control_gate: Refreshing state... [id=4f38135f-b85d-3c66-bc4d-62e6bd696d0f]
data.external.modal_source_hash[0]: Reading...
null_resource.slack_bot_build[0]: Refreshing state... [id=8218184084727750027]
null_resource.control_plane_build: Refreshing state... [id=2068601036937546848]
module.modal_app[0].null_resource.modal_secrets[0]: Refreshing state... [id=5762703892982030197]
module.slack_kv[0].cloudflare_workers_kv_namespace.this: Refreshing state... [id=b9e2c38df71f4855be972c58b6a1ee8d]
cloudflare_d1_database.main: Refreshing state... [id=f39968f8-149f-4efd-ba33-ba502ffd6f9d]
module.session_index_kv.cloudflare_workers_kv_namespace.this: Refreshing state... [id=58ad1907c80c44b1b2ecbdfe268bcac5]
cloudflare_r2_bucket.media: Refreshing state... [id=open-inspect-media-contractor]
null_resource.linear_bot_build[0]: Refreshing state... [id=7605461394166201776]
module.linear_kv[0].cloudflare_workers_kv_namespace.this: Refreshing state... [id=03d1b14a4d12477f81cfcc3e4af77cf9]
data.external.modal_source_hash[0]: Read complete after 0s [id=-]
module.modal_app[0].null_resource.modal_deploy: Refreshing state... [id=7520787001111393755]
module.web_app[0].vercel_project.this: Refreshing state... [id=prj_0jIgFgS1feKoUhF8wrcP4TtVTPuM]
module.slack_bot_worker[0].cloudflare_worker.this: Refreshing state... [id=393472842b684911bc95603990b37de0]
module.linear_bot_worker[0].cloudflare_worker.this: Refreshing state... [id=437dba8e03dd48cbbf55638723ad9074]
null_resource.d1_migrations: Refreshing state... [id=4884201322476132031]
module.slack_bot_worker[0].cloudflare_worker_version.this: Refreshing state... [id=5ae51c15-9895-4c87-bfe1-798a81cc53a7]
module.web_app[0].vercel_project_environment_variable.env[1]: Refreshing state... [id=qCP45SaeSAHKBDwG]
module.web_app[0].vercel_project_environment_variable.env[15]: Refreshing state... [id=c9JZitmNRb7At0uS]
module.web_app[0].vercel_project_environment_variable.env[7]: Refreshing state... [id=phHvYylvduIAtRD9]
module.web_app[0].vercel_project_environment_variable.env[13]: Refreshing state... [id=BQ6FduHlAYzIYzLE]
module.web_app[0].vercel_project_environment_variable.env[17]: Refreshing state... [id=7nrNLHbYGvbPQPgC]
module.web_app[0].vercel_project_environment_variable.env[4]: Refreshing state... [id=XMc4F44ltfL3QK3t]
module.web_app[0].vercel_project_environment_variable.env[12]: Refreshing state... [id=Ap5I5tGTeNlwbrsh]
module.web_app[0].vercel_project_environment_variable.env[14]: Refreshing state... [id=lNKiGcjHdNjbHB4E]
module.web_app[0].vercel_project_environment_variable.env[3]: Refreshing state... [id=5nuLo1tvZdpqcXJR]
module.web_app[0].vercel_project_environment_variable.env[16]: Refreshing state... [id=bjIku856byjGpqDu]
module.web_app[0].vercel_project_environment_variable.env[8]: Refreshing state... [id=9WvoP0YuLFeFgkd0]
module.web_app[0].vercel_project_environment_variable.env[5]: Refreshing state... [id=LO7XAE8j1pNTY7Jd]
module.web_app[0].vercel_project_environment_variable.env[0]: Refreshing state... [id=7aTLYrxQVXP0SSTu]
module.web_app[0].vercel_project_environment_variable.env[18]: Refreshing state... [id=gx7cq4XYtx7XFyxH]
module.web_app[0].vercel_project_environment_variable.env[2]: Refreshing state... [id=TYuSgPHntfm46xtc]
module.web_app[0].vercel_project_environment_variable.env[6]: Refreshing state... [id=T8zVCcakiFWjGZBZ]
module.web_app[0].vercel_project_environment_variable.env[10]: Refreshing state... [id=sqeMnRMF0xd8CIRe]
module.web_app[0].vercel_project_environment_variable.env[11]: Refreshing state... [id=DrDtGPyyny9UCMp4]
module.web_app[0].vercel_project_environment_variable.env[9]: Refreshing state... [id=KcvEppk5QtWTRvpH]
module.linear_bot_worker[0].cloudflare_worker_version.this: Refreshing state... [id=72081a9d-c2ef-48e6-a825-868aececb722]
module.slack_bot_worker[0].cloudflare_workers_deployment.this: Refreshing state... [id=55c2eb1e-ec67-40b7-8e5a-c8b0eb0e6c07]
module.linear_bot_worker[0].cloudflare_workers_deployment.this: Refreshing state... [id=88ed99ac-30b6-408e-9228-f9d843b8f32e]
module.control_plane_worker.cloudflare_worker.this: Refreshing state... [id=fd3805c0f7cb454e8165d9959be40dad]
module.control_plane_worker.cloudflare_worker_version.this: Refreshing state... [id=f22fa5a2-851e-4eab-89f1-e3b85482067c]
module.control_plane_worker.cloudflare_workers_deployment.this: Refreshing state... [id=81f350bd-83f4-4ddb-ade1-08002add0372]
module.control_plane_worker.cloudflare_workers_cron_trigger.this[0]: Refreshing state... [id=open-inspect-control-plane-contractor]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # null_resource.control_plane_build must be replaced
-/+ resource "null_resource" "control_plane_build" {
      ~ id       = "2068601036937546848" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-06-30T19:03:37Z" -> (known after apply)
        }
    }

  # null_resource.d1_migrations must be replaced
-/+ resource "null_resource" "d1_migrations" {
      ~ id       = "4884201322476132031" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "migrations_sha" = "d24a5e6e436c9a30ddcff3e083c349fdd25f5f079853fac1a7e526fc4b697e74" -> "a5e33d18bfbcf570fbafe720af71701a0e94188e464a0592fef8612a63d6e439"
            # (1 unchanged element hidden)
        }
    }

  # null_resource.linear_bot_build[0] must be replaced
-/+ resource "null_resource" "linear_bot_build" {
      ~ id       = "7605461394166201776" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-06-30T19:03:37Z" -> (known after apply)
        }
    }

  # null_resource.slack_bot_build[0] must be replaced
-/+ resource "null_resource" "slack_bot_build" {
      ~ id       = "8218184084727750027" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "always_run" = "2026-06-30T19:03:37Z" -> (known after apply)
        }
    }

  # terraform_data.cloudflare_custom_domain_gate will be created
  + resource "terraform_data" "cloudflare_custom_domain_gate" {
      + id = (known after apply)
    }

  # module.control_plane_worker.cloudflare_worker.this will be updated in-place
  ~ resource "cloudflare_worker" "this" {
        id             = "fd3805c0f7cb454e8165d9959be40dad"
        name           = "open-inspect-control-plane-contractor"
      ~ observability  = {
          ~ logs               = {
              + destinations       = (known after apply)
                # (4 unchanged attributes hidden)
            }
          ~ traces             = {
              + destinations       = (known after apply)
                # (3 unchanged attributes hidden)
            }
            # (2 unchanged attributes hidden)
        }
      ~ references     = {
          ~ dispatch_namespace_outbounds = [] -> (known after apply)
          ~ domains                      = [] -> (known after apply)
          ~ durable_objects              = [
              - {
                  - namespace_id   = "e84875aa1fef447bb067596a2dbba9d6" -> null
                  - namespace_name = "open-inspect-control-plane-contractor_SessionDO" -> null
                  - worker_id      = "fd3805c0f7cb454e8165d9959be40dad" -> null
                  - worker_name    = "open-inspect-control-plane-contractor" -> null
                },
              - {
                  - namespace_id   = "a4717e3e330545deab51e1c9b65d7c83" -> null
                  - namespace_name = "open-inspect-control-plane-contractor_SchedulerDO" -> null
                  - worker_id      = "fd3805c0f7cb454e8165d9959be40dad" -> null
                  - worker_name    = "open-inspect-control-plane-contractor" -> null
                },
            ] -> (known after apply)
          ~ queues                       = [] -> (known after apply)
          ~ workers                      = [
              - {
                  - id   = "393472842b684911bc95603990b37de0" -> null
                  - name = "open-inspect-slack-bot-contractor" -> null
                },
              - {
                  - id   = "437dba8e03dd48cbbf55638723ad9074" -> null
                  - name = "open-inspect-linear-bot-contractor" -> null
                },
            ] -> (known after apply)
        } -> (known after apply)
        tags           = []
      ~ updated_on     = "2026-06-30T19:03:39Z" -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.control_plane_worker.cloudflare_worker_version.this must be replaced
-/+ resource "cloudflare_worker_version" "this" {
      ~ annotations         = {
          + workers_message      = (known after apply)
          + workers_tag          = (known after apply)
          ~ workers_triggered_by = "create_version_api" -> (known after apply)
        } -> (known after apply)
      ~ bindings            = (sensitive value) # forces replacement
      ~ created_on          = "2026-06-30T19:03:39Z" -> (known after apply)
      ~ id                  = "f22fa5a2-851e-4eab-89f1-e3b85482067c" -> (known after apply)
      + limits              = (known after apply)
      + main_script_base64  = (known after apply)
      ~ migration_tag       = "v1" -> (known after apply)
      ~ modules             = [
          - { # forces replacement
              - content_file   = "../../..//packages/control-plane/dist/index.js" -> null
              - content_sha256 = "2458cefe96118df3fc1e41a6be46df2645eaeb41233d8862961585025380a11f" -> null
              - content_type   = "application/javascript+module" -> null
              - name           = "index.js" -> null
            },
          + { # forces replacement
              + content_file   = "../../..//packages/control-plane/dist/index.js"
              + content_sha256 = "c90b226b333ff46b2b3765be8fede9fc5b029fbe835ada31706cf1732e57673c"
              + content_type   = "application/javascript+module"
              + name           = "index.js"
            },
        ]
      ~ number              = 31 -> (known after apply)
      ~ source              = "terraform" -> (known after apply)
      ~ startup_time_ms     = 24 -> (known after apply)
      ~ urls                = [] -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.control_plane_worker.cloudflare_workers_deployment.this must be replaced
-/+ resource "cloudflare_workers_deployment" "this" {
      ~ annotations  = {
          + workers_message      = (known after apply)
          ~ workers_triggered_by = "deployment" -> (known after apply)
        } -> (known after apply)
      ~ author_email = "kyle.adams.ic@companycam.com" -> (known after apply)
      ~ created_on   = "2026-06-30T19:03:41Z" -> (known after apply)
      ~ id           = "81f350bd-83f4-4ddb-ade1-08002add0372" -> (known after apply)
      ~ source       = "terraform" -> (known after apply)
      ~ versions     = [ # forces replacement
          ~ {
              ~ version_id = "f22fa5a2-851e-4eab-89f1-e3b85482067c" -> (known after apply)
                # (1 unchanged attribute hidden)
            },
        ]
        # (3 unchanged attributes hidden)
    }

  # module.linear_bot_worker[0].cloudflare_worker.this will be updated in-place
  ~ resource "cloudflare_worker" "this" {
        id             = "437dba8e03dd48cbbf55638723ad9074"
        name           = "open-inspect-linear-bot-contractor"
      ~ observability  = {
          ~ logs               = {
              + destinations       = (known after apply)
                # (4 unchanged attributes hidden)
            }
          ~ traces             = {
              + destinations       = (known after apply)
                # (3 unchanged attributes hidden)
            }
            # (2 unchanged attributes hidden)
        }
      ~ references     = {
          ~ dispatch_namespace_outbounds = [] -> (known after apply)
          ~ domains                      = [] -> (known after apply)
          ~ durable_objects              = [] -> (known after apply)
          ~ queues                       = [] -> (known after apply)
          ~ workers                      = [
              - {
                  - id   = "fd3805c0f7cb454e8165d9959be40dad" -> null
                  - name = "open-inspect-control-plane-contractor" -> null
                },
            ] -> (known after apply)
        } -> (known after apply)
        tags           = []
      ~ updated_on     = "2026-06-30T19:03:37Z" -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.linear_bot_worker[0].cloudflare_worker_version.this must be replaced
-/+ resource "cloudflare_worker_version" "this" {
      ~ annotations         = {
          + workers_message      = (known after apply)
          + workers_tag          = (known after apply)
          ~ workers_triggered_by = "create_version_api" -> (known after apply)
        } -> (known after apply)
      ~ bindings            = (sensitive value) # forces replacement
      ~ created_on          = "2026-06-30T19:03:38Z" -> (known after apply)
      ~ id                  = "72081a9d-c2ef-48e6-a825-868aececb722" -> (known after apply)
      + limits              = (known after apply)
      + main_script_base64  = (known after apply)
      + migration_tag       = (known after apply)
      ~ modules             = [
          - { # forces replacement
              - content_file   = "../../..//packages/linear-bot/dist/index.js" -> null
              - content_sha256 = "7e34053075e9e1e28a8763e39da6bc3090f801984d8c8114654e947b7feb107a" -> null
              - content_type   = "application/javascript+module" -> null
              - name           = "index.js" -> null
            },
          + { # forces replacement
              + content_file   = "../../..//packages/linear-bot/dist/index.js"
              + content_sha256 = "e19cbaff12e9b6c79aaad7fadeba94e7fa799c6e0104f40ec3988e0a3884fb9b"
              + content_type   = "application/javascript+module"
              + name           = "index.js"
            },
        ]
      ~ number              = 31 -> (known after apply)
      ~ source              = "terraform" -> (known after apply)
      ~ startup_time_ms     = 21 -> (known after apply)
      ~ urls                = [
          - "https://72081a9d-open-inspect-linear-bot-contractor.companycam.workers.dev",
        ] -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.linear_bot_worker[0].cloudflare_workers_deployment.this must be replaced
-/+ resource "cloudflare_workers_deployment" "this" {
      ~ annotations  = {
          + workers_message      = (known after apply)
          ~ workers_triggered_by = "deployment" -> (known after apply)
        } -> (known after apply)
      ~ author_email = "kyle.adams.ic@companycam.com" -> (known after apply)
      ~ created_on   = "2026-06-30T19:03:38Z" -> (known after apply)
      ~ id           = "88ed99ac-30b6-408e-9228-f9d843b8f32e" -> (known after apply)
      ~ source       = "terraform" -> (known after apply)
      ~ versions     = [ # forces replacement
          ~ {
              ~ version_id = "72081a9d-c2ef-48e6-a825-868aececb722" -> (known after apply)
                # (1 unchanged attribute hidden)
            },
        ]
        # (3 unchanged attributes hidden)
    }

  # module.modal_app[0].null_resource.modal_deploy must be replaced
-/+ resource "null_resource" "modal_deploy" {
      ~ id       = "7520787001111393755" -> (known after apply)
      ~ triggers = { # forces replacement
          ~ "source_hash"       = "ee48b7750021443e5bcf71517b67a8b93551f952beda7ee19ce3902fcc763f14" -> "6507c9711d0503ede4e6e11167633451b9d29a1f660dfad0091ed5f1e5d8931a"
            # (3 unchanged elements hidden)
        }
    }

  # module.slack_bot_worker[0].cloudflare_worker.this will be updated in-place
  ~ resource "cloudflare_worker" "this" {
        id             = "393472842b684911bc95603990b37de0"
        name           = "open-inspect-slack-bot-contractor"
      ~ observability  = {
          ~ logs               = {
              + destinations       = (known after apply)
                # (4 unchanged attributes hidden)
            }
          ~ traces             = {
              + destinations       = (known after apply)
                # (3 unchanged attributes hidden)
            }
            # (2 unchanged attributes hidden)
        }
      ~ references     = {
          ~ dispatch_namespace_outbounds = [] -> (known after apply)
          ~ domains                      = [] -> (known after apply)
          ~ durable_objects              = [] -> (known after apply)
          ~ queues                       = [] -> (known after apply)
          ~ workers                      = [
              - {
                  - id   = "fd3805c0f7cb454e8165d9959be40dad" -> null
                  - name = "open-inspect-control-plane-contractor" -> null
                },
            ] -> (known after apply)
        } -> (known after apply)
        tags           = []
      ~ updated_on     = "2026-06-30T19:03:38Z" -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.slack_bot_worker[0].cloudflare_worker_version.this must be replaced
-/+ resource "cloudflare_worker_version" "this" {
      ~ annotations         = {
          + workers_message      = (known after apply)
          + workers_tag          = (known after apply)
          ~ workers_triggered_by = "create_version_api" -> (known after apply)
        } -> (known after apply)
      ~ bindings            = (sensitive value) # forces replacement
      ~ created_on          = "2026-06-30T19:03:38Z" -> (known after apply)
      ~ id                  = "5ae51c15-9895-4c87-bfe1-798a81cc53a7" -> (known after apply)
      + limits              = (known after apply)
      + main_script_base64  = (known after apply)
      + migration_tag       = (known after apply)
      ~ modules             = [
          - { # forces replacement
              - content_file   = "../../..//packages/slack-bot/dist/index.js" -> null
              - content_sha256 = "1a08aca56f98ecdd7ac8af52f19ff76dedcd282e439d8120465971a4157fcf9c" -> null
              - content_type   = "application/javascript+module" -> null
              - name           = "index.js" -> null
            },
          + { # forces replacement
              + content_file   = "../../..//packages/slack-bot/dist/index.js"
              + content_sha256 = "15ae157488e63d6dbfa88dcb9dff0e07eda3764d876ac1c927ab5e5bcb9f6ba0"
              + content_type   = "application/javascript+module"
              + name           = "index.js"
            },
        ]
      ~ number              = 31 -> (known after apply)
      ~ source              = "terraform" -> (known after apply)
      ~ startup_time_ms     = 30 -> (known after apply)
      ~ urls                = [
          - "https://5ae51c15-open-inspect-slack-bot-contractor.companycam.workers.dev",
        ] -> (known after apply)
        # (6 unchanged attributes hidden)
    }

  # module.slack_bot_worker[0].cloudflare_workers_deployment.this must be replaced
-/+ resource "cloudflare_workers_deployment" "this" {
      ~ annotations  = {
          + workers_message      = (known after apply)
          ~ workers_triggered_by = "deployment" -> (known after apply)
        } -> (known after apply)
      ~ author_email = "kyle.adams.ic@companycam.com" -> (known after apply)
      ~ created_on   = "2026-06-30T19:03:39Z" -> (known after apply)
      ~ id           = "55c2eb1e-ec67-40b7-8e5a-c8b0eb0e6c07" -> (known after apply)
      ~ source       = "terraform" -> (known after apply)
      ~ versions     = [ # forces replacement
          ~ {
              ~ version_id = "5ae51c15-9895-4c87-bfe1-798a81cc53a7" -> (known after apply)
                # (1 unchanged attribute hidden)
            },
        ]
        # (3 unchanged attributes hidden)
    }

Plan: 12 to add, 3 to change, 11 to destroy.

─────────────────────────────────────────────────────────────────────────────

Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"

Pushed by: @colemurray-31531

@keegan-companycam
keegan-companycam merged commit 3d9946f into main Jul 10, 2026
17 checks passed
@keegan-companycam
keegan-companycam deleted the merge-20260709-upstream branch July 10, 2026 13:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants